diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateTimeMethodTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateTimeMethodTranslator.cs index d1a91cbc0..7869b530c 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateTimeMethodTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateTimeMethodTranslator.cs @@ -316,37 +316,41 @@ public class NpgsqlDateTimeMethodTranslator( MethodInfo method, IReadOnlyList arguments) { - if (method.DeclaringType == typeof(TimeZoneInfo) && method.Name == nameof(TimeZoneInfo.ConvertTimeBySystemTimeZoneId) - && arguments is [var convertDateTime, var timeZoneId] - && convertDateTime.Type == typeof(DateTime)) + if (method.DeclaringType != typeof(TimeZoneInfo)) { - var typeMapping = convertDateTime.TypeMapping; - if (typeMapping is null - || (typeMapping.StoreType != "timestamp with time zone" && typeMapping.StoreType != "timestamptz")) - { - throw new InvalidOperationException( - "TimeZoneInfo.ConvertTimeBySystemTimeZoneId is only supported on columns with type 'timestamp with time zone'"); - } - - return _sqlExpressionFactory.AtTimeZone(convertDateTime, timeZoneId, typeof(DateTime), _timestampMapping); + return null; } - if (method.DeclaringType == typeof(TimeZoneInfo) && method.Name == nameof(TimeZoneInfo.ConvertTimeToUtc) - && arguments is [var utcDateTime] - && utcDateTime.Type == typeof(DateTime)) + return (method.Name, arguments) switch { - var typeMapping = utcDateTime.TypeMapping; - if (typeMapping is null - || (typeMapping.StoreType != "timestamp without time zone" && typeMapping.StoreType != "timestamp")) - { - throw new InvalidOperationException( - "TimeZoneInfo.ConvertTimeToUtc) is only supported on columns with type 'timestamp without time zone'"); - } + (nameof(TimeZoneInfo.ConvertTimeBySystemTimeZoneId), [var convertDateTime, var timeZoneId]) + when convertDateTime.Type == typeof(DateTime) + => convertDateTime.TypeMapping?.StoreType is "timestamp with time zone" or "timestamptz" + ? _sqlExpressionFactory.AtTimeZone(convertDateTime, timeZoneId, typeof(DateTime), _timestampMapping) + : throw new InvalidOperationException( + "TimeZoneInfo.ConvertTimeBySystemTimeZoneId is only supported on columns with type 'timestamp with time zone'"), + + // As with the two-parameter overload, return a timestamp even when the destination is UTC. + (nameof(TimeZoneInfo.ConvertTimeBySystemTimeZoneId), [var sourceDateTime, var sourceTimeZoneId, var destinationTimeZoneId]) + when sourceDateTime.Type == typeof(DateTime) + => sourceDateTime.TypeMapping?.StoreTypeNameBase is "timestamp without time zone" or "timestamp" + ? _sqlExpressionFactory.AtTimeZone( + _sqlExpressionFactory.AtTimeZone(sourceDateTime, sourceTimeZoneId, typeof(DateTime), _timestampTzMapping), + destinationTimeZoneId, + typeof(DateTime), + _timestampMapping) + : throw new InvalidOperationException( + "TimeZoneInfo.ConvertTimeBySystemTimeZoneId is only supported on columns with type 'timestamp without time zone'"), + + (nameof(TimeZoneInfo.ConvertTimeToUtc), [var utcDateTime]) + when utcDateTime.Type == typeof(DateTime) + => utcDateTime.TypeMapping?.StoreType is "timestamp without time zone" or "timestamp" + ? _sqlExpressionFactory.Convert(utcDateTime, utcDateTime.Type, _timestampTzMapping) + : throw new InvalidOperationException( + "TimeZoneInfo.ConvertTimeToUtc is only supported on columns with type 'timestamp without time zone'"), - return _sqlExpressionFactory.Convert(utcDateTime, utcDateTime.Type, _timestampTzMapping); - } - - return null; + _ => null + }; } private SqlExpression? CreateIntervalExpression(SqlExpression intervalNum, string datePart) diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/TimestampTranslationsTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/TimestampTranslationsTest.cs index a8c0edb66..1c2878980 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/TimestampTranslationsTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/TimestampTranslationsTest.cs @@ -733,6 +733,140 @@ await Assert.ThrowsAsync( == new DateTime(1998, 4, 12, 15, 26, 38)).ToListAsync()); } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Where_ConvertTimeBySystemTimeZoneId_with_source_time_zone_on_DateTime_timestamp_column(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where( + e => TimeZoneInfo.ConvertTimeBySystemTimeZoneId(e.TimestampDateTime, "Europe/Berlin", "UTC") + == new DateTime(1998, 4, 12, 13, 26, 38)), + expectedQuery: ss => ss.Set().Where( + e => TimeZoneInfo.ConvertTimeBySystemTimeZoneId( + DateTime.SpecifyKind(e.TimestampDateTime, DateTimeKind.Unspecified), "Europe/Berlin", "UTC") + == new DateTime(1998, 4, 12, 13, 26, 38))); + + AssertSql( + """ +SELECT e."Id", e."TimestampDateTime", e."TimestampDateTimeArray", e."TimestampDateTimeOffset", e."TimestampDateTimeOffsetArray", e."TimestampDateTimeRange", e."TimestamptzDateTime", e."TimestamptzDateTimeArray", e."TimestamptzDateTimeRange" +FROM "Entities" AS e +WHERE (e."TimestampDateTime" AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC' = TIMESTAMP '1998-04-12T13:26:38' +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task ConvertTimeBySystemTimeZoneId_with_source_time_zone_and_SpecifyKind_to_utc(bool async) + { + await using var ctx = CreateContext(); + + var query = ctx.Entities.Where(e => e.Id == 1) + .Select(e => DateTime.SpecifyKind( + TimeZoneInfo.ConvertTimeBySystemTimeZoneId(e.TimestampDateTime, "Europe/Berlin", "UTC"), DateTimeKind.Utc)); + + var result = async ? await query.SingleAsync() : query.Single(); + + Assert.Equal(new DateTime(1998, 4, 12, 13, 26, 38, DateTimeKind.Utc), result); + Assert.Equal(DateTimeKind.Utc, result.Kind); + + AssertSql( + """ +SELECT ((e."TimestampDateTime" AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' +FROM "Entities" AS e +WHERE e."Id" = 1 +LIMIT 2 +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task ConvertTimeBySystemTimeZoneId_with_source_and_destination_time_zone_parameters(bool async) + { + await using var ctx = CreateContext(); + + var sourceTimeZoneId = "Europe/Berlin"; + var destinationTimeZoneId = "Asia/Tokyo"; + var query = ctx.Entities.Where(e => e.Id == 1) + .Select(e => TimeZoneInfo.ConvertTimeBySystemTimeZoneId(e.TimestampDateTime, sourceTimeZoneId, destinationTimeZoneId)); + + var result = async ? await query.SingleAsync() : query.Single(); + + Assert.Equal(new DateTime(1998, 4, 12, 22, 26, 38), result); + Assert.Equal(DateTimeKind.Unspecified, result.Kind); + + AssertSql( + """ +@sourceTimeZoneId='Europe/Berlin' +@destinationTimeZoneId='Asia/Tokyo' + +SELECT (e."TimestampDateTime" AT TIME ZONE @sourceTimeZoneId) AT TIME ZONE @destinationTimeZoneId +FROM "Entities" AS e +WHERE e."Id" = 1 +LIMIT 2 +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task ConvertTimeBySystemTimeZoneId_with_source_and_destination_time_zone_columns(bool async) + { + await using var ctx = CreateContext(); + + var query = ctx.Database.SqlQueryRaw( + """ +SELECT * FROM (VALUES + (1, TIMESTAMP '1998-04-12T15:26:38', 'Europe/Berlin', 'UTC'), + (2, TIMESTAMP '1998-04-12T15:26:38', 'America/New_York', 'Asia/Tokyo') +) AS v("Id", "LocalTimestamp", "SourceTimeZoneId", "DestinationTimeZoneId") +""") + .OrderBy(e => e.Id) + .Select(e => TimeZoneInfo.ConvertTimeBySystemTimeZoneId(e.LocalTimestamp, e.SourceTimeZoneId, e.DestinationTimeZoneId)); + + var results = async ? await query.ToListAsync() : query.ToList(); + + Assert.Equal([new DateTime(1998, 4, 12, 13, 26, 38), new DateTime(1998, 4, 13, 4, 26, 38)], results); + Assert.All(results, result => Assert.Equal(DateTimeKind.Unspecified, result.Kind)); + + AssertSql( + """ +SELECT (m."LocalTimestamp" AT TIME ZONE m."SourceTimeZoneId") AT TIME ZONE m."DestinationTimeZoneId" +FROM ( + SELECT * FROM (VALUES + (1, TIMESTAMP '1998-04-12T15:26:38', 'Europe/Berlin', 'UTC'), + (2, TIMESTAMP '1998-04-12T15:26:38', 'America/New_York', 'Asia/Tokyo') + ) AS v("Id", "LocalTimestamp", "SourceTimeZoneId", "DestinationTimeZoneId") +) AS m +ORDER BY m."Id" NULLS FIRST +"""); + } + + [ConditionalFact] + public void Where_ConvertTimeBySystemTimeZoneId_with_source_time_zone_fails_on_DateTime_timestamptz_column() + { + using var ctx = CreateContext(); + + var exception = Assert.Throws( + () => ctx.Entities.Where( + e => TimeZoneInfo.ConvertTimeBySystemTimeZoneId(e.TimestamptzDateTime, "UTC", "Europe/Berlin") + == new DateTime(1998, 4, 12, 15, 26, 38)).ToQueryString()); + + Assert.Equal( + "TimeZoneInfo.ConvertTimeBySystemTimeZoneId is only supported on columns with type 'timestamp without time zone'", + exception.Message); + } + + [ConditionalFact] + public void Where_ConvertTimeBySystemTimeZoneId_fails_on_DateTimeOffset_column() + { + using var ctx = CreateContext(); + + Assert.Throws( + () => ctx.Entities.Where( + e => TimeZoneInfo.ConvertTimeBySystemTimeZoneId(e.TimestampDateTimeOffset, "Europe/Berlin") + == new DateTimeOffset(1998, 4, 12, 15, 26, 38, TimeSpan.FromHours(2))).ToQueryString()); + } + [ConditionalFact] public virtual async Task Where_ConvertTimeToUtc_on_DateTime_timestamp_column() { @@ -988,6 +1122,18 @@ public class Entity public NpgsqlRange TimestampDateTimeRange { get; set; } } + private class TimeZoneConversion + { + public int Id { get; set; } + + [Column(TypeName = "timestamp(6) without time zone")] + public DateTime LocalTimestamp { get; set; } + + public string SourceTimeZoneId { get; set; } = null!; + + public string DestinationTimeZoneId { get; set; } = null!; + } + public class TimestampQueryFixture : QueryFixtureBase, ITestSqlLoggerFactory { protected override string StoreName