diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ce51b..ce7c034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ The first preview of the 11 line: the same public API as 10.0.0, on .NET 11. Not - EF Core: any `Compare(x, y)` over ranges or sets, `RangeLowerBoundComparer.Compare` and every other `IComparer` included, runs in memory only in a query, as the method called. A filter or an ordering fails translation. In SQL it compared in the range order, so `Compare(r.Period, p) == 0` missed rows with the same lower bound ([#464](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/464), [Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#move-rangelowerboundcomparer-out-of-queries)). - 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 ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-string-set-columns-with-a-collation)). - 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 ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-date-bounds-at-an-exclusive-infinity-in-queries)). +- 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` ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-yearmonthrange-upper-bounds-compared-in-queries)). ### Added diff --git a/docs/architecture.md b/docs/architecture.md index a43574c..b03fbd7 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 d297aa6..9ae70bd 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. @@ -942,8 +943,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 973dbba..5ee5d50 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -834,10 +834,13 @@ where, so check these: - **EF Core: a date bound at an exclusive infinity reads in a query as the row loads.** On `[2026-01-01,infinity)`, `UpperBound()` returned 9999-12-31 in a query and 9999-12-30 in memory ([details](#check-date-bounds-at-an-exclusive-infinity-in-queries)). +- **EF Core: a `YearMonthRange` upper bound compares with a month in a query as in memory.** On + `[2026-01,2026-01]`, `UpperBound() == 2026-01` was `false` in a query and `true` in memory + ([details](#check-yearmonthrange-upper-bounds-compared-in-queries)). -The first four also shipped in 10.2.0, the next two in 10.3.0, and the last two in 10.4.0. If you +The first four also shipped in 10.2.0, the next two in 10.3.0, and the last three in 10.4.0. If you upgrade from 10.2.0 or later, you've already met the first four, from 10.3.0 or later the first six, -and from 10.4.0 or later all eight. +and from 10.4.0 or later all nine. ### Check bound comparisons with `null` in queries @@ -1053,6 +1056,30 @@ it stored give the same answers as before. A range your query computes can hold - **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. + ### Move `RangeLowerBoundComparer` out of queries Check EF Core queries that call `Compare` on `RangeLowerBoundComparer`, on diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md index 76bab84..347afbd 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/CHANGELOG.md @@ -15,6 +15,7 @@ The first preview of the 11 line: the same public API as 10.0.0, on .NET 11, EF - EF Core: `Contains(T)` on a range or multirange is `false` in a query when the element is NULL: a column of a missing child in an outer join, or a nullable column read with `.Value`. Its negation dropped the row, and a projection threw ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-predicates-over-a-null-range-or-a-null-column)). - EF Core: `CreateFinite`, `CreateUnboundedStart` and `CreateUnboundedEnd` over a NULL element are NULL in a query, as EF Core's own `AddDays` is. They built `empty` or `(,)`, so `Contains(v)` selected a row without the element ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-predicates-over-a-null-range-or-a-null-column)). - 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 ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-date-bounds-at-an-exclusive-infinity-in-queries)). +- 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` ([Migration](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/docs/migration.md#check-yearmonthrange-upper-bounds-compared-in-queries)). ### Added diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs index 9d72ebf..81eeb89 100644 --- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs +++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs @@ -53,6 +53,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 0000000..fc4ae1f --- /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 0daf2a7..cf77a01 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 ad8096a..ebb3252 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.InfinityDisabled.IntegrationTests/YearMonthUpperBoundTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/YearMonthUpperBoundTests.cs new file mode 100644 index 0000000..70b6292 --- /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(); + }); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ClampDomainParityTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/ClampDomainParityTests.cs index b8d80f7..f331bec 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 0000000..8e9d124 --- /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 77f47c3..b6a1f2d 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] diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 105527c..983e59d 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -1525,6 +1525,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", diff --git a/test/oracles/oracles.json b/test/oracles/oracles.json index f3f39ea..4a05cff 100644 --- a/test/oracles/oracles.json +++ b/test/oracles/oracles.json @@ -499,5 +499,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 0000000..5f20c5d --- /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 0000000..7098cd8 --- /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.