From 69bcedf23a292f20377598b2db633745e31a202e Mon Sep 17 00:00:00 2001 From: Jeremy Morren Date: Thu, 29 Oct 2026 19:09:30 -0400 Subject: [PATCH] Document EFCore.PG 11.0 date/time arithmetic and interval fixes Documents the EFCore.PG 11.0 fixes to date/time arithmetic with intervals, in the translation tables, the NodaTime page and the 11.0 release notes: arithmetic on timestamptz computed in UTC, truncated TimeSpan/Duration components, and negative TimeSpan/Duration values with days. Also documents the Npgsql.ParseOldEFCoreJsonTimeSpans AppContext switch (in the release notes and the date and time page), and that existing JSON data with negative values with days must be migrated manually, with SQL to do so, and isn't read correctly by previous versions. Co-Authored-By: Claude Opus 5.5 --- conceptual/EFCore.PG/mapping/nodatime.md | 20 +- conceptual/EFCore.PG/mapping/translations.md | 185 ++++++++++--------- conceptual/EFCore.PG/release-notes/11.0.md | 70 +++++++ conceptual/Npgsql/types/datetime.md | 12 ++ 4 files changed, 190 insertions(+), 97 deletions(-) diff --git a/conceptual/EFCore.PG/mapping/nodatime.md b/conceptual/EFCore.PG/mapping/nodatime.md index d17b8ef4..73dd3be1 100644 --- a/conceptual/EFCore.PG/mapping/nodatime.md +++ b/conceptual/EFCore.PG/mapping/nodatime.md @@ -87,8 +87,8 @@ Starting with version 11.0, `EF.Functions.DateTrunc()` supports `LocalDateTime`, .NET | SQL | Notes -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------- SystemClock.Instance.GetCurrentInstant() | [now()](https://www.postgresql.org/docs/current/functions-datetime.html) | -EF.Functions.DateTrunc("month", value) | [date_trunc('month', value)](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 -EF.Functions.DateTrunc("month", instant, "Europe/Berlin") | [date_trunc('month', instant, 'Europe/Berlin')](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 +EF.Functions.DateTrunc("month", value) | [date_trunc('month', value)](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 +EF.Functions.DateTrunc("month", instant, "Europe/Berlin") | [date_trunc('month', instant, 'Europe/Berlin')](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 LocalDateTime.Date | [date_trunc('day', timestamp)](https://www.postgresql.org/docs/current/functions-datetime.html) | LocalDateTime.Second (also LocalTime, ZonedDateTime) | [date_part('second', timestamp)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | LocalDateTime.Minute (also LocalTime, ZonedDateTime) | [date_part('minute', timestamp)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | @@ -100,10 +100,10 @@ LocalDateTime.DayOfWeek (also LocalDate, ZonedDateTime) LocalDateTime.DayOfYear (also LocalDate, ZonedDateTime) | [date_part('doy', timestamp)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | LocalDate.At(time) | [date + time](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 10.0 LocalDate.AtMidnight() | [date + '00:00:00'](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 10.0 -Period.Seconds (also Duration) | [date_part('second', interval)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -Period.Minutes (also Duration) | [date_part('minute', interval)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -Period.Hours (also Duration) | [date_part('hour', interval)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -Period.Days (also Duration) | [date_part('day', interval)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +Period.Seconds | [date_part('second', interval)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +Period.Minutes | [date_part('minute', interval)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +Period.Hours | [date_part('hour', interval)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +Period.Days | [date_part('day', interval)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Period.Months | [date_part('month', interval)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Period.Years | [date_part('year', interval)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Period.FromSeconds | [make_interval(seconds => int)](https://www.postgresql.org/docs/current/functions-datetime.html) | @@ -113,11 +113,17 @@ Period.FromDays Period.FromWeeks | [make_interval(weeks => int)](https://www.postgresql.org/docs/current/functions-datetime.html) | Period.FromMonths | [make_interval(months => int)](https://www.postgresql.org/docs/current/functions-datetime.html) | Period.FromYears | [make_interval(years => int)](https://www.postgresql.org/docs/current/functions-datetime.html) | +Duration.Days | [date_part('day', justify_hours(interval))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Changed in 11.0 +Duration.Hours | [date_part('hour', justify_hours(interval))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Changed in 11.0 +Duration.Minutes | [date_part('minute', justify_hours(interval))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Changed in 11.0 +Duration.Seconds | [trunc(date_part('second', justify_hours(interval)))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Changed in 11.0 +Duration.Milliseconds | [trunc(date_part('millisecond', justify_hours(interval)))::INT % 1000](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 Duration.TotalMilliseconds | [date_part('epoch', interval) / 0.001](https://www.postgresql.org/docs/current/functions-datetime.html) | Duration.TotalSeconds | [date_part('epoch', interval)](https://www.postgresql.org/docs/current/functions-datetime.html) | Duration.TotalMinutes | [date_part('epoch', interval) / 60.0](https://www.postgresql.org/docs/current/functions-datetime.html) | Duration.TotalDays | [date_part('epoch', interval) / 86400.0](https://www.postgresql.org/docs/current/functions-datetime.html) | Duration.TotalHours | [date_part('epoch', interval) / 3600.0](https://www.postgresql.org/docs/current/functions-datetime.html) | +instant + duration (also ZonedDateTime, -) | [(instant AT TIME ZONE 'UTC' + interval) AT TIME ZONE 'UTC'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Computed in UTC since 11.0 ZonedDateTime.LocalDateTime | [timestamptz AT TIME ZONE 'UTC'](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT) | DateInterval.Length | [upper(daterange) - lower(daterange)](https://www.postgresql.org/docs/current/functions-range.html) | DateInterval.Start | [lower(daterange)](https://www.postgresql.org/docs/current/functions-range.html) | @@ -138,3 +144,5 @@ EF.Functions.Average(periods) EF.Functions.Average(durations) | [avg(durations)](https://www.postgresql.org/docs/current/functions-aggregate.html#FUNCTIONS-AGGREGATE-TABLE) | See [Aggregate functions](translations.md#aggregate-functions). In addition to the above, most arithmetic operators are also translated (e.g. LocalDate + Period). + +Starting with version 11.0, adding or subtracting a `Duration` to an `Instant` or `ZonedDateTime` is computed in UTC. This is because PostgreSQL's `timestamp with time zone + interval` adds the interval's days in the session time zone, so that adding a day across a daylight saving time transition would add 23 or 25 hours, rather than the 24 hours added by NodaTime ([#4087](https://github.com/npgsql/efcore.pg/issues/4087)). Similarly, the `Duration` components (`Days`, `Hours`, etc.) are now truncated towards zero like in NodaTime, and are also correct for durations computed in SQL, whose day and time fields can have different signs ([#4088](https://github.com/npgsql/efcore.pg/issues/4088)). diff --git a/conceptual/EFCore.PG/mapping/translations.md b/conceptual/EFCore.PG/mapping/translations.md index 366677d9..a4cb9c33 100644 --- a/conceptual/EFCore.PG/mapping/translations.md +++ b/conceptual/EFCore.PG/mapping/translations.md @@ -60,98 +60,101 @@ EF.Functions.StringToArray(s, "\|", "FOO") | [string_agg(s, ' Starting with version 11.0, `EF.Functions.DateTrunc()` supports `DateTime`, `DateTimeOffset` and `TimeSpan`. The optional time zone argument is only supported for values mapped to `timestamp with time zone`; when omitted for such values, PostgreSQL uses the session time zone. -.NET | SQL | Notes ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -------- -DateTime.UtcNow (6.0+) | [now()](https://www.postgresql.org/docs/current/functions-datetime.html) | -DateTime.Now (6.0+) | [now()::timestamp](https://www.postgresql.org/docs/current/functions-datetime.html) | -DateTime.Today (6.0+) | [date_trunc('day', now()::timestamp)](https://www.postgresql.org/docs/current/functions-datetime.html) | -DateTime.UtcNow (legacy) | [now() AT TIME ZONE 'UTC'](https://www.postgresql.org/docs/current/functions-datetime.html) | -DateTime.Now (legacy) | [now()](https://www.postgresql.org/docs/current/functions-datetime.html) | -DateTime.Today (legacy) | [date_trunc('day', now())](https://www.postgresql.org/docs/current/functions-datetime.html) | -EF.Functions.DateTrunc("month", value) | [date_trunc('month', value)](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 -EF.Functions.DateTrunc("month", utcDateTime, "Europe/Berlin") | [date_trunc('month', utcDateTime, 'Europe/Berlin')](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0; timestamp with time zone only -dateTime.AddDays(1) | [dateTime + INTERVAL '1 days'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | -dateTime.AddHours(value) | [dateTime + INTERVAL '1 hours'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | -dateTime.AddMilliseconds(value) (also DateTimeOffset) | [dateTime + value * INTERVAL '1 milliseconds'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Added in 11.0 -dateTime.AddMicroseconds(value) (also DateTimeOffset) | [dateTime + value * INTERVAL '1 microseconds'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Added in 11.0 -dateTime.AddMinutes(1) | [dateTime + INTERVAL '1 minutes'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | -dateTime.AddMonths(1) | [dateTime + INTERVAL '1 months'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | -dateTime.AddSeconds(1) | [dateTime + INTERVAL '1 seconds'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | -dateTime.AddYears(1) | [dateTime + INTERVAL '1 years'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | -dateTime.Date | [date_trunc('day', dateTime)](https://www.postgresql.org/docs/current/functions-datetime.html) | -dateTime.Day | [date_part('day', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -dateTime.DayOfWeek | [floor(date_part('dow', dateTime))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -dateTime.DayOfYear | [date_part('doy', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -dateTime.Hour | [date_part('hour', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -dateTime.Millisecond (also DateTimeOffset, TimeOnly) | [floor(date_part('milliseconds', dateTime))::INT % 1000](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 -dateTime.Microsecond (also DateTimeOffset, TimeOnly) | [date_part('microseconds', dateTime)::INT % 1000](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 -dateTime.Minute | [date_part('minute', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -dateTime.Month | [date_part('month', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -dateTime.Second | [date_part('second', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -dateTime.Year | [date_part('year', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -dateTime.ToUniversalTime | [dateTime::timestamptz](https://www.postgresql.org/docs/current/datatype-datetime.html#id-1.5.7.13.18.7) | -dateTime.ToLocalTime | [dateTime::timestamp](https://www.postgresql.org/docs/current/datatype-datetime.html#id-1.5.7.13.18.7) | -dateOnly.DayNumber | [dateOnly - DATE '0001-01-01'](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 9.0 -DateOnly.FromDayNumber(x) | [DATE '0001-01-01' + x](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 9.0 -dateOnly1.DayNumber - dateOnly2.DayNumber | [dateOnly1 - dateOnly2](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 9.0 -dateTimeOffset.DateTime | [dateTimeOffset AT TIME ZONE 'UTC'](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT) | -dateTimeOffset.UtcDateTime | No PG operation (.NET-side conversion from DateTimeOffset to DateTime only) | -dateTimeOffset.LocalDateTime | [dateTimeOffset::timestamp](https://www.postgresql.org/docs/current/datatype-datetime.html#id-1.5.7.13.18.7) | -timeSpan.Days | [floor(date_part('day', timeSpan))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -timeSpan.Hours | [floor(date_part('hour', timeSpan))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -timeSpan.Minutes | [floor(date_part('minute', timeSpan))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -timeSpan.Seconds | [floor(date_part('second', timeSpan))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -timeSpan.Milliseconds | [floor(date_part('millisecond', timeSpan))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -timeSpan.Milliseconds | [floor(date_part('millisecond', timeSpan))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | -timeSpan.TotalMilliseconds | [date_part('epoch', interval) / 0.001](https://www.postgresql.org/docs/current/functions-datetime.html) | -timeSpan.TotalSeconds | [date_part('epoch', interval)](https://www.postgresql.org/docs/current/functions-datetime.html) | -timeSpan.TotalMinutes | [date_part('epoch', interval) / 60.0](https://www.postgresql.org/docs/current/functions-datetime.html) | -timeSpan.TotalDays | [date_part('epoch', interval) / 86400.0](https://www.postgresql.org/docs/current/functions-datetime.html) | -timeSpan.TotalHours | [date_part('epoch', interval) / 3600.0](https://www.postgresql.org/docs/current/functions-datetime.html) | -dateTime1 - dateTime2 | [dateTime1 - dateTime2](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | -TimeZoneInfo.ConvertTimeBySystemTimeZoneId(utcDateTime, timezone) | [utcDateTime AT TIME ZONE timezone](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT) | Only for timestamptz columns +Starting with version 11.0, arithmetic with intervals on `DateTime` and `DateTimeOffset` values mapped to `timestamp with time zone` (e.g. `dateTime + timeSpan` or `dateTime.AddDays(1)`) is computed in UTC, as `(dateTime AT TIME ZONE 'UTC' + INTERVAL '1 days') AT TIME ZONE 'UTC'`. This is because PostgreSQL's `timestamp with time zone + interval` adds the interval's days and months in the session time zone, so that adding a day across a daylight saving time transition would add 23 or 25 hours, rather than the 24 hours added by .NET ([#4087](https://github.com/npgsql/efcore.pg/issues/4087)). With the [legacy timestamp behavior](../release-notes/6.0.md#opting-out-of-the-new-timestamp-mapping-logic), a `DateTime` mapped to `timestamp with time zone` is a local date and time, whose arithmetic keeps the local time across a daylight saving time transition (like PostgreSQL's in the session time zone), so `DateTime` arithmetic isn't computed in UTC in that case. Similarly, a `DateTimeOffset` then has the local offset, in which `AddMonths()` and `AddYears()` add calendar months, so these aren't computed in UTC either (other `DateTimeOffset` arithmetic adds an exact amount of time, and still is). + +.NET | SQL | Notes +----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------- +DateTime.UtcNow (6.0+) | [now()](https://www.postgresql.org/docs/current/functions-datetime.html) | +DateTime.Now (6.0+) | [now()::timestamp](https://www.postgresql.org/docs/current/functions-datetime.html) | +DateTime.Today (6.0+) | [date_trunc('day', now()::timestamp)](https://www.postgresql.org/docs/current/functions-datetime.html) | +DateTime.UtcNow (legacy) | [now() AT TIME ZONE 'UTC'](https://www.postgresql.org/docs/current/functions-datetime.html) | +DateTime.Now (legacy) | [now()](https://www.postgresql.org/docs/current/functions-datetime.html) | +DateTime.Today (legacy) | [date_trunc('day', now())](https://www.postgresql.org/docs/current/functions-datetime.html) | +EF.Functions.DateTrunc("month", value) | [date_trunc('month', value)](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 +EF.Functions.DateTrunc("month", utcDateTime, "Europe/Berlin") | [date_trunc('month', utcDateTime, 'Europe/Berlin')](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0; timestamp with time zone only +dateTime.AddDays(1) | [dateTime + INTERVAL '1 days'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Computed in UTC for timestamptz (changed in 11.0) +dateTime.AddHours(value) | [dateTime + INTERVAL '1 hours'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Computed in UTC for timestamptz (changed in 11.0) +dateTime.AddMilliseconds(value) (also DateTimeOffset) | [dateTime + value * INTERVAL '1 milliseconds'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Added in 11.0; computed in UTC for timestamptz +dateTime.AddMicroseconds(value) (also DateTimeOffset) | [dateTime + value * INTERVAL '1 microseconds'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Added in 11.0; computed in UTC for timestamptz +dateTime.AddMinutes(1) | [dateTime + INTERVAL '1 minutes'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Computed in UTC for timestamptz (changed in 11.0) +dateTime.AddMonths(1) | [dateTime + INTERVAL '1 months'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Computed in UTC for timestamptz (changed in 11.0) +dateTime.AddSeconds(1) | [dateTime + INTERVAL '1 seconds'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Computed in UTC for timestamptz (changed in 11.0) +dateTime.AddYears(1) | [dateTime + INTERVAL '1 years'](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Computed in UTC for timestamptz (changed in 11.0) +dateTime.Date | [date_trunc('day', dateTime)](https://www.postgresql.org/docs/current/functions-datetime.html) | +dateTime.Day | [date_part('day', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +dateTime.DayOfWeek | [floor(date_part('dow', dateTime))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +dateTime.DayOfYear | [date_part('doy', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +dateTime.Hour | [date_part('hour', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +dateTime.Millisecond (also DateTimeOffset, TimeOnly) | [floor(date_part('milliseconds', dateTime))::INT % 1000](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 +dateTime.Microsecond (also DateTimeOffset, TimeOnly) | [date_part('microseconds', dateTime)::INT % 1000](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 11.0 +dateTime.Minute | [date_part('minute', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +dateTime.Month | [date_part('month', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +dateTime.Second | [date_part('second', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +dateTime.Year | [date_part('year', dateTime)::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | +dateTime.ToUniversalTime | [dateTime::timestamptz](https://www.postgresql.org/docs/current/datatype-datetime.html#id-1.5.7.13.18.7) | +dateTime.ToLocalTime | [dateTime::timestamp](https://www.postgresql.org/docs/current/datatype-datetime.html#id-1.5.7.13.18.7) | +dateOnly.DayNumber | [dateOnly - DATE '0001-01-01'](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 9.0 +DateOnly.FromDayNumber(x) | [DATE '0001-01-01' + x](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 9.0 +dateOnly1.DayNumber - dateOnly2.DayNumber | [dateOnly1 - dateOnly2](https://www.postgresql.org/docs/current/functions-datetime.html) | Added in 9.0 +dateOnly.AddMonths(1) (also AddYears) | [CAST(dateOnly + INTERVAL '1 months' AS date)](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Cast back to date since 11.0, so that the result can be projected +dateTimeOffset.DateTime | [dateTimeOffset AT TIME ZONE 'UTC'](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT) | +dateTimeOffset.UtcDateTime | No PG operation (.NET-side conversion from DateTimeOffset to DateTime only) | +dateTimeOffset.LocalDateTime | [dateTimeOffset::timestamp](https://www.postgresql.org/docs/current/datatype-datetime.html#id-1.5.7.13.18.7) | +timeSpan.Days | [date_part('day', justify_hours(timeSpan))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Changed in 11.0 +timeSpan.Hours | [date_part('hour', justify_hours(timeSpan))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Changed in 11.0 +timeSpan.Minutes | [date_part('minute', justify_hours(timeSpan))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Changed in 11.0 +timeSpan.Seconds | [trunc(date_part('second', justify_hours(timeSpan)))::INT](https://www.postgresql.org/docs/current/functions-datetime.html) | Changed in 11.0 +timeSpan.Milliseconds | [trunc(date_part('millisecond', justify_hours(timeSpan)))::INT % 1000](https://www.postgresql.org/docs/current/functions-datetime.html) | Changed in 11.0 +timeSpan.TotalMilliseconds | [date_part('epoch', interval) / 0.001](https://www.postgresql.org/docs/current/functions-datetime.html) | +timeSpan.TotalSeconds | [date_part('epoch', interval)](https://www.postgresql.org/docs/current/functions-datetime.html) | +timeSpan.TotalMinutes | [date_part('epoch', interval) / 60.0](https://www.postgresql.org/docs/current/functions-datetime.html) | +timeSpan.TotalDays | [date_part('epoch', interval) / 86400.0](https://www.postgresql.org/docs/current/functions-datetime.html) | +timeSpan.TotalHours | [date_part('epoch', interval) / 3600.0](https://www.postgresql.org/docs/current/functions-datetime.html) | +dateTime1 - dateTime2 | [dateTime1 - dateTime2](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | +dateTime + timeSpan (also DateTimeOffset, -) | [dateTime + timeSpan](https://www.postgresql.org/docs/current/functions-datetime.html#OPERATORS-DATETIME-TABLE) | Computed in UTC for timestamptz (changed in 11.0) +TimeZoneInfo.ConvertTimeBySystemTimeZoneId(utcDateTime, timezone) | [utcDateTime AT TIME ZONE timezone](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT) | Only for timestamptz columns TimeZoneInfo.ConvertTimeBySystemTimeZoneId(dateTime, sourceTimeZoneId, destinationTimeZoneId) | [(dateTime AT TIME ZONE sourceTimeZoneId) AT TIME ZONE destinationTimeZoneId](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT) | Added in 11.0. Requires timestamp without time zone input; returns timestamp without time zone (`DateTimeKind.Unspecified`), even for UTC destinations. Not supported for `DateTimeOffset`. -TimeZoneInfo.ConvertTimeToUtc(nonUtcDateTime) | [nonUtcDateTime::timestamptz](https://www.postgresql.org/docs/current/functions-datetime.html) | Only for timestamp columns -DateTime.SpecifyKind(utcDateTime, DateTimeKind.Unspecified) | [utcDateTime AT TIME ZONE 'UTC'](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT) | Only for timestamptz columns -DateTime.SpecifyKind(nonUtcDateTime, DateTimeKind.Utc) | [nonUtcDateTime AT TIME ZONE 'UTC'](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT) | Only for timestamp columns -new DateTime(year, month, day) | [make_date(year, month, day)](https://www.postgresql.org/docs/current/functions-datetime.html) | -new DateTime(y, m, d, h, m, s) | [make_timestamp(y, m, d, h, m, s)](https://www.postgresql.org/docs/current/functions-datetime.html) | -new DateTime(y, m, d, h, m, s, kind) | [make_timestamp or make_timestamptz](https://www.postgresql.org/docs/current/functions-datetime.html), based on `kind` | -EF.Functions.Sum(timespans) | [sum(timespans)](https://www.postgresql.org/docs/current/functions-aggregate.html#FUNCTIONS-AGGREGATE-TABLE) | See [Aggregate functions](#aggregate-functions). -EF.Functions.Average(timespans) | [avg(timespans)](https://www.postgresql.org/docs/current/functions-aggregate.html#FUNCTIONS-AGGREGATE-TABLE) | See [Aggregate functions](#aggregate-functions). +TimeZoneInfo.ConvertTimeToUtc(nonUtcDateTime) | [nonUtcDateTime::timestamptz](https://www.postgresql.org/docs/current/functions-datetime.html) | Only for timestamp columns +DateTime.SpecifyKind(utcDateTime, DateTimeKind.Unspecified) | [utcDateTime AT TIME ZONE 'UTC'](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT) | Only for timestamptz columns +DateTime.SpecifyKind(nonUtcDateTime, DateTimeKind.Utc) | [nonUtcDateTime AT TIME ZONE 'UTC'](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-ZONECONVERT) | Only for timestamp columns +new DateTime(year, month, day) | [make_date(year, month, day)](https://www.postgresql.org/docs/current/functions-datetime.html) | +new DateTime(y, m, d, h, m, s) | [make_timestamp(y, m, d, h, m, s)](https://www.postgresql.org/docs/current/functions-datetime.html) | +new DateTime(y, m, d, h, m, s, kind) | [make_timestamp or make_timestamptz](https://www.postgresql.org/docs/current/functions-datetime.html), based on `kind` | +EF.Functions.Sum(timespans) | [sum(timespans)](https://www.postgresql.org/docs/current/functions-aggregate.html#FUNCTIONS-AGGREGATE-TABLE) | See [Aggregate functions](#aggregate-functions). +EF.Functions.Average(timespans) | [avg(timespans)](https://www.postgresql.org/docs/current/functions-aggregate.html#FUNCTIONS-AGGREGATE-TABLE) | See [Aggregate functions](#aggregate-functions). ## JSON functions See [JSON mapping](json.md) for mapping options and [PostgreSQL's JSON functions and operators](https://www.postgresql.org/docs/current/functions-json.html) for operator semantics. JSON aggregate functions are listed under [Aggregate functions](#aggregate-functions). -.NET | SQL | Notes ------------------------------------------------ | ------------------------------------ | ----- -EF.Functions.JsonContains(json, contained) | json @> contained | jsonb only -EF.Functions.JsonContained(contained, json) | contained <@ json | jsonb only -EF.Functions.JsonExists(json, key) | json ? key | jsonb only -EF.Functions.JsonExistAny(json, key1, key2) | json ?\| ARRAY[key1, key2] | jsonb only -EF.Functions.JsonExistAll(json, key1, key2) | json ?& ARRAY[key1, key2] | jsonb only -EF.Functions.JsonTypeof(json) | jsonb_typeof(json) or json_typeof(json) | Selected according to the mapped column type -EF.Functions.JsonPathExists(json, "$.Age") | JSON_EXISTS(json, '$.Age') | Added in 11.0; requires PostgreSQL 17 or above +.NET | SQL | Notes +----------------------------------------------- | --------------------------------------- | ----- +EF.Functions.JsonContains(json, contained) | json @> contained | jsonb only +EF.Functions.JsonContained(contained, json) | contained <@ json | jsonb only +EF.Functions.JsonExists(json, key) | json ? key | jsonb only +EF.Functions.JsonExistAny(json, key1, key2) | json ?\| ARRAY[key1, key2] | jsonb only +EF.Functions.JsonExistAll(json, key1, key2) | json ?& ARRAY[key1, key2] | jsonb only +EF.Functions.JsonTypeof(json) | jsonb_typeof(json) or json_typeof(json) | Selected according to the mapped column type +EF.Functions.JsonPathExists(json, "$.Age") | JSON_EXISTS(json, '$.Age') | Added in 11.0; requires PostgreSQL 17 or above ## Hstore translations The following operations are supported for `Dictionary` and `ImmutableDictionary` mapped to PostgreSQL [`hstore`](https://www.postgresql.org/docs/current/hstore.html). Operations shown with `immutableDictionary` apply only to `ImmutableDictionary`. -| .NET | SQL | Notes | -| ---- | --- | ----- | -| dictionary[key] | dictionary -> key | Added in 11.0 | -| dictionary.GetValueOrDefault(key) | dictionary -> key | Added in 11.0 | -| dictionary.GetValueOrDefault(key, fallback) | CASE WHEN dictionary ? key THEN dictionary -> key ELSE fallback END | Added in 11.0 | -| dictionary.ContainsKey(key) | dictionary ? key | Added in 11.0 | -| dictionary.ContainsValue(value) | array_position(avals(dictionary), value) IS NOT NULL | Added in 11.0 | -| dictionary.Count | cardinality(akeys(dictionary)) | Added in 11.0 | -| immutableDictionary.IsEmpty | cardinality(akeys(immutableDictionary)) = 0 | Added in 11.0 | -| immutableDictionary.Remove(key) | immutableDictionary - key | Added in 11.0 | -| immutableDictionary.RemoveRange(keys) | immutableDictionary - keys | Added in 11.0 | -| immutableDictionary.SetItems(otherDictionary) | immutableDictionary \|\| otherDictionary | Added in 11.0 | -| keys.All(key => dictionary.ContainsKey(key)) | dictionary ?& keys | Added in 11.0 | -| keys.Any(key => dictionary.ContainsKey(key)) | dictionary ?\| keys | Added in 11.0 | +| .NET | SQL | Notes | +|--------------------------------------------- | ----------------------------------------------------------------- | ----------- | +| dictionary[key] | dictionary -> key | Added in 11.0 | +| dictionary.GetValueOrDefault(key) | dictionary -> key | Added in 11.0 | +| dictionary.GetValueOrDefault(key, fallback) | CASE WHEN dictionary ? key THEN dictionary -> key ELSE fallback END | Added in 11.0 | +| dictionary.ContainsKey(key) | dictionary ? key | Added in 11.0 | +| dictionary.ContainsValue(value) | array_position(avals(dictionary), value) IS NOT NULL | Added in 11.0 | +| dictionary.Count | cardinality(akeys(dictionary)) | Added in 11.0 | +| immutableDictionary.IsEmpty | cardinality(akeys(immutableDictionary)) = 0 | Added in 11.0 | +| immutableDictionary.Remove(key) | immutableDictionary - key | Added in 11.0 | +| immutableDictionary.RemoveRange(keys) | immutableDictionary - keys | Added in 11.0 | +| immutableDictionary.SetItems(otherDictionary) | immutableDictionary \|\| otherDictionary | Added in 11.0 | +| keys.All(key => dictionary.ContainsKey(key)) | dictionary ?& keys | Added in 11.0 | +| keys.Any(key => dictionary.ContainsKey(key)) | dictionary ?\| keys | Added in 11.0 | Lookups return SQL `NULL` for missing keys, stored NULL values and NULL hstores, rather than throwing for a missing key. The explicit `GetValueOrDefault` fallback distinguishes a missing key from a key whose stored value is NULL. Key existence and counts include stored-NULL entries, and `ContainsValue(null)` finds stored NULL values. @@ -159,22 +162,22 @@ Lookups return SQL `NULL` for missing keys, stored NULL values and NULL hstores, ## Miscellaneous functions -.NET | SQL | Notes ----------------------------------------- | -------------------- | ----- +.NET | SQL | Notes +---------------------------------------- | ------------------------------------- | ----- collection.Contains(item) | item IN collection enumValue.HasFlag(flag) | enumValue & flag = flag Guid.CreateVersion7() | [uuidv7()](https://www.postgresql.org/docs/current/functions-uuid.html) (added in 10 when targeting PG18, client-evaluated otherwise) Guid.NewGuid() | [gen_random_uuid()](https://www.postgresql.org/docs/current/functions-uuid.html), or [uuid_generate_v4()](https://www.postgresql.org/docs/current/uuid-ossp.html) from [`uuid-ossp`](https://www.postgresql.org/docs/current/uuid-ossp.html) when targeting pre-PG13 nullable.GetValueOrDefault() | coalesce(nullable, 0) nullable.GetValueOrDefault(defaultValue) | coalesce(nullable, defaultValue) -bool.Parse(stringValue) | CAST(stringValue AS boolean) | Added in 11.0 -byte.Parse(stringValue) | CAST(stringValue AS smallint) | Added in 11.0 -short.Parse(stringValue) | CAST(stringValue AS smallint) | Added in 11.0 -int.Parse(stringValue) | CAST(stringValue AS integer) | Added in 11.0 -long.Parse(stringValue) | CAST(stringValue AS bigint) | Added in 11.0 -float.Parse(stringValue) | CAST(stringValue AS real) | Added in 11.0 +bool.Parse(stringValue) | CAST(stringValue AS boolean) | Added in 11.0 +byte.Parse(stringValue) | CAST(stringValue AS smallint) | Added in 11.0 +short.Parse(stringValue) | CAST(stringValue AS smallint) | Added in 11.0 +int.Parse(stringValue) | CAST(stringValue AS integer) | Added in 11.0 +long.Parse(stringValue) | CAST(stringValue AS bigint) | Added in 11.0 +float.Parse(stringValue) | CAST(stringValue AS real) | Added in 11.0 double.Parse(stringValue) | CAST(stringValue AS double precision) | Added in 11.0 -decimal.Parse(stringValue) | CAST(stringValue AS numeric) | Added in 11.0 +decimal.Parse(stringValue) | CAST(stringValue AS numeric) | Added in 11.0 ## Binary functions @@ -298,7 +301,7 @@ The below translations provide functionality for determining similarities and di ------------------------------------------------------------------------------------------------------ | -------------------- | ----- EF.Functions.FuzzyStringMatchSoundex(text) | [soundex(text)](https://www.postgresql.org/docs/current/fuzzystrmatch.html#FUZZYSTRMATCH-SOUNDEX) EF.Functions.FuzzyStringMatchDifference(source, target) | [difference(source, target)](https://www.postgresql.org/docs/current/fuzzystrmatch.html#FUZZYSTRMATCH-SOUNDEX) -EF.Functions.FuzzyStringMatchDaitchMokotoff(text) | [daitch_mokotoff(text)](https://www.postgresql.org/docs/current/fuzzystrmatch.html#FUZZYSTRMATCH-DAITCH-MOKOTOFF) | Added in 11.0. Requires PostgreSQL 16+ +EF.Functions.FuzzyStringMatchDaitchMokotoff(text) | [daitch_mokotoff(text)](https://www.postgresql.org/docs/current/fuzzystrmatch.html#FUZZYSTRMATCH-DAITCH-MOKOTOFF) | Added in 11.0. Requires PostgreSQL 16+ EF.Functions.FuzzyStringMatchLevenshtein(source, target) | [levenshtein(source, target)](https://www.postgresql.org/docs/current/fuzzystrmatch.html#FUZZYSTRMATCH-LEVENSHTEIN) EF.Functions.FuzzyStringMatchLevenshtein(source, target, ins_cost, del_cost, sub_cost) | [levenshtein(source, target, ins_cost, del_cost, sub_cost)](https://www.postgresql.org/docs/current/fuzzystrmatch.html#FUZZYSTRMATCH-LEVENSHTEIN) EF.Functions.FuzzyStringMatchLevenshteinLessEqual(source, target, max_d) | [levenshtein(source, target, max_d)](https://www.postgresql.org/docs/current/fuzzystrmatch.html#FUZZYSTRMATCH-LEVENSHTEIN) diff --git a/conceptual/EFCore.PG/release-notes/11.0.md b/conceptual/EFCore.PG/release-notes/11.0.md index 60f97b39..bb035dc2 100644 --- a/conceptual/EFCore.PG/release-notes/11.0.md +++ b/conceptual/EFCore.PG/release-notes/11.0.md @@ -119,6 +119,7 @@ EFCore.PG 11 brings many new query translations across the board: | [dateTime.AddMilliseconds(value)](../mapping/translations.md#date-and-time-functions) | dateTime + value * INTERVAL '1 milliseconds' | Also DateTimeOffset ([#3449](https://github.com/npgsql/efcore.pg/issues/3449)). | | [dateTime.AddMicroseconds(value)](../mapping/translations.md#date-and-time-functions) | dateTime + value * INTERVAL '1 microseconds' | Also DateTimeOffset ([#3449](https://github.com/npgsql/efcore.pg/issues/3449)). | | [LocalDateTime.ToDateTimeUnspecified()](../mapping/nodatime.md#operation-translation) | No PG operation | NodaTime ([#4048](https://github.com/npgsql/efcore.pg/issues/4048)). | +| [Duration.Milliseconds](../mapping/nodatime.md#operation-translation) | trunc(date_part('millisecond', justify_hours(duration)))::INT % 1000 | NodaTime ([#4088](https://github.com/npgsql/efcore.pg/issues/4088)). | | stringValue.IndexOf(value) | strpos(stringValue, value) - 1 | char overload ([#3547](https://github.com/npgsql/efcore.pg/issues/3547)). | | stringValue.Replace(oldValue, newValue) | replace(stringValue, oldValue, newValue) | char overload | | stringValue.StartsWith(value) | stringValue LIKE value \|\| '%' | char overload | @@ -163,6 +164,7 @@ The following [hstore translations](../mapping/translations.md#hstore-translatio * [Creating PostgreSQL extensions](../misc/other.md#postgresql-extensions) now uses `CASCADE` to install their dependencies automatically ([#3682](https://github.com/npgsql/efcore.pg/issues/3682)). * Support specifying the encoding of newly created PostgreSQL databases with `ModelBuilder.UseDatabaseEncoding()` ([#3526](https://github.com/npgsql/efcore.pg/issues/3526)). * Custom collation references now respect the model's default schema and correctly handle dots in collation and schema names across migrations, queries and scaffolding ([#4072](https://github.com/npgsql/efcore.pg/pull/4072)). +* `DateOnly.AddMonths()` and `DateOnly.AddYears()` now cast the result back to `date` (`CAST(dateOnly + INTERVAL '1 months' AS date)`), so that it can be projected, and are also translated for a `DateOnly` parameter. Previously, PostgreSQL returned a `timestamp`, which couldn't be read as a `DateOnly`. See the [11.0.0 milestone](https://github.com/npgsql/efcore.pg/milestone/75?closed=1) for the full list of issues. @@ -185,6 +187,74 @@ In addition, PostgreSQL serialization failures (`40001`) and deadlocks (`40P01`) These changes do not enable automatic retries. See [execution strategies](../misc/other.md#execution-strategy) and [database concurrency failures](../modeling/concurrency.md#database-concurrency-failures) for details ([#3098](https://github.com/npgsql/efcore.pg/issues/3098)). +### Date/time arithmetic on timestamptz is computed in UTC + +Adding or subtracting a `TimeSpan` or `Duration` to a `DateTime`, `DateTimeOffset`, NodaTime `Instant` or `ZonedDateTime` mapped to `timestamp with time zone`, as well as `AddYears()`, `AddMonths()`, `AddDays()` etc. on `DateTime` and `DateTimeOffset`, is now computed in UTC, as `(x AT TIME ZONE 'UTC' + interval) AT TIME ZONE 'UTC'`. Previously, PostgreSQL added the interval's days and months in the session time zone, so that across a daylight saving time transition, `instant + Duration.FromDays(1)` added 23 or 25 hours rather than the 24 hours added by NodaTime and .NET. Such queries now return the same results as the equivalent .NET code, regardless of the session time zone. Values mapped to `timestamp without time zone` aren't affected. With the [legacy timestamp behavior](6.0.md#opting-out-of-the-new-timestamp-mapping-logic), arithmetic which follows the local time, like PostgreSQL's in the session time zone, isn't affected either: a `DateTime` mapped to `timestamp with time zone` is then a local date and time, whose arithmetic keeps the local time across a daylight saving time transition, and a `DateTimeOffset` has the local offset, in which `AddMonths()` and `AddYears()` add calendar months ([#4087](https://github.com/npgsql/efcore.pg/issues/4087)). + +### TimeSpan and Duration components are truncated + +`Days`, `Hours`, `Minutes`, `Seconds` and `Milliseconds` on `TimeSpan` and NodaTime `Duration` now return the same values as .NET for negative values, and for intervals computed in SQL (e.g. the difference between two timestamps). Previously, `Seconds` and `Milliseconds` were rounded down with `floor()`, whereas .NET truncates them towards zero (`TimeSpan.FromMilliseconds(-1500).Seconds` is -1, not -2). Each component was also read from a single one of PostgreSQL's interval fields, whereas an interval computed in SQL can have a time field of more than 24 hours, or with a different sign from its day field (e.g. `1 day -01:00:00`). The components are now computed from [`justify_hours()`](https://www.postgresql.org/docs/current/functions-datetime.html) ([#4088](https://github.com/npgsql/efcore.pg/issues/4088)). + +### Negative TimeSpan and Duration values with days + +PostgreSQL applies a sign in an interval [to the field it precedes only](https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-INTERVAL-INPUT), so `INTERVAL '-1 02:00:00'` is -1 day and +2 hours, i.e. -22 hours. Previous versions generated negative `TimeSpan` and `Duration` values with days that way, so that for example -26 hours became -22 hours in SQL literals (including seed data and default values in migrations). Negative values are now generated with a sign on both the days and the time (`'-1 -02:00:00'`), in SQL and in JSON. Seed data and default values already created by previous versions aren't changed ([#4086](https://github.com/npgsql/efcore.pg/issues/4086)). + +Previous versions also wrote negative values with days in this format to JSON, meaning that the whole value is negative. By default, values with a sign on the days only (e.g. `"-1 02:00:00"`) are therefore still read from JSON with that meaning (-26 hours), so that JSON data written by previous versions is read back unchanged. If your JSON data was written by another application, which intended PostgreSQL's meaning (-22 hours), disable the `Npgsql.ParseOldEFCoreJsonTimeSpans` [AppContext switch](https://learn.microsoft.com/dotnet/api/system.appcontext) at the start of your application, before any Npgsql or EF Core operations are invoked: + +```c# +AppContext.SetSwitch("Npgsql.ParseOldEFCoreJsonTimeSpans", false); +``` + +Like the other Npgsql AppContext switches, this applies to the whole application. It can also be set in your project file: + +```xml + + + +``` + +Values written by this version, values without days, and values read from `interval` columns aren't affected by the switch. + +#### Migrating negative TimeSpan and Duration values in JSON + +The `Npgsql.ParseOldEFCoreJsonTimeSpans` switch only applies when the provider reads JSON in .NET, e.g. when loading an entity or a whole JSON document. A query which evaluates a JSON member in PostgreSQL, e.g. filtering or ordering on it, or projecting the member alone, converts it to `interval` in PostgreSQL, which gives `"-1 02:00:00"` its PostgreSQL meaning (-22 hours). This was already the case in previous versions; in addition, since constants are now generated in the new format (`INTERVAL '-1 -02:00:00'`), comparing an old JSON value with a constant no longer matches. A query can't tell which format a value was written in, so existing JSON data with negative `TimeSpan` or `Duration` values with days must be converted to the new format manually, e.g. in a migration. + +Convert each JSON property which holds a `TimeSpan` or `Duration`, by its path, rather than by searching the serialized JSON (which could also change unrelated strings). An old value has a sign on the days only (`-1 02:00:00`), and is converted by adding a sign to the time (`-1 -02:00:00`). For example, for a `Delay` property in a `jsonb` column `Details`: + +```sql +UPDATE "Customers" +SET "Details" = jsonb_set( + "Details", + '{Delay}', + to_jsonb(split_part("Details"->>'Delay', ' ', 1) || ' -' || split_part("Details"->>'Delay', ' ', 2))) +WHERE "Details"->>'Delay' LIKE '-% __:__:__%' + AND "Details"->>'Delay' NOT LIKE '-% -%' + AND "Details"->>'Delay' NOT LIKE '% % %'; +``` + +For a nested property, use its full path in each place, e.g. `'{Address,Delay}'` in `jsonb_set()` and `"Details"#>>'{Address,Delay}'` instead of `"Details"->>'Delay'`. For a collection, convert each element of the JSON array, e.g. for a `Delays` property: + +```sql +UPDATE "Customers" +SET "Details" = jsonb_set( + "Details", + '{Delays}', + (SELECT jsonb_agg( + CASE + WHEN e #>> '{}' LIKE '-% __:__:__%' AND e #>> '{}' NOT LIKE '-% -%' AND e #>> '{}' NOT LIKE '% % %' + THEN to_jsonb(split_part(e #>> '{}', ' ', 1) || ' -' || split_part(e #>> '{}', ' ', 2)) + ELSE e + END + ORDER BY i) + FROM jsonb_array_elements("Details"->'Delays') WITH ORDINALITY AS a(e, i))) +WHERE jsonb_typeof("Details"->'Delays') = 'array' AND "Details"->'Delays' <> '[]'; +``` + +For a collection of objects, convert the property of each element `e` in the same way, with `jsonb_set(e, '{Delay}', ...)`. These statements only change values in the old format; values without days, positive values, values already in the new format and `null` values are left unchanged, so they can safely be run more than once. Don't convert values written by another application which intended PostgreSQL's meaning (see the switch above). + +> [!WARNING] +> Previous versions of the provider read the new format incorrectly: `"-1 -02:00:00"` (-26 hours) is read as -22 hours. Once 11.0 writes negative values with days to JSON, or the data is converted as above, it must no longer be read by applications using a previous version of the provider, e.g. during a rolling deployment where old and new versions run side by side, or after rolling back an upgrade. Upgrade all applications which read the JSON data before writing or converting it with 11.0. + ### Initial migration rollback removes model-level objects Rolling back a newly scaffolded initial migration now drops enum, range and collation definitions that were previously left behind. If other applications use these objects, the rollback can fail due to their dependencies; remove the corresponding drop operations from `Down()` if the objects must be preserved ([#3604](https://github.com/npgsql/efcore.pg/issues/3604)). diff --git a/conceptual/Npgsql/types/datetime.md b/conceptual/Npgsql/types/datetime.md index 6718df18..66af6848 100644 --- a/conceptual/Npgsql/types/datetime.md +++ b/conceptual/Npgsql/types/datetime.md @@ -79,3 +79,15 @@ interval | TimeSpan | 1 UTC DateTime is written as `timestamp with time zone`, Local/Unspecified DateTimes are written as `timestamp without time zone`. In versions prior to 6.0 (or when `Npgsql.EnableLegacyTimestampBehavior` is enabled), DateTime is always written as `timestamp without time zone`. 2In versions prior to 6.0 (or when `Npgsql.EnableLegacyTimestampBehavior` is enabled), `DbType.DateTime` is mapped to `timestamp without time zone`. + +## Negative intervals in JSON (EF Core provider) + +Prior to version 11.0, the EF Core provider wrote invalid values for `TimeSpan` and `Duration` to JSON for values with days. +PostgreSQL applies a sign in an interval [to the field it precedes only](https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-INTERVAL-INPUT), so `-1 02:00:00` is -1 day and +2 hours, i.e. -22 hours. Starting with version 11.0, the EF Core provider writes negative `TimeSpan` and NodaTime `Duration` values with days to JSON with a sign on both the days and the time (`-1 -02:00:00` for -26 hours). Previous versions wrote a sign on the days only (`-1 02:00:00` for -26 hours). +By default, such values are still read from JSON as -26 hours, and JSON data written by previous versions is read back unchanged. If your JSON data was written by another application, which intended PostgreSQL's meaning (-22 hours), set the following AppContext switch at the start of your application, before any Npgsql or EF Core operations are invoked: + +```csharp +AppContext.SetSwitch("Npgsql.ParseOldEFCoreJsonTimeSpans", false); +``` + +This only affects JSON read by the EF Core provider; values read from `interval` columns aren't affected. See the EF Core provider's 11.0 release notes on [negative values with days](/efcore/release-notes/11.0.html#negative-timespan-and-duration-values-with-days) and [migrating them in JSON](/efcore/release-notes/11.0.html#migrating-negative-timespan-and-duration-values-in-json) for details.