From 89b5910057741d79144174dfd704430d6c846f75 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Thu, 5 Nov 2026 07:14:46 +0300 Subject: [PATCH] Return NULL from DateInterval.Intersection for disjoint intervals NodaTime returns null when two intervals share no day, but PostgreSQL's daterange * operator returns an empty range, so null tests on the result filtered the wrong rows. Translate to CASE WHEN a && b THEN a * b END. Fixes #4083 --- ...pgsqlNodaTimeMethodCallTranslatorPlugin.cs | 10 +++- .../NodaTime/DateIntervalTranslationsTest.cs | 49 ++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMethodCallTranslatorPlugin.cs b/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMethodCallTranslatorPlugin.cs index 78c1a695f9..9104730f2c 100644 --- a/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMethodCallTranslatorPlugin.cs +++ b/src/EFCore.PG.NodaTime/Query/Internal/NpgsqlNodaTimeMethodCallTranslatorPlugin.cs @@ -353,7 +353,15 @@ PgFunctionExpression IntervalPartOverBigInt(string datePart, SqlExpression param if (method.DeclaringType == typeof(DateInterval) && method.Name == nameof(DateInterval.Intersection)) { - return _sqlExpressionFactory.MakePostgresBinary(PgExpressionType.RangeIntersect, instance!, arguments[0]); + // NodaTime returns null when the two intervals share no day, but PostgreSQL's * operator returns an empty range, + // which isn't NULL. Return NULL unless the ranges overlap. + return _sqlExpressionFactory.Case( + [ + new CaseWhenClause( + _sqlExpressionFactory.Overlaps(instance!, arguments[0]), + _sqlExpressionFactory.MakePostgresBinary(PgExpressionType.RangeIntersect, instance!, arguments[0])) + ], + elseResult: null); } if (method.DeclaringType == typeof(DateInterval) && method.Name == nameof(DateInterval.Union)) diff --git a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/DateIntervalTranslationsTest.cs b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/DateIntervalTranslationsTest.cs index 35fc4cf586..cd1b1939b7 100644 --- a/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/DateIntervalTranslationsTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/Translations/NodaTime/DateIntervalTranslationsTest.cs @@ -132,7 +132,54 @@ 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."DateInterval" * @dateInterval = '[2018-04-22,2018-04-24]'::daterange +WHERE CASE + WHEN n."DateInterval" && @dateInterval THEN n."DateInterval" * @dateInterval +END = '[2018-04-22,2018-04-24]'::daterange +"""); + } + + [ConditionalTheory] // #4083 + [MemberData(nameof(IsAsyncData))] + public async Task Intersection_with_disjoint_interval_is_null(bool async) + { + var dateInterval = new DateInterval(new LocalDate(2018, 5, 1), new LocalDate(2018, 5, 5)); + + await AssertQuery( + async, + ss => ss.Set().Where(t => t.DateInterval.Intersection(dateInterval) == null)); + + AssertSql( + """ +@dateInterval='[2018-05-01, 2018-05-05]' (DbType = Object) + +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 CASE + WHEN n."DateInterval" && @dateInterval THEN n."DateInterval" * @dateInterval +END IS NULL +"""); + } + + [ConditionalTheory] // #4083 + [MemberData(nameof(IsAsyncData))] + public async Task Intersection_with_disjoint_interval_not_null(bool async) + { + var dateInterval = new DateInterval(new LocalDate(2018, 5, 1), new LocalDate(2018, 5, 5)); + + await AssertQuery( + async, + ss => ss.Set().Where(t => t.DateInterval.Intersection(dateInterval) != null), + assertEmpty: true); + + AssertSql( + """ +@dateInterval='[2018-05-01, 2018-05-05]' (DbType = Object) + +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 CASE + WHEN n."DateInterval" && @dateInterval THEN n."DateInterval" * @dateInterval +END IS NOT NULL """); }