diff --git a/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMemberTranslatorPlugin.cs b/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMemberTranslatorPlugin.cs
index f1c15a1b6..fe4da8f59 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 34ef3c1bc..f069c6a05 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 a191699f2..e242b1cea 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 6aaa1481b..407add883 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 fae0f797b..e1e75a3eb 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 1b43e609c..5f7a04919 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 7a0ef8be6..5d6969d67 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 41d5fdbaf..72ac5877c 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 3b241f305..51fa683a8 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 f4f29a20b..998a8c540 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 24129e49f..118934642 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 5fb8a83d7..f98192f91 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 000000000..45bbc1d73
--- /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 000000000..637ae1dea
--- /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 5d96330f2..6012a9ddc 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 b6736b4c5..7eb1ec9a4 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 3087e1be6..cc2f3eb90 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 85742bd94..9c594b5a3 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 c32ecd936..19deff432 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 05b2ed750..6b173f7fc 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 000000000..55eae69ad
--- /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 bd50ea8c9..04b685e73 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 1a2c96764..304ead0fe 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