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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,34 @@ Versions follow [Semantic Versioning](https://semver.org/). Entries are newest-f
are accepted or rejected in bounded time, `TryParse` never throws, and no rejection echoes the
payload.

## [6.2.1] — 2026-08-16

### Fixed

- **⚠️ `IsAdjacentTo` answered `false` whenever the receiver was unbounded, and normalization
inherited it.** The predicate switched on the receiver's shape and handled only
`IFiniteRange<T>`; every other shape fell through to `false`. Its *inner* switch did handle
unbounded operands, so the relation was asymmetric — `[1,3].IsAdjacentTo((,0])` was `true` while
`(,0].IsAdjacentTo([1,3])` was `false`. PostgreSQL's `-|-` is symmetric and answers `true` for
both; the XML doc asserted the broken behaviour as if it were intended, which is why reading the
code confirmed it.

The consequence was not confined to the predicate. `RangeSet.From` and `RangeSet.Union` merge
neighbours with `current.IsAdjacentTo(next)` after sorting by lower bound, so an unbounded-start
element is *always* the receiver and always took the broken direction. Sets were built violating
the invariant they document:

```
RangeSet.From([(,0], [1,)]) was {(,0],[1,)} now {(,)}
blocks.Union(blocks.Complement()) was {(,0],[1,)} now the Infinite set
```

Two sets that should be equal compared unequal depending on how they were built, and a set
covering the whole domain did not equal `RangeSet.Infinite`. **Results change for any range or
set involving an unbounded element adjacent to its neighbour** — always from a wrong answer to
the one PostgreSQL gives. Model-versus-server agreement is now pinned by the live suite for
every affected shape pair. Applies to the NodaTime range types, which share the predicate.

## [6.2.0] — 2026-08-16

### Changed
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>6.2.0</Version>
<Version>6.2.1</Version>
<Authors>CaffeinatedCoder</Authors>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,24 @@ var y = DecimalRange.CreateFinite(5m, 10m, startInclusive: false); // (5, 10)
x.IsAdjacentTo(y); // true — one side claims 5, the other does not
```

An unbounded range is adjacent on its bounded edge, and the relation is symmetric — the receiver's shape does not change the answer, matching PostgreSQL's `-|-`:

```csharp
var upTo = Int32Range.CreateUnboundedStart(0, true); // (-∞, 0]
var from = Int32Range.CreateUnboundedEnd(1); // [1, +∞)
var between = Int32Range.CreateFinite(1, 3); // [1, 3]

upTo.IsAdjacentTo(between); // true between.IsAdjacentTo(upTo); // true
upTo.IsAdjacentTo(from); // true — the two halves close the domain with no overlap

// The empty and infinite ranges are adjacent to nothing, and two ranges open at the
// same end always overlap:
Int32Range.Infinite.IsAdjacentTo(between); // false
upTo.IsAdjacentTo(Int32Range.CreateUnboundedStart(9, true)); // false
```

> **Changed in 6.2.1.** Before 6.2.1 `IsAdjacentTo` answered `false` whenever the *receiver* was unbounded, so the relation was asymmetric and disagreed with PostgreSQL. Because `RangeSet` normalization merges neighbours after sorting by lower bound — which always puts an unbounded-start element in the receiver position — `RangeSet.From([(,0], [1,)])` returned `{(,0],[1,)}` instead of `{(,)}`. See the [changelog](CHANGELOG.md).

### Directional Comparisons

```csharp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
Entries affecting the NodaTime EF Core satellite. The [root changelog](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/CHANGELOG.md)
covers all four packages, which share one version number and release together.

## [6.2.1] — 2026-08-16

No source change in this package. See `CodoMetis.ValueRanges` 6.2.1 for the `IsAdjacentTo`
asymmetry fix, which the NodaTime range types inherit.

## [6.2.0] — 2026-08-16

No source change in this package, but its behaviour changes with the packages it depends on.
Expand Down
7 changes: 7 additions & 0 deletions src/CodoMetis.ValueRanges.EFCore.PostgreSQL/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
Entries affecting the EF Core (Npgsql) plugin. The [root changelog](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/CHANGELOG.md)
covers all four packages, which share one version number and release together.

## [6.2.1] — 2026-08-16

No source change in this package. The translation of `IsAdjacentTo` to `-|-` was always correct —
PostgreSQL's operator is symmetric — but the in-memory predicate it mirrors was not, so client-side
and server-side answers disagreed for unbounded operands. Fixed in `CodoMetis.ValueRanges` 6.2.1;
the live-PostgreSQL suite here now pins the agreement for every affected shape pair.

## [6.2.0] — 2026-08-16

### Fixed
Expand Down
10 changes: 10 additions & 0 deletions src/CodoMetis.ValueRanges.NodaTime/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
Entries affecting the NodaTime satellite. The [root changelog](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/blob/main/CHANGELOG.md)
covers all four packages, which share one version number and release together.

## [6.2.1] — 2026-08-16

### Fixed

- **⚠️ `IsAdjacentTo` answered `false` whenever the receiver was unbounded**, and the NodaTime
range types share the core predicate, so `LocalDateRange`, `LocalDateTimeRange`, `InstantRange`
and `YearMonthRange` all had it — along with the `RangeSet` normalization built on top of it.
Fixed in `CodoMetis.ValueRanges` 6.2.1 and inherited here. **Results change for any range or set
with an unbounded element adjacent to its neighbour**, from a wrong answer to PostgreSQL's.

## [6.2.0] — 2026-08-16

No source change in this package, but its behaviour changes with the core package it depends on.
Expand Down
15 changes: 15 additions & 0 deletions src/CodoMetis.ValueRanges/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ covers all four packages, which share one version number and release together.
are accepted or rejected in bounded time, `TryParse` never throws, and no rejection echoes the
payload.

## [6.2.1] — 2026-08-16

### Fixed

- **⚠️ `IsAdjacentTo` answered `false` whenever the receiver was unbounded.** The predicate
switched on the receiver and handled only `IFiniteRange<T>`, while its inner switch handled
unbounded *operands* — so the relation was asymmetric: `[1,3].IsAdjacentTo((,0])` was `true`,
`(,0].IsAdjacentTo([1,3])` was `false`. PostgreSQL's `-|-` answers `true` for both.
`RangeSet.From` and `RangeSet.Union` merge neighbours after sorting by lower bound, which puts an
unbounded-start element in the receiver position every time, so they built sets that violated the
pairwise-non-adjacent invariant: `From([(,0], [1,)])` returned `{(,0],[1,)}` rather than `{(,)}`,
and a set unioned with its complement did not equal `RangeSet.Infinite`. **Results change for any
range or set with an unbounded element adjacent to its neighbour**, in every case from a wrong
answer to PostgreSQL's.

## [6.2.0] — 2026-08-16

### Changed
Expand Down
64 changes: 42 additions & 22 deletions src/CodoMetis.ValueRanges/RangeExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -403,41 +403,61 @@ public bool DoesNotExtendLeftOf(IRange<T> other) =>
/// complementary inclusiveness: one side must claim the boundary point and the other must not.
/// </para>
/// <para>
/// Only <see cref="IFiniteRange{T}"/> instances can be adjacent to other ranges;
/// <see cref="IUnboundedEndRange{T}"/>, <see cref="IUnboundedStartRange{T}"/>, and
/// <see cref="IInfinityRange{T}"/> always return <see langword="false"/>.
/// An unbounded range is adjacent on its bounded edge: <c>(-∞, e]</c> is adjacent to a
/// range starting immediately after <c>e</c>, and <c>[s, +∞)</c> to one ending
/// immediately before <c>s</c> — including to each other, where the two halves close
/// the domain. <see cref="IEmptyRange{T}"/> and <see cref="IInfinityRange{T}"/> are never
/// adjacent to anything, and two ranges open at the same end always overlap.
/// </para>
/// </remarks>
/// <param name="other">The range to test against.</param>
/// <returns>
/// <see langword="true"/> if the ranges are contiguous with no gap and no overlap.
/// </returns>
public bool IsAdjacentTo(IRange<T> other) =>
range switch
public bool IsAdjacentTo(IRange<T> other)
{
// Adjacency is symmetric — PostgreSQL's -|- is too — so each unordered pair of
// shapes is decided once and both receiver orders route to the same test. Deciding
// per receiver is how the two unbounded receivers came to answer false while their
// mirrored operand cases answered true.
static bool Meets(T leftEnd, bool leftInc, T rightStart, bool rightInc)
=> BoundaryMeetsAdjacently<TRange, T>(leftEnd, leftInc, rightStart, rightInc);

// Either may come first, so both orders are tried.
static bool FiniteFinite(IFiniteRange<T> a, IFiniteRange<T> b)
=> Meets(a.End, a.EndInclusive, b.Start, b.StartInclusive)
|| Meets(b.End, b.EndInclusive, a.Start, a.StartInclusive);

// (-∞, s.End] runs to negative infinity, so it can only be followed by f.
static bool StartThenFinite(IUnboundedStartRange<T> s, IFiniteRange<T> f)
=> Meets(s.End, s.EndInclusive, f.Start, f.StartInclusive);

// [e.Start, +∞) runs to positive infinity, so it can only follow f.
static bool FiniteThenEnd(IFiniteRange<T> f, IUnboundedEndRange<T> e)
=> Meets(f.End, f.EndInclusive, e.Start, e.StartInclusive);

// The two halves meeting exactly: no gap, no overlap, and together the whole domain.
static bool StartThenEnd(IUnboundedStartRange<T> s, IUnboundedEndRange<T> e)
=> Meets(s.End, s.EndInclusive, e.Start, e.StartInclusive);

return (range, other) switch
{
IFiniteRange<T> b =>
other switch
{
IFiniteRange<T> o => BoundaryMeetsAdjacently<TRange, T>(
b.End, b.EndInclusive, o.Start, o.StartInclusive
) ||
BoundaryMeetsAdjacently<TRange, T>(
o.End, o.EndInclusive, b.Start, b.StartInclusive
),
(IFiniteRange<T> a, IFiniteRange<T> b) => FiniteFinite(a, b),

IUnboundedStartRange<T> s => BoundaryMeetsAdjacently<TRange, T>(
s.End, s.EndInclusive, b.Start, b.StartInclusive
),
(IUnboundedStartRange<T> s, IFiniteRange<T> f) => StartThenFinite(s, f),
(IFiniteRange<T> f, IUnboundedStartRange<T> s) => StartThenFinite(s, f),

IUnboundedEndRange<T> e => BoundaryMeetsAdjacently<TRange, T>(
b.End, b.EndInclusive, e.Start, e.StartInclusive
),
(IFiniteRange<T> f, IUnboundedEndRange<T> e) => FiniteThenEnd(f, e),
(IUnboundedEndRange<T> e, IFiniteRange<T> f) => FiniteThenEnd(f, e),

_ => false
},
(IUnboundedStartRange<T> s, IUnboundedEndRange<T> e) => StartThenEnd(s, e),
(IUnboundedEndRange<T> e, IUnboundedStartRange<T> s) => StartThenEnd(s, e),

// Empty is adjacent to nothing; Infinity overlaps everything non-empty; two
// ranges open at the same end always overlap.
_ => false
};
}

/// <summary>
/// Returns the smallest single range containing both this range and
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,59 @@ public async Task SetOperations_MatchInMemory()
Assert.AreEqual(period.Except(operand), server.Difference);
}

