From 26c2d57bd02d15d210ed1ac6f213903ec24e634e Mon Sep 17 00:00:00 2001 From: Jeremy Morren Date: Thu, 29 Oct 2026 12:33:53 -0400 Subject: [PATCH] Fix date/time arithmetic with intervals: time zone, negative literals and components Date/time arithmetic on timestamptz followed the session time zone (#4087): PostgreSQL's timestamptz +/- interval adds the interval's days and months as calendar units in the session TimeZone (https://www.postgresql.org/docs/current/functions-datetime.html, timestamptz_pl_interval_internal: https://github.com/postgres/postgres/blob/REL_18_6/src/backend/utils/adt/timestamp.c#L3254-L3381), so e.g. adding a day across a daylight saving time transition adds 23 or 25 hours. NodaTime's Instant/ZonedDateTime +/- Duration is exact, and DateTime/DateTimeOffset values mapped to timestamptz are UTC; this affected both the +/- operators (Duration/TimeSpan) and DateTime/DateTimeOffset.AddYears/Months/Days/... The arithmetic is now performed on UTC timestamps: (x AT TIME ZONE 'UTC' + interval) AT TIME ZONE 'UTC'. A resulting (y AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' pair is elided in SQL generation. * The rewrite is applied once the timestamp's type mapping is known, so that an unmapped operand (e.g. a parameter) can still be inferred as timestamp without time zone from the rest of the query; AddDays() etc. produce an interval typed as such, so that this also applies to a constant amount (e.g. EF.Parameter(date).AddDays(1)). * timestamptz is recognized by its NpgsqlDbType, so that aliases (timestamptz) and precisions (timestamp(3) with time zone) are included, and the result keeps its store type. * With Npgsql.EnableLegacyTimestampBehavior, a DateTime mapped to timestamptz is a local date and time, and a DateTimeOffset has the local offset; their calendar arithmetic (DateTime arithmetic, DateTimeOffset.AddMonths/AddYears) keeps the local time like PostgreSQL's in the session time zone, so it isn't performed in UTC. * DateOnly.AddMonths/AddYears cast the result back to date (date + interval is a timestamp), so that it can be projected, and map a DateOnly parameter to date. Negative TimeSpan/Duration literals with a days component were wrong (#4086): PostgreSQL applies a sign to the field it precedes only (https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-INTERVAL-INPUT), so '-1 02:00:00' is -22 hours, not -26. Negative values are now generated with a sign on both the days and the time ('-1 -02:00:00'), in SQL and in JSON. * Reading a negative interval with a days component and a sign on the days only from JSON is ambiguous: older versions of the provider wrote negative values that way, meaning that the whole value is negative (-26 hours), whereas for PostgreSQL the sign applies to the days only (-22 hours). Such values keep the provider's old meaning by default; the new Npgsql.ParseOldEFCoreJsonTimeSpans AppContext switch can be turned off to give them PostgreSQL's meaning instead, e.g. when the JSON was written by another application. * The days and time of a JSON interval are combined before range-checking, so that e.g. '10675200 -23:00:00' (which fits in a TimeSpan) is read; malformed values throw a FormatException. TimeSpan/Duration components were wrong for negative values and for intervals computed in SQL (#4088): .Days/.Hours/.Minutes/.Seconds/.Milliseconds read the raw interval fields with floor(), but .NET and NodaTime truncate towards zero, and an interval computed in SQL keeps separate (possibly differently-signed) day and time fields, with a time field which can exceed a day. The components are now read from justify_hours(x), which moves whole days into the day field and leaves both fields with the same sign (interval_justify_hours: https://github.com/postgres/postgres/blob/REL_18_6/src/backend/utils/adt/timestamp.c#L3022-L3056), and truncated. A TimeSpan mapped to time (e.g. DateTime.TimeOfDay) is read without justify_hours(), which only accepts interval. NodaTime Duration.Milliseconds is now translated too, and TimeSpan and Duration members are translated by a single shared method. Adds NodaTimeParityTest: each case is a lambda, its arguments and its expected result, which .NET/NodaTime must return (via Compile()), as must PostgreSQL, with the arguments both as parameters and as constants. Its fixture uses a session time zone with daylight saving time, rejects queries which would be (partly) evaluated on the client, and verifies that every SQL expression involving a NodaTime type has a matching type mapping and that date_part()/trunc() are double precision. The TimeSpan/Duration cases cover intervals computed in SQL whose fields have the same or opposite signs, or a time field of a day or more, and Interval_components_require_justify_hours checks that justify_hours() is required exactly for the latter two. Also adds tests for timestamptz precisions/aliases, mapping inference, the legacy timestamp behavior and reading intervals from JSON. Fixes #4086 Fixes #4087 Fixes #4088 Co-Authored-By: Claude Opus 5.5 --- .../NpgsqlNodaTimeMemberTranslatorPlugin.cs | 25 +- .../NpgsqlDateTimeMethodTranslator.cs | 51 +- .../NpgsqlTimeSpanMemberTranslator.cs | 108 +++- .../Query/Internal/NpgsqlQuerySqlGenerator.cs | 26 + .../Query/NpgsqlSqlExpressionFactory.cs | 63 +- .../Mapping/NpgsqlIntervalTypeMapping.cs | 53 +- .../Internal/NpgsqlTypeMappingSource.cs | 9 + .../Query/LegacyTimestampQueryTest.cs | 104 +++- .../NorthwindMiscellaneousQueryNpgsqlTest.cs | 2 +- .../Query/OperatorsQueryNpgsqlTest.cs | 2 +- .../NodaTime/DurationTranslationsTest.cs | 24 +- .../NodaTime/InstantTranslationsTest.cs | 61 +- .../NodaTime/NodaTimeParityFixture.cs | 138 +++++ .../NodaTime/NodaTimeParityTest.cs | 569 ++++++++++++++++++ .../NodaTime/ZonedDateTimeTranslationsTest.cs | 2 +- .../DateOnlyTranslationsNpgsqlTest.cs | 8 +- .../DateTimeOffsetTranslationsNpgsqlTest.cs | 18 +- .../DateTimeTranslationsNpgsqlTest.cs | 20 +- .../TimeSpanTranslationsNpgsqlTest.cs | 8 +- .../Translations/TimestampTranslationsTest.cs | 402 ++++++++++++- .../Storage/NpgsqlIntervalJsonParsingTest.cs | 132 ++++ .../Storage/NpgsqlNodaTimeTypeMappingTest.cs | 6 +- .../Storage/NpgsqlTypeMappingTest.cs | 29 +- 23 files changed, 1725 insertions(+), 135 deletions(-) create mode 100644 test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/NodaTimeParityFixture.cs create mode 100644 test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/NodaTimeParityTest.cs create mode 100644 test/EFCore.PG.Tests/Storage/NpgsqlIntervalJsonParsingTest.cs diff --git a/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMemberTranslatorPlugin.cs b/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMemberTranslatorPlugin.cs index f1c15a1b6f..fe4da8f598 100644 --- a/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMemberTranslatorPlugin.cs +++ b/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMemberTranslatorPlugin.cs @@ -1,4 +1,5 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Query; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; namespace Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime.Query.Internal; @@ -107,26 +108,12 @@ public class NpgsqlNodaTimeMemberTranslator( return null; } + // Duration has the same component (e.g. Hours) and total (e.g. TotalHours) members as TimeSpan + // with the same semantics, so we can reuse the translation. +#pragma warning disable EF1001 private SqlExpression? TranslateDuration(SqlExpression instance, MemberInfo member) - { - return member.Name switch - { - nameof(Duration.TotalDays) => TranslateDurationTotalMember(instance, 86400), - nameof(Duration.TotalHours) => TranslateDurationTotalMember(instance, 3600), - nameof(Duration.TotalMinutes) => TranslateDurationTotalMember(instance, 60), - nameof(Duration.TotalSeconds) => GetDatePartExpressionDouble(instance, "epoch"), - nameof(Duration.TotalMilliseconds) => TranslateDurationTotalMember(instance, 0.001), - nameof(Duration.Days) => GetDatePartExpression(instance, "day"), - nameof(Duration.Hours) => GetDatePartExpression(instance, "hour"), - nameof(Duration.Minutes) => GetDatePartExpression(instance, "minute"), - nameof(Duration.Seconds) => GetDatePartExpression(instance, "second", true), - nameof(Duration.Milliseconds) => null, // Too annoying, floating point and sub-millisecond handling - _ => null, - }; - - SqlExpression TranslateDurationTotalMember(SqlExpression instance, double divisor) - => _sqlExpressionFactory.Divide(GetDatePartExpressionDouble(instance, "epoch"), _sqlExpressionFactory.Constant(divisor)); - } + => NpgsqlTimeSpanMemberTranslator.TranslateIntervalMember(_sqlExpressionFactory, instance, member.Name); +#pragma warning restore EF1001 private SqlExpression? TranslateInterval(SqlExpression instance, MemberInfo member) { diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateTimeMethodTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateTimeMethodTranslator.cs index 34ef3c1bcb..f069c6a051 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateTimeMethodTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateTimeMethodTranslator.cs @@ -1,5 +1,6 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions; using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; using static Npgsql.EntityFrameworkCore.PostgreSQL.Utilities.Statics; @@ -113,9 +114,29 @@ public class NpgsqlDateTimeMethodTranslator( _ => null }; - return datePart is not null && CreateIntervalExpression(arguments[0], datePart) is SqlExpression interval - ? _sqlExpressionFactory.Add(instance, interval, instance.TypeMapping) - : null; + if (datePart is null || CreateIntervalExpression(arguments[0], datePart) is not { } interval) + { + return null; + } + + // With the legacy timestamp behavior, a DateTimeOffset has the local offset, and AddMonths/AddYears add calendar months in that + // offset, like PostgreSQL's timestamptz + interval in the session time zone; so that arithmetic is left as-is (the SQL binary + // expression is created with its type mapping, so NpgsqlSqlExpressionFactory doesn't perform it in UTC). + // AddDays etc. add an exact amount of time, so they're performed in UTC like other DateTimeOffset arithmetic. + if (NpgsqlTypeMappingSource.LegacyTimestampBehavior + && instance.Type == typeof(DateTimeOffset) + && datePart is "years" or "months") + { + var dateTimeOffset = _sqlExpressionFactory.ApplyDefaultTypeMapping(instance); + return new SqlBinaryExpression( + ExpressionType.Add, dateTimeOffset, interval, typeof(DateTimeOffset), dateTimeOffset.TypeMapping); + } + + // PostgreSQL timestamptz + interval adds days and months in the session time zone, + // whereas DateTime/DateTimeOffset values mapped to timestamptz are UTC. + // The arithmetic is performed on a UTC timestamp instead by NpgsqlSqlExpressionFactory, + // once the instance's type mapping is known (e.g. a parameter's is inferred from the rest of the query). + return _sqlExpressionFactory.Add(instance, interval, instance.TypeMapping); } private SqlExpression? TranslateDateTime( @@ -241,19 +262,16 @@ public class NpgsqlDateTimeMethodTranslator( } // For months and years, date + interval yields a timestamp (since interval could have a time component), so we need to cast - // the results back to date - if (method.DeclaringType == typeof(DateOnly) && method.Name == nameof(DateOnly.AddMonths) - && CreateIntervalExpression(arguments[0], "months") is SqlExpression interval1) - { - return _sqlExpressionFactory.Convert( - _sqlExpressionFactory.Add(instance, interval1, instance.TypeMapping), typeof(DateOnly)); - } - - if (method.DeclaringType == typeof(DateOnly) && method.Name == nameof(DateOnly.AddYears) - && CreateIntervalExpression(arguments[0], "years") is SqlExpression interval2) + // the results back to date. + // DateOnly is always mapped to date: apply that mapping to an unmapped instance (e.g. a parameter), as it would otherwise be + // inferred from the interval. + if (method.DeclaringType == typeof(DateOnly) + && method.Name is nameof(DateOnly.AddMonths) or nameof(DateOnly.AddYears) + && CreateIntervalExpression(arguments[0], method.Name == nameof(DateOnly.AddMonths) ? "months" : "years") is { } interval) { + var date = _sqlExpressionFactory.ApplyDefaultTypeMapping(instance); return _sqlExpressionFactory.Convert( - _sqlExpressionFactory.Add(instance, interval2, instance.TypeMapping), typeof(DateOnly)); + _sqlExpressionFactory.Add(date, interval, _timestampMapping), typeof(DateOnly), date.TypeMapping); } } @@ -380,7 +398,10 @@ public class NpgsqlDateTimeMethodTranslator( return null; } - return _sqlExpressionFactory.Fragment(FormattableString.Invariant($"INTERVAL '{constantExpression.Value} {datePart}'")); + // The fragment is typed as an interval, so that e.g. timestamptz + interval is recognized as such (see + // NpgsqlSqlExpressionFactory), including when the timestamp's type mapping is only inferred later (e.g. a parameter). + return new SqlFragmentExpression( + FormattableString.Invariant($"INTERVAL '{constantExpression.Value} {datePart}'"), typeof(TimeSpan), _intervalMapping); } // For non-constants, we can't parameterize INTERVAL '1 days'. Instead, we use CAST($1 || ' days' AS interval). diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlTimeSpanMemberTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlTimeSpanMemberTranslator.cs index a191699f2e..e242b1ceab 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlTimeSpanMemberTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlTimeSpanMemberTranslator.cs @@ -1,3 +1,4 @@ +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping; using static Npgsql.EntityFrameworkCore.PostgreSQL.Utilities.Statics; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal; @@ -40,48 +41,91 @@ public NpgsqlTimeSpanMemberTranslator(ISqlExpressionFactory sqlExpressionFactory Check.NotNull(member, nameof(member)); Check.NotNull(returnType, nameof(returnType)); - if (member.DeclaringType == typeof(TimeSpan) && instance is not null) - { - return member.Name switch - { - nameof(TimeSpan.Days) => Floor(DatePart("day", instance)), - nameof(TimeSpan.Hours) => Floor(DatePart("hour", instance)), - nameof(TimeSpan.Minutes) => Floor(DatePart("minute", instance)), - nameof(TimeSpan.Seconds) => Floor(DatePart("second", instance)), - nameof(TimeSpan.Milliseconds) => _sqlExpressionFactory.Modulo( - Floor(DatePart("millisecond", instance)), - _sqlExpressionFactory.Constant(1000)), + return member.DeclaringType == typeof(TimeSpan) && instance is not null + ? TranslateIntervalMember(_sqlExpressionFactory, instance, member.Name) + : null; + } + + /// + /// Translates a member of a type representing an exact amount of time which is mapped to PostgreSQL interval, + /// i.e. and NodaTime's Duration, which have the same component (e.g. Hours) + /// and total (e.g. TotalHours) members, with the same semantics. + /// + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// 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 static SqlExpression? TranslateIntervalMember( + ISqlExpressionFactory sqlExpressionFactory, + SqlExpression interval, + string memberName) + { + // A TimeSpan can also be mapped to PostgreSQL time (e.g. DateTime.TimeOfDay), which is a time of day from 00:00 to 24:00, + // so its date_part() values are the components already (except for 24:00, i.e. one day), and justify_hours() doesn't accept it. + var isTime = interval.TypeMapping is INpgsqlTypeMapping { NpgsqlDbType: NpgsqlDbType.Time }; - nameof(TimeSpan.TotalDays) => TranslateDurationTotalMember(instance, 86400), - nameof(TimeSpan.TotalHours) => TranslateDurationTotalMember(instance, 3600), - nameof(TimeSpan.TotalMinutes) => TranslateDurationTotalMember(instance, 60), - nameof(TimeSpan.TotalSeconds) => DatePart("epoch", instance), - nameof(TimeSpan.TotalMilliseconds) => TranslateDurationTotalMember(instance, 0.001), + return memberName switch + { + // The components are truncated towards zero, and all have the value's sign. + // An interval's day and time fields are kept separate (e.g. '1 day -02:00:00'), and the time field can exceed a day. + // justify_hours() moves whole days into the day field and gives both fields the same sign, + // so that each date_part() returns the corresponding component + nameof(TimeSpan.Days) when isTime => sqlExpressionFactory.Divide( + ToInt(DatePart("hour", interval)), sqlExpressionFactory.Constant(24)), + nameof(TimeSpan.Hours) when isTime => sqlExpressionFactory.Modulo( + ToInt(DatePart("hour", interval)), sqlExpressionFactory.Constant(24)), + nameof(TimeSpan.Days) => ToInt(DatePart("day", JustifyHours())), + nameof(TimeSpan.Hours) => ToInt(DatePart("hour", JustifyHours())), + nameof(TimeSpan.Minutes) => ToInt(DatePart("minute", JustifyHours())), + // date_part('second') includes the fractional seconds + nameof(TimeSpan.Seconds) => ToInt(Truncate(DatePart("second", JustifyHours()))), + // date_part('millisecond') includes the seconds and PostgreSQL's integer % truncates + nameof(TimeSpan.Milliseconds) => sqlExpressionFactory.Modulo( + ToInt(Truncate(DatePart("millisecond", JustifyHours()))), + sqlExpressionFactory.Constant(1000)), - _ => null - }; - } + nameof(TimeSpan.TotalDays) => TotalMember(86400), + nameof(TimeSpan.TotalHours) => TotalMember(3600), + nameof(TimeSpan.TotalMinutes) => TotalMember(60), + nameof(TimeSpan.TotalSeconds) => DatePart("epoch", interval), + nameof(TimeSpan.TotalMilliseconds) => TotalMember(0.001), - return null; + _ => null + }; - SqlExpression Floor(SqlExpression value) - => _sqlExpressionFactory.Convert( - _sqlExpressionFactory.Function( - "floor", - [value], + SqlExpression JustifyHours() + => isTime + ? interval + : sqlExpressionFactory.Function( + "justify_hours", + [interval], nullable: true, argumentsPropagateNullability: TrueArrays[1], - typeof(double)), - typeof(int)); + interval.Type, + interval.TypeMapping); SqlExpression DatePart(string part, SqlExpression value) - => _sqlExpressionFactory.Function( - "date_part", [_sqlExpressionFactory.Constant(part), value], + => sqlExpressionFactory.Function( + "date_part", + [sqlExpressionFactory.Constant(part), value], nullable: true, argumentsPropagateNullability: FalseTrueArray, - returnType); + typeof(double)); + + SqlExpression Truncate(SqlExpression value) + => sqlExpressionFactory.Function( + "trunc", + [value], + nullable: true, + argumentsPropagateNullability: TrueArrays[1], + typeof(double)); + + SqlExpression ToInt(SqlExpression value) + => sqlExpressionFactory.Convert(value, typeof(int)); - SqlExpression TranslateDurationTotalMember(SqlExpression instance, double divisor) - => _sqlExpressionFactory.Divide(DatePart("epoch", instance), _sqlExpressionFactory.Constant(divisor)); + SqlExpression TotalMember(double divisor) + => sqlExpressionFactory.Divide(DatePart("epoch", interval), sqlExpressionFactory.Constant(divisor)); } } diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs index 6aaa1481b6..407add883e 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs @@ -599,6 +599,32 @@ protected virtual Expression VisitArrayIndex(SqlBinaryExpression expression) return expression; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// 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. + /// + protected override Expression VisitAtTimeZone(AtTimeZoneExpression atTimeZoneExpression) + { + // Simplify: "(x AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'" + // This is equivalent to "x", as converting to and from UTC is exact (UTC has no daylight saving time). + // This happens e.g. when timestamptz arithmetic is performed in UTC, and the result is then converted to UTC again. + if (atTimeZoneExpression is + { + TimeZone: SqlConstantExpression { Value: "UTC" }, + Operand: AtTimeZoneExpression { TimeZone: SqlConstantExpression { Value: "UTC" }, Operand: var operand } + }) + { + Sql.Append("("); + Visit(operand); + Sql.Append(")"); + return atTimeZoneExpression; + } + + return base.VisitAtTimeZone(atTimeZoneExpression); + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs index fae0f797b3..e1e75a3eb0 100644 --- a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs +++ b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs @@ -113,17 +113,14 @@ public virtual AtTimeZoneExpression AtTimeZone( { // PostgreSQL AT TIME ZONE flips the given type from timestamptz to timestamp and vice versa // See https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT - typeMapping = timestamp.TypeMapping ?? _typeMappingSource.FindMapping(timestamp.Type, Dependencies.Model)!; - var storeType = typeMapping.StoreType; - - typeMapping = storeType.StartsWith("timestamp with time zone", StringComparison.Ordinal) - || storeType.StartsWith("timestamptz", StringComparison.Ordinal) - ? _typeMappingSource.FindMapping("timestamp without time zone")! - : storeType.StartsWith("timestamp without time zone", StringComparison.Ordinal) - || storeType.StartsWith("timestamp", StringComparison.Ordinal) - ? _typeMappingSource.FindMapping("timestamp with time zone")! - : throw new ArgumentException( - $"timestamp argument to AtTimeZone had unknown store type {storeType}", nameof(timestamp)); + // The store type may be an alias (timestamptz) or have a precision (timestamp(3) with time zone), so check the NpgsqlDbType. + typeMapping = (timestamp.TypeMapping ?? _typeMappingSource.FindMapping(timestamp.Type, Dependencies.Model)!) switch + { + INpgsqlTypeMapping { NpgsqlDbType: NpgsqlDbType.TimestampTz } => _typeMappingSource.FindMapping("timestamp without time zone")!, + INpgsqlTypeMapping { NpgsqlDbType: NpgsqlDbType.Timestamp } => _typeMappingSource.FindMapping("timestamp with time zone")!, + var mapping => throw new ArgumentException( + $"timestamp argument to AtTimeZone had unknown store type {mapping.StoreType}", nameof(timestamp)) + }; } return new AtTimeZoneExpression( @@ -267,6 +264,15 @@ public virtual PgNewArrayExpression NewArray( return (SqlBinaryExpression)ApplyTypeMapping( new SqlBinaryExpression(ExpressionType.Subtract, left, right, typeof(int), null), typeMapping); } + + // The arithmetic is rewritten when the timestamp is mapped to timestamptz (see ApplyTypeMappingOnSqlBinary), + // which may result in an AtTimeZoneExpression rather than a SqlBinaryExpression. + case ExpressionType.Add or ExpressionType.Subtract + when left.Type.FullName is "NodaTime.Instant" or "NodaTime.ZonedDateTime" && right.Type.FullName == "NodaTime.Duration" + || (left.Type == typeof(DateTime) || left.Type == typeof(DateTimeOffset)) && right.Type == typeof(TimeSpan): + { + return ApplyTypeMapping(new SqlBinaryExpression(operatorType, left, right, left.Type, null), typeMapping); + } } return base.MakeBinary(operatorType, left, right, typeMapping, existingExpr); @@ -427,7 +433,7 @@ public virtual PgFunctionExpression AggregateFunction( return sqlExpression; } - private SqlBinaryExpression ApplyTypeMappingOnSqlBinary(SqlBinaryExpression binary, RelationalTypeMapping? typeMapping) + private SqlExpression ApplyTypeMappingOnSqlBinary(SqlBinaryExpression binary, RelationalTypeMapping? typeMapping) { var (left, right) = (binary.Left, binary.Right); @@ -468,7 +474,34 @@ private SqlBinaryExpression ApplyTypeMappingOnSqlBinary(SqlBinaryExpression bina { var newLeft = ApplyTypeMapping(left, typeMapping); var newRight = ApplyDefaultTypeMapping(right); - return new SqlBinaryExpression(binary.OperatorType, newLeft, newRight, binary.Type, typeMapping ?? newLeft.TypeMapping); + var resultMapping = typeMapping ?? newLeft.TypeMapping; + + // PostgreSQL timestamptz +/- interval adds the interval's days as calendar days in the session time zone + // i.e. not necessarily 24 hours across a daylight saving transition. + // A Duration/TimeSpan is an exact amount of time and values mapped to timestamptz are UTC. + // Perform the arithmetic on UTC timestamps instead, where days are always 24 hours. + // This is done only once the mapping is known: an unmapped timestamp (e.g. a parameter) may still be inferred as + // timestamp without time zone from the rest of the query, e.g. the other side of a comparison. + // With the legacy timestamp behavior, a DateTime mapped to timestamptz is read as a local DateTime, + // whose arithmetic (e.g. AddDays) keeps the local time across a daylight saving transition, like PostgreSQL's + // in the session time zone; so it's left as-is. + // A legacy DateTimeOffset has the local offset too, but its other arithmetic is still exact, so only its + // AddMonths/AddYears are left as-is, by NpgsqlDateTimeMethodTranslator (which doesn't come through here). + if (IsTimestampTz(resultMapping) + && (right.Type == typeof(TimeSpan) || right.Type.FullName == "NodaTime.Duration") + && !(NpgsqlTypeMappingSource.LegacyTimestampBehavior && binary.Type == typeof(DateTime))) + { + var timestampMapping = _typeMappingSource.FindMapping("timestamp without time zone")!; + var utcTimestamp = AtTimeZone(newLeft, Constant("UTC"), timestampMapping.ClrType, timestampMapping); + + return AtTimeZone( + new SqlBinaryExpression(binary.OperatorType, utcTimestamp, newRight, timestampMapping.ClrType, timestampMapping), + Constant("UTC"), + binary.Type, + resultMapping); + } + + return new SqlBinaryExpression(binary.OperatorType, newLeft, newRight, binary.Type, resultMapping); } // DateTime - DateTime => TimeSpan @@ -556,6 +589,10 @@ private SqlBinaryExpression ApplyTypeMappingOnSqlBinary(SqlBinaryExpression bina return (SqlBinaryExpression)base.ApplyTypeMapping(binary, typeMapping); + // The store type may be an alias (timestamptz) or have a precision (timestamp(3) with time zone), so check the NpgsqlDbType. + static bool IsTimestampTz(RelationalTypeMapping? mapping) + => mapping is INpgsqlTypeMapping { NpgsqlDbType: NpgsqlDbType.TimestampTz }; + static bool IsComparison(ExpressionType expressionType) { switch (expressionType) diff --git a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlIntervalTypeMapping.cs b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlIntervalTypeMapping.cs index 1b43e609cd..5f7a049192 100644 --- a/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlIntervalTypeMapping.cs +++ b/src/EFCore.PG/Storage/Internal/Mapping/NpgsqlIntervalTypeMapping.cs @@ -95,9 +95,17 @@ public static string FormatTimeSpanAsInterval(TimeSpan ts) // otherwise a value with only sub-microsecond ticks gets formatted with a trailing decimal separator (e.g. 00:23:00.). ts = new TimeSpan(ts.Ticks - ts.Ticks % TimeSpan.TicksPerMicrosecond); - return ts.ToString( - $@"{(ts < TimeSpan.Zero ? "\\-" : "")}{(ts.Days == 0 ? "" : "d\\ ")}hh\:mm\:ss{(ts.Ticks % 10000000 == 0 ? "" : "\\.FFFFFF")}", + // TimeSpan's custom format specifiers output absolute values, so the sign is added explicitly. + // PostgreSQL applies a sign to the field it precedes only ('-1 02:00:00' is -1 day +2 hours, i.e. -22 hours), + // so a negative value with a days component needs a sign on both the days and the time ('-1 -02:00:00'). + var sign = ts < TimeSpan.Zero ? "-" : ""; + var time = ts.ToString( + $@"hh\:mm\:ss{(ts.Ticks % 10000000 == 0 ? "" : "\\.FFFFFF")}", CultureInfo.InvariantCulture); + + return ts.Days == 0 + ? sign + time + : FormattableString.Invariant($"{sign}{Math.Abs(ts.Days)} {sign}{time}"); } /// @@ -111,7 +119,7 @@ public static TimeSpan ParseIntervalAsTimeSpan(ReadOnlySpan s) // We store the a PG-compatible interval representation in JSON so it can be queried out, but unfortunately this isn't // compatible with TimeSpan, so we have to parse manually. - // Note + // A leading sign applies to the days if there are any, otherwise to the time var isNegated = false; if (s[0] == '-') { @@ -135,32 +143,45 @@ public static TimeSpan ParseIntervalAsTimeSpan(ReadOnlySpan s) // Space is the separator between the days and the hours:minutes:seconds components var days = 0; + var isTimeNegated = isNegated; if (s[i] == ' ') { days = int.Parse(s[..i]); - s = s[i..]; + s = s[(i + 1)..]; + + // PostgreSQL applies a sign to the field it precedes only + // i.e. negative values are written with a sign on both the days and the time (-1 -02:00:00 for -26 hours). + // Older versions of the provider wrote a sign on the days only (-1 02:00:00), + // which to PostgreSQL is -1 day +2 hours, i.e. -22 hours. + // The Npgsql.ParseOldEFCoreJsonTimeSpans AppContext switch (on by default) determines which meaning such a value is given. + if (s.Length > 0 && s[0] == '-') + { + isTimeNegated = true; + s = s[1..]; + } + else + { + isTimeNegated = isNegated && NpgsqlTypeMappingSource.ParseOldEFCoreJsonTimeSpans; + } } - var timeSpan = TimeSpan.Parse(s, CultureInfo.InvariantCulture); + var time = TimeSpan.Parse(s, CultureInfo.InvariantCulture); // We've already extracted the days, so there shouldn't be a days component in the rest (indicates an incompatible/malformed string) - if (timeSpan.Days != 0) - { - throw new FormatException(); - } - - if (days > 0) + if (time.Days != 0 || time < TimeSpan.Zero) { - timeSpan = new TimeSpan( - days, timeSpan.Hours, timeSpan.Minutes, timeSpan.Seconds, timeSpan.Milliseconds, timeSpan.Microseconds); + throw new FormatException("Error parsing interval: invalid format"); } - if (isNegated) + // The days and the time can have opposite signs, so only the combined value is range-checked + // (e.g. 10675200 -23:00:00 fits in a TimeSpan, but 10675200 days don't) + var ticks = (Int128)(isNegated ? -days : days) * TimeSpan.TicksPerDay + (isTimeNegated ? -time.Ticks : time.Ticks); + if (ticks < TimeSpan.MinValue.Ticks || ticks > TimeSpan.MaxValue.Ticks) { - timeSpan = -timeSpan; + throw new OverflowException("Error parsing interval: the value is out of range for a TimeSpan"); } - return timeSpan; + return new TimeSpan((long)ticks); } /// diff --git a/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs b/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs index 7a0ef8be61..5d6969d672 100644 --- a/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs +++ b/src/EFCore.PG/Storage/Internal/NpgsqlTypeMappingSource.cs @@ -27,15 +27,24 @@ public class NpgsqlTypeMappingSource : RelationalTypeMappingSource // ReSharper disable once FieldCanBeMadeReadOnly.Global internal static bool DisableDateTimeInfinityConversions; + + // ReSharper disable once FieldCanBeMadeReadOnly.Global + internal static bool ParseOldEFCoreJsonTimeSpans; #else internal static readonly bool LegacyTimestampBehavior; internal static readonly bool DisableDateTimeInfinityConversions; + internal static readonly bool ParseOldEFCoreJsonTimeSpans; #endif static NpgsqlTypeMappingSource() { LegacyTimestampBehavior = AppContext.TryGetSwitch("Npgsql.EnableLegacyTimestampBehavior", out var enabled) && enabled; DisableDateTimeInfinityConversions = AppContext.TryGetSwitch("Npgsql.DisableDateTimeInfinityConversions", out enabled) && enabled; + + // Older versions of the provider wrote negative intervals with a days component to JSON with a sign on the days only. + // They meant the whole value to be negative (-1 02:00:00 for -26 hours), whereas PostgreSQL applies that sign to the days only. + // When reading such a value, by default we should read it assuming the old provider wrote it + ParseOldEFCoreJsonTimeSpans = !AppContext.TryGetSwitch("Npgsql.ParseOldEFCoreJsonTimeSpans", out enabled) || enabled; } private readonly ISqlGenerationHelper _sqlGenerationHelper; diff --git a/test/EFCore.PG.FunctionalTests/Query/LegacyTimestampQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/LegacyTimestampQueryTest.cs index 41d5fdbafa..72ac5877c2 100644 --- a/test/EFCore.PG.FunctionalTests/Query/LegacyTimestampQueryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/LegacyTimestampQueryTest.cs @@ -70,8 +70,109 @@ WHERE now() AT TIME ZONE 'UTC' <> @myDatetime """); } + [ConditionalFact] + public async Task Interval_arithmetic_on_timestamptz() + { + await using var ctx = CreateContext(); + + // With the legacy timestamp behavior, a DateTime mapped to timestamptz is read as a local DateTime, + // and e.g. AddDays(1) keeps the local time across a daylight saving transition; so does timestamptz + interval + // in the session time zone (Europe/Berlin, which moves to daylight saving time on 2018-03-25). + // So unlike without the legacy behavior, the arithmetic isn't performed in UTC. + // DateTimeOffset elapsed-time arithmetic (e.g. AddDays) is still exact, so it's still performed in UTC + // (see DateTimeOffset_AddMonths_keeps_the_local_offset for AddMonths/AddYears). + var result = await ctx.Database.SqlQueryRaw( + """ +SELECT TIMESTAMPTZ '2018-03-24T11:00:00Z' AS "BeforeDst", TIMESTAMPTZ '2018-03-24T11:00:00Z' AS "BeforeDstOffset", TIMESTAMP '2018-03-24T12:00:00' AS "BeforeDstTimestamp", INTERVAL '1 day' AS "Interval" +""") + .Select( + a => new + { + AddDays = a.BeforeDst.AddDays(1), + Add = a.BeforeDst + a.Interval, + OffsetAddDays = a.BeforeDstOffset.AddDays(1), + OffsetAdd = a.BeforeDstOffset + a.Interval, + TimestampAddDays = a.BeforeDstTimestamp.AddDays(1) + }) + .SingleAsync(); + + // 12:00 in Europe/Berlin on both days, i.e. 23 hours later; values are read as UTC at the ADO.NET level (see above) + Assert.Equal(new DateTime(2018, 3, 25, 10, 0, 0, DateTimeKind.Utc), result.AddDays); + Assert.Equal(new DateTime(2018, 3, 25, 10, 0, 0, DateTimeKind.Utc), result.Add); + Assert.Equal(new DateTimeOffset(2018, 3, 25, 11, 0, 0, TimeSpan.Zero), result.OffsetAddDays); + Assert.Equal(new DateTimeOffset(2018, 3, 25, 11, 0, 0, TimeSpan.Zero), result.OffsetAdd); + Assert.Equal(new DateTime(2018, 3, 25, 12, 0, 0), result.TimestampAddDays); + + AssertSql( + """ +SELECT m."BeforeDst" + INTERVAL '1 days' AS "AddDays", m."BeforeDst" + m."Interval" AS "Add", ((m."BeforeDstOffset" AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' AS "OffsetAddDays", ((m."BeforeDstOffset" AT TIME ZONE 'UTC') + m."Interval") AT TIME ZONE 'UTC' AS "OffsetAdd", m."BeforeDstTimestamp" + INTERVAL '1 days' AS "TimestampAddDays" +FROM ( + SELECT TIMESTAMPTZ '2018-03-24T11:00:00Z' AS "BeforeDst", TIMESTAMPTZ '2018-03-24T11:00:00Z' AS "BeforeDstOffset", TIMESTAMP '2018-03-24T12:00:00' AS "BeforeDstTimestamp", INTERVAL '1 day' AS "Interval" +) AS m +LIMIT 2 +"""); + } + + [ConditionalFact] + public async Task DateTimeOffset_AddMonths_keeps_the_local_offset() + { + await using var ctx = CreateContext(); + + // With the legacy timestamp behavior, a DateTimeOffset is read with the local offset, and AddMonths/AddYears add calendar + // months in that offset, like timestamptz + interval in the session time zone (Europe/Berlin). E.g. 2018-01-31 00:30+01:00 + // plus one month is 2018-02-28 00:30+01:00, i.e. 2018-02-27 23:30Z, whereas in UTC it would be 2018-02-28 23:30Z. + // AddDays adds an exact amount of time, which is the same in UTC. + // Values are sent and read as UTC at the ADO.NET level (see above), i.e. 2018-01-30 23:30Z. + var monthEnd = new DateTimeOffset(2018, 1, 30, 23, 30, 0, TimeSpan.Zero); + + var result = await ctx.Database.SqlQueryRaw( + """ +SELECT TIMESTAMPTZ '2018-01-30T23:30:00Z' AS "MonthEnd" +""") + .Select( + a => new + { + AddMonths = a.MonthEnd.AddMonths(1), + AddMonthsParameter = EF.Parameter(monthEnd).AddMonths(1), + AddDays = a.MonthEnd.AddDays(1) + }) + .SingleAsync(); + + Assert.Equal(new DateTimeOffset(2018, 2, 27, 23, 30, 0, TimeSpan.Zero), result.AddMonths); + Assert.Equal(new DateTimeOffset(2018, 2, 27, 23, 30, 0, TimeSpan.Zero), result.AddMonthsParameter); + Assert.Equal(new DateTimeOffset(2018, 1, 31, 23, 30, 0, TimeSpan.Zero), result.AddDays); + + AssertSql( + """ +@monthEnd='2018-01-30T23:30:00.0000000+00:00' (DbType = DateTime) + +SELECT m."MonthEnd" + INTERVAL '1 months' AS "AddMonths", @monthEnd + INTERVAL '1 months' AS "AddMonthsParameter", ((m."MonthEnd" AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' AS "AddDays" +FROM ( + SELECT TIMESTAMPTZ '2018-01-30T23:30:00Z' AS "MonthEnd" +) AS m +LIMIT 2 +"""); + } + #region Support + private class OffsetMonthEnd + { + public DateTimeOffset MonthEnd { get; set; } + } + + private class IntervalArithmetic + { + [Column(TypeName = "timestamp with time zone")] + public DateTime BeforeDst { get; set; } + + public DateTimeOffset BeforeDstOffset { get; set; } + + public DateTime BeforeDstTimestamp { get; set; } + + public TimeSpan Interval { get; set; } + } + private TimestampQueryContext CreateContext() => Fixture.CreateContext(); @@ -99,8 +200,9 @@ public class LegacyTimestampQueryFixture : SharedStoreFixtureBase "TimestampQueryTest"; + // A session time zone with daylight saving time, see Interval_arithmetic_on_timestamptz protected override ITestStoreFactory TestStoreFactory - => NpgsqlTestStoreFactory.Instance; + => new NpgsqlTestStoreFactory(connectionStringOptions: "-c TimeZone=Europe/Berlin"); public TestSqlLoggerFactory TestSqlLoggerFactory => (TestSqlLoggerFactory)ListLoggerFactory; diff --git a/test/EFCore.PG.FunctionalTests/Query/NorthwindMiscellaneousQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/NorthwindMiscellaneousQueryNpgsqlTest.cs index 3b241f3051..51fa683a88 100644 --- a/test/EFCore.PG.FunctionalTests/Query/NorthwindMiscellaneousQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/NorthwindMiscellaneousQueryNpgsqlTest.cs @@ -92,7 +92,7 @@ await AssertFirst( AssertSql( """ -SELECT floor(date_part('day', date_trunc('day', now()::timestamp) - date_trunc('day', o."OrderDate")))::int AS "Elapsed" +SELECT date_part('day', justify_hours(date_trunc('day', now()::timestamp) - date_trunc('day', o."OrderDate")))::int AS "Elapsed" FROM "Orders" AS o WHERE o."OrderDate" IS NOT NULL LIMIT 1 diff --git a/test/EFCore.PG.FunctionalTests/Query/OperatorsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/OperatorsQueryNpgsqlTest.cs index f4f29a20bb..998a8c5408 100644 --- a/test/EFCore.PG.FunctionalTests/Query/OperatorsQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/OperatorsQueryNpgsqlTest.cs @@ -174,7 +174,7 @@ public virtual async Task AtTimeZone_and_addition() """ SELECT o."Id", o."Value" FROM "OperatorEntityDateTime" AS o -WHERE DATE '2020-01-15' > CAST((o."Value" + INTERVAL '1 days') AT TIME ZONE 'UTC' AS date) +WHERE DATE '2020-01-15' > CAST(((o."Value" AT TIME ZONE 'UTC') + INTERVAL '1 days') AS date) LIMIT 2 """); } diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/DurationTranslationsTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/DurationTranslationsTest.cs index 24129e49f5..1189346421 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/DurationTranslationsTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/DurationTranslationsTest.cs @@ -104,7 +104,7 @@ await AssertQuery( """ SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime" FROM "NodaTimeTypes" AS n -WHERE date_part('day', n."Duration")::int = 27 +WHERE date_part('day', justify_hours(n."Duration"))::int = 27 """); } @@ -120,7 +120,7 @@ await AssertQuery( """ SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime" FROM "NodaTimeTypes" AS n -WHERE date_part('hour', n."Duration")::int = 9 +WHERE date_part('hour', justify_hours(n."Duration"))::int = 9 """); } @@ -136,7 +136,7 @@ await AssertQuery( """ SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime" FROM "NodaTimeTypes" AS n -WHERE date_part('minute', n."Duration")::int = 4 +WHERE date_part('minute', justify_hours(n."Duration"))::int = 4 """); } @@ -152,7 +152,23 @@ await AssertQuery( """ SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime" FROM "NodaTimeTypes" AS n -WHERE floor(date_part('second', n."Duration"))::int = 8 +WHERE trunc(date_part('second', justify_hours(n."Duration")))::int = 8 +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Milliseconds(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Where(t => t.Duration.Milliseconds == 20)); + + AssertSql( + """ +SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime" +FROM "NodaTimeTypes" AS n +WHERE trunc(date_part('millisecond', justify_hours(n."Duration")))::int % 1000 = 20 """); } diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/InstantTranslationsTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/InstantTranslationsTest.cs index 5fb8a83d71..f98192f914 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/InstantTranslationsTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/InstantTranslationsTest.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore.TestModels.NodaTime; using NodaTime; @@ -24,10 +25,68 @@ await AssertQuery( """ SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime" FROM "NodaTimeTypes" AS n -WHERE (n."Instant" + INTERVAL '1 00:00:00') - n."Instant" = INTERVAL '1 00:00:00' +WHERE (((n."Instant" AT TIME ZONE 'UTC') + INTERVAL '1 00:00:00') AT TIME ZONE 'UTC') - n."Instant" = INTERVAL '1 00:00:00' """); } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Duration_arithmetic_on_timestamptz_with_precision_or_alias(bool async) + { + await using var ctx = CreateContext(); + + // Europe/Berlin moves to daylight saving time on 2018-03-25, so arithmetic in the session time zone would be an hour off + // NodaTime has no Duration + Instant operator, so the Duration is always on the right of the arithmetic. + var duration = Duration.FromDays(1); + var instant = Instant.FromUtc(2018, 3, 24, 12, 0); + var query = ctx.Database.SqlQueryRaw( + """ +SELECT TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "Instant", TIMESTAMPTZ '2018-03-25T12:00:00Z' AS "ZonedDateTime", INTERVAL '1 day' AS "Duration" +""") + .Select( + a => new + { + Instant = a.Instant + a.Duration, + ZonedDateTime = a.ZonedDateTime - a.Duration, + InstantParameter = a.Instant + duration, + ZonedDateTimeParameter = a.ZonedDateTime - duration, + // The parameter's mapping is inferred from the column on the other side of the comparison + InstantComparison = instant + a.Duration == a.ZonedDateTime.ToInstant() + && a.ZonedDateTime.ToInstant() == instant + a.Duration + }); + + var result = async ? await query.SingleAsync() : query.Single(); + + Assert.Equal(Instant.FromUtc(2018, 3, 25, 12, 0), result.Instant); + Assert.Equal(Instant.FromUtc(2018, 3, 24, 12, 0).InUtc(), result.ZonedDateTime); + Assert.Equal(Instant.FromUtc(2018, 3, 25, 12, 0), result.InstantParameter); + Assert.Equal(Instant.FromUtc(2018, 3, 24, 12, 0).InUtc(), result.ZonedDateTimeParameter); + Assert.True(result.InstantComparison); + + AssertSql( + """ +@duration='1:00:00:00' (DbType = Object) +@instant='2018-03-24T12:00:00Z' (DbType = DateTime) + +SELECT ((m."Instant" AT TIME ZONE 'UTC') + m."Duration") AT TIME ZONE 'UTC' AS "Instant", ((m."ZonedDateTime" AT TIME ZONE 'UTC') - m."Duration") AT TIME ZONE 'UTC' AS "ZonedDateTime", ((m."Instant" AT TIME ZONE 'UTC') + @duration) AT TIME ZONE 'UTC' AS "InstantParameter", ((m."ZonedDateTime" AT TIME ZONE 'UTC') - @duration) AT TIME ZONE 'UTC' AS "ZonedDateTimeParameter", ((@instant AT TIME ZONE 'UTC') + m."Duration") AT TIME ZONE 'UTC' = m."ZonedDateTime" AND m."ZonedDateTime" = ((@instant AT TIME ZONE 'UTC') + m."Duration") AT TIME ZONE 'UTC' AS "InstantComparison" +FROM ( + SELECT TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "Instant", TIMESTAMPTZ '2018-03-25T12:00:00Z' AS "ZonedDateTime", INTERVAL '1 day' AS "Duration" +) AS m +LIMIT 2 +"""); + } + + private class DurationArithmetic + { + [Precision(3)] + public Instant Instant { get; set; } + + [Column(TypeName = "timestamptz(3)")] + public ZonedDateTime ZonedDateTime { get; set; } + + public Duration Duration { get; set; } + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public async Task InUtc(bool async) diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/NodaTimeParityFixture.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/NodaTimeParityFixture.cs new file mode 100644 index 0000000000..45bbc1d73e --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/NodaTimeParityFixture.cs @@ -0,0 +1,138 @@ +using System.Diagnostics.CodeAnalysis; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Internal; + +namespace Microsoft.EntityFrameworkCore.Query.Translations.NodaTime; + +/// +/// A which additionally verifies, before generating SQL, that every SQL expression involving +/// a NodaTime type has a type mapping for that same type. A mismatch (e.g. an expression typed as ZonedDateTime but carrying +/// the Instant type mapping) means that the expression can't be projected or further composed correctly. +/// It also verifies that date_part() (and trunc() of its result) are typed as , +/// i.e. double precision, which is what PostgreSQL returns, e.g. for the intermediate results of Milliseconds. +/// Finally, it rejects queries whose result is (partly) evaluated on the client, i.e. whose shaper does more than read the +/// projected value (or materialize an entity). +/// +public class NodaTimeParityFixture : NodaTimeQueryNpgsqlFixture +{ + protected override string StoreName + => "NodaTimeParityTest"; + + protected override IServiceCollection AddServices(IServiceCollection serviceCollection) + => base.AddServices( + serviceCollection + .AddSingleton() + .AddScoped()); + +#pragma warning disable EF1001 // Internal EF Core API usage. + private sealed class ServerEvaluationCheckingPostprocessorFactory( + QueryTranslationPostprocessorDependencies dependencies, + RelationalQueryTranslationPostprocessorDependencies relationalDependencies) + : NpgsqlQueryTranslationPostprocessorFactory(dependencies, relationalDependencies) + { + public override QueryTranslationPostprocessor Create(QueryCompilationContext queryCompilationContext) + => new ServerEvaluationCheckingPostprocessor( + Dependencies, RelationalDependencies, (RelationalQueryCompilationContext)queryCompilationContext); + } + + private sealed class ServerEvaluationCheckingPostprocessor( + QueryTranslationPostprocessorDependencies dependencies, + RelationalQueryTranslationPostprocessorDependencies relationalDependencies, + RelationalQueryCompilationContext queryCompilationContext) + : NpgsqlQueryTranslationPostprocessor(dependencies, relationalDependencies, queryCompilationContext) + { + public override Expression Process(Expression query) + { + var result = base.Process(query); + + if (result is ShapedQueryExpression { ShaperExpression: var shaper } && !ReadsServerResult(shaper)) + { + throw new InvalidOperationException( + $"The query result would be evaluated on the client, rather than translated: {shaper.Print()}"); + } + + return result; + + static bool ReadsServerResult(Expression shaper) + => shaper switch + { + UnaryExpression { NodeType: ExpressionType.Convert, Operand: var operand } => ReadsServerResult(operand), + ProjectionBindingExpression or StructuralTypeShaperExpression => true, + _ => false + }; + } + } + + private sealed class TypeCheckingQuerySqlGeneratorFactory : NpgsqlQuerySqlGeneratorFactory + { + private readonly QuerySqlGeneratorDependencies _dependencies; + private readonly IRelationalTypeMappingSource _typeMappingSource; + private readonly INpgsqlSingletonOptions _npgsqlSingletonOptions; + + public TypeCheckingQuerySqlGeneratorFactory( + QuerySqlGeneratorDependencies dependencies, + IRelationalTypeMappingSource typeMappingSource, + INpgsqlSingletonOptions npgsqlSingletonOptions) + : base(dependencies, typeMappingSource, npgsqlSingletonOptions) + { + _dependencies = dependencies; + _typeMappingSource = typeMappingSource; + _npgsqlSingletonOptions = npgsqlSingletonOptions; + } + + public override QuerySqlGenerator Create() + => new TypeCheckingQuerySqlGenerator( + _dependencies, + _typeMappingSource, + _npgsqlSingletonOptions.ReverseNullOrderingEnabled, + _npgsqlSingletonOptions.PostgresVersion); + } + + private sealed class TypeCheckingQuerySqlGenerator( + QuerySqlGeneratorDependencies dependencies, + IRelationalTypeMappingSource typeMappingSource, + bool reverseNullOrderingEnabled, + Version postgresVersion) + : NpgsqlQuerySqlGenerator(dependencies, typeMappingSource, reverseNullOrderingEnabled, postgresVersion) + { + public override IRelationalCommand GetCommand(Expression queryExpression) + { + new TypeMappingVerifier().Visit(queryExpression); + + return base.GetCommand(queryExpression); + } + } +#pragma warning restore EF1001 + + private sealed class TypeMappingVerifier : ExpressionVisitor + { + [return: NotNullIfNotNull(nameof(node))] + public override Expression? Visit(Expression? node) + { + // date_part() returns double precision, and so does trunc(double precision) + if (node is SqlFunctionExpression { Name: "date_part" or "trunc" } function + && (function.Name == "date_part" || function.Arguments is [{ Type: var argumentType }] && argumentType == typeof(double)) + && (function.Type != typeof(double) || function.TypeMapping?.StoreType != "double precision")) + { + throw new InvalidOperationException( + $"SQL expression '{function.Print()}' has .NET type '{function.Type.Name}' and store type " + + $"'{function.TypeMapping?.StoreType}', but should be double precision."); + } + + if (node is SqlExpression { TypeMapping: { } typeMapping } sqlExpression) + { + var type = Nullable.GetUnderlyingType(sqlExpression.Type) ?? sqlExpression.Type; + var mappedType = Nullable.GetUnderlyingType(typeMapping.ClrType) ?? typeMapping.ClrType; + + if (type != mappedType && (type.Namespace == "NodaTime" || mappedType.Namespace == "NodaTime")) + { + throw new InvalidOperationException( + $"SQL expression '{sqlExpression.Print()}' has .NET type '{type.Name}', but a type mapping for '{mappedType.Name}' " + + $"('{typeMapping.StoreType}')."); + } + } + + return base.Visit(node); + } + } +} diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/NodaTimeParityTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/NodaTimeParityTest.cs new file mode 100644 index 0000000000..637ae1dea9 --- /dev/null +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/NodaTimeParityTest.cs @@ -0,0 +1,569 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.EntityFrameworkCore.TestModels.NodaTime; +using NodaTime; + +namespace Microsoft.EntityFrameworkCore.Query.Translations.NodaTime; + +/// +/// Checks that NodaTime expressions evaluated by PostgreSQL produce exactly the same results as NodaTime itself. +/// +/// +/// +/// Each case is a lambda, its arguments and its expected (correct) result. The lambda is evaluated in .NET +/// (via ), which must return the expected result (so that each case's expectation +/// is checked against NodaTime/.NET itself), and twice in the database: once with each lambda parameter replaced by +/// , and once by (so that literal generation is covered too), which +/// must also return the expected result. This prevents EF from evaluating the expression on the client, and +/// verifies that it didn't. The result is projected, so it must also be read back as the +/// right type; in addition, verifies that every SQL expression in the query has a type mapping +/// matching its NodaTime type. +/// +/// +/// To reproduce a discrepancy, add a single yield return Case(...) line to the relevant group below. +/// Known discrepancies are marked with . +/// +/// +[SuppressMessage("Usage", "xUnit1046:Avoid using TheoryDataRow arguments that are not serializable")] +public class NodaTimeParityTest : IClassFixture +{ + public NodaTimeParityTest(NodaTimeParityFixture fixture, ITestOutputHelper testOutputHelper) + { + Fixture = fixture; + Fixture.TestSqlLoggerFactory.Clear(); + Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + private NodaTimeParityFixture Fixture { get; } + + // Europe/Berlin (the fixture's session time zone) switched to daylight saving time at 2018-03-25T01:00Z + // Durations of whole days must not be affected by this. + private static readonly Instant Instant = Instant.FromUtc(2018, 4, 20, 10, 31, 33) + Duration.FromMilliseconds(666); + private static readonly Instant BeforeDst = Instant.FromUtc(2018, 3, 24, 12, 0); + private static readonly ZonedDateTime Zoned = Instant.InUtc(); + private static readonly ZonedDateTime ZonedBeforeDst = BeforeDst.InUtc(); + + #region Instant + + public static IEnumerable> InstantCases() + { + // Instant +/- Duration, across a daylight saving time transition in the session time zone + yield return Case((i, d) => i + d, BeforeDst, Duration.FromDays(1), Instant.FromUtc(2018, 3, 25, 12, 0)); + yield return Case((i, d) => i + d, BeforeDst, Duration.FromHours(36), Instant.FromUtc(2018, 3, 26, 0, 0)); + yield return Case((i, d) => i + d, BeforeDst, Duration.FromHours(5), Instant.FromUtc(2018, 3, 24, 17, 0)); + yield return Case( + (i, d) => i - d, Instant, Duration.FromDays(30), + Instant.FromUtc(2018, 3, 21, 10, 31, 33) + Duration.FromMilliseconds(666)); + + // Negative durations (with a days component) + yield return Case((i, d) => i + d, BeforeDst, Duration.FromHours(-26), Instant.FromUtc(2018, 3, 23, 10, 0)); + yield return Case((i, d) => i - d, BeforeDst, Duration.FromHours(-36), Instant.FromUtc(2018, 3, 26, 0, 0)); + } + + [ConditionalTheory] + [MemberData(nameof(InstantCases))] + public Task Instant_matches_NodaTime(LambdaExpression expression, object?[] arguments, object? expected) + => AssertCorrect(expression, arguments, expected); + + #endregion Instant + + #region ZonedDateTime + + public static IEnumerable> ZonedDateTimeCases() + { + // ZonedDateTime +/- Duration, across a daylight saving time transition in the session time zone + yield return Case( + (z, d) => z + d, ZonedBeforeDst, Duration.FromDays(1), Instant.FromUtc(2018, 3, 25, 12, 0).InUtc()); + yield return Case( + (z, d) => z + d, ZonedBeforeDst, Duration.FromHours(36), Instant.FromUtc(2018, 3, 26, 0, 0).InUtc()); + yield return Case( + (z, d) => z - d, Zoned, Duration.FromDays(30), + (Instant.FromUtc(2018, 3, 21, 10, 31, 33) + Duration.FromMilliseconds(666)).InUtc()); + } + + [ConditionalTheory] + [MemberData(nameof(ZonedDateTimeCases))] + public Task ZonedDateTime_matches_NodaTime(LambdaExpression expression, object?[] arguments, object? expected) + => AssertCorrect(expression, arguments, expected); + + #endregion ZonedDateTime + + #region TimeSpan and Duration + + // TimeSpan and NodaTime's Duration are both an exact amount of time mapped to PostgreSQL interval, with the same arithmetic and the + // same component/total members (with the same semantics), so the exact same cases are generated for both. + // Each value with its expected total and components, which are truncated towards zero and all have the value's sign + private static readonly (TimeSpan Value, IntervalParts Expected)[] IntervalValues = + [ + (TimeSpan.Zero, new(0, 0, 0, 0, 0, 0)), + (new(1, 2, 3, 4, 500), new(93_784_500_000, 1, 2, 3, 4, 500)), + (-new TimeSpan(1, 2, 3, 4, 500), new(-93_784_500_000, -1, -2, -3, -4, -500)), + (TimeSpan.FromHours(36), new(129_600_000_000, 1, 12, 0, 0, 0)), + (TimeSpan.FromHours(-26), new(-93_600_000_000, -1, -2, 0, 0, 0)), + (TimeSpan.FromSeconds(-90), new(-90_000_000, 0, 0, -1, -30, 0)), + (TimeSpan.FromMilliseconds(-1500), new(-1_500_000, 0, 0, 0, -1, -500)), + (TimeSpan.FromMilliseconds(-1500.5), new(-1_500_500, 0, 0, 0, -1, -500)), + (TimeSpan.FromMicroseconds(500), new(500, 0, 0, 0, 0, 0)), + (TimeSpan.FromMicroseconds(-999_999), new(-999_999, 0, 0, 0, 0, -999)), + (new(1000, 23, 59, 59, 999, 999), new(86_486_399_999_999, 1000, 23, 59, 59, 999)) + ]; + + // Pairs of values which are added and subtracted in SQL, with the expected results. + // PostgreSQL adds and subtracts an interval's day and time fields separately and Npgsql writes a + // TimeSpan/Duration with the same sign in both fields and less than a day in the time field. + // So the result's fields can have opposite signs, or a time field of a day or more, + // in which case the components must be read from justify_hours() (JustifyRequired). + private static readonly IntervalPair[] IntervalPairs = + [ + // Fields with the same sign, and a time field of less than a day + new(TimeSpan.FromHours(1), TimeSpan.FromMinutes(30), + Sum: new(5_400_000_000, 0, 1, 30, 0, 0), SumJustifyRequired: false, + Difference: new(1_800_000_000, 0, 0, 30, 0, 0), DifferenceJustifyRequired: false), + new(TimeSpan.FromDays(-1), TimeSpan.FromHours(-1), + Sum: new(-90_000_000_000, -1, -1, 0, 0, 0), SumJustifyRequired: false, + // -1 day +01:00:00 + Difference: new(-82_800_000_000, 0, -23, 0, 0, 0), DifferenceJustifyRequired: true), + + // Fields with opposite signs + new(TimeSpan.FromDays(1), TimeSpan.FromHours(-1), + // 1 day -01:00:00 + Sum: new(82_800_000_000, 0, 23, 0, 0, 0), SumJustifyRequired: true, + Difference: new(90_000_000_000, 1, 1, 0, 0, 0), DifferenceJustifyRequired: false), + new(TimeSpan.FromDays(-1), TimeSpan.FromMilliseconds(2500.5), + // -1 day +00:00:02.5005 + Sum: new(-86_397_499_500, 0, -23, -59, -57, -499), SumJustifyRequired: true, + Difference: new(-86_402_500_500, -1, 0, 0, -2, -500), DifferenceJustifyRequired: false), + new(TimeSpan.FromDays(1), TimeSpan.FromMilliseconds(1500), + Sum: new(86_401_500_000, 1, 0, 0, 1, 500), SumJustifyRequired: false, + // 1 day -00:00:01.5 + Difference: new(86_398_500_000, 0, 23, 59, 58, 500), DifferenceJustifyRequired: true), + new(TimeSpan.FromDays(1), TimeSpan.FromHours(25), + // 2 days 01:00:00 + Sum: new(176_400_000_000, 2, 1, 0, 0, 0), SumJustifyRequired: false, + // -01:00:00 (0 days) + Difference: new(-3_600_000_000, 0, -1, 0, 0, 0), DifferenceJustifyRequired: false), + + // A time field of a day or more + new(TimeSpan.FromHours(20), TimeSpan.FromHours(20), + // 40:00:00 + Sum: new(144_000_000_000, 1, 16, 0, 0, 0), SumJustifyRequired: true, + Difference: new(0, 0, 0, 0, 0, 0), DifferenceJustifyRequired: false), + new(TimeSpan.FromHours(-20), TimeSpan.FromHours(20), + Sum: new(0, 0, 0, 0, 0, 0), SumJustifyRequired: false, + // -40:00:00 + Difference: new(-144_000_000_000, -1, -16, 0, 0, 0), DifferenceJustifyRequired: true) + ]; + + private static readonly string[] IntervalMembers = + [ + nameof(TimeSpan.Days), nameof(TimeSpan.Hours), nameof(TimeSpan.Minutes), nameof(TimeSpan.Seconds), + nameof(TimeSpan.Milliseconds), nameof(TimeSpan.TotalDays), nameof(TimeSpan.TotalHours), nameof(TimeSpan.TotalMinutes), + nameof(TimeSpan.TotalSeconds), nameof(TimeSpan.TotalMilliseconds) + ]; + + // TimeSpan and NodaTime's Duration are both an exact amount of time mapped to PostgreSQL interval, with the same arithmetic + // and the same component/total members (with the same semantics), so the exact same cases are generated for both. + public static IEnumerable> IntervalCases(Type type) + { + var a = Expression.Parameter(type, "a"); + var b = Expression.Parameter(type, "b"); + + foreach (var (value, expected) in IntervalValues) + { + yield return Row(Expression.Lambda(Expression.Negate(a), a), [Convert(value)], Convert(expected.Negate().ToTimeSpan())); + + foreach (var member in IntervalMembers) + { + yield return Row(Expression.Lambda(Expression.Property(a, member), a), [Convert(value)], expected.Member(member)); + } + } + + foreach (var pair in IntervalPairs) + { + object?[] arguments = [Convert(pair.Left), Convert(pair.Right)]; + + yield return Row(Expression.Lambda(Expression.Add(a, b), a, b), arguments, Convert(pair.Sum.ToTimeSpan())); + yield return Row(Expression.Lambda(Expression.Subtract(a, b), a, b), arguments, Convert(pair.Difference.ToTimeSpan())); + yield return Row(Expression.Lambda(Expression.Equal(a, b), a, b), [Convert(pair.Left), Convert(pair.Left)], true); + yield return Row(Expression.Lambda(Expression.Equal(a, b), a, b), arguments, pair.Left == pair.Right); + + foreach (var member in IntervalMembers) + { + yield return Row( + Expression.Lambda(Expression.Property(Expression.Add(a, b), member), a, b), arguments, pair.Sum.Member(member)); + yield return Row( + Expression.Lambda(Expression.Property(Expression.Subtract(a, b), member), a, b), arguments, + pair.Difference.Member(member)); + } + } + + yield break; + + object Convert(TimeSpan value) + => type == typeof(Duration) ? Duration.FromTimeSpan(value) : value; + } + + [ConditionalTheory] + [MemberData(nameof(IntervalCases), typeof(TimeSpan))] + public Task TimeSpan_matches_NET(LambdaExpression expression, object?[] arguments, object? expected) + => AssertCorrect(expression, arguments, expected); + + [ConditionalTheory] + [MemberData(nameof(IntervalCases), typeof(Duration))] + public Task Duration_matches_NodaTime(LambdaExpression expression, object?[] arguments, object? expected) + => AssertCorrect(expression, arguments, expected); + + public static IEnumerable> IntervalPairCases() + => IntervalPairs.SelectMany(p => new TheoryDataRow[] + { + new(p.Left, p.Right, false, p.Sum, p.SumJustifyRequired) { TestDisplayName = $"{p.Left} + {p.Right}" }, + new(p.Left, p.Right, true, p.Difference, p.DifferenceJustifyRequired) { TestDisplayName = $"{p.Left} - {p.Right}" } + }); + + /// + /// Checks that the components of an interval computed in SQL can only be read from justify_hours() when its fields have opposite + /// signs or its time field is a day or more, i.e. that the justify_hours() in the translation is required, + /// and that the pairs above cover both cases. + /// + [ConditionalTheory] + [MemberData(nameof(IntervalPairCases))] + public async Task Interval_components_require_justify_hours( + TimeSpan left, + TimeSpan right, + bool subtract, + IntervalParts expected, + bool justifyRequired) + { + await using var context = Fixture.CreateContext(); + + var expectedFields = new IntervalFields + { + Days = expected.Days, + Hours = expected.Hours, + Minutes = expected.Minutes, + Seconds = expected.Seconds, + Milliseconds = expected.Milliseconds + }; + + Assert.Equal(expectedFields, await ReadComponents("justify_hours(v)")); + + var withoutJustifyHours = await ReadComponents("v"); + Assert.True( + justifyRequired != (withoutJustifyHours == expectedFields), + justifyRequired + ? $"Expected the components without justify_hours() to be wrong, but they were {withoutJustifyHours}" + : $"Expected {expectedFields} without justify_hours(), but got {withoutJustifyHours}"); + + return; + + Task ReadComponents(string interval) + { + // Only fixed SQL fragments are interpolated; the values are parameters + var sql = $""" + SELECT + date_part('day', {interval})::int AS "Days", + date_part('hour', {interval})::int AS "Hours", + date_part('minute', {interval})::int AS "Minutes", + trunc(date_part('second', {interval}))::int AS "Seconds", + trunc(date_part('millisecond', {interval}))::int % 1000 AS "Milliseconds" + FROM (SELECT @left {(subtract ? "-" : "+")} @right AS v) AS i + """; + + return context.Database + .SqlQueryRaw(sql, new NpgsqlParameter("left", left), new NpgsqlParameter("right", right)) + .SingleAsync(); + } + } + + public sealed record IntervalParts(long Microseconds, int Days, int Hours, int Minutes, int Seconds, int Milliseconds) + { + public TimeSpan ToTimeSpan() + => TimeSpan.FromTicks(Microseconds * TimeSpan.TicksPerMicrosecond); + + public IntervalParts Negate() + => new(-Microseconds, -Days, -Hours, -Minutes, -Seconds, -Milliseconds); + + // The components are int and the totals double (without the object cast, a switch expression would make them all double) + public object Member(string name) + => name switch + { + nameof(TimeSpan.Days) => (object)Days, + nameof(TimeSpan.Hours) => Hours, + nameof(TimeSpan.Minutes) => Minutes, + nameof(TimeSpan.Seconds) => Seconds, + nameof(TimeSpan.Milliseconds) => Milliseconds, + nameof(TimeSpan.TotalDays) => Microseconds / 86_400_000_000.0, + nameof(TimeSpan.TotalHours) => Microseconds / 3_600_000_000.0, + nameof(TimeSpan.TotalMinutes) => Microseconds / 60_000_000.0, + nameof(TimeSpan.TotalSeconds) => Microseconds / 1_000_000.0, + nameof(TimeSpan.TotalMilliseconds) => Microseconds / 1_000.0, + _ => throw new ArgumentOutOfRangeException(nameof(name), name, null) + }; + } + + private sealed record IntervalPair( + TimeSpan Left, + TimeSpan Right, + IntervalParts Sum, + bool SumJustifyRequired, + IntervalParts Difference, + bool DifferenceJustifyRequired); + + private sealed record IntervalFields + { + public int Days { get; init; } + public int Hours { get; init; } + public int Minutes { get; init; } + public int Seconds { get; init; } + public int Milliseconds { get; init; } + } + + #endregion TimeSpan and Duration + + #region BCL date/time types + + // The same discrepancies apply to the BCL date/time types; they're covered here since this fixture's session time zone has daylight + // saving time. DateTime/DateTimeOffset values are mapped to timestamptz, so they're UTC. + public static IEnumerable> BclCases() + { + var utcBeforeDst = new DateTime(2018, 3, 24, 12, 0, 0, DateTimeKind.Utc); + var offsetBeforeDst = new DateTimeOffset(2018, 3, 24, 12, 0, 0, TimeSpan.Zero); + var monthEndBeforeDst = new DateTimeOffset(2018, 2, 28, 23, 30, 0, TimeSpan.Zero); + + // Arithmetic on timestamptz + yield return Case((DateTime d, double n) => d.AddDays(n), utcBeforeDst, 1, Utc(2018, 3, 25, 12)); + yield return Case( + (d, n) => d.AddMonths(n), new DateTime(2018, 2, 24, 12, 0, 0, DateTimeKind.Utc), 1, Utc(2018, 3, 24, 12)); + yield return Case((DateTime d, double n) => d.AddHours(n), utcBeforeDst, 24, Utc(2018, 3, 25, 12)); + yield return Case((d, t) => d + t, utcBeforeDst, TimeSpan.FromDays(1), Utc(2018, 3, 25, 12)); + yield return Case( + (d, t) => d - t, new DateTime(2018, 3, 26, 12, 0, 0, DateTimeKind.Utc), TimeSpan.FromHours(36), + Utc(2018, 3, 25, 0)); + yield return Case((d, t) => d + t, utcBeforeDst, TimeSpan.FromHours(-26), Utc(2018, 3, 23, 10)); + yield return Case( + (DateTimeOffset d, double n) => d.AddDays(n), offsetBeforeDst, 1, new DateTimeOffset(2018, 3, 25, 12, 0, 0, TimeSpan.Zero)); + yield return Case( + (d, n) => d.AddMonths(n), monthEndBeforeDst, 1, new DateTimeOffset(2018, 3, 28, 23, 30, 0, TimeSpan.Zero)); + yield return Case( + (d, t) => d + t, offsetBeforeDst, TimeSpan.FromDays(1), + new DateTimeOffset(2018, 3, 25, 12, 0, 0, TimeSpan.Zero)); + + // TimeSpan arithmetic and members are covered by IntervalCases, along with Duration's + yield break; + + static DateTime Utc(int year, int month, int day, int hour) + => new(year, month, day, hour, 0, 0, DateTimeKind.Utc); + } + + [ConditionalTheory] + [MemberData(nameof(BclCases))] + public Task Bcl_matches_NET(LambdaExpression expression, object?[] arguments, object? expected) + => AssertCorrect(expression, arguments, expected); + + #endregion BCL date/time types + + #region Support + + private static TheoryDataRow Case( + Expression> expression, + T argument, + TResult expected) + => Row(expression, [argument], expected); + + private static TheoryDataRow Case( + Expression> expression, + T1 argument1, + T2 argument2, + TResult expected) + => Row(expression, [argument1, argument2], expected); + + private static TheoryDataRow Row( + LambdaExpression expression, + object?[] arguments, + object? expected) + => new(expression, arguments, expected) { TestDisplayName = $"{Describe(expression, arguments)} = {expected}" }; + + private static string Describe(LambdaExpression expression, object?[] arguments) + => $"{expression.Body} with " + string.Join(", ", expression.Parameters.Select((p, i) => $"{p.Name}={arguments[i]}")); + + private Task AssertCorrect(LambdaExpression expression, object?[] arguments, object? expected) + => (Task)AssertCorrectMethod.MakeGenericMethod(expression.ReturnType).Invoke(this, [expression, arguments, expected])!; + + private static readonly MethodInfo AssertCorrectMethod + = typeof(NodaTimeParityTest).GetMethod(nameof(AssertCorrectCore), BindingFlags.NonPublic | BindingFlags.Instance)!; + + private async Task AssertCorrectCore(LambdaExpression expression, object?[] arguments, object? expectedObject) + { + if (expectedObject is not null) + { + Assert.IsType(expectedObject); + } + + var expected = (TResult)expectedObject!; + + // The case's expected result must be NodaTime's/.NET's own result + var client = (TResult)expression.Compile().DynamicInvoke(arguments)!; + Assert.True( + IsEqual(expression, expected, client), + $"{Describe(expression, arguments)}: expected {expected}, .NET returned {client}"); + + await using var context = Fixture.CreateContext(); + + foreach (var argumentMethod in new[] { EfParameterMethod, EfConstantMethod }) + { + Fixture.TestSqlLoggerFactory.Clear(); + var actual = await QueryDatabase(context, ToServerSelector(expression, arguments, argumentMethod)); + + AssertEvaluatedInDatabase(argumentMethod == EfParameterMethod ? arguments.Length : 0); + Assert.True( + IsEqual(expression, expected, actual), + $"{Describe(expression, arguments)} (as {argumentMethod.Name}): expected {expected}, PostgreSQL returned {actual}"); + } + } + + private static Task QueryDatabase(NodaTimeContext context, Expression> selector) + => context.Set().Select(selector).SingleAsync(); + + /// + /// EF evaluates an untranslated final projection on the client (e.g. SELECT @p, with the method applied to the parameter's + /// value when reading it), which would make a case pass without testing the database. + /// rejects such queries. + /// + [ConditionalFact] + public async Task Client_evaluation_is_rejected() + { + await using var context = Fixture.CreateContext(); + + foreach (var argumentMethod in new[] { EfParameterMethod, EfConstantMethod }) + { + var selector = ToServerSelector((Duration d) => ClientOnly(d), [Duration.FromDays(1)], argumentMethod); + + var exception = await Assert.ThrowsAsync(() => QueryDatabase(context, selector)); + Assert.Contains("evaluated on the client", exception.Message); + } + } + + private static long ClientOnly(Duration duration) + => duration.BclCompatibleTicks; + + private static bool IsEqual(LambdaExpression expression, TResult expected, TResult actual) + => EqualityComparer.Default.Equals(expected, actual) || IsWithinTotalMemberTolerance(expression, expected, actual); + + /// + /// Known discrepancies are skipped, but their expected result must still be NodaTime's/.NET's own result. + /// + [ConditionalFact] + public void Known_discrepancies_expect_the_NodaTime_result() + { + var rows = typeof(NodaTimeParityTest).GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(m => m.ReturnType == typeof(IEnumerable>)) + .SelectMany(m => m.GetParameters() is [{ ParameterType: var parameterType }] && parameterType == typeof(Type) + ? new object?[][] { [typeof(TimeSpan)], [typeof(Duration)] }.Select(a => m.Invoke(null, a)) + : [m.Invoke(null, [])]) + .SelectMany(r => (IEnumerable>)r!) + .Where(r => r.Skip is not null); + + foreach (var row in rows) + { + var (expression, arguments, expected) = row.Data; + var client = expression.Compile().DynamicInvoke(arguments); + + Assert.True( + Equals(expected, client) || IsWithinTotalMemberTolerance(expression, expected, client), + $"{Describe(expression, arguments)}: expected {expected}, .NET returned {client}"); + } + } + + /// + /// TimeSpan/Duration's TotalDays...TotalMilliseconds are floating-point, and can differ from the database's result in the last digits. + /// The database computes date_part('epoch', x) / n, rounding twice; TimeSpan computes ticks / n and + /// NodaTime's Duration computes days * unitsPerDay + nanoOfDay / n, whose error is relative to days * unitsPerDay. + /// So these are compared within 1e-14 * (|expected| + unitsPerDay), which still catches any actual mistranslation (e.g. wrong units or sign). + /// + private static bool IsWithinTotalMemberTolerance(LambdaExpression expression, object? expected, object? actual) + { + if (expected is not double expectedDouble + || actual is not double actualDouble + || expression.Body is not MemberExpression { Member.Name: var memberName }) + { + return false; + } + + double? unitsPerDay = memberName switch + { + nameof(TimeSpan.TotalDays) => 1, + nameof(TimeSpan.TotalHours) => 24, + nameof(TimeSpan.TotalMinutes) => 24 * 60, + nameof(TimeSpan.TotalSeconds) => 24 * 60 * 60, + nameof(TimeSpan.TotalMilliseconds) => 24 * 60 * 60 * 1000, + _ => null + }; + + return unitsPerDay is not null + && Math.Abs(expectedDouble - actualDouble) <= 1e-14 * (Math.Abs(expectedDouble) + unitsPerDay.Value); + } + + /// + /// Rewrites (a, b) => a.Plus(b) to t => EF.Parameter(a).Plus(EF.Parameter(b)) (or ), + /// so that the database evaluates the whole expression. + /// + private static Expression> ToServerSelector( + LambdaExpression expression, + object?[] arguments, + MethodInfo argumentMethod) + { + var body = expression.Body; + for (var i = 0; i < expression.Parameters.Count; i++) + { + var parameter = expression.Parameters[i]; + var holder = Activator.CreateInstance(typeof(ValueHolder<>).MakeGenericType(parameter.Type), arguments[i])!; + var value = Expression.Call( + argumentMethod.MakeGenericMethod(parameter.Type), + Expression.Field(Expression.Constant(holder), nameof(ValueHolder<>.Value))); + + body = ReplacingExpressionVisitor.Replace(parameter, value, body); + } + + return Expression.Lambda>(body, Expression.Parameter(typeof(NodaTimeTypes), "t")); + } + + /// + /// Guards against accidental client-side evaluation: each argument must have been sent as its own parameter, and the projection + /// must reference all of them (rather than e.g. a single pre-computed parameter). Note that EF can't evaluate a subtree containing + /// on the client, so it would fail rather than silently pre-compute it. + /// + private void AssertEvaluatedInDatabase(int argumentCount) + { + var sql = Assert.Single(Fixture.TestSqlLoggerFactory.SqlStatements); + + var parameterNames = Regex.Matches(sql, @"^(@\w+)=", RegexOptions.Multiline).Select(m => m.Groups[1].Value).ToList(); + Assert.Equal(argumentCount, parameterNames.Count); + + var projection = Regex.Match(sql, @"^SELECT (.*?)\s+FROM ", RegexOptions.Multiline | RegexOptions.Singleline).Groups[1].Value; + Assert.All(parameterNames, p => Assert.Matches(Regex.Escape(p) + @"\b", projection)); + } + + private static readonly MethodInfo EfParameterMethod = typeof(EF).GetMethod(nameof(EF.Parameter))!; + private static readonly MethodInfo EfConstantMethod = typeof(EF).GetMethod(nameof(EF.Constant))!; + + private sealed class ValueHolder(T value) + { + public readonly T Value = value; + } + + #endregion Support +} + +internal static class NodaTimeParityTestExtensions +{ + /// + /// Marks a case as a known discrepancy between PostgreSQL and NodaTime. + /// + public static TheoryDataRow Skip( + this TheoryDataRow row, + string reason) + { + row.Skip = reason; + return row; + } +} diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/ZonedDateTimeTranslationsTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/ZonedDateTimeTranslationsTest.cs index 5d96330f25..6012a9ddc4 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/ZonedDateTimeTranslationsTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/ZonedDateTimeTranslationsTest.cs @@ -24,7 +24,7 @@ await AssertQuery( """ SELECT n."Id", n."DateInterval", n."Duration", n."Instant", n."InstantRange", n."Interval", n."LocalDate", n."LocalDate2", n."LocalDateRange", n."LocalDateTime", n."LocalTime", n."Long", n."OffsetTime", n."Period", n."TimeZoneId", n."ZonedDateTime" FROM "NodaTimeTypes" AS n -WHERE (n."ZonedDateTime" + INTERVAL '1 00:00:00') - n."ZonedDateTime" = INTERVAL '1 00:00:00' +WHERE (((n."ZonedDateTime" AT TIME ZONE 'UTC') + INTERVAL '1 00:00:00') AT TIME ZONE 'UTC') - n."ZonedDateTime" = INTERVAL '1 00:00:00' """); } diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsNpgsqlTest.cs index b6736b4c56..7eb1ec9a4e 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsNpgsqlTest.cs @@ -91,7 +91,7 @@ public override async Task AddYears() """ SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE b."DateOnly" + INTERVAL '3 years' = DATE '1993-11-10' +WHERE CAST(b."DateOnly" + INTERVAL '3 years' AS date) = DATE '1993-11-10' """); } @@ -103,7 +103,7 @@ public override async Task AddMonths() """ SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE b."DateOnly" + INTERVAL '3 months' = DATE '1991-02-10' +WHERE CAST(b."DateOnly" + INTERVAL '3 months' AS date) = DATE '1991-02-10' """); } @@ -132,7 +132,7 @@ public virtual async Task Add_with_nullable_argument(int id, int? amount) $""" @id='{id}' -SELECT date_part('year', n."DateOnly" + CAST((n."Int"::text || ' years') AS interval))::int AS "Years", date_part('month', n."DateOnly" + CAST((n."Int"::text || ' months') AS interval))::int AS "Months" +SELECT date_part('year', CAST(n."DateOnly" + CAST((n."Int"::text || ' years') AS interval) AS date))::int AS "Years", date_part('month', CAST(n."DateOnly" + CAST((n."Int"::text || ' months') AS interval) AS date))::int AS "Months" FROM "NullableBasicTypesEntities" AS n WHERE n."Id" = @id LIMIT 2 @@ -247,7 +247,7 @@ public override async Task ToDateTime_with_complex_DateTime() """ SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE b."DateOnly" + INTERVAL '1 years' + b."TimeOnly" = TIMESTAMP '2021-01-01T15:30:10' +WHERE CAST(b."DateOnly" + INTERVAL '1 years' AS date) + b."TimeOnly" = TIMESTAMP '2021-01-01T15:30:10' """); } diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsNpgsqlTest.cs index 3087e1be66..cc2f3eb906 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsNpgsqlTest.cs @@ -190,7 +190,7 @@ public override async Task AddYears() AssertSql( """ -SELECT b."DateTimeOffset" + INTERVAL '1 years' +SELECT ((b."DateTimeOffset" AT TIME ZONE 'UTC') + INTERVAL '1 years') AT TIME ZONE 'UTC' FROM "BasicTypesEntities" AS b """); } @@ -201,7 +201,7 @@ public override async Task AddMonths() AssertSql( """ -SELECT b."DateTimeOffset" + INTERVAL '1 months' +SELECT ((b."DateTimeOffset" AT TIME ZONE 'UTC') + INTERVAL '1 months') AT TIME ZONE 'UTC' FROM "BasicTypesEntities" AS b """); } @@ -212,7 +212,7 @@ public override async Task AddDays() AssertSql( """ -SELECT b."DateTimeOffset" + INTERVAL '1 days' +SELECT ((b."DateTimeOffset" AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' FROM "BasicTypesEntities" AS b """); } @@ -223,7 +223,7 @@ public override async Task AddHours() AssertSql( """ -SELECT b."DateTimeOffset" + INTERVAL '1 hours' +SELECT ((b."DateTimeOffset" AT TIME ZONE 'UTC') + INTERVAL '1 hours') AT TIME ZONE 'UTC' FROM "BasicTypesEntities" AS b """); } @@ -234,7 +234,7 @@ public override async Task AddMinutes() AssertSql( """ -SELECT b."DateTimeOffset" + INTERVAL '1 mins' +SELECT ((b."DateTimeOffset" AT TIME ZONE 'UTC') + INTERVAL '1 mins') AT TIME ZONE 'UTC' FROM "BasicTypesEntities" AS b """); } @@ -245,7 +245,7 @@ public override async Task AddSeconds() AssertSql( """ -SELECT b."DateTimeOffset" + INTERVAL '1 secs' +SELECT ((b."DateTimeOffset" AT TIME ZONE 'UTC') + INTERVAL '1 secs') AT TIME ZONE 'UTC' FROM "BasicTypesEntities" AS b """); } @@ -256,7 +256,7 @@ public override async Task AddMilliseconds() AssertSql( """ -SELECT b."DateTimeOffset" + 300.0 * INTERVAL '1 milliseconds' +SELECT ((b."DateTimeOffset" AT TIME ZONE 'UTC') + 300.0 * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' FROM "BasicTypesEntities" AS b """); } @@ -269,7 +269,7 @@ await AssertQuery( AssertSql( """ -SELECT b."DateTimeOffset" + -1500.0 * INTERVAL '1 microseconds' +SELECT ((b."DateTimeOffset" AT TIME ZONE 'UTC') + -1500.0 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' FROM "BasicTypesEntities" AS b """); } @@ -289,7 +289,7 @@ await AssertQuery( """ SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE b."Id" = 1 AND b."DateTimeOffset" + 2592000000.0 * INTERVAL '1 milliseconds' = b."DateTimeOffset" + INTERVAL '720 hours' AND b."DateTimeOffset" + -2592000000.0 * INTERVAL '1 milliseconds' = b."DateTimeOffset" + INTERVAL '-720 hours' AND b."DateTimeOffset" + 3600000000.0 * INTERVAL '1 microseconds' = b."DateTimeOffset" + INTERVAL '1 hours' AND b."DateTimeOffset" + -3600000000.0 * INTERVAL '1 microseconds' = b."DateTimeOffset" + INTERVAL '-1 hours' +WHERE b."Id" = 1 AND ((b."DateTimeOffset" AT TIME ZONE 'UTC') + 2592000000.0 * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' = ((b."DateTimeOffset" AT TIME ZONE 'UTC') + INTERVAL '720 hours') AT TIME ZONE 'UTC' AND ((b."DateTimeOffset" AT TIME ZONE 'UTC') + -2592000000.0 * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' = ((b."DateTimeOffset" AT TIME ZONE 'UTC') + INTERVAL '-720 hours') AT TIME ZONE 'UTC' AND ((b."DateTimeOffset" AT TIME ZONE 'UTC') + 3600000000.0 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' = ((b."DateTimeOffset" AT TIME ZONE 'UTC') + INTERVAL '1 hours') AT TIME ZONE 'UTC' AND ((b."DateTimeOffset" AT TIME ZONE 'UTC') + -3600000000.0 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' = ((b."DateTimeOffset" AT TIME ZONE 'UTC') + INTERVAL '-1 hours') AT TIME ZONE 'UTC' """); } diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsNpgsqlTest.cs index 85742bd94b..9c594b5a32 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsNpgsqlTest.cs @@ -79,7 +79,7 @@ public override async Task AddYear() """ SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE date_part('year', (b."DateTime" + INTERVAL '1 years') AT TIME ZONE 'UTC')::int = 1999 +WHERE date_part('year', ((b."DateTime" AT TIME ZONE 'UTC') + INTERVAL '1 years'))::int = 1999 """); } @@ -98,7 +98,7 @@ await AssertQuery( AssertSql( """ -SELECT b."DateTime" + 1.5 * INTERVAL '1 milliseconds' AS "Milliseconds", b."DateTime" + -0.125 * INTERVAL '1 milliseconds' AS "NegativeMilliseconds", b."DateTime" + 1500.0 * INTERVAL '1 microseconds' AS "Microseconds", b."DateTime" + -1.0 * INTERVAL '1 microseconds' AS "NegativeMicroseconds" +SELECT ((b."DateTime" AT TIME ZONE 'UTC') + 1.5 * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' AS "Milliseconds", ((b."DateTime" AT TIME ZONE 'UTC') + -0.125 * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' AS "NegativeMilliseconds", ((b."DateTime" AT TIME ZONE 'UTC') + 1500.0 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' AS "Microseconds", ((b."DateTime" AT TIME ZONE 'UTC') + -1.0 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' AS "NegativeMicroseconds" FROM "BasicTypesEntities" AS b ORDER BY b."Id" NULLS FIRST """); @@ -119,7 +119,7 @@ await AssertQuery( """ SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE b."Id" = 1 AND b."DateTime" + 2592000000.0 * INTERVAL '1 milliseconds' = b."DateTime" + INTERVAL '720 hours' AND b."DateTime" + -2592000000.0 * INTERVAL '1 milliseconds' = b."DateTime" + INTERVAL '-720 hours' AND b."DateTime" + 3600000000.0 * INTERVAL '1 microseconds' = b."DateTime" + INTERVAL '1 hours' AND b."DateTime" + -3600000000.0 * INTERVAL '1 microseconds' = b."DateTime" + INTERVAL '-1 hours' +WHERE b."Id" = 1 AND ((b."DateTime" AT TIME ZONE 'UTC') + 2592000000.0 * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' = ((b."DateTime" AT TIME ZONE 'UTC') + INTERVAL '720 hours') AT TIME ZONE 'UTC' AND ((b."DateTime" AT TIME ZONE 'UTC') + -2592000000.0 * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' = ((b."DateTime" AT TIME ZONE 'UTC') + INTERVAL '-720 hours') AT TIME ZONE 'UTC' AND ((b."DateTime" AT TIME ZONE 'UTC') + 3600000000.0 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' = ((b."DateTime" AT TIME ZONE 'UTC') + INTERVAL '1 hours') AT TIME ZONE 'UTC' AND ((b."DateTime" AT TIME ZONE 'UTC') + -3600000000.0 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' = ((b."DateTime" AT TIME ZONE 'UTC') + INTERVAL '-1 hours') AT TIME ZONE 'UTC' """); } @@ -141,7 +141,7 @@ await AssertQuery( SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE b."DateTime" + @value * INTERVAL '1 milliseconds' = b."DateTime" + @microseconds * INTERVAL '1 microseconds' +WHERE ((b."DateTime" AT TIME ZONE 'UTC') + @value * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' = ((b."DateTime" AT TIME ZONE 'UTC') + @microseconds * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' """); } @@ -158,7 +158,7 @@ await AssertQuery( AssertSql( """ -SELECT b."DateTime" + 1.0000000000000001E-05 * INTERVAL '1 milliseconds' AS "Milliseconds", b."DateTime" + 1.0000000000000001E-05 * INTERVAL '1 microseconds' AS "Microseconds", b."DateTime" + 1E+17 * INTERVAL '1 microseconds' AS "LargeMicroseconds" +SELECT ((b."DateTime" AT TIME ZONE 'UTC') + 1.0000000000000001E-05 * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' AS "Milliseconds", ((b."DateTime" AT TIME ZONE 'UTC') + 1.0000000000000001E-05 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' AS "Microseconds", ((b."DateTime" AT TIME ZONE 'UTC') + 1E+17 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' AS "LargeMicroseconds" FROM "BasicTypesEntities" AS b WHERE b."Id" = 1 """); @@ -183,7 +183,7 @@ await AssertQuery( @milliseconds='{milliseconds}' @microseconds='{microseconds}' -SELECT b."DateTime" + @milliseconds * INTERVAL '1 milliseconds' AS "Milliseconds", b."DateTime" + @microseconds * INTERVAL '1 microseconds' AS "Microseconds" +SELECT ((b."DateTime" AT TIME ZONE 'UTC') + @milliseconds * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' AS "Milliseconds", ((b."DateTime" AT TIME ZONE 'UTC') + @microseconds * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' AS "Microseconds" FROM "BasicTypesEntities" AS b WHERE b."Id" = 1 """); @@ -216,7 +216,7 @@ public virtual async Task Add_subsecond_with_nullable_argument(int id, int? valu @date='2000-01-01T00:00:00.0000000Z' (DbType = DateTime) @id='{id}' -SELECT @date + n."Int"::double precision * INTERVAL '1 milliseconds' AS "Milliseconds", @date + n."Int"::double precision * INTERVAL '1 microseconds' AS "Microseconds" +SELECT ((@date AT TIME ZONE 'UTC') + n."Int"::double precision * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' AS "Milliseconds", ((@date AT TIME ZONE 'UTC') + n."Int"::double precision * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' AS "Microseconds" FROM "NullableBasicTypesEntities" AS n WHERE n."Id" = @id LIMIT 2 @@ -246,7 +246,7 @@ await AssertQuery( AssertSql( """ -SELECT b."DateTime" + 0.00075000000000000002 * INTERVAL '1 milliseconds' AS "Milliseconds", b."DateTime" + -0.00025000000000000001 * INTERVAL '1 milliseconds' AS "NegativeMilliseconds", b."DateTime" + 0.25 * INTERVAL '1 microseconds' AS "Microseconds", b."DateTime" + -0.75 * INTERVAL '1 microseconds' AS "NegativeMicroseconds" +SELECT ((b."DateTime" AT TIME ZONE 'UTC') + 0.00075000000000000002 * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' AS "Milliseconds", ((b."DateTime" AT TIME ZONE 'UTC') + -0.00025000000000000001 * INTERVAL '1 milliseconds') AT TIME ZONE 'UTC' AS "NegativeMilliseconds", ((b."DateTime" AT TIME ZONE 'UTC') + 0.25 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' AS "Microseconds", ((b."DateTime" AT TIME ZONE 'UTC') + -0.75 * INTERVAL '1 microseconds') AT TIME ZONE 'UTC' AS "NegativeMicroseconds" FROM "BasicTypesEntities" AS b ORDER BY b."Id" NULLS FIRST """); @@ -274,7 +274,7 @@ public virtual async Task AddDays_with_nullable_argument(int id, int? days) @date='2000-01-01T00:00:00.0000000Z' (DbType = DateTime) @id='{id}' -SELECT @date + CAST((n."Int"::double precision::text || ' days') AS interval) +SELECT ((@date AT TIME ZONE 'UTC') + CAST((n."Int"::double precision::text || ' days') AS interval)) AT TIME ZONE 'UTC' FROM "NullableBasicTypesEntities" AS n WHERE n."Id" = @id LIMIT 2 @@ -405,7 +405,7 @@ public virtual async Task Millisecond_truncates_fractional_milliseconds(int micr $""" @fraction='{fraction:c}' (DbType = Object) -SELECT floor(date_part('milliseconds', (b."DateTime" + @fraction) AT TIME ZONE 'UTC'))::int % 1000 AS "DateTime", floor(date_part('milliseconds', (b."DateTimeOffset" + @fraction) AT TIME ZONE 'UTC'))::int % 1000 AS "DateTimeOffset", floor(date_part('milliseconds', b."TimeOnly" + @fraction))::int % 1000 AS "TimeOnly" +SELECT floor(date_part('milliseconds', ((b."DateTime" AT TIME ZONE 'UTC') + @fraction)))::int % 1000 AS "DateTime", floor(date_part('milliseconds', ((b."DateTimeOffset" AT TIME ZONE 'UTC') + @fraction)))::int % 1000 AS "DateTimeOffset", floor(date_part('milliseconds', b."TimeOnly" + @fraction))::int % 1000 AS "TimeOnly" FROM "BasicTypesEntities" AS b WHERE b."Id" = 1 LIMIT 2 diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsNpgsqlTest.cs index c32ecd9369..19deff4324 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsNpgsqlTest.cs @@ -17,7 +17,7 @@ public override async Task Hours() """ SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE floor(date_part('hour', b."TimeSpan"))::int = 3 +WHERE date_part('hour', justify_hours(b."TimeSpan"))::int = 3 """); } @@ -29,7 +29,7 @@ public override async Task Minutes() """ SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE floor(date_part('minute', b."TimeSpan"))::int = 4 +WHERE date_part('minute', justify_hours(b."TimeSpan"))::int = 4 """); } @@ -41,7 +41,7 @@ public override async Task Seconds() """ SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE floor(date_part('second', b."TimeSpan"))::int = 5 +WHERE trunc(date_part('second', justify_hours(b."TimeSpan")))::int = 5 """); } @@ -53,7 +53,7 @@ public override async Task Milliseconds() """ SELECT b."Id", b."Bool", b."Byte", b."ByteArray", b."DateOnly", b."DateTime", b."DateTimeOffset", b."Decimal", b."Double", b."Enum", b."FlagsEnum", b."Float", b."Guid", b."Int", b."Long", b."Short", b."String", b."TimeOnly", b."TimeSpan" FROM "BasicTypesEntities" AS b -WHERE floor(date_part('millisecond', b."TimeSpan"))::int % 1000 = 678 +WHERE trunc(date_part('millisecond', justify_hours(b."TimeSpan")))::int % 1000 = 678 """); } diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/TimestampTranslationsTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/TimestampTranslationsTest.cs index 05b2ed7502..6b173f7fc7 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/TimestampTranslationsTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/TimestampTranslationsTest.cs @@ -84,6 +84,315 @@ public async Task Cannot_insert_local_datetime_into_timestamptz() #endregion Basic mapping + #region Arithmetic + + // Arithmetic on timestamptz is performed in UTC, since timestamptz + interval follows the session time zone (see the + // corresponding timestamptz tests); timestamp has no time zone, so its arithmetic must be left as-is. + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Add_TimeSpan_to_timestamp_column(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Select(e => e.TimestampDateTime + TimeSpan.FromHours(36))); + + AssertSql( + """ +SELECT e."TimestampDateTime" + INTERVAL '1 12:00:00' +FROM "Entities" AS e +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Subtract_TimeSpan_from_timestamp_column(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Select(e => e.TimestampDateTime - TimeSpan.FromHours(-26))); + + AssertSql( + """ +SELECT e."TimestampDateTime" - INTERVAL '-1 -02:00:00' +FROM "Entities" AS e +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task AddDays_on_timestamp_column(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Select(e => e.TimestampDateTime.AddDays(1))); + + AssertSql( + """ +SELECT e."TimestampDateTime" + INTERVAL '1 days' +FROM "Entities" AS e +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Add_TimeSpan_to_timestamptz_column(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Select(e => e.TimestamptzDateTime + TimeSpan.FromHours(36))); + + AssertSql( + """ +SELECT ((e."TimestamptzDateTime" AT TIME ZONE 'UTC') + INTERVAL '1 12:00:00') AT TIME ZONE 'UTC' +FROM "Entities" AS e +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Interval_arithmetic_on_DateTime_parameter_infers_timestamp_from_comparison(bool async) + { + await using var ctx = CreateContext(); + + // The parameter's mapping is inferred from the timestamp column on the other side of the comparison + var date = new DateTime(2018, 3, 24, 12, 0, 0, DateTimeKind.Unspecified); + + // .NET has no TimeSpan + DateTime operator, so the TimeSpan is always on the right of the arithmetic; + // the arithmetic is on either side of the comparison. + var query = Arithmetic(ctx).Where( + a => date + a.Interval == a.Later + && date - a.Interval == a.Earlier + && a.Later == date + a.Interval + && a.Earlier == date - a.Interval + && EF.Constant(date) + a.Interval == a.Later + && EF.Constant(date) - a.Interval == a.Earlier + && a.Later == EF.Constant(date) + a.Interval + && a.Earlier == EF.Constant(date) - a.Interval); + + Assert.Equal(1, async ? await query.CountAsync() : query.Count()); + + AssertSql( + """ +@date='2018-03-24T12:00:00.0000000' + +SELECT count(*)::int +FROM ( + SELECT TIMESTAMP '2018-03-23T12:00:00' AS "Earlier", TIMESTAMP '2018-03-25T12:00:00' AS "Later", INTERVAL '1 day' AS "Interval", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDst", TIMESTAMPTZ '2018-03-25T12:00:00Z' AS "AfterDst", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstAlias", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstOffset" +) AS m +WHERE @date + m."Interval" = m."Later" AND @date - m."Interval" = m."Earlier" AND m."Later" = @date + m."Interval" AND m."Earlier" = @date - m."Interval" AND TIMESTAMP '2018-03-24T12:00:00' + m."Interval" = m."Later" AND TIMESTAMP '2018-03-24T12:00:00' - m."Interval" = m."Earlier" AND m."Later" = TIMESTAMP '2018-03-24T12:00:00' + m."Interval" AND m."Earlier" = TIMESTAMP '2018-03-24T12:00:00' - m."Interval" +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Interval_arithmetic_on_DateTime_parameter_infers_timestamptz_from_comparison(bool async) + { + await using var ctx = CreateContext(); + + // The parameter's mapping is inferred from the timestamptz column on the other side of the comparison, after which + // the arithmetic is performed in UTC (Europe/Berlin moves to daylight saving time on 2018-03-25). + var date = new DateTime(2018, 3, 24, 12, 0, 0, DateTimeKind.Utc); + + var query = Arithmetic(ctx).Where( + a => date + a.Interval == a.AfterDst + && a.AfterDst == date + a.Interval + && date + a.Interval - a.Interval == a.BeforeDst + && a.BeforeDst == date + a.Interval - a.Interval); + + Assert.Equal(1, async ? await query.CountAsync() : query.Count()); + + AssertSql( + """ +@date='2018-03-24T12:00:00.0000000Z' (DbType = DateTime) + +SELECT count(*)::int +FROM ( + SELECT TIMESTAMP '2018-03-23T12:00:00' AS "Earlier", TIMESTAMP '2018-03-25T12:00:00' AS "Later", INTERVAL '1 day' AS "Interval", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDst", TIMESTAMPTZ '2018-03-25T12:00:00Z' AS "AfterDst", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstAlias", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstOffset" +) AS m +WHERE ((@date AT TIME ZONE 'UTC') + m."Interval") AT TIME ZONE 'UTC' = m."AfterDst" AND m."AfterDst" = ((@date AT TIME ZONE 'UTC') + m."Interval") AT TIME ZONE 'UTC' AND ((((@date AT TIME ZONE 'UTC') + m."Interval")) - m."Interval") AT TIME ZONE 'UTC' = m."BeforeDst" AND m."BeforeDst" = ((((@date AT TIME ZONE 'UTC') + m."Interval")) - m."Interval") AT TIME ZONE 'UTC' +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task AddDays_with_constant_on_DateTime_parameter(bool async) + { + await using var ctx = CreateContext(); + + // The parameter's mapping is only inferred after AddDays() etc. are translated: from the column on the other side of the + // comparison, or from the default mapping when projected. The arithmetic must then be performed in UTC for timestamptz + // (Europe/Berlin moves to daylight saving time on 2018-03-25), and left as-is for timestamp. + var utc = new DateTime(2018, 3, 24, 12, 0, 0, DateTimeKind.Utc); + var unspecified = new DateTime(2018, 3, 24, 12, 0, 0, DateTimeKind.Unspecified); + var offset = new DateTimeOffset(utc); + + var query = Arithmetic(ctx) + .Where( + a => EF.Parameter(utc).AddDays(1) == a.AfterDst + && a.AfterDst == EF.Parameter(utc).AddDays(1) + && EF.Parameter(unspecified).AddDays(1) == a.Later + && a.Earlier == EF.Parameter(unspecified).AddDays(-1)) + .Select( + a => new + { + AddDays = EF.Parameter(utc).AddDays(1), + AddMonths = EF.Parameter(utc).AddMonths(1), + OffsetAddDays = EF.Parameter(offset).AddDays(1) + }); + + var result = async ? await query.SingleAsync() : query.Single(); + + Assert.Equal(new DateTime(2018, 3, 25, 12, 0, 0, DateTimeKind.Utc), result.AddDays); + Assert.Equal(new DateTime(2018, 4, 24, 12, 0, 0, DateTimeKind.Utc), result.AddMonths); + Assert.Equal(new DateTimeOffset(2018, 3, 25, 12, 0, 0, TimeSpan.Zero), result.OffsetAddDays); + + AssertSql( + """ +@utc='2018-03-24T12:00:00.0000000Z' (DbType = DateTime) +@offset='2018-03-24T12:00:00.0000000+00:00' (DbType = DateTime) +@utc0='2018-03-24T12:00:00.0000000Z' (DbType = DateTime) +@unspecified='2018-03-24T12:00:00.0000000' + +SELECT ((@utc AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' AS "AddDays", ((@utc AT TIME ZONE 'UTC') + INTERVAL '1 months') AT TIME ZONE 'UTC' AS "AddMonths", ((@offset AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' AS "OffsetAddDays" +FROM ( + SELECT TIMESTAMP '2018-03-23T12:00:00' AS "Earlier", TIMESTAMP '2018-03-25T12:00:00' AS "Later", INTERVAL '1 day' AS "Interval", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDst", TIMESTAMPTZ '2018-03-25T12:00:00Z' AS "AfterDst", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstAlias", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstOffset" +) AS m +WHERE ((@utc0 AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' = m."AfterDst" AND m."AfterDst" = ((@utc0 AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' AND @unspecified + INTERVAL '1 days' = m."Later" AND m."Earlier" = @unspecified + INTERVAL '-1 days' +LIMIT 2 +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Interval_arithmetic_on_timestamptz_with_precision_or_alias(bool async) + { + await using var ctx = CreateContext(); + + // Europe/Berlin moves to daylight saving time on 2018-03-25, so arithmetic in the session time zone would be an hour off + var interval = TimeSpan.FromDays(1); + var query = Arithmetic(ctx).Select( + a => new + { + AddDays = a.BeforeDst.AddDays(1), + Add = a.BeforeDst + a.Interval, + Subtract = a.AfterDst - a.Interval, + AddParameter = a.BeforeDst + interval, + SubtractParameter = a.AfterDst - interval, + AliasAddDays = a.BeforeDstAlias.AddDays(1), + AliasAdd = a.BeforeDstAlias + a.Interval, + OffsetAddDays = a.BeforeDstOffset.AddDays(1), + OffsetAdd = a.BeforeDstOffset + a.Interval + }); + + var result = async ? await query.SingleAsync() : query.Single(); + + var beforeDst = new DateTime(2018, 3, 24, 12, 0, 0, DateTimeKind.Utc); + var afterDst = new DateTime(2018, 3, 25, 12, 0, 0, DateTimeKind.Utc); + Assert.Equal(afterDst, result.AddDays); + Assert.Equal(afterDst, result.Add); + Assert.Equal(beforeDst, result.Subtract); + Assert.Equal(afterDst, result.AddParameter); + Assert.Equal(beforeDst, result.SubtractParameter); + Assert.Equal(afterDst, result.AliasAddDays); + Assert.Equal(afterDst, result.AliasAdd); + Assert.Equal(new DateTimeOffset(afterDst), result.OffsetAddDays); + Assert.Equal(new DateTimeOffset(afterDst), result.OffsetAdd); + + AssertSql( + """ +@interval='1.00:00:00' (DbType = Object) + +SELECT ((m."BeforeDst" AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' AS "AddDays", ((m."BeforeDst" AT TIME ZONE 'UTC') + m."Interval") AT TIME ZONE 'UTC' AS "Add", ((m."AfterDst" AT TIME ZONE 'UTC') - m."Interval") AT TIME ZONE 'UTC' AS "Subtract", ((m."BeforeDst" AT TIME ZONE 'UTC') + @interval) AT TIME ZONE 'UTC' AS "AddParameter", ((m."AfterDst" AT TIME ZONE 'UTC') - @interval) AT TIME ZONE 'UTC' AS "SubtractParameter", ((m."BeforeDstAlias" AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' AS "AliasAddDays", ((m."BeforeDstAlias" AT TIME ZONE 'UTC') + m."Interval") AT TIME ZONE 'UTC' AS "AliasAdd", ((m."BeforeDstOffset" AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' AS "OffsetAddDays", ((m."BeforeDstOffset" AT TIME ZONE 'UTC') + m."Interval") AT TIME ZONE 'UTC' AS "OffsetAdd" +FROM ( + SELECT TIMESTAMP '2018-03-23T12:00:00' AS "Earlier", TIMESTAMP '2018-03-25T12:00:00' AS "Later", INTERVAL '1 day' AS "Interval", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDst", TIMESTAMPTZ '2018-03-25T12:00:00Z' AS "AfterDst", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstAlias", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstOffset" +) AS m +LIMIT 2 +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Interval_arithmetic_on_timestamptz_with_precision_compared_to_parameter(bool async) + { + await using var ctx = CreateContext(); + + // The parameters' mappings are inferred from the arithmetic, on either side of the comparison + var add = new DateTime(2018, 3, 25, 12, 0, 0, DateTimeKind.Utc); + var addDays = add; + var subtract = new DateTime(2018, 3, 24, 12, 0, 0, DateTimeKind.Utc); + + var query = Arithmetic(ctx).Where( + a => a.BeforeDst + a.Interval == add + && a.BeforeDst.AddDays(1) == addDays + && subtract == a.AfterDst - a.Interval); + + Assert.Equal(1, async ? await query.CountAsync() : query.Count()); + + AssertSql( + """ +@add='2018-03-25T12:00:00.0000000Z' (DbType = DateTime) +@addDays='2018-03-25T12:00:00.0000000Z' (DbType = DateTime) +@subtract='2018-03-24T12:00:00.0000000Z' (DbType = DateTime) + +SELECT count(*)::int +FROM ( + SELECT TIMESTAMP '2018-03-23T12:00:00' AS "Earlier", TIMESTAMP '2018-03-25T12:00:00' AS "Later", INTERVAL '1 day' AS "Interval", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDst", TIMESTAMPTZ '2018-03-25T12:00:00Z' AS "AfterDst", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstAlias", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstOffset" +) AS m +WHERE ((m."BeforeDst" AT TIME ZONE 'UTC') + m."Interval") AT TIME ZONE 'UTC' = @add AND ((m."BeforeDst" AT TIME ZONE 'UTC') + INTERVAL '1 days') AT TIME ZONE 'UTC' = @addDays AND @subtract = ((m."AfterDst" AT TIME ZONE 'UTC') - m."Interval") AT TIME ZONE 'UTC' +"""); + } + + [ConditionalTheory] + [InlineData("timestamp(3) with time zone", "timestamp(3) with time zone", typeof(DateTime))] + [InlineData("timestamptz(3)", "timestamp(3) with time zone", typeof(DateTime))] + [InlineData("timestamptz", "timestamptz", typeof(DateTime))] + [InlineData("timestamp(3) with time zone", "timestamp(3) with time zone", typeof(DateTimeOffset))] + public void Interval_arithmetic_on_timestamptz_keeps_store_type(string storeType, string expectedStoreType, Type clrType) + { + using var ctx = CreateContext(); + + var sqlExpressionFactory = ctx.GetService(); + var mapping = ctx.GetService().FindMapping(clrType, storeType)!; + var instance = new SqlParameterExpression("p", "p", clrType, nullable: false, ParameterTranslationMode.Parameter, mapping); + var interval = sqlExpressionFactory.Constant(TimeSpan.FromDays(1)); + + // The arithmetic is performed on 'timestamp without time zone' (AT TIME ZONE 'UTC'), and the result converted back + // to the instance's 'timestamp with time zone' mapping, including its precision. + AssertResult(sqlExpressionFactory.Add(instance, interval)); + AssertResult(sqlExpressionFactory.Subtract(instance, interval)); + + var addDays = ctx.GetService().Translate( + ctx.Model, + instance, + clrType.GetMethod(nameof(DateTime.AddDays), [typeof(double)])!, + [sqlExpressionFactory.Constant(1.0)], + ctx.GetService>()); + AssertResult(addDays); + + void AssertResult(SqlExpression? result) + { + var outer = Assert.IsType(result); + Assert.Same(mapping, outer.TypeMapping); + Assert.Equal(expectedStoreType, outer.TypeMapping!.StoreType); + Assert.Equal(clrType, outer.Type); + + var arithmetic = Assert.IsType(outer.Operand); + Assert.Equal("timestamp without time zone", arithmetic.TypeMapping!.StoreType); + var utc = Assert.IsType(arithmetic.Left); + Assert.Equal("timestamp without time zone", utc.TypeMapping!.StoreType); + Assert.Same(instance, utc.Operand); + } + } + + private static IQueryable Arithmetic(TimestampQueryContext ctx) + => ctx.Database.SqlQueryRaw( + """ +SELECT TIMESTAMP '2018-03-23T12:00:00' AS "Earlier", TIMESTAMP '2018-03-25T12:00:00' AS "Later", INTERVAL '1 day' AS "Interval", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDst", TIMESTAMPTZ '2018-03-25T12:00:00Z' AS "AfterDst", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstAlias", TIMESTAMPTZ '2018-03-24T12:00:00Z' AS "BeforeDstOffset" +"""); + + #endregion Arithmetic + #region Comparisons [ConditionalTheory] @@ -790,7 +1099,7 @@ public async Task ConvertTimeBySystemTimeZoneId_with_source_time_zone_and_Specif AssertSql( """ -SELECT ((e."TimestampDateTime" AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' +SELECT (e."TimestampDateTime" AT TIME ZONE 'Europe/Berlin') FROM "Entities" AS e WHERE e."Id" = 1 LIMIT 2 @@ -1004,7 +1313,38 @@ await AssertQuery( """ SELECT e."Id", e."TimestampDateTime", e."TimestampDateTimeArray", e."TimestampDateTimeOffset", e."TimestampDateTimeOffsetArray", e."TimestampDateTimeRange", e."TimestamptzDateTime", e."TimestamptzDateTimeArray", e."TimestamptzDateTimeRange" FROM "Entities" AS e -WHERE CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS date) - CAST((e."TimestamptzDateTime" - INTERVAL '3 00:00:00') AT TIME ZONE 'UTC' AS date) = 3 +WHERE CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS date) - CAST(((e."TimestamptzDateTime" AT TIME ZONE 'UTC') - INTERVAL '3 00:00:00') AS date) = 3 +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task DateOnly_AddMonths_and_AddYears_on_parameter(bool async) + { + await using var ctx = CreateContext(); + + // The parameter must be mapped to date, rather than inferred from the interval + var monthEnd = new DateOnly(2018, 1, 31); + var leapDay = new DateOnly(2016, 2, 29); + + var query = ctx.Entities + .Where(e => e.Id == 1 && EF.Parameter(monthEnd).AddMonths(1) == new DateOnly(2018, 2, 28)) + .Select(e => new { AddMonths = EF.Parameter(monthEnd).AddMonths(1), AddYears = EF.Parameter(leapDay).AddYears(1) }); + + var result = async ? await query.SingleAsync() : query.Single(); + + Assert.Equal(new DateOnly(2018, 2, 28), result.AddMonths); + Assert.Equal(new DateOnly(2017, 2, 28), result.AddYears); + + AssertSql( + """ +@monthEnd='01/31/2018' (DbType = Date) +@leapDay='02/29/2016' (DbType = Date) + +SELECT CAST(@monthEnd + INTERVAL '1 months' AS date) AS "AddMonths", CAST(@leapDay + INTERVAL '1 years' AS date) AS "AddYears" +FROM "Entities" AS e +WHERE e."Id" = 1 AND CAST(@monthEnd + INTERVAL '1 months' AS date) = DATE '2018-02-28' +LIMIT 2 """); } @@ -1026,6 +1366,41 @@ await AssertQuery( #endregion DateOnly + #region TimeOfDay + + // TimeOfDay is a TimeSpan mapped to PostgreSQL time, which justify_hours() (used for intervals) doesn't accept + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task TimeOfDay_components(bool async) + { + await AssertQuery( + async, + ss => ss.Set() + .Where(e => e.TimestamptzDateTime.TimeOfDay.Hours == 13 || e.TimestampDateTime.TimeOfDay.Milliseconds == 345) + .Select( + e => new + { + e.Id, + e.TimestamptzDateTime.TimeOfDay.Days, + e.TimestamptzDateTime.TimeOfDay.Hours, + e.TimestamptzDateTime.TimeOfDay.Minutes, + e.TimestamptzDateTime.TimeOfDay.Seconds, + e.TimestamptzDateTime.TimeOfDay.Milliseconds, + TimestampHours = e.TimestampDateTime.TimeOfDay.Hours, + TimestampMilliseconds = e.TimestampDateTime.TimeOfDay.Milliseconds + }), + elementSorter: e => e.Id); + + AssertSql( + """ +SELECT e."Id", date_part('hour', CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS time))::int / 24 AS "Days", date_part('hour', CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS time))::int % 24 AS "Hours", date_part('minute', CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS time))::int AS "Minutes", trunc(date_part('second', CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS time)))::int AS "Seconds", trunc(date_part('millisecond', CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS time)))::int % 1000 AS "Milliseconds", date_part('hour', e."TimestampDateTime"::time)::int % 24 AS "TimestampHours", trunc(date_part('millisecond', e."TimestampDateTime"::time))::int % 1000 AS "TimestampMilliseconds" +FROM "Entities" AS e +WHERE date_part('hour', CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS time))::int % 24 = 13 OR trunc(date_part('millisecond', e."TimestampDateTime"::time))::int % 1000 = 345 +"""); + } + + #endregion TimeOfDay + #region TimeOnly [ConditionalTheory] @@ -1152,6 +1527,29 @@ private class TimeZoneConversion public string DestinationTimeZoneId { get; set; } = null!; } + private class IntervalArithmetic + { + [Column(TypeName = "timestamp without time zone")] + public DateTime Earlier { get; set; } + + [Column(TypeName = "timestamp without time zone")] + public DateTime Later { get; set; } + + public TimeSpan Interval { get; set; } + + [Precision(3)] + public DateTime BeforeDst { get; set; } + + [Precision(3)] + public DateTime AfterDst { get; set; } + + [Column(TypeName = "timestamptz")] + public DateTime BeforeDstAlias { get; set; } + + [Precision(3)] + public DateTimeOffset BeforeDstOffset { get; set; } + } + public class TimestampQueryFixture : QueryFixtureBase, ITestSqlLoggerFactory { protected override string StoreName diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlIntervalJsonParsingTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlIntervalJsonParsingTest.cs new file mode 100644 index 0000000000..55eae69adb --- /dev/null +++ b/test/EFCore.PG.Tests/Storage/NpgsqlIntervalJsonParsingTest.cs @@ -0,0 +1,132 @@ +#if DEBUG + +using System.Globalization; +using System.Text; +using Microsoft.EntityFrameworkCore.Storage.Json; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage; + +/// +/// Tests reading intervals (TimeSpan, NodaTime Duration) from JSON, with both values of the Npgsql.ParseOldEFCoreJsonTimeSpans +/// AppContext switch, which determines the meaning of a negative value with a days component and a sign on the days only. +/// +[Collection("ParseOldEFCoreJsonTimeSpansTest")] +public class NpgsqlIntervalJsonParsingTest +{ + [Theory] + // Older versions of the provider wrote a sign on the days only, however for PostgreSQL the sign applies to the days only. + // Values written by the current version have a sign on both the days and the time, and are unambiguous + [InlineData("-1 02:00:00", true, "-1.02:00:00")] + [InlineData("-1 02:00:00", false, "-22:00:00")] + [InlineData("-3 04:05:06.007", true, "-3.04:05:06.007")] + [InlineData("-3 04:05:06.007", false, "-2.19:54:53.993")] + [InlineData("-10675199 02:48:05.47758", true, "-10675199.02:48:05.47758")] + [InlineData("-10675199 02:48:05.47758", false, "-10675198.21:11:54.52242")] + [InlineData("-1 -02:00:00", true, "-1.02:00:00")] + [InlineData("-1 -02:00:00", false, "-1.02:00:00")] + [InlineData("-3 -04:05:06.007", true, "-3.04:05:06.007")] + [InlineData("-3 -04:05:06.007", false, "-3.04:05:06.007")] + // A sign on the time only + [InlineData("1 -02:00:00", true, "22:00:00")] + [InlineData("1 -02:00:00", false, "22:00:00")] + // Values without a days component, or without a negative sign, aren't affected + [InlineData("-02:00:00", true, "-02:00:00")] + [InlineData("-02:00:00", false, "-02:00:00")] + [InlineData("-00:00:00.000001", true, "-00:00:00.000001")] + [InlineData("-00:00:00.000001", false, "-00:00:00.000001")] + [InlineData("1 02:00:00", true, "1.02:00:00")] + [InlineData("1 02:00:00", false, "1.02:00:00")] + [InlineData("00:00:00", true, "00:00:00")] + [InlineData("00:00:00", false, "00:00:00")] + // The days alone are out of range for a TimeSpan, but the value isn't (e.g. an interval computed in SQL) + [InlineData("10675200 -23:00:00", true, "10675199.01:00:00")] + [InlineData("10675200 -23:00:00", false, "10675199.01:00:00")] + [InlineData("-10675200 23:00:00", false, "-10675199.01:00:00")] + // TimeSpan.MaxValue/MinValue, truncated to microseconds + [InlineData("10675199 02:48:05.47758", true, "10675199.02:48:05.47758")] + [InlineData("-10675199 -02:48:05.47758", true, "-10675199.02:48:05.47758")] + [InlineData("-10675199 -02:48:05.47758", false, "-10675199.02:48:05.47758")] + public void Read_interval_from_json(string json, bool parseOldEFCoreJsonTimeSpans, string expected) + { + var expectedTimeSpan = TimeSpan.Parse(expected, CultureInfo.InvariantCulture); + + var previous = NpgsqlTypeMappingSource.ParseOldEFCoreJsonTimeSpans; + NpgsqlTypeMappingSource.ParseOldEFCoreJsonTimeSpans = parseOldEFCoreJsonTimeSpans; + try + { + Assert.Equal(expectedTimeSpan, ReadJson(Mapper.FindMapping(typeof(TimeSpan))!, json)); + Assert.Equal(Duration.FromTimeSpan(expectedTimeSpan), ReadJson(Mapper.FindMapping(typeof(Duration))!, json)); + } + finally + { + NpgsqlTypeMappingSource.ParseOldEFCoreJsonTimeSpans = previous; + } + } + + [Theory] + // No time component + [InlineData("123")] + [InlineData("-123")] + // A days component in the time + [InlineData("1 1.02:00:00")] + // More than one sign on the time + [InlineData("-1 --02:00:00")] + [InlineData("1 --02:00:00")] + public void Read_malformed_interval_from_json_throws(string json) + { + Assert.Throws(() => ReadJson(Mapper.FindMapping(typeof(TimeSpan))!, json)); + Assert.Throws(() => ReadJson(Mapper.FindMapping(typeof(Duration))!, json)); + } + + [Theory] + [InlineData("10675200 00:00:00", true)] + [InlineData("10675199 23:00:00", false)] + [InlineData("-10675200 -00:00:00", false)] + // With the old meaning, the time is negated too (-10675200 days -23 hours) + [InlineData("-10675200 23:00:00", true)] + public void Read_out_of_range_interval_from_json_throws(string json, bool parseOldEFCoreJsonTimeSpans) + { + var previous = NpgsqlTypeMappingSource.ParseOldEFCoreJsonTimeSpans; + NpgsqlTypeMappingSource.ParseOldEFCoreJsonTimeSpans = parseOldEFCoreJsonTimeSpans; + try + { + Assert.Throws(() => ReadJson(Mapper.FindMapping(typeof(TimeSpan))!, json)); + Assert.Throws(() => ReadJson(Mapper.FindMapping(typeof(Duration))!, json)); + } + finally + { + NpgsqlTypeMappingSource.ParseOldEFCoreJsonTimeSpans = previous; + } + } + + [Fact] + public void ParseOldEFCoreJsonTimeSpans_is_on_by_default() + => Assert.True(NpgsqlTypeMappingSource.ParseOldEFCoreJsonTimeSpans); + + private static object ReadJson(RelationalTypeMapping mapping, string json) + { + var readerManager = new Utf8JsonReaderManager(new JsonReaderData(Encoding.UTF8.GetBytes($"\"{json}\"")), null); + readerManager.MoveNext(); + return mapping.JsonValueReaderWriter!.FromJson(ref readerManager, existingObject: null); + } + + private static readonly NpgsqlTypeMappingSource Mapper = new( + new TypeMappingSourceDependencies( + new ValueConverterSelector(new ValueConverterSelectorDependencies()), + new JsonValueReaderWriterSource(new JsonValueReaderWriterSourceDependencies()), + []), + new RelationalTypeMappingSourceDependencies( + [ + new NpgsqlNodaTimeTypeMappingSourcePlugin(new NpgsqlSqlGenerationHelper(new RelationalSqlGenerationHelperDependencies())) + ]), + new NpgsqlSqlGenerationHelper(new RelationalSqlGenerationHelperDependencies()), + new NpgsqlSingletonOptions()); +} + +[CollectionDefinition("ParseOldEFCoreJsonTimeSpansTest", DisableParallelization = true)] +public class ParseOldEFCoreJsonTimeSpansTestCollection; + +#endif diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlNodaTimeTypeMappingTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlNodaTimeTypeMappingTest.cs index bd50ea8c9e..04b685e73e 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlNodaTimeTypeMappingTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlNodaTimeTypeMappingTest.cs @@ -1015,7 +1015,8 @@ public void GenerateCodeLiteral_returns_Duration_literal() } [ConditionalTheory] - [InlineData("-10675199:02:48:05.477580", "-10675199 02:48:05.47758")] + [InlineData("-10675199:02:48:05.477580", "-10675199 -02:48:05.47758")] + [InlineData("-1:02:00:00", "-1 -02:00:00")] [InlineData("10675199:02:48:05.477580", "10675199 02:48:05.47758")] [InlineData("00:00:00", "00:00:00")] [InlineData("12:23:23.801885", "12:23:23.801885")] @@ -1038,6 +1039,9 @@ public void Duration_json(string durationString, string json) Assert.Equal(duration, actualDuration); } + // Reading older/ambiguous negative values is tested in NpgsqlIntervalJsonParsingTest, with both values of the + // Npgsql.ParseOldEFCoreJsonTimeSpans switch + [ConditionalTheory] [InlineData("P2018Y4M20DT4H3M2S")] public void Period_json(string intervalString) diff --git a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs index 1a2c967643..304ead0fe0 100644 --- a/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs +++ b/test/EFCore.PG.Tests/Storage/NpgsqlTypeMappingTest.cs @@ -363,7 +363,10 @@ public void GenerateSqlLiteral_returns_interval_literal() Assert.Equal("INTERVAL '3 04:05:06.007'", mapping.GenerateSqlLiteral(new TimeSpan(3, 4, 5, 6, 7))); Assert.Equal("INTERVAL '3 04:05:06'", mapping.GenerateSqlLiteral(new TimeSpan(3, 4, 5, 6))); Assert.Equal("INTERVAL '04:05:06'", mapping.GenerateSqlLiteral(new TimeSpan(4, 5, 6))); - Assert.Equal("INTERVAL '-3 04:05:06.007'", mapping.GenerateSqlLiteral(new TimeSpan(-3, -4, -5, -6, -7))); + // PostgreSQL applies a sign to the field it precedes only, so negative values need a sign on both the days and the time + Assert.Equal("INTERVAL '-3 -04:05:06.007'", mapping.GenerateSqlLiteral(new TimeSpan(-3, -4, -5, -6, -7))); + Assert.Equal("INTERVAL '-04:05:06'", mapping.GenerateSqlLiteral(new TimeSpan(-4, -5, -6))); + Assert.Equal("INTERVAL '-1 -02:00:00'", mapping.GenerateSqlLiteral(TimeSpan.FromHours(-26))); Assert.Equal( "INTERVAL '3 04:05:06'", mapping.GenerateSqlLiteral( new TimeSpan(3, 4, 5, 6) @@ -395,6 +398,30 @@ public void TimeSpan_json(string timeSpanString, string json) Assert.Equal(TimeSpan.Parse(json, CultureInfo.InvariantCulture), actualTimeSpan); } + [Theory] + [InlineData("1.02:03:04.5", "1 02:03:04.5")] + [InlineData("-1.02:03:04.5", "-1 -02:03:04.5")] + [InlineData("-10675199.02:48:05.477580", "-10675199 -02:48:05.47758")] + public void TimeSpan_json_with_days(string timeSpanString, string json) + { + // PostgreSQL applies a sign to the field it precedes only, so negative values need a sign on both the days and the time + var readerWriter = GetMapping(typeof(TimeSpan)).JsonValueReaderWriter!; + var timeSpan = TimeSpan.Parse(timeSpanString, CultureInfo.InvariantCulture); + + Assert.Equal(json, readerWriter.ToJsonString(timeSpan)[1..^1]); + Assert.Equal(timeSpan, ReadJson(readerWriter, json)); + } + + // Reading older/ambiguous negative values is tested in NpgsqlIntervalJsonParsingTest, with both values of the + // Npgsql.ParseOldEFCoreJsonTimeSpans switch + + private static object ReadJson(JsonValueReaderWriter readerWriter, string json) + { + var readerManager = new Utf8JsonReaderManager(new JsonReaderData(Encoding.UTF8.GetBytes($"\"{json}\"")), null); + readerManager.MoveNext(); + return readerWriter.FromJson(ref readerManager, existingObject: null); + } + #endregion Date/Time #region Networking