diff --git a/CHANGELOG.md b/CHANGELOG.md index 842f063..dd07604 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 f45fdd4..83c24a2 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 3120f3d..ff456b7 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 03bbb8f..b16eeba 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 453b2cb..0f35129 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/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/Internal/YearMonthRangeTypeDefinition.cs index 1398e8b..57bdb3f 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 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 33dd6ba..7f3766e 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", diff --git a/test/oracles/oracles.json b/test/oracles/oracles.json index 29bdf99..894afee 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 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.