/// <summary>
/// Adjacency against the server that defines it, across the shape pairs involving an
/// unbounded operand. The model answered <see langword="false"/> for every pair whose
/// *receiver* was unbounded while PostgreSQL answered <see langword="true"/>; this pins the
/// agreement in both directions.
/// </summary>
[TestMethod]
public async Task RangeAdjacency_UnboundedShapes_MatchPostgres()
{
ContainerLifecycle.RequireDatabase();

var openStart = Int32Range.CreateUnboundedStart(0, true); // (-∞, 0]
var openEnd = Int32Range.CreateUnboundedEnd(4); // [4, +∞)
var meeting = Int32Range.CreateUnboundedEnd(1); // [1, +∞)
var finite = Int32Range.CreateFinite(1, 3); // [1, 3]

await Seed(
new Reservation { Id = 2091, Seats = openStart },
new Reservation { Id = 2092, Seats = openEnd },
new Reservation { Id = 2093, Seats = finite });

await using var context = new IntegrationDbContext();

var server = await context.Reservations
.Where(r => r.Id >= 2091 && r.Id <= 2093)
.OrderBy(r => r.Id)
.Select(r => new
{
r.Id,
r.Seats,
ToFinite = r.Seats.IsAdjacentTo(finite),
ToOpenEnd = r.Seats.IsAdjacentTo(openEnd),
ToMeeting = r.Seats.IsAdjacentTo(meeting)
})
.ToListAsync();

foreach (var row in server)
{
Assert.AreEqual(row.Seats.IsAdjacentTo(finite), row.ToFinite, $"'{row.Seats}' -|- '{finite}'");
Assert.AreEqual(row.Seats.IsAdjacentTo(openEnd), row.ToOpenEnd, $"'{row.Seats}' -|- '{openEnd}'");
Assert.AreEqual(row.Seats.IsAdjacentTo(meeting), row.ToMeeting, $"'{row.Seats}' -|- '{meeting}'");
}

// The three that used to disagree, pinned explicitly against the server's answers.
var fromOpenStart = server.Single(row => row.Id == 2091);
Assert.IsTrue(fromOpenStart.ToFinite, "(-∞,0] -|- [1,3]");
Assert.IsTrue(fromOpenStart.ToMeeting, "(-∞,0] -|- [1,+∞)");
Assert.IsFalse(fromOpenStart.ToOpenEnd, "(-∞,0] and [4,+∞) leave a gap");

var fromOpenEnd = server.Single(row => row.Id == 2092);
Assert.IsTrue(fromOpenEnd.ToFinite, "[4,+∞) -|- [1,3]");
}

