Repository navigation
Translate RangeSet.Count as a counted unnest of the multirange, built after the alias pass as #478 builds its subqueries (split from #94) #484
Description
Problem
RangeSet.Count doesn't translate. bookings.Where(b => b.Blocked.Count > 2) ("how fragmented is this availability?") throws, and in a projection EF Core loads the multirange and counts it in memory.
#94's spike (2026-10-18) stopped here. PostgreSQL has no multirange cardinality function, so Count needs a scalar subquery, (SELECT count(*) FROM unnest(m)), and a member translator has no alias manager to build one.
#478 removed that obstacle. ValueSetSqlGuard builds its canonical subqueries after EF Core's alias pass (SqlAliasManager.PostprocessAliases), under vrN aliases that can't collide, and the same code works on EF Core 10 and 11. The count can be built the same way.
Change
- The translator emits a placeholder function for
RangeSet<TRange, T>.Count. The processor that runs the guard expands it into(SELECT count(*)::integer FROM unnest(m) AS vrN).count(*)isbigintandCountis anint. - NULL needs the NULL-over-NULL wrapper. Measured on PostgreSQL 16.15 and 18.1:
(SELECT count(*) FROM unnest(NULL::int4multirange))is0, not NULL. Value setCountover a NULL column iscardinality(NULL), which is NULL, so the count goes inside Translate value set Intersect, Except, Add and a canonical Union for the non-text families with composed SQL #478'sCASE(NullOverNullOperand). - The placeholder fails closed like Translate value set Intersect, Except, Add and a canonical Union for the non-text families with composed SQL #478's. Without the decorated processor, PostgreSQL rejects an unknown function instead of counting.
- No canonical-form caveat, unlike
array_cat. PostgreSQL stores a multirange canonical:{[1,3),[3,8),[10,12)}counts 2. That is the number of ranges memory holds after reading the row. - Decide where the expansion lives.
ValueSetSqlGuardis about value sets, and the multirange count would be its first non-set expression. A shared expander that both use may read better.
Out of scope:
- The indexer,
set[i]. Memory throws past the end where SQL gives NULL, so it stays in memory, like the value set indexer. Any,AllandSelectMany. They stay on Translate enumeration of a multirange column: Count, Any, All, SelectMany over a RangeSet (unnest) #94.
Does it change an answer?
No. A predicate that throws today gets the in-memory answer. A projection that counted in memory counts in SQL, with the same result. It's an addition: it lands on main first and is cherry-picked with -x into release/10.x as a 10.x minor.
Done when
Counttranslates over a multirange column, a parameter and a server-computed multirange (Union,Except,Gaps()), for every range type, core and NodaTime- the no-database tests assert the whole SQL, including the
CASEand the cast - a live test on PostgreSQL 16 and 18 agrees with memory for 0, 1 and 3 ranges and a NULL column
docs/efcore.md#what-runs-where:Countmoves out of the row "Countand the indexer on aRangeSet" into "Runs as SQL", and the indexer staystest/oracles/postgres-surface.json:unnest(anymultirange)names the translation (it points at Translate enumeration of a multirange column: Count, Any, All, SelectMany over a RangeSet (unnest) #94 today)- a claim covers it in
claims.json, and a seed that drops the NULL wrapper is caught - Translate enumeration of a multirange column: Count, Any, All, SelectMany over a RangeSet (unnest) #94 narrows to
Any,AllandSelectMany
Split out of #94.
Activity
Merge pull request #491 from CaffeinatedCoder/feat/484-translate-rang…
Merge pull request #499 from CaffeinatedCoder/feat/495-translate-rang…