Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Some calls answer differently: [Changed answers in 10.4.0](https://github.com/Ca

- EF Core: a `StringSet` or `StringSet<TElement>` 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

Expand Down
7 changes: 7 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions docs/efcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ internal sealed class YearMonthRangeTypeDefinition : IRangeTypeDefinition

public bool SupportsSqlConstruction => false;

/// <summary>
/// A month back from the stored exclusive bound is the first day of the last month, as a <see cref="YearMonth"/>
/// operand binds; a day back is its last day, which compares unequal to it (#506). 9999-12 ends on
/// <c>infinity</c>.
/// </summary>
public CoarseUpperBound CoarseUpperBound { get; } = new("1 month", new YearMonth(9999, 12));

public RelationalTypeMapping? ElementTypeMapping { get; } = new YearMonthTypeMapping();

public RelationalTypeMapping RangeTypeMapping { get; } = new YearMonthRangeTypeMapping();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace CodoMetis.ValueRanges.EntityFrameworkCore.PostgreSQL.Internal;

/// <summary>
/// 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 <see cref="Step"/> rather than by 1, and
/// to <see cref="Last"/> from an inclusive <c>infinity</c> (#506).
/// </summary>
/// <remarks>
/// A <c>YearMonthRange</c> is a month-aligned <c>daterange</c>, and a <c>YearMonth</c> operand binds as the first
/// day of its month. <c>upper(x) - 1</c> is the last day of the last month: it loads as that month, but compares
/// unequal to it, and greater, in a filter. <c>upper(x) - INTERVAL '1 month'</c> is the first day. The last month,
/// 9999-12, ends on the date Npgsql writes as <c>infinity</c>, which no interval steps back from, so the reader
/// answers <see cref="Last"/> there. With <c>Npgsql.DisableDateTimeInfinityConversions</c> 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.
/// </remarks>
/// <param name="Step">The span of one element, as a PostgreSQL interval literal, e.g. <c>1 month</c>.</param>
/// <param name="Last">The element an inclusive <c>infinity</c> upper bound stands for.</param>
internal sealed record CoarseUpperBound(string Step, object Last);
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ internal interface IRangeTypeDefinition
/// </summary>
ExclusiveInfinity? ExclusiveInfinity => null;

/// <summary>
/// For a discrete type whose element spans several values of its store type (<c>YearMonthRange</c>, a month of
/// dates), the step the upper bound readers take back from the stored bound instead of 1, and the element an
/// inclusive <c>infinity</c> stands for. <see langword="null"/> for every other type.
/// </summary>
CoarseUpperBound? CoarseUpperBound => null;

/// <summary>
/// The type mapping for elements of the range, or <see langword="null"/> to resolve it
/// from the type mapping source via <see cref="ElementClrType"/>/<see cref="ElementStoreType"/>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1050,12 +1050,15 @@ private SqlExpression LowerBoundFunction(SqlExpression argument, IRangeTypeDefin
/// model's inclusive upper: <c>upper(x) - 1</c> restores agreement. NULL propagates
/// through the subtraction for empty or unbounded operands. An exclusive <c>infinity</c>,
/// which <c>- 1</c> leaves as it is, is read as memory loads it on a type that has an
/// <see cref="IRangeTypeDefinition.ExclusiveInfinity"/> (<see cref="InsideExclusiveInfinity"/>).
/// <see cref="IRangeTypeDefinition.ExclusiveInfinity"/> (<see cref="InsideExclusiveInfinity"/>). A type with a
/// <see cref="IRangeTypeDefinition.CoarseUpperBound"/> steps back by its element's span instead
/// (<see cref="CoarseUpper"/>).
/// </summary>
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.
Expand All @@ -1066,6 +1069,27 @@ private SqlExpression UpperBoundFunction(SqlExpression argument, IRangeTypeDefin
: closed;
}

/// <summary>
/// <c>CASE WHEN NOT isfinite(upper(x)) THEN last ELSE CAST(upper(x) - INTERVAL 'step' AS store) END</c>: the
/// first value of the last element, as an operand of the element type binds, not the last value of the store
/// type (#506, <see cref="CoarseUpperBound"/>). A NULL bound takes the <c>ELSE</c> and stays NULL.
/// </summary>
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));
}

/// <summary>
/// <c>CASE WHEN bound = extreme AND NOT inclusive(x) THEN inside ELSE otherwise END</c>: a bound at the
/// infinity an extreme is written as, if it is exclusive, read as the value memory loads for it (#489).
Expand Down Expand Up @@ -1125,9 +1149,9 @@ private SqlExpression InsideExclusiveInfinity(
/// <c>infinity</c>, and a range ending at the last date is stored <c>infinity]</c>, 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
/// <c>YearMonthRange</c> 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.
/// <c>YearMonthRange</c> 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).
/// </para>
/// </remarks>
private SqlExpression ClampFunction(SqlExpression range, SqlExpression value, IRangeTypeDefinition definition)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using NodaTime;

namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests;

/// <summary>
/// A <c>YearMonthRange</c> 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 <c>[…,10000-01-01)</c>: 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 <c>infinity</c> arm.
/// </summary>
[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<MonthRow> Rows => Set<MonthRow>();

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseNpgsql(ContainerLifecycle.ConnectionString!, npgsql => npgsql.UseValueRangesNodaTime());

protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<MonthRow>(row =>
{
row.ToTable("ym_bounds");
row.Property(r => r.Id).ValueGeneratedNever();
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Func<ClampOf<TRange, T>, T?>> clamp = r => r.Range.Clamp(point);
Expression<Func<T?>> operand = () => other;
var row = clamp.Parameters[0];
foreach (var (op, compare) in new (string, Func<Expression, Expression, BinaryExpression>)[]
{ ("==", Expression.Equal), ("<", Expression.LessThan), (">", Expression.GreaterThan) })
{
yield return ($"r.Range.Clamp(@{Show(point)}) {op} @{Show(other)}",
Expression.Lambda<Func<ClampOf<TRange, T>, bool>>(compare(clamp.Body, operand.Body), row));
}
}
}

/// <summary>
Expand Down
Loading
Loading