[TestMethod]
public async Task MultirangeComparisons_MatchInMemory()
{
Expand Down
92 changes: 92 additions & 0 deletions test/CodoMetis.ValueRanges.Tests/RangeIsAdjacentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,96 @@ public void IsAdjacentTo_Discrete_FiniteAdjacentToOpenStart_OneStepApart()
var openStart2 = Int32Range.CreateUnboundedStart(5, true);
Assert.IsTrue(finite.IsAdjacentTo(openStart2));
}

// -------------------------------------------------------------------------
// Symmetry — the receiver's shape must not decide the answer
// -------------------------------------------------------------------------

/// <summary>
/// Adjacency is a symmetric relation, and PostgreSQL's <c>-|-</c> is symmetric too
/// (confirmed directly against the server). Answering on the receiver's shape rather than
/// on the pair breaks both.
/// </summary>
[TestMethod]
public void IsAdjacentTo_IsSymmetric_AcrossEveryShapePair()
{
Int32Range[] shapes =
[
Int32Range.Empty,
Int32Range.Infinite,
Int32Range.CreateFinite(1, 3),
Int32Range.CreateFinite(4, 6),
Int32Range.CreateUnboundedStart(0, true), // (-∞, 0]
Int32Range.CreateUnboundedEnd(1), // [1, +∞)
Int32Range.CreateUnboundedEnd(4), // [4, +∞)
Int32Range.CreateUnboundedEnd(7) // [7, +∞)
];

foreach (var a in shapes)
foreach (var b in shapes)
Assert.AreEqual(
a.IsAdjacentTo(b), b.IsAdjacentTo(a),
$"'{a}' -|- '{b}' = {a.IsAdjacentTo(b)} but '{b}' -|- '{a}' = {b.IsAdjacentTo(a)}");
}

