Skip to content

Any/Count over a captured list of ranges silently merge overlapping ranges and drop empty ones #76

Open

Description

@CaffeinatedCoder

Any, Count and other LINQ over a captured list of ranges run in PostgreSQL and return a different answer from the same expression in memory, without throwing:

var wanted = new List<Int32Range> { Int32Range.CreateFinite(1, 5), Int32Range.CreateFinite(3, 8) };
// row: Seats = [1,5]
db.Reservations.Select(r => wanted.Count(w => w.Overlaps(r.Seats)));   // database 1, memory 2
db.Reservations.Select(r => wanted.Count(w => w == r.Seats));          // database 0, memory 1

var withEmpty = new List<Int32Range> { Int32Range.Empty, Int32Range.CreateFinite(1, 5) };
db.Reservations.Select(r => withEmpty.Any(w => w.IsEmpty()));          // database false, memory true

Measured against live PostgreSQL 17 while pinning down #71.

Cause: EF Core sends the captured list as one parameter, which Npgsql receives as an untyped NpgsqlRange<T>[]. Npgsql infers that type as a multirange, not an array of ranges, so the SQL becomes FROM unnest(@wanted). unnest of a multirange returns its ranges after the multirange has merged overlapping and adjacent ones and dropped empty ones. So the query sees {[1,8]} where the list holds [1,5] and [3,8]. This is the same mistyping that makes wanted.Contains(r.Seats) fail loudly with 42809 (#71).

Fix (ready): the interceptor change for #71 already sends a captured range collection one parameter per range (VALUES (@wanted1), (@wanted2)), which gives the in-memory answer for all of these. Measured: 2/2, 1/1, true/true. For 9.1.0 it is limited to collections a Contains reads, because those queries failed before. For every other use it would change an answer that queries return today, so by the release rule it goes in 10.0.0: drop that limit (rangeContainsSources in ValueRangesQueryExpressionInterceptor). A test should cover overlapping, adjacent and empty ranges in the list.

Activity

  1. added this to the 10.0.0 milestone on Oct 17, 2026
  2. added
    bugSomething isn't working
    silentAnswers wrongly without throwing — fixes go in the next major, never deferred
    breakingChanges an answer or a signature — needs a major
    area/rangesRange unions, variants and the shape engines
    package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQL
    severity/highWrong results, crash or failed writes in a realistic scenario
    on Oct 17, 2026
  3. CaffeinatedCoder commented on Oct 17, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Oracle check (main at 3ecfa10): no oracle on main catches this (pending). Verified by a run: every class in oracles.json passes on 3ecfa10 with the defect present, and no candidate is registered for it.
    Gap: the same as #46 and #71. The EF Core Operand position axis in axes.md has no captured collection value, and DifferentialFuzzParityTests binds operands only as a column, a constant or a parameter. Worse, claim E38 states today's unnest behaviour as the documented workaround, so the catalogue pins the divergence instead of the parity. The fix has to restate E38 as "equals memory". The missing pair is captured collection × Any/Count × every range type, outside route B steps 2 and 3.

  4. CaffeinatedCoder commented on Oct 17, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Would be caught by CapturedCollectionProbeTests (#138, closes #136). That's the probe sweep the gap above called for. It registers this issue as candidate row , alone: (method Ranges_AnswerAsInMemory), and the row will fail as candidate fixed once the fix lands.

    Measured wider than filed: 1,710 lines across all 11 range types and every collection form (array, List, HashSet, IEnumerable), whenever no Contains in the query reads the collection.

    • Silent: Any(IsEmpty), Count(Overlaps) and Count(IsAdjacentTo) answer differently for the overlapping, adjacent, duplicate, with-an-empty-range and every-shape lists.
    • Loud: Any(w => w == c) is rewritten to = ANY (@p) and fails with 42809, the list.Contains(rangeColumn) fails with 42809 'op ANY/ALL (array) requires array on right side' #71 error, because the interceptor only looks for a Contains call. For TimeRange, every operation throws InvalidCastException, since an untyped NpgsqlRange<TimeOnly>[] can't be written.

    For the fix: dropping the rangeContainsSources limit makes an empty range collection throw "Empty collections are not supported as inline query roots", as value sets do today. That is #137, so the two need to be fixed together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/rangesRange unions, variants and the shape enginesbreakingChanges an answer or a signature — needs a majorbugSomething isn't workingfound-by/oracleFound by the generated-oracle machinery (claims × API surfaces)package/efcoreAffects CodoMetis.ValueRanges.EFCore.PostgreSQLseverity/highWrong results, crash or failed writes in a realistic scenariosilentAnswers wrongly without throwing — fixes go in the next major, never deferred

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions