From a1814b0820b383ab9187b1d086c0907da2c69c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 26 Oct 2026 17:58:54 +0100 Subject: [PATCH 1/4] fix: read a YearMonthRange upper bound in a query as the first day of its month (#506) A YearMonthRange is a month-aligned daterange, and a YearMonth operand binds as the first day of its month. UpperBound() read upper(x) - 1, the last day of the last month: it loaded as that month, but in a filter it compared unequal to it, and greater. UpperBound() == january and Clamp(february) == january were false for [2026-01,2026-01], where memory gives true; RangeSet.UpperBound() and Clamp read the same helper. A definition can name a CoarseUpperBound: the span of one element as an interval, and the element an inclusive infinity stands for. YearMonthRange steps back a month, CAST(upper(x) - INTERVAL '1 month' AS date), and answers 9999-12-01 when the bound is infinity, which no interval steps back from (9999-12 ends on the date Npgsql writes as infinity). With the infinity conversions disabled the bound is a finite 10000-01-01 and the interval reaches 9999-12-01 itself. ClampDomainParityTests compares Clamp(@p) with every point by ==, < and > in a filter on all thirteen range types; YearMonthBoundParityTests does the same for LowerBound() and UpperBound() of the range and its set. With the dispatch removed both fail on YearMonthRange (420 Clamp disagreements), and the other twelve types agree. Found-By: RV Defect-Class: SQL Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 2ca9c86a3b4367cd257d2fb978883d84636a366c) --- .../Internal/YearMonthRangeTypeDefinition.cs | 7 ++ .../Internal/CoarseUpperBound.cs | 18 ++++ .../Internal/IRangeTypeDefinition.cs | 7 ++ .../Query/ValueRangesMethodCallTranslator.cs | 32 ++++++- .../ClampDomainParityTests.cs | 18 ++++ .../YearMonthBoundParityTests.cs | 84 +++++++++++++++++++ .../NodaQueryTranslationTests.cs | 15 +++- 7 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/CoarseUpperBound.cs create mode 100644 test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/YearMonthBoundParityTests.cs diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs index 1398e8be..57bdb3f6 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs @@ -51,6 +51,13 @@ internal sealed class YearMonthRangeTypeDefinition : IRangeTypeDefinition public bool SupportsSqlConstruction => false; + /// + /// A month back from the stored exclusive bound is the first day of the last month, as a + /// operand binds; a day back is its last day, which compares unequal to it (#506). 9999-12 ends on + /// infinity. + /// + public CoarseUpperBound CoarseUpperBound { get; } = new("1 month", new YearMonth(9999, 12)); + public RelationalTypeMapping? ElementTypeMapping { get; } = new YearMonthTypeMapping(); public RelationalTypeMapping RangeTypeMapping { get; } = new YearMonthRangeTypeMapping(); diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/CoarseUpperBound.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/CoarseUpperBound.cs new file mode 100644 index 00000000..fc4ae1f9 --- /dev/null +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/CoarseUpperBound.cs @@ -0,0 +1,18 @@ +namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal; + +/// +/// For a discrete type whose element spans several values of its store type, how the upper bound readers step +/// back from the stored exclusive bound to the element's own value: by rather than by 1, and +/// to from an inclusive infinity (#506). +/// +/// +/// A YearMonthRange is a month-aligned daterange, and a YearMonth operand binds as the first +/// day of its month. upper(x) - 1 is the last day of the last month: it loads as that month, but compares +/// unequal to it, and greater, in a filter. upper(x) - INTERVAL '1 month' is the first day. The last month, +/// 9999-12, ends on the date Npgsql writes as infinity, which no interval steps back from, so the reader +/// answers there. With Npgsql.DisableDateTimeInfinityConversions set the bound is a +/// finite 10000-01-01 and the interval reaches 9999-12-01 on its own. Measured on PostgreSQL 16 and 18. +/// +/// The span of one element, as a PostgreSQL interval literal, e.g. 1 month. +/// The element an inclusive infinity upper bound stands for. +internal sealed record CoarseUpperBound(string Step, object Last); diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/IRangeTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/IRangeTypeDefinition.cs index 0daf2a7d..cf77a01f 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/IRangeTypeDefinition.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Internal/IRangeTypeDefinition.cs @@ -53,6 +53,13 @@ internal interface IRangeTypeDefinition /// ExclusiveInfinity? ExclusiveInfinity => null; + /// + /// For a discrete type whose element spans several values of its store type (YearMonthRange, a month of + /// dates), the step the upper bound readers take back from the stored bound instead of 1, and the element an + /// inclusive infinity stands for. for every other type. + /// + CoarseUpperBound? CoarseUpperBound => null; + /// /// The type mapping for elements of the range, or to resolve it /// from the type mapping source via /. diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs index ad8096ae..ebb3252a 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs @@ -1050,12 +1050,15 @@ private SqlExpression LowerBoundFunction(SqlExpression argument, IRangeTypeDefin /// model's inclusive upper: upper(x) - 1 restores agreement. NULL propagates /// through the subtraction for empty or unbounded operands. An exclusive infinity, /// which - 1 leaves as it is, is read as memory loads it on a type that has an - /// (). + /// (). A type with a + /// steps back by its element's span instead + /// (). /// private SqlExpression UpperBoundFunction(SqlExpression argument, IRangeTypeDefinition definition) { var upper = BoundFunction("upper", argument, definition); if (!definition.IsDiscrete) return upper; + if (definition.CoarseUpperBound is { } coarse) return CoarseUpper(upper, coarse, definition); // The step constant must keep its own integer mapping: `date - 1` is date arithmetic // in PostgreSQL, and applying the element mapping to the 1 would render it as a date. @@ -1066,6 +1069,27 @@ private SqlExpression UpperBoundFunction(SqlExpression argument, IRangeTypeDefin : closed; } + /// + /// CASE WHEN NOT isfinite(upper(x)) THEN last ELSE CAST(upper(x) - INTERVAL 'step' AS store) END: the + /// first value of the last element, as an operand of the element type binds, not the last value of the store + /// type (#506, ). A NULL bound takes the ELSE and stays NULL. + /// + private SqlExpression CoarseUpper(SqlExpression upper, CoarseUpperBound coarse, IRangeTypeDefinition definition) + { + var type = typeof(Nullable<>).MakeGenericType(definition.ElementClrType); + var mapping = ElementMapping(definition); + + // date - interval is a timestamp, typed as one so the cast back to the subtype is written: at midnight of a + // whole day, it loses nothing. + var stepped = new SqlBinaryExpression( + ExpressionType.Subtract, upper, new SqlFragmentExpression($"INTERVAL '{coarse.Step}'", typeof(object), null), + typeof(DateTime), typeMappingSource.FindMapping(typeof(DateTime), "timestamp without time zone")); + + return sqlExpressionFactory.Case( + [new CaseWhenClause(sqlExpressionFactory.Not(BoolFunction("isfinite", upper)), sqlExpressionFactory.Constant(coarse.Last, type, mapping))], + sqlExpressionFactory.Convert(stepped, type, mapping)); + } + /// /// CASE WHEN bound = extreme AND NOT inclusive(x) THEN inside ELSE otherwise END: a bound at the /// infinity an extreme is written as, if it is exclusive, read as the value memory loads for it (#489). @@ -1125,9 +1149,9 @@ private SqlExpression InsideExclusiveInfinity( /// infinity, and a range ending at the last date is stored infinity], inclusive, so that is the /// date memory clamps to. An exclusive infinity, which only another tool or the server's algebra writes, is /// clamped to the day inside it that memory loads, as the two bound readers read it (#489). A - /// YearMonthRange steps back one day from its - /// first-of-month upper bound to the last day of its last month, which reads as that month; its value is - /// the first day of its month, so it compares as memory compares months. + /// YearMonthRange steps back a month from its first-of-month upper bound, to the first day of its last + /// month, which is how its value binds: a day back read as that month, but compared unequal to it, and greater, + /// in a filter (#506). /// /// private SqlExpression ClampFunction(SqlExpression range, SqlExpression value, IRangeTypeDefinition definition) diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ClampDomainParityTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ClampDomainParityTests.cs index b8d80f7d..f331bec2 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ClampDomainParityTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ClampDomainParityTests.cs @@ -301,6 +301,24 @@ private static void ClearPool(string connectionString) if (IsNegativeZero(point)) continue; yield return ($"r.Range.Clamp({Show(point)})", Expression.Lambda(new Inline().Visit(parameter.Body), parameter.Parameters)); } + + // The clamped value compared with every point in a filter, where it meets a bound the way a point is bound, + // not as read back: a YearMonthRange's upper bound read as the last day of its month projected as that + // month, and compared unequal to it (#506). + var bound = points.Select(Bound).Distinct().ToArray(); + foreach (var point in bound) + foreach (var other in bound) + { + Expression, T?>> clamp = r => r.Range.Clamp(point); + Expression> operand = () => other; + var row = clamp.Parameters[0]; + foreach (var (op, compare) in new (string, Func)[] + { ("==", Expression.Equal), ("<", Expression.LessThan), (">", Expression.GreaterThan) }) + { + yield return ($"r.Range.Clamp(@{Show(point)}) {op} @{Show(other)}", + Expression.Lambda, bool>>(compare(clamp.Body, operand.Body), row)); + } + } } /// diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/YearMonthBoundParityTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/YearMonthBoundParityTests.cs new file mode 100644 index 00000000..8e9d124a --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/YearMonthBoundParityTests.cs @@ -0,0 +1,84 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; + +/// +/// The bound readers of a YearMonthRange and its range set, compared with a month in a filter, against memory +/// over the loaded rows (#506). +/// +/// +/// A projection can't show this: the server's date loads as its month whatever day it is, so a bound read as the +/// month's last day projects as memory's answer. A filter compares the date with the month as a parameter binds it, +/// the first day. ClampDomainParityTests holds Clamp the same way. +/// +[TestClass] +public sealed class YearMonthBoundParityTests +{ + private static YearMonth Ym(int year, int month) => new(year, month); + + /// + /// LowerBound() and UpperBound() of a range and of its set compared with a month in a filter, where + /// the bound meets the month as it binds, the first day, rather than as it loads. upper(x) - 1 was the last + /// day of the last month: it loaded as that month and compared unequal to it, and greater (#506). The months + /// include 9999-12, whose last day Npgsql writes as infinity. + /// + [TestMethod] + public async Task YearMonthRange_BoundReadersInAFilter_CompareAsInMemory() + { + ContainerLifecycle.RequireDatabase(); + + YearMonth[] months = [Ym(1, 1), Ym(2026, 1), Ym(2026, 3), Ym(9999, 11), Ym(9999, 12)]; + var ranges = new List { YearMonthRange.Empty, YearMonthRange.Infinite }; + foreach (var start in months) + { + ranges.Add(YearMonthRange.CreateUnboundedStart(start)); + ranges.Add(YearMonthRange.CreateUnboundedEnd(start)); + ranges.AddRange(months.Where(end => end >= start).Select(end => YearMonthRange.CreateFinite(start, end))); + } + + var rows = ranges.Select((range, index) => new Reservation + { + Id = 7301 + index, GroupKey = 7300, BillingPeriod = range, + BillingPeriods = RangeSet.From(range, YearMonthRange.CreateFinite(Ym(2, 1), Ym(2, 2))) + }).ToArray(); + await using (var seed = new IntegrationDbContext()) + { + seed.Reservations.AddRange(rows); + await seed.SaveChangesAsync(); + } + + await using var context = new IntegrationDbContext(); + var loaded = await context.Reservations.AsNoTracking().Where(r => r.GroupKey == 7300).ToListAsync(); + Assert.AreEqual(rows.Length, loaded.Count); + + Expression>[] readers = + [ + r => r.BillingPeriod.LowerBound(), + r => r.BillingPeriod.UpperBound(), + r => r.BillingPeriods.LowerBound(), + r => r.BillingPeriods.UpperBound() + ]; + + var failures = new List(); + foreach (var reader in readers) + foreach (var month in months) + foreach (var (op, compare) in new (string, Func)[] + { ("==", Expression.Equal), ("<", Expression.LessThan), (">", Expression.GreaterThan) }) + { + Expression> operand = () => month; + var predicate = Expression.Lambda>(compare(reader.Body, operand.Body), reader.Parameters); + + var memory = loaded.Where(predicate.Compile()).Select(r => r.Id).Order().ToList(); + var server = await context.Reservations.Where(r => r.GroupKey == 7300).Where(predicate).Select(r => r.Id).Order().ToListAsync(); + if (!memory.SequenceEqual(server)) + { + var differ = memory.Except(server).Concat(server.Except(memory)).Select(id => loaded.Single(r => r.Id == id).BillingPeriod); + failures.Add($"{reader.Body} {op} {month}: server and memory differ on {string.Join(", ", differ)}"); + } + } + + Assert.AreEqual(0, failures.Count, string.Join(Environment.NewLine, failures)); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaQueryTranslationTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaQueryTranslationTests.cs index 77f47c3c..b6a1f2d6 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaQueryTranslationTests.cs +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/NodaQueryTranslationTests.cs @@ -137,7 +137,8 @@ public void YearMonthRangeBounds_HaveNoExclusiveInfinityToRead() { var sql = Sql(db => db.Reservations.Select(r => new { Lower = r.BillingPeriod.LowerBound(), Upper = r.BillingPeriod.UpperBound() })); - StringAssert.Contains(sql, "SELECT lower(r.\"BillingPeriod\") AS \"Lower\", upper(r.\"BillingPeriod\") - 1 AS \"Upper\""); + StringAssert.Contains(sql, "SELECT lower(r.\"BillingPeriod\") AS \"Lower\", CASE"); + Assert.IsFalse(sql.Contains("upper_inc", StringComparison.Ordinal), sql); } [TestMethod] @@ -294,9 +295,17 @@ public void YearMonthRange_Overlaps_ConstantRendersDateRangeLiteral() public void YearMonthRange_UpperBound_Discrete_CompensatesCanonicalForm() { // upper() of the month-aligned daterange is the first day of the month after the - // end month; - 1 lands on the last day of the end month, which reads back as it. + // end month; a month back is the first day of the end month, as a YearMonth operand + // binds. A day back read back as the month, but compared unequal to it (#506). The + // end month 9999-12 is stored as infinity, which no interval steps back from. var sql = Sql(db => db.Reservations.Select(r => (object?)r.BillingPeriod.UpperBound())); - StringAssert.Contains(sql, "upper(r.\"BillingPeriod\") - 1"); + StringAssert.Contains(sql, """ + CASE + WHEN NOT (isfinite(upper(r."BillingPeriod"))) THEN DATE '9999-12-01' + ELSE CAST(upper(r."BillingPeriod") - INTERVAL '1 month' AS date) + END + """.ReplaceLineEndings("\n")); + Assert.IsFalse(sql.Contains("upper(r.\"BillingPeriod\") - 1", StringComparison.Ordinal), sql); } [TestMethod] From b346c4d82113f184e76b8fbad57a9bd2d9a62a7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 26 Oct 2026 18:00:16 +0100 Subject: [PATCH 2/4] test: register YearMonthBoundParityTests and seed the YearMonth upper bound (#506) Two seeds: the bound stepping back a day (both oracles catch it), and the infinity arm dropped, which only the bound readers reach, since no month lies above 9999-12 for Clamp to clamp to it. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 615639a76c35d5e8a1c457c852a135ec38e8a29c) --- test/oracles/oracles.json | 9 +++++++++ ...yearmonth-upper-bound-keeps-infinity.patch | 20 +++++++++++++++++++ ...armonth-upper-bound-steps-back-a-day.patch | 17 ++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 test/oracles/seeds/yearmonth-upper-bound-keeps-infinity.patch create mode 100644 test/oracles/seeds/yearmonth-upper-bound-steps-back-a-day.patch diff --git a/test/oracles/oracles.json b/test/oracles/oracles.json index 29bdf99b..894afeeb 100644 --- a/test/oracles/oracles.json +++ b/test/oracles/oracles.json @@ -436,5 +436,14 @@ "needs": [ "docker" ] + }, + { + "class": "YearMonthBoundParityTests", + "project": "CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests", + "kind": "differential", + "against": "memory over the loaded rows, for LowerBound() and UpperBound() of YearMonthRange and its RangeSet over every shape between five months (9999-12 among them), each compared with each month by ==, < and > in a filter", + "needs": [ + "docker" + ] } ] diff --git a/test/oracles/seeds/yearmonth-upper-bound-keeps-infinity.patch b/test/oracles/seeds/yearmonth-upper-bound-keeps-infinity.patch new file mode 100644 index 00000000..5f20c5d2 --- /dev/null +++ b/test/oracles/seeds/yearmonth-upper-bound-keeps-infinity.patch @@ -0,0 +1,20 @@ +Seed: a YearMonthRange upper bound at infinity steps back by a month and stays infinity, where 9999-12 binds as 9999-12-01 +Oracles: YearMonthBoundParityTests +Fails: UpperBound() == / > 9999-12 on the ranges ending in 9999-12, stored infinity]. Clamp never reaches it: no month lies above 9999-12, so it never clamps to that bound +History: never shipped; the CASE in #506's fix + +diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +index ebb3252..6cf1fea 100644 +--- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs ++++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +@@ -1085,9 +1085,7 @@ internal sealed class ValueRangesMethodCallTranslator( + ExpressionType.Subtract, upper, new SqlFragmentExpression($"INTERVAL '{coarse.Step}'", typeof(object), null), + typeof(DateTime), typeMappingSource.FindMapping(typeof(DateTime), "timestamp without time zone")); + +- return sqlExpressionFactory.Case( +- [new CaseWhenClause(sqlExpressionFactory.Not(BoolFunction("isfinite", upper)), sqlExpressionFactory.Constant(coarse.Last, type, mapping))], +- sqlExpressionFactory.Convert(stepped, type, mapping)); ++ return sqlExpressionFactory.Convert(stepped, type, mapping); + } + + /// diff --git a/test/oracles/seeds/yearmonth-upper-bound-steps-back-a-day.patch b/test/oracles/seeds/yearmonth-upper-bound-steps-back-a-day.patch new file mode 100644 index 00000000..7098cd8e --- /dev/null +++ b/test/oracles/seeds/yearmonth-upper-bound-steps-back-a-day.patch @@ -0,0 +1,17 @@ +Seed: a YearMonthRange upper bound steps back one day, to the last day of its last month, which loads as that month but compares unequal to it in a filter +Oracles: ClampDomainParityTests, YearMonthBoundParityTests +Fails: UpperBound() == / > a month and Clamp(@later) == / > the upper month, for every range ending in that month, on the range and its RangeSet +History: UpperBound() since YearMonthRange's EF mapping (v5), Clamp since #486 (11.0.0-preview.1, 10.4.0); fixed by #506 + +diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +index ebb3252..217dba1 100644 +--- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs ++++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesMethodCallTranslator.cs +@@ -1058,7 +1058,6 @@ internal sealed class ValueRangesMethodCallTranslator( + { + var upper = BoundFunction("upper", argument, definition); + if (!definition.IsDiscrete) return upper; +- if (definition.CoarseUpperBound is { } coarse) return CoarseUpper(upper, coarse, definition); + + // The step constant must keep its own integer mapping: `date - 1` is date arithmetic + // in PostgreSQL, and applying the element mapping to the 1 would render it as a date. From f86ee1b24dcf089b4889261674125b25a1fd208e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 26 Oct 2026 18:16:27 +0100 Subject: [PATCH 3/4] docs: say a YearMonthRange upper bound compares with a month in a query, with the migration and changelog (#511) Store a YearMonthRange says the query reads the first day of the end month and compares with a month in a filter as memory does, 9999-12 included; the discrete-bounds note points there; architecture.md says how (CoarseUpperBound). A section under Changed answers in 10.4.0, the Changed answers rows of 10.4.0 in the root and EF Core NodaTime changelogs linking #511, and claim E88. (cherry picked from commit cd767203cca22dae417e6c7826027894eac13036, with the 10.x changelog and migration layout) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/architecture.md | 7 ++++++ docs/efcore.md | 9 ++++--- docs/migration.md | 24 +++++++++++++++++++ .../CHANGELOG.md | 1 + test/oracles/claims.json | 19 +++++++++++++++ 6 files changed, 58 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 842f0635..dd07604f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Some calls answer differently: [Changed answers in 10.4.0](https://github.com/Ca - EF Core: a `StringSet` or `StringSet` property with a collation that may not compare bytes throws when the model is built, and so does `EF.Functions.Collate` over one in a query. Under a case-insensitive collation, `Contains("a")` matched `{A}` and `Remove("a")` deleted `A`, unlike in memory ([#496](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/496), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)). - EF Core: on a `DateRange`, a `LocalDateRange` and their multiranges, `LowerBound()`, `UpperBound()`, `LowerBoundInclusive()` and `Clamp` read an exclusive `infinity` or `-infinity` in a query as the row loads. On `[2026-01-01,infinity)`, `UpperBound()` returned 9999-12-31 where memory gives 9999-12-30 ([#502](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/502), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)). +- EF Core: on a `YearMonthRange` and its multirange, `UpperBound()` and `Clamp` compare with a month in a query as in memory. They read the last day of the month, so on `[2026-01,2026-01]` `UpperBound() == 2026-01` was `false` and `UpperBound() > 2026-01` was `true` ([#511](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/511), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)). ### Added diff --git a/docs/architecture.md b/docs/architecture.md index f45fdd4c..83c24a23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -114,6 +114,13 @@ NOT isempty(x)`, as the upper side is, because `lower_inc` is false for an exclu PostgreSQL's dates. With `Npgsql.DisableDateTimeInfinityConversions` set no extreme is an infinity, and the SQL is the plain one. `ExclusiveInfinityBoundParityTests` asserts every reader over every form. +`YearMonthRange` is discrete, but one element spans a month of its `date` subtype (#506). `upper(x) - 1` is the +last day of the last month, which loads as that month and compares unequal to it, because a `YearMonth` operand +binds as the first day. Its definition names a `CoarseUpperBound`, so the upper bound readers write +`CAST(upper(x) - INTERVAL '1 month' AS date)`, and `9999-12-01` where the bound is `infinity` (9999-12 ends on +`LocalDate.MaxIsoValue`). `YearMonthBoundParityTests` and `ClampDomainParityTests` compare the readers with each +month in a filter, where a projection would load both answers as the same month. + **Calendars.** NodaTime equality includes the calendar system, and of its nineteen calendars only ISO and Gregorian can represent `9999-12-31` at all. Gregorian shares ISO's arithmetic but is a distinct `CalendarSystem` instance, so a Gregorian-spelled domain maximum compares unequal to diff --git a/docs/efcore.md b/docs/efcore.md index 3120f3d0..ff456b71 100644 --- a/docs/efcore.md +++ b/docs/efcore.md @@ -192,7 +192,8 @@ Good to know: returns the same results as memory ([verified against live PostgreSQL](../README.md#verified-against-postgresql)). On a date range, an exclusive `infinity` or `-infinity` is read one day inside, as the row loads - ([Rows written by other tools](#rows-written-by-other-tools)). + ([Rows written by other tools](#rows-written-by-other-tools)). On a `YearMonthRange`, `UpperBound()` + steps back a month instead of a day ([Store a YearMonthRange](#store-a-yearmonthrange)). - **Aggregates over zero rows differ.** In SQL the aggregates return `NULL` for zero input rows, which is standard PostgreSQL aggregate behaviour. The in-memory `RangeAgg()` returns the empty set. `RangeIntersectAgg()` returns `null` in both. @@ -865,8 +866,10 @@ adding the constraint fails until you fix them. The NodaTime package stores `YearMonthRange` as a **month-aligned `daterange`**. For example, `[2024-01, 2024-03]` becomes `[2024-01-01, 2024-04-01)`. You need no custom database type, and every operator, bound accessor and aggregate runs as SQL and agrees with the in-memory results. -The `upper()` adjustment lands on the last day of the end month, and that month is the inclusive -upper bound in memory. +In a query, `UpperBound()` reads the first day of the end month, which is how a `YearMonth` you pass +in is sent. So `r.BillingPeriod.UpperBound() == month` and `r.BillingPeriod.Clamp(month)` compare +with a month in a filter as they do in memory. The last month, 9999-12, is stored as `infinity` and +reads as 9999-12-01 too. Reading checks month alignment. A `daterange` that covers part of a month fails loudly, instead of silently shifting the boundaries. diff --git a/docs/migration.md b/docs/migration.md index 03bbb8f5..b16eeba4 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1048,3 +1048,27 @@ it stored give the same answers as before. A range your query computes can hold and a query that returns its infinite bound throws as it did. - **A `YearMonthRange` can't hold these forms.** Loading one throws, as before ([Rows written by other tools](efcore.md#rows-written-by-other-tools)). + +### Check `YearMonthRange` upper bounds compared in queries + +Check EF Core queries that compare `UpperBound()` or `Clamp` of a `YearMonthRange`, or `UpperBound()` +of a `RangeSet` of them, with a month. Look in `Where`, in `OrderBy` keys compared later, and in +projected comparisons. + +A `YearMonthRange` is stored as a month-aligned `daterange`, and a month you pass in is sent as its +first day. Before 10.4.0, a query read the upper bound as the last day of the month. It loaded as the +right month, but a comparison saw two different dates: + +| Query, on `[2026-01,2026-01]` | Memory | Before 10.4.0 | +|---|---|---| +| `Where(r => r.BillingPeriod.UpperBound() == new YearMonth(2026, 1))` | matches | no match | +| `Where(r => r.BillingPeriod.UpperBound() > new YearMonth(2026, 1))` | no match | matches | +| `Where(r => r.BillingPeriod.Clamp(new YearMonth(2026, 2)) == new YearMonth(2026, 1))` | matches | no match | +| `Select(r => r.BillingPeriod.UpperBound())` | 2026-01 | 2026-01 | + +Now the query reads the first day of the month and compares as memory does. A projected bound loads +as before. + +- **`Clamp` on a `YearMonthRange` first runs as SQL in 10.4.0.** Before, it ran in memory in a final + projection and failed translation in a filter, so only `UpperBound()` gave other answers. +- **`LowerBound()` doesn't change.** It always read the first day of the month. diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md index 453b2cb7..0f35129d 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md @@ -10,6 +10,7 @@ Some calls answer differently: [Changed answers in 10.4.0](https://github.com/Ca ### Changed answers - EF Core: on a `LocalDateRange` and its multirange, `UpperBound()` and `Clamp` read an exclusive `infinity` as 9999-12-30 in a query, as the row loads, where they returned 9999-12-31, and `LowerBoundInclusive()` is `true` at an exclusive `-infinity`. `LowerBound()` there keeps `LocalDate.MinIsoValue`, because memory's -9998-01-02 isn't a PostgreSQL date ([#502](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/502), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)). +- EF Core: on a `YearMonthRange` and its multirange, `UpperBound()` and `Clamp` compare with a month in a query as in memory. They read the last day of the month, so on `[2026-01,2026-01]` `UpperBound() == 2026-01` was `false` and `UpperBound() > 2026-01` was `true` ([#511](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/511), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#changed-answers-in-1040)). ### Added diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 33dd6ba8..7f3766ea 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1431,6 +1431,25 @@ "QueryTranslationTests" ] }, + { + "id": "E88-yearmonth-upper-bound-compares-as-a-month", + "source": "docs/efcore.md#store-a-yearmonthrange", + "quote": "So `r.BillingPeriod.UpperBound() == month` and `r.BillingPeriod.Clamp(month)` compare with a month in a filter as they do in memory. The last month, 9999-12, is stored as `infinity` and reads as 9999-12-01 too.", + "claim": "On YearMonthRange and RangeSet, LowerBound(), UpperBound() and Clamp compared with a month by ==, < and > in a filter select the rows memory selects over the loaded rows, for every shape between months including 9999-12, whose upper bound is stored as infinity.", + "oracle": "differential", + "surfaces": [ + "RangeExtensions.UpperBound", + "RangeExtensions.LowerBound", + "RangeExtensions.Clamp", + "RangeSet.UpperBound", + "RangeSet.LowerBound" + ], + "heldBy": [ + "YearMonthBoundParityTests", + "ClampDomainParityTests", + "NodaQueryTranslationTests" + ] + }, { "id": "J01-tostring-is-postgres-literal", "source": "docs/serialization.md#print-a-range-as-a-literal", From f0ceeaf814c456531fe4b88fd715118aa283b813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Mon, 26 Oct 2026 18:03:28 +0100 Subject: [PATCH 4/4] test: a YearMonthRange upper bound compares with its month with the infinity conversions off (#506) 9999-12 ends on 9999-12-31, which Npgsql then sends as a date, so the server keeps [...,10000-01-01): a month back reaches 9999-12-01 without the infinity arm. Fails with the step reverted to a day. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 989d54d2945e802789034e9a3866c74be5172cb6) (cherry picked from commit 03bdb40f155b61bdf7f339f05702da35ccf2d661) --- .../YearMonthUpperBoundTests.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/YearMonthUpperBoundTests.cs diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/YearMonthUpperBoundTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/YearMonthUpperBoundTests.cs new file mode 100644 index 00000000..70b6292e --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/YearMonthUpperBoundTests.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests; + +/// +/// A YearMonthRange upper bound read in a query with the switch set (#506). 9999-12 ends on 9999-12-31, which +/// Npgsql then sends as a date, so the server keeps […,10000-01-01): no row with that end loads, but a filter +/// reads the bound a month back, at 9999-12-01, where a month operand binds, without the infinity arm. +/// +[TestClass] +public sealed class YearMonthUpperBoundTests +{ + [TestMethod] + public async Task AYearMonthUpperBound_ComparesWithItsMonth_InAFilter() + { + ContainerLifecycle.RequireDatabase(); + + YearMonth[] months = [new(2026, 1), new(9999, 11), new(9999, 12)]; + await using (var write = new MonthContext()) + { + await write.Database.ExecuteSqlRawAsync("CREATE TABLE IF NOT EXISTS ym_bounds (\"Id\" integer PRIMARY KEY, \"Period\" daterange NOT NULL)"); + write.Rows.AddRange(months.Select((month, i) => new MonthRow { Id = 506 + i, Period = YearMonthRange.CreateFinite(new YearMonth(2025, 6), month) })); + await write.SaveChangesAsync(); + } + + await using var context = new MonthContext(); + foreach (var (month, i) in months.Select((month, i) => (month, i))) + { + var id = 506 + i; + Assert.AreEqual(1, await context.Rows.CountAsync(r => r.Id == id && r.Period.UpperBound() == month), $"UpperBound() == {month}"); + Assert.AreEqual(0, await context.Rows.CountAsync(r => r.Id == id && r.Period.UpperBound() > month), $"UpperBound() > {month}"); + } + } + + public sealed class MonthRow + { + public int Id { get; set; } + public YearMonthRange Period { get; set; } = YearMonthRange.Empty; + } + + private sealed class MonthContext : DbContext + { + public DbSet Rows => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql(ContainerLifecycle.ConnectionString!, npgsql => npgsql.UseValueRangesNodaTime()); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity(row => + { + row.ToTable("ym_bounds"); + row.Property(r => r.Id).ValueGeneratedNever(); + }); + } +}