Repository navigation
Add DeferredProjectionPager - #29
Merged
Merged
Conversation
The old names described the mechanism rather than what a caller passes in: the narrow query is where every filter goes, and the closure builds the select for the page, it does not hydrate objects. Parameter names are public API through named arguments, so they change before the first release. Adds coverage for filtering on a related table from both sides: EXISTS on the matching keys, and a bound condition on the projection join.
Symfony 8.1 deprecates HttpKernel's BundleInterface and renames the loadExtension() parameters. Its replacement base class does not exist before 8.1, so with ^7.4 || ^8.0 no signature satisfies both ranges.
The create() docblock explained the fold and the CTE, not what a caller passes. It now says which rows go in matchingKeys, what a row looks like in projection, and when the closure arguments are needed.
veewee
marked this pull request as ready for review
September 25, 2026 06:50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a second
Pagerimplementation for lists whose rows carry aggregated or joined objects.The problem
WindowCountPageraddsCOUNT(1) OVER(), and PostgreSQL evaluates a window function over every matching row beforeLIMITdiscards all but one page. That is fine for a narrow row. It is not fine when the row carries ajsonb_build_object/jsonb_agg_strictprojection over joined tables: the whole matching set gets buffered at the fat row's width.Measured on a real list of 20k rows with two joined tables folded into jsonb (page size 50):
WindowAggrow width dropped 134 → 24, andStorage: Disk 6480 kBwithtemp read=1148 written=811becameStorage: Memory 1038 kBwith no temp files at all. The deep pages additionally spilled anexternal merge Disk: 20320 kBsort before; they no longer spill.The approach
Page the narrow rows (the key column plus the count window) inside a CTE, then join the fat projection onto that CTE. The window buffers
(key, total)rows; the expensive projection is evaluated only for the page that is actually returned.It stays one statement. That is the whole design constraint, and everything else follows from it: no snapshot skew between a page query and a hydration query, no
IN (...)list and therefore no bind-parameter ceiling, and no second round trip.Both pagers stay
WindowCountPageris correct and cheaper for a narrow row, and it keeps its$countExpressionescape hatch. This one is for rows carrying aggregated or joined objects. Each docblock and the README say which to reach for. Nothing is deprecated and no existing behaviour changes.Design notes worth reviewing
$hydrateclosure returns aQueryBuilderrather than writing into the folded composite. It has to:moveMainQueryToSubQuery()hands back a fresh empty main query, and DBAL 4.4 keepsQueryBuilder::$select/$from/$joinprivate with no getters or setters, so a pre-built query cannot be transplanted. It is installed withCompositeQuery::map().getQueryPart()and$orderByis private — and a caller applying it only once yields the right rows in arbitrary order.MAX()is not an alternative: it would turn a non-aggregating hydration query into an aggregate one and collapse the page to one row.WITHlist as the same object, andexecute()merges every registered CTE builder's parameters. Two tests pin this directly.totalResults()/totalPages()are byte-identical toWindowCountPager's, including1page for an empty result.Tests
19 integration tests over the existing
users/postsexample fixtures, covering: a one-to-manyjsonb_agg_stricthydration (including a key whose aggregate is empty), a caller-registered CTE surviving the fold, a bound parameter surviving the fold, exact ordered sequences ascending and descending on both a first and a deep page, the total counting keys rather than joined rows, an empty result set, a page past the end, a custom count field,traverse(), re-iterability without a second query, that the caller's query is not mutated, and the refusal of an unqualified key.Two of those deserve a mention:
finally.ORDER BYfails 7 of the 19 tests. Worth stating because at scale it is not detectable: the planner nest-loops over the CTE in stored order, so the bug hides precisely where you would measure it. The small fixture world is what catches it.Gates
phpunit --testsuite=unit46 tests ·--testsuite=integration179 tests (160 onmain) ·psalmno errors, no suppressions added ·php-cs-fixer0 of 149 files changed ·paratest225 tests with clover at 726/726 statements = 100%, the new pager 51/51 ·grumphp runall 5 tasks pass.One pre-existing snag, unrelated to this change:
grumphp runinside the php container aborts before analysis withThe package "symfony/cache" conflicts with the extension "redis". Reproduced on a cleanmain. The individual tasks were run in docker and the full grumphp gate on the host.