/// <summary>
/// The concrete pairs PostgreSQL was asked about directly, all of which it answers
/// <see langword="true"/> in both directions.
/// </summary>
[TestMethod]
public void IsAdjacentTo_UnboundedReceiver_MatchesPostgres()
{
var openStart = Int32Range.CreateUnboundedStart(0, true); // (-∞, 0]
var finite = Int32Range.CreateFinite(1, 3); // [1, 3]
var openEnd = Int32Range.CreateUnboundedEnd(4); // [4, +∞)
var meeting = Int32Range.CreateUnboundedEnd(1); // [1, +∞)

Assert.IsTrue(openStart.IsAdjacentTo(finite), "(-∞,0] -|- [1,3]");
Assert.IsTrue(finite.IsAdjacentTo(openStart), "[1,3] -|- (-∞,0]");

Assert.IsTrue(openEnd.IsAdjacentTo(finite), "[4,+∞) -|- [1,3]");
Assert.IsTrue(finite.IsAdjacentTo(openEnd), "[1,3] -|- [4,+∞)");

Assert.IsTrue(openStart.IsAdjacentTo(meeting), "(-∞,0] -|- [1,+∞)");
Assert.IsTrue(meeting.IsAdjacentTo(openStart), "[1,+∞) -|- (-∞,0]");
}

[TestMethod]
public void IsAdjacentTo_Continuous_UnboundedReceiver_NeedsXorInclusiveness()
{
var openStart = DecimalRange.CreateUnboundedStart(5m, false); // (-∞, 5)
var claiming = DecimalRange.CreateFinite(5m, 10m, true, true); // [5, 10]
var leaving = DecimalRange.CreateFinite(5m, 10m, false, true); // (5, 10]

Assert.IsTrue(openStart.IsAdjacentTo(claiming), "one side claims 5");
Assert.IsFalse(openStart.IsAdjacentTo(leaving), "neither side claims 5 — a gap");

var openEnd = DecimalRange.CreateUnboundedEnd(5m, true); // [5, +∞)
var upToFive = DecimalRange.CreateFinite(1m, 5m, true, false); // [1, 5)

Assert.IsTrue(openEnd.IsAdjacentTo(upToFive));
Assert.IsFalse(openEnd.IsAdjacentTo(DecimalRange.CreateFinite(1m, 5m, true, true)), "both claim 5 — overlap");
}

/// <summary>
/// Widening the receiver must not make overlapping or degenerate operands adjacent.
/// </summary>
[TestMethod]
public void IsAdjacentTo_UnboundedReceiver_StillFalseWhenOverlappingOrDegenerate()
{
var openStart = Int32Range.CreateUnboundedStart(0, true); // (-∞, 0]
var openEnd = Int32Range.CreateUnboundedEnd(4); // [4, +∞)

// Two ranges running to the same infinity always overlap.
Assert.IsFalse(openStart.IsAdjacentTo(Int32Range.CreateUnboundedStart(9, true)));
Assert.IsFalse(openEnd.IsAdjacentTo(Int32Range.CreateUnboundedEnd(9)));

// A gap, not a meeting point.
Assert.IsFalse(openStart.IsAdjacentTo(openEnd));

Assert.IsFalse(openStart.IsAdjacentTo(Int32Range.Infinite));
Assert.IsFalse(openStart.IsAdjacentTo(Int32Range.Empty));
Assert.IsFalse(Int32Range.Infinite.IsAdjacentTo(openStart));
Assert.IsFalse(Int32Range.Empty.IsAdjacentTo(openStart));
}
}
Loading
Loading