Skip to content

Add DeferredProjectionPager - #29

Merged
veewee merged 6 commits into
phpro:mainfrom
veewee:feature/deferred-projection-pager
Sep 25, 2026
Merged

veewee merged 6 commits into
phpro:mainfrom
veewee:feature/deferred-projection-pager

Conversation

@veewee

@veewee veewee commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Adds a second Pager implementation for lists whose rows carry aggregated or joined objects.

The problem

WindowCountPager adds COUNT(1) OVER(), and PostgreSQL evaluates a window function over every matching row before LIMIT discards all but one page. That is fine for a narrow row. It is not fine when the row carries a jsonb_build_object / jsonb_agg_strict projection 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):

before after
page 1 139.8 ms 3.97 ms
deep page (offset 12450) 160.1 ms 4.08 ms
page 1, with a CTE-based scope 90.6 ms 18.1 ms
deep page, with a CTE-based scope 97.9 ms 19.0 ms

WindowAgg row width dropped 134 → 24, and Storage: Disk 6480 kB with temp read=1148 written=811 became Storage: Memory 1038 kB with no temp files at all. The deep pages additionally spilled an external merge Disk: 20320 kB sort 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.

$narrow = CompositeQuery::from($connection);
$narrow->mainQuery()
    ->select(UsersTableColumns::Id->select())
    ->from(UsersTable::name());

$pager = DeferredProjectionPager::create(
    new Pagination(page: 2, limit: 3),
    $narrow,
    UsersTableColumns::Id->column(),
    new OrderBy(OrderBy::field(UsersTableColumns::Username->column(), OrderBy::ASC)),
    static fn (CompositeQuery $folded, string $pageAlias): QueryBuilder => $connection
        ->createQueryBuilder()
        ->select(
            ...UsersTable::columns()->select(),
            ...[new Alias(JsonbAggStrict::onManyLeftJoinedJsonObjects(/* ... */), 'posts')->toSQL()],
        )
        ->from(UsersTable::name())
        ->leftJoin(...UsersTable::joinOntoPosts())
        ->groupBy(UsersTableColumns::Id->use()),
);

Both pagers stay

WindowCountPager is correct and cheaper for a narrow row, and it keeps its $countExpression escape 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

  • The $hydrate closure returns a QueryBuilder rather than writing into the folded composite. It has to: moveMainQueryToSubQuery() hands back a fresh empty main query, and DBAL 4.4 keeps QueryBuilder::$select/$from/$join private with no getters or setters, so a pre-built query cannot be transplanted. It is installed with CompositeQuery::map().
  • The pager owns the join, the count and the order. The closure supplies only its projection. A caller who had to remember the join would get a silent cartesian product on forgetting it.
  • The order is a parameter, not something the caller pre-applies. The pager applies it to the narrow query (which decides which rows the page holds) and to the outer query (which decides the order they come back in). It cannot be recovered from the query — DBAL 4 dropped getQueryPart() and $orderBy is private — and a caller applying it only once yields the right rows in arbitrary order.
  • The count is read as a scalar sub-query, not as a column of the joined CTE. A plain column reference is neither aggregated nor functionally dependent on the group key, so any hydration query that aggregates would be rejected by PostgreSQL unless it also grouped by the pager's count field — forcing callers to know its name. MAX() is not an alternative: it would turn a non-aggregating hydration query into an aggregate one and collapse the page to one row.
  • CTEs and parameters registered by the caller survive the fold. The narrow builder moves into the WITH list as the same object, and execute() merges every registered CTE builder's parameters. Two tests pin this directly.
  • totalResults() / totalPages() are byte-identical to WindowCountPager's, including 1 page for an empty result.

Tests

19 integration tests over the existing users / posts example fixtures, covering: a one-to-many jsonb_agg_strict hydration (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:

  • Re-iterability is proved, not asserted. The test iterates, inserts a row that sorts ahead of everything inside a nested savepoint, re-iterates and asserts the same page and total — while a freshly built pager does see the new row. The savepoint is rolled back in a finally.
  • The ordering assertions are falsifiable. Deleting the outer ORDER BY fails 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=unit 46 tests · --testsuite=integration 179 tests (160 on main) · psalm no errors, no suppressions added · php-cs-fixer 0 of 149 files changed · paratest 225 tests with clover at 726/726 statements = 100%, the new pager 51/51 · grumphp run all 5 tasks pass.

One pre-existing snag, unrelated to this change: grumphp run inside the php container aborts before analysis with The package "symfony/cache" conflicts with the extension "redis". Reproduced on a clean main. The individual tasks were run in docker and the full grumphp gate on the host.

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
veewee marked this pull request as ready for review September 25, 2026 06:50
@veewee
veewee merged commit d808fcf into phpro:main Sep 25, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant