From 0163ab8aaa745b8e3da72bea0cfff69d41de7bce Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sun, 25 Oct 2026 11:09:01 +0200 Subject: [PATCH] Fix NodaTime BCE year extraction Correct negative PostgreSQL date/time years to NodaTime astronomical numbering without changing Period.Years or LocalDate subtraction. Partially addresses #4089. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../NpgsqlNodaTimeMemberTranslatorPlugin.cs | 11 +++- .../NodaTime/LocalDateTimeTranslationsTest.cs | 40 ++++++++++++++- .../NodaTime/LocalDateTranslationsTest.cs | 40 ++++++++++++++- .../NodaTime/PeriodTranslationsTest.cs | 15 ++++++ .../NodaTime/ZonedDateTimeTranslationsTest.cs | 50 ++++++++++++++++++- 5 files changed, 152 insertions(+), 4 deletions(-) diff --git a/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMemberTranslatorPlugin.cs b/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMemberTranslatorPlugin.cs index 833c4ffc1c..bd3116f842 100644 --- a/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMemberTranslatorPlugin.cs +++ b/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMemberTranslatorPlugin.cs @@ -225,7 +225,16 @@ SqlExpression Upper() private SqlExpression? TranslateDateTime(SqlExpression instance, MemberInfo member) => member.Name switch { - "Year" or "Years" => GetDatePartExpression(instance, "year"), + // PostgreSQL has no year zero, whereas NodaTime uses astronomical year numbering. + "Year" when GetDatePartExpression(instance, "year") is var year + => _sqlExpressionFactory.Case( + [ + new CaseWhenClause( + _sqlExpressionFactory.LessThan(year, _sqlExpressionFactory.Constant(0)), + _sqlExpressionFactory.Add(year, _sqlExpressionFactory.Constant(1))) + ], + year), + "Years" => GetDatePartExpression(instance, "year"), "Month" or "Months" => GetDatePartExpression(instance, "month"), "DayOfYear" => GetDatePartExpression(instance, "doy"), "Day" or "Days" => GetDatePartExpression(instance, "day"), diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/LocalDateTimeTranslationsTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/LocalDateTimeTranslationsTest.cs index f9e75948af..99434990cd 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/LocalDateTimeTranslationsTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/LocalDateTimeTranslationsTest.cs @@ -40,10 +40,48 @@ 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('year', n."LocalDateTime")::int = 2018 +WHERE CASE + WHEN date_part('year', n."LocalDateTime")::int < 0 THEN date_part('year', n."LocalDateTime")::int + 1 + ELSE date_part('year', n."LocalDateTime")::int +END = 2018 """); } + [ConditionalTheory] + [InlineData(false, -100)] + [InlineData(true, -100)] + [InlineData(false, 0)] + [InlineData(true, 0)] + [InlineData(false, 1)] + [InlineData(true, 1)] + public async Task Year_astronomical(bool async, int year) + { + await using var context = CreateContext(); + await context.Database.CreateExecutionStrategy().ExecuteAsync( + async () => + { + await using var transaction = await context.Database.BeginTransactionAsync(); + + var entity = await context.NodaTimeTypes.SingleAsync(); + entity.LocalDateTime = new LocalDateTime(year, 12, 31, 12, 0); + await context.SaveChangesAsync(); + Fixture.TestSqlLoggerFactory.Clear(); + + var query = context.NodaTimeTypes.Select(t => t.LocalDateTime.Year); + Assert.Equal(year, async ? await query.SingleAsync() : query.Single()); + + AssertSql( + """ +SELECT CASE + WHEN date_part('year', n."LocalDateTime")::int < 0 THEN date_part('year', n."LocalDateTime")::int + 1 + ELSE date_part('year', n."LocalDateTime")::int +END +FROM "NodaTimeTypes" AS n +LIMIT 2 +"""); + }); + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public async Task Month(bool async) diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/LocalDateTranslationsTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/LocalDateTranslationsTest.cs index b0b7055839..d0872a475b 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/LocalDateTranslationsTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/LocalDateTranslationsTest.cs @@ -108,10 +108,48 @@ 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('year', n."LocalDate")::int = 2018 +WHERE CASE + WHEN date_part('year', n."LocalDate")::int < 0 THEN date_part('year', n."LocalDate")::int + 1 + ELSE date_part('year', n."LocalDate")::int +END = 2018 """); } + [ConditionalTheory] + [InlineData(false, -100)] + [InlineData(true, -100)] + [InlineData(false, 0)] + [InlineData(true, 0)] + [InlineData(false, 1)] + [InlineData(true, 1)] + public async Task Year_astronomical(bool async, int year) + { + await using var context = CreateContext(); + await context.Database.CreateExecutionStrategy().ExecuteAsync( + async () => + { + await using var transaction = await context.Database.BeginTransactionAsync(); + + var entity = await context.NodaTimeTypes.SingleAsync(); + entity.LocalDate = new LocalDate(year, 12, 31); + await context.SaveChangesAsync(); + Fixture.TestSqlLoggerFactory.Clear(); + + var query = context.NodaTimeTypes.Select(t => t.LocalDate.Year); + Assert.Equal(year, async ? await query.SingleAsync() : query.Single()); + + AssertSql( + """ +SELECT CASE + WHEN date_part('year', n."LocalDate")::int < 0 THEN date_part('year', n."LocalDate")::int + 1 + ELSE date_part('year', n."LocalDate")::int +END +FROM "NodaTimeTypes" AS n +LIMIT 2 +"""); + }); + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public async Task Month(bool async) diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/PeriodTranslationsTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/PeriodTranslationsTest.cs index 01a9abc945..2407ed3f4d 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/PeriodTranslationsTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/PeriodTranslationsTest.cs @@ -28,6 +28,21 @@ WHERE date_part('year', n."Period")::int = 2018 """); } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Years_negative(bool async) + { + await AssertQuery( + async, + ss => ss.Set().Select(t => Period.FromYears(-t.Id).Years)); + + AssertSql( + """ +SELECT date_part('year', make_interval(years => -n."Id"))::int +FROM "NodaTimeTypes" AS n +"""); + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public async Task Months(bool async) diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/ZonedDateTimeTranslationsTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/ZonedDateTimeTranslationsTest.cs index e222aa5232..f45930d20c 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/ZonedDateTimeTranslationsTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/ZonedDateTimeTranslationsTest.cs @@ -40,10 +40,58 @@ 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('year', n."ZonedDateTime" AT TIME ZONE 'UTC')::int = 2018 +WHERE CASE + WHEN date_part('year', n."ZonedDateTime" AT TIME ZONE 'UTC')::int < 0 THEN date_part('year', n."ZonedDateTime" AT TIME ZONE 'UTC')::int + 1 + ELSE date_part('year', n."ZonedDateTime" AT TIME ZONE 'UTC')::int +END = 2018 """); } + [ConditionalTheory] + [InlineData(false, -100)] + [InlineData(true, -100)] + [InlineData(false, 0)] + [InlineData(true, 0)] + [InlineData(false, 1)] + [InlineData(true, 1)] + public async Task Year_astronomical(bool async, int year) + { + await using var context = CreateContext(); + await context.Database.CreateExecutionStrategy().ExecuteAsync( + async () => + { + await using var transaction = await context.Database.BeginTransactionAsync(); + + var entity = await context.NodaTimeTypes.SingleAsync(); + entity.ZonedDateTime = new LocalDateTime(year, 12, 31, 12, 0).InUtc(); + await context.SaveChangesAsync(); + Fixture.TestSqlLoggerFactory.Clear(); + + var query = context.NodaTimeTypes.Select( + t => new + { + t.ZonedDateTime.Year, + YearInZone = t.ZonedDateTime.ToInstant().InZone(DateTimeZoneProviders.Tzdb["Europe/Berlin"]).Year + }); + var result = async ? await query.SingleAsync() : query.Single(); + Assert.Equal(year, result.Year); + Assert.Equal(entity.ZonedDateTime.WithZone(DateTimeZoneProviders.Tzdb["Europe/Berlin"]).Year, result.YearInZone); + + AssertSql( + """ +SELECT CASE + WHEN date_part('year', n."ZonedDateTime" AT TIME ZONE 'UTC')::int < 0 THEN date_part('year', n."ZonedDateTime" AT TIME ZONE 'UTC')::int + 1 + ELSE date_part('year', n."ZonedDateTime" AT TIME ZONE 'UTC')::int +END AS "Year", CASE + WHEN date_part('year', n."ZonedDateTime" AT TIME ZONE 'Europe/Berlin')::int < 0 THEN date_part('year', n."ZonedDateTime" AT TIME ZONE 'Europe/Berlin')::int + 1 + ELSE date_part('year', n."ZonedDateTime" AT TIME ZONE 'Europe/Berlin')::int +END AS "YearInZone" +FROM "NodaTimeTypes" AS n +LIMIT 2 +"""); + }); + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public async Task Month(bool async)