diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CalendarFactoryParityTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CalendarFactoryParityTests.cs index ed8226d..5e56df7 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CalendarFactoryParityTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CalendarFactoryParityTests.cs @@ -121,6 +121,36 @@ async Task Agree(string label, Expression> key, bo await Agree("OrderBy(IsoWeek(WeekYear, Week))", r => DateRange.IsoWeek(r.WeekYear, r.Week), descending: false); } + /// + /// "Filter, join and sort by it" (#451): a join on a period pairs the rows memory pairs, the first and last + /// periods included, where a date form meets an integer form. + /// + [TestMethod] + public async Task CalendarFactories_AsAJoinKey_PairTheRowsMemoryPairs() + { + ContainerLifecycle.RequireServer(); + var connectionString = await Seed(); + + await using var context = new CalendarContext(connectionString); + var loaded = await context.Rows.AsNoTracking().ToListAsync(); + var (minimum, maximum) = (loaded.Single(r => r.Day == DateOnly.MinValue).Id, loaded.Single(r => r.Day == DateOnly.MaxValue).Id); + + async Task Agree(string label, Expression> outer, Expression> inner) + { + var server = (await context.Rows.Join(context.Rows, outer, inner, (x, y) => new { X = x.Id, Y = y.Id }).ToListAsync()) + .Select(pair => (pair.X, pair.Y)).Order().ToList(); + var memory = loaded.Join(loaded, outer.Compile(), inner.Compile(), (x, y) => (x.Id, y.Id)).Order().ToList(); + + CollectionAssert.AreEqual(memory, server, $"{label}: server {server.Count} pairs, memory {memory.Count}"); + Assert.IsTrue(memory.Any(pair => pair.Item1 == minimum) && memory.Any(pair => pair.Item1 == maximum), + $"{label}: the first and last periods must find a partner, or the extremes are never joined."); + } + + await Agree("Month(x.Day) == Month(y.Year, y.Month)", x => DateRange.Month(x.Day), y => DateRange.Month(y.Year, y.Month)); + await Agree("Quarter(x.Day) == Quarter(y.Year, y.Quarter)", x => DateRange.Quarter(x.Day), y => DateRange.Quarter(y.Year, y.Quarter)); + await Agree("Year(x.Day) == Year(y.Year)", x => DateRange.Year(x.Day), y => DateRange.Year(y.Year)); + } + [TestMethod] public async Task CalendarFactories_OverAnArgumentMemoryRefuses_RaiseInPostgres() { diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 57cbd63..ac645e1 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1229,7 +1229,7 @@ "id": "E76-calendar-factories-over-a-column-run-as-sql", "source": "docs/efcore.md#what-runs-where", "quote": "**`DateRange.Month`, `Quarter`, `Year` and `IsoWeek` over a column build the same range in the database as in memory.** `DateRange.Month(b.Day)` gives the month that holds each row's day, so you can filter, join and sort by it. The first period starts at `-infinity` and the last ends at `infinity`, as Npgsql writes `DateOnly.MinValue` and `MaxValue` (the days themselves with `Npgsql.DisableDateTimeInfinityConversions`), so they still equal a stored range.", - "claim": "DateRange.Month, Quarter, Year and IsoWeek over a date column or integer columns run as SQL (daterange(first, last, '[]') with memory's first and last periods as constants, -infinity/infinity under Npgsql's conversions and the days themselves without them) and match memory over every period boundary, the ISO weeks of the neighbouring year and the extremes, NULL arguments (rule (c)) and a missing outer-join child, in a filter, its negation, a null test, equality with a stored range, a compound predicate, an ordering and a final projection; an argument memory rejects raises a PostgreSQL error, never a rolled-over week.", + "claim": "DateRange.Month, Quarter, Year and IsoWeek over a date column or integer columns run as SQL (daterange(first, last, '[]') with memory's first and last periods as constants, -infinity/infinity under Npgsql's conversions and the days themselves without them) and match memory over every period boundary, the ISO weeks of the neighbouring year and the extremes, NULL arguments (rule (c)) and a missing outer-join child, in a filter, its negation, a null test, equality with a stored range, a compound predicate, an ordering, a join key (a date form against an integer form, the first and last periods included) and a final projection; an argument memory rejects raises a PostgreSQL error, never a rolled-over week.", "oracle": "differential", "surfaces": [ "DateRange.Month", diff --git a/test/oracles/seeds/calendar-iso-week-53-of-a-52-week-year-rolls-over.patch b/test/oracles/seeds/calendar-iso-week-53-of-a-52-week-year-rolls-over.patch new file mode 100644 index 0000000..69bed61 --- /dev/null +++ b/test/oracles/seeds/calendar-iso-week-53-of-a-52-week-year-rolls-over.patch @@ -0,0 +1,18 @@ +Seed: the ISO week check accepts one week more than the week-year has, so week 53 of a 52-week year rolls over to the next year's week 1 where memory throws +Oracles: CalendarFactoryParityTests +Fails: CalendarFactories_OverAnArgumentMemoryRefuses_RaiseInPostgres alone: IsoWeek(2025, 53) Select and Where, the server answered, where memory throws +History: never shipped; the class of #191's to_date rollover, kept out by the week guard (#451) + +diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +index a9a151d..e1dc3a0 100644 +--- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs ++++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +@@ -841,7 +841,7 @@ internal sealed class ValueRangesMethodCallTranslator( + var checkedWeek = sqlExpressionFactory.Case( + [ + new CaseWhenClause( +- sqlExpressionFactory.AndAlso(sqlExpressionFactory.GreaterThanOrEqual(week, Int(1)), sqlExpressionFactory.LessThanOrEqual(week, weeks)), ++ sqlExpressionFactory.AndAlso(sqlExpressionFactory.GreaterThanOrEqual(week, Int(1)), sqlExpressionFactory.LessThanOrEqual(week, sqlExpressionFactory.Add(weeks, Int(1)))), + week) + ], + Raise([$"{name}: the week-year ", Text(year), " has no week ", Text(week)], typeof(int), IntMapping)); diff --git a/test/oracles/seeds/calendar-last-period-ends-on-a-day-not-infinity.patch b/test/oracles/seeds/calendar-last-period-ends-on-a-day-not-infinity.patch index 51abb59..c59b654 100644 --- a/test/oracles/seeds/calendar-last-period-ends-on-a-day-not-infinity.patch +++ b/test/oracles/seeds/calendar-last-period-ends-on-a-day-not-infinity.patch @@ -1,7 +1,6 @@ Seed: the calendar factories write the last period's end as the day 9999-12-31 rather than the infinity Npgsql binds for DateOnly.MaxValue Oracles: CalendarFactoryParityTests -Fails: Month(r.Day) == r.Period and Month(r.Day).Contains(DateOnly.MaxValue) on the rows in 9999-12: server drops them, rule (c) selects them; Month(r.Day) in a Select does not load ([9999-12-01,10000-01-01)) -diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +Fails: Month(r.Day) == r.Period and Month(r.Day).Contains(DateOnly.MaxValue) on the rows in 9999-12: server drops them, rule (c) selects them; Month(r.Day) in a Select does not load ([9999-12-01,10000-01-01)); the join on Month(x.Day) == Month(y.Year, y.Month) loses the pairs of the rows in 9999-12 diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs index a9a151d..317b133 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs