From ab3be94c755be432b28a13e63f05e631568257fe Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Wed, 9 Sep 2026 15:18:35 +0200 Subject: [PATCH 1/6] Add DeferredProjectionPager --- README.md | 94 ++++ src/Pager/DeferredProjectionPager.php | 190 ++++++++ .../Pager/DeferredProjectionPagerTest.php | 438 ++++++++++++++++++ 3 files changed, 722 insertions(+) create mode 100644 src/Pager/DeferredProjectionPager.php create mode 100644 tests/Integration/Pager/DeferredProjectionPagerTest.php diff --git a/README.md b/README.md index c08ba35..b1083cc 100644 --- a/README.md +++ b/README.md @@ -629,6 +629,13 @@ App\Domain\Model\User: Dealing with pagination is a task that is often needed. This package contains some tools to help you with that: +* `WindowCountPager` - the default: one query, the total counted with a window function. +* `DeferredProjectionPager` - for rows that carry aggregated or joined objects. + +Both are honest implementations of `Pager` and both stay: pick the one that matches the width of your rows. + +## WindowCountPager + ```php use Phpro\DbalTools\Pager\MappingPager; use Phpro\DbalTools\Pager\Pager; @@ -652,6 +659,93 @@ $totalPages = $usersPager->totalPages(); $users = [...$usersPager]; ``` +## DeferredProjectionPager + +`WindowCountPager` adds `COUNT(1) OVER()` to the query it pages. +PostgreSQL evaluates window functions before `ORDER BY`/`LIMIT` at the same query level, so every matching +row is buffered before the page is cut. +That is free for a narrow row, and expensive as soon as the row carries a `jsonb_build_object` or a +`jsonb_agg_strict` projection over joined tables: those buffered rows are kilobytes wide and spill to temp +files. + +`DeferredProjectionPager` pages *narrow* rows - the key column plus the count window - inside a CTE, and +joins the fat projection onto that CTE. +The total is still computed over the whole matching set, but out of `(key, total)` rows, and the expensive +projection is only evaluated for the page that is returned. +It remains a single statement, so there is one snapshot and no second round trip. + +**Which one to reach for:** + +* `WindowCountPager` for a narrow row. It is correct, cheaper, and pays nothing for a CTE it does not need. +* `DeferredProjectionPager` when a row carries aggregated or joined objects. + +```php +use Doctrine\DBAL\Query\QueryBuilder; +use Phpro\DbalTools\Expression\Alias; +use Phpro\DbalTools\Expression\JsonbAggStrict; +use Phpro\DbalTools\Expression\JsonbBuildObject; +use Phpro\DbalTools\Expression\OrderBy; +use Phpro\DbalTools\Pager\DeferredProjectionPager; +use Phpro\DbalTools\Pager\MappingPager; +use Phpro\DbalTools\Pager\Pagination; +use Phpro\DbalTools\Query\CompositeQuery; + +// The narrow half: the key column and the filters. No projection, no join. +$narrowPage = new CompositeQuery( + $connection, + $connection->createQueryBuilder() + ->select(UsersTableColumns::Id->select()) + ->from(UsersTable::name()), + [], +); + +$usersPager = new MappingPager( + DeferredProjectionPager::create( + new Pagination(page: $page, limit: $limit), + $narrowPage, + UsersTableColumns::Id->column(), + new OrderBy(OrderBy::field(UsersTableColumns::Username->column(), OrderBy::ASC)), + // The fat half: returned, not written in place, and it knows nothing about the page CTE. + static fn (CompositeQuery $folded, string $pageAlias): QueryBuilder => $connection->createQueryBuilder() + ->select( + ...UsersTable::columns()->select(), + ...[new Alias( + JsonbAggStrict::onManyLeftJoinedJsonObjects( + new JsonbBuildObject([ + 'id' => PostsTableColumns::Id->column(), + 'post' => PostsTableColumns::Post->column(), + ]), + PostsTableColumns::Id->column(), + ), + 'posts', + )->toSQL()], + ) + ->from(UsersTable::name()) + ->leftJoin(...UsersTable::joinOntoPosts()) + ->groupBy(UsersTableColumns::Id->use()), + ), + $userMapper, +); +``` + +Things worth knowing: + +* The filters and the scope joins belong on the **narrow** query. A filter applied by the hydration closure + would narrow the page after the total was computed, so the total would over-report. +* Every CTE you registered on `$narrowPage` survives the fold, and so does a parameter bound on its main + query: that same query builder is moved into the `WITH` list, and `CompositeQuery::execute()` merges the + parameters of every registered builder. +* The key must be table qualified (the join is derived from it) and unique in the narrow set. A duplicate + multiplies the hydrated rows and makes the total disagree with the page. +* Aggregate freely. The pager reads its count as a scalar sub-query rather than as a column of the joined + CTE, so a hydration query that groups needs no group-by for it. +* The pager owns the join onto the page CTE, the count column, and the order - which it applies to both + levels, since the narrow sort decides *which* rows the page holds and the outer one the order they come + back in. The closure only supplies its projection. +* The hydration closure **returns** its query rather than writing onto the folded main query, because + `QueryBuilder` keeps its select, from and join parts private with no setters. +* Rows are yielded verbatim, the count field included, exactly like `WindowCountPager`. + # Building queries This package contains a set of tools that helps you build queries based on partial SQL Expressions. diff --git a/src/Pager/DeferredProjectionPager.php b/src/Pager/DeferredProjectionPager.php new file mode 100644 index 0000000..5c862f5 --- /dev/null +++ b/src/Pager/DeferredProjectionPager.php @@ -0,0 +1,190 @@ +> + */ +final readonly class DeferredProjectionPager implements Pager +{ + /** + * Fixed rather than a parameter: a caller registering a CTE of this name fails loudly at parse time, + * which is the good failure, where a parameter would be one more thing every call site passes. + */ + private const string PAGE_ALIAS = 'deferred_page'; + + /** + * @param non-empty-string $countField + * @param Iterator> $iterator + */ + private function __construct( + private Pagination $pagination, + private Iterator $iterator, + private string $countField, + ) { + } + + /** + * `$narrowPage` is a composite whose main query selects only `$key` and carries the filters; every CTE + * registered on it survives the fold. + * + * `$key` is the column joining narrow to fat, and must be unique in the narrow set: a duplicate + * multiplies the hydrated rows and the total disagrees with the page. + * + * `$order` is applied to both levels, since the narrow sort decides which rows the page holds and the + * outer one the order they come back in. + * + * `$hydrate` returns the fat projection for the folded composite. The join onto the page and the total + * are the pager's job, not its. + * + * @param \Closure(CompositeQuery, non-empty-string): QueryBuilder $hydrate + * @param non-empty-string $countField + */ + public static function create( + Pagination $pagination, + CompositeQuery $narrowPage, + Column $key, + OrderBy $order, + \Closure $hydrate, + string $countField = 'total_results', + ): self { + invariant(null !== $key->from, 'The page key must be table qualified, so the hydration join can be derived.'); + + // First clone the composite to avoid updates on the provided query. + $narrowPage = clone $narrowPage; + $narrow = $narrowPage->mainQuery(); + $narrow->addSelect( + new Alias( + Over::aggregation(new Count(SqlExpression::int(1)), Over::fullWindow()), + $countField, + )->toSQL(), + ); + self::applyOrder($narrow, $order); + $narrow->setMaxResults($pagination->limit); + $narrow->setFirstResult(($pagination->page - 1) * $pagination->limit); + + /** + * The narrow query becomes a CTE and the main query a fresh empty one, with every CTE the caller + * registered preserved. A parameter bound on the narrow query survives this: the very same builder + * moves into the `WITH` list, and `execute()` merges every registered CTE builder's parameters. + */ + $folded = $narrowPage->moveMainQueryToSubQuery(self::PAGE_ALIAS); + + $fat = $hydrate($folded, self::PAGE_ALIAS); + + /** + * The count is read as a scalar sub-query rather than as a column of the joined CTE, so that a + * hydration query which aggregates does not have to group by it. A plain column reference would be + * neither aggregated nor functionally dependent on the group key, which PostgreSQL rejects, and + * wrapping it in `MAX()` is not an option either: that would turn a non-aggregating hydration query + * into an aggregate one and collapse the page to a single row. The value is identical on every row + * of the page, so reading one is exact. The sub-query is the second reference to a CTE holding one + * page of narrow rows, which costs nothing. + */ + $fat->addSelect( + new Alias( + SqlExpression::parenthesized(sprintf( + 'SELECT %s FROM %s LIMIT 1', + $folded->cteColumn(self::PAGE_ALIAS, $countField)->toSQL(), + self::PAGE_ALIAS, + )), + $countField, + )->toSQL(), + )->innerJoin(...$folded->joinOntoCte( + self::PAGE_ALIAS, + $key->from, + $key, + )); + self::applyOrder($fat, $order); + + /** + * The closure returns its projection rather than writing onto the empty main query, because + * `QueryBuilder` keeps its select, from and join parts private with no setters: a pre-built query + * cannot be transplanted, so it is installed wholesale instead. + */ + $hydrated = $folded->map(static fn (QueryBuilder $mainQuery): QueryBuilder => $fat); + + return new self( + $pagination, + Iterator::from( + /** + * @return iterable> + */ + static function () use ($hydrated): iterable { + yield from $hydrated->execute()->iterateAssociative(); + }, + ), + $countField, + ); + } + + public function pagination(): Pagination + { + return $this->pagination; + } + + public function totalResults(): int + { + $first = first($this->iterator) ?? []; + + return (int) ($first[$this->countField] ?? 0); + } + + public function totalPages(): int + { + $totalResults = $this->totalResults(); + $limit = $this->pagination()->limit; + if (!$totalResults) { + return 1; + } + + return (int) ceil($totalResults / $limit); + } + + public function traverse(\Closure $mapper): iterable + { + return map($this, $mapper); + } + + public function getIterator(): \Traversable + { + yield from $this->iterator; + } + + /** + * `OrderBy` renders its own `ORDER BY` keyword, which `QueryBuilder::orderBy()` prepends in turn; the + * builder joins its parts with a comma, so the whole expression goes in as one part. + */ + private static function applyOrder(QueryBuilder $query, OrderBy $order): void + { + $query->orderBy(Str\strip_prefix($order->toSQL(), 'ORDER BY ')); + } +} diff --git a/tests/Integration/Pager/DeferredProjectionPagerTest.php b/tests/Integration/Pager/DeferredProjectionPagerTest.php new file mode 100644 index 0000000..0d78a5a --- /dev/null +++ b/tests/Integration/Pager/DeferredProjectionPagerTest.php @@ -0,0 +1,438 @@ +insert(UsersTable::name(), $this->fixtures['user'.$i] = [ + UsersTableColumns::Id->value => $userId = Uuid::generate()->value, + UsersTableColumns::Username->value => 'user'.$i, + UsersTableColumns::FirstName->value => 0 === $i % 2 ? 'even' : 'odd', + UsersTableColumns::LastName->value => 'er'.$i, + ]); + + // user6 owns no posts at all, which is the row whose aggregate comes back as NULL. + for ($j = 1; $j < $i && $i < self::USERS; ++$j) { + self::connection()->insert(PostsTable::name(), [ + PostsTableColumns::Id->value => Uuid::generate()->value, + PostsTableColumns::UserId->value => $userId, + PostsTableColumns::Post->value => 'post '.$j.' of user '.$i, + ]); + } + } + } + + /** + * The total counts every matching key while the page holds only its own, which is the whole premise: + * the count window sits inside the narrow query, where `LIMIT` cannot reach it. + */ + #[Test] + #[TestWith([1, ['user1', 'user2']])] + #[TestWith([2, ['user3', 'user4']])] + #[TestWith([3, ['user5', 'user6']])] + public function it_pages_the_answer_while_counting_all_of_it(int $page, array $expected): void + { + $pager = self::pager(new Pagination($page, 2)); + + self::assertSame($expected, self::usernames($pager)); + self::assertSame(6, $pager->totalResults()); + self::assertSame(3, $pager->totalPages()); + self::assertSame($page, $pager->pagination()->page); + self::assertSame(2, $pager->pagination()->limit); + } + + /** + * The total is the number of keys in the narrow set, not the number of rows the hydration join + * produces. That is the difference from counting over the fat query, where the one-to-many join + * inflates every total. + */ + #[Test] + public function it_counts_keys_rather_than_joined_rows(): void + { + $joinedRows = (int) self::connection()->fetchOne( + self::connection()->createQueryBuilder() + ->select(UsersTableColumns::Id->apply(static fn ($column) => new Count($column))) + ->from(UsersTable::name()) + ->innerJoin(...UsersTable::joinOntoPosts()) + ->getSQL(), + ); + + self::assertSame(10, $joinedRows); + self::assertSame(6, self::pager(new Pagination(1, 2))->totalResults()); + } + + /** + * The projection the pager exists for: a one-to-many aggregate of joined json objects, evaluated for + * the two rows of the page rather than for all six. + */ + #[Test] + public function it_hydrates_every_row_of_the_page_with_its_aggregated_objects(): void + { + $rows = values(self::pager(new Pagination(2, 2))); + + self::assertSame(['user3', 'user4'], self::usernames(self::pager(new Pagination(2, 2)))); + self::assertSame(['post 1 of user 3', 'post 2 of user 3'], self::posts($rows[0])); + self::assertSame( + ['post 1 of user 4', 'post 2 of user 4', 'post 3 of user 4'], + self::posts($rows[1]), + ); + } + + /** + * A key without related rows is still a page row: the left join keeps it and the strict aggregate + * drops the NULL, so its projection is an empty array rather than a missing row. + */ + #[Test] + public function it_keeps_a_row_whose_aggregate_is_empty(): void + { + $rows = values(self::pager(new Pagination(3, 2))); + + self::assertSame('user6', $rows[1][UsersTableColumns::Username->value]); + self::assertSame([], self::posts($rows[1])); + } + + /** + * A CTE the caller registered survives the fold: `moveMainQueryToSubQuery()` merges the narrow query + * into the `WITH` list it already holds rather than replacing it. + */ + #[Test] + public function a_caller_registered_cte_survives_the_fold(): void + { + $composite = self::narrow(); + $composite->addSubQuery( + 'prolific', + self::connection()->createQueryBuilder() + ->select(PostsTableColumns::UserId->select()) + ->from(PostsTable::name()) + ->groupBy(PostsTableColumns::UserId->use()) + ->having(PostsTableColumns::Id->apply(static fn ($column) => new Count($column)).' >= 3'), + ); + $composite->mainQuery()->innerJoin(...$composite->joinOntoCte( + 'prolific', + UsersTable::name(), + UsersTableColumns::Id->column(), + PostsTableColumns::UserId->column(), + )); + + $pager = self::createPager($composite, new Pagination(1, 10)); + + self::assertSame(['user4', 'user5'], self::usernames($pager)); + self::assertSame(2, $pager->totalResults()); + self::assertSame(1, $pager->totalPages()); + } + + /** + * A parameter bound on the narrow main query survives the fold too, which reads like a violation of + * "bind on the main query": the very same builder moves into the `WITH` list, and + * `CompositeQuery::execute()` merges every registered builder's parameters before the main query's. + */ + #[Test] + public function a_parameter_bound_on_the_narrow_query_survives_the_fold(): void + { + $composite = self::narrow(); + $group = NamedParameter::createForTableColumn( + $composite->mainQuery(), + UsersTableColumns::FirstName, + 'even', + ':group', + ); + $composite->mainQuery()->where( + Comparison::equal(UsersTableColumns::FirstName->column(), $group)->toSQL(), + ); + + $pager = self::createPager($composite, new Pagination(1, 10)); + + self::assertSame(['user2', 'user4', 'user6'], self::usernames($pager)); + self::assertSame(3, $pager->totalResults()); + } + + /** + * The order is applied to the outer query too, not only to the narrow one: a join does not preserve + * its input's order, so reading a deep page back in the requested direction is what pins the second + * application. An exact-set assertion cannot see a missing outer `ORDER BY`; an exact sequence can. + */ + #[Test] + #[TestWith([1, OrderBy::ASC, ['user1', 'user2', 'user3']])] + #[TestWith([2, OrderBy::ASC, ['user4', 'user5', 'user6']])] + #[TestWith([1, OrderBy::DESC, ['user6', 'user5', 'user4']])] + #[TestWith([2, OrderBy::DESC, ['user3', 'user2', 'user1']])] + public function it_orders_the_hydrated_page(int $page, string $direction, array $expected): void + { + $pager = self::pager( + new Pagination($page, 3), + new OrderBy(OrderBy::field(UsersTableColumns::Username->column(), $direction)), + ); + + self::assertSame($expected, self::usernames($pager)); + } + + #[Test] + public function it_can_deal_with_empty_resultset(): void + { + $composite = self::narrow(); + $composite->mainQuery()->where('false'); + + $pager = self::createPager($composite, new Pagination(2, 3)); + + self::assertSame([], self::usernames($pager)); + self::assertSame(0, $pager->totalResults()); + self::assertSame(1, $pager->totalPages()); + } + + /** + * A page past the end is the empty-result shape as well: `total_results` is absent from an empty + * result, so the total falls back to zero and the page count to one, exactly as `WindowCountPager`. + */ + #[Test] + public function a_page_past_the_end_is_empty_and_reports_one_page(): void + { + $pager = self::pager(new Pagination(9, 2)); + + self::assertSame([], self::usernames($pager)); + self::assertSame(0, $pager->totalResults()); + self::assertSame(1, $pager->totalPages()); + } + + /** + * Rows come back verbatim, the count field included under the name it was given: every mapper + * tolerates the extra key, and stripping it would cost an array copy per row for nothing. + */ + #[Test] + public function it_yields_the_count_field_on_every_row_under_the_name_it_was_given(): void + { + $rows = values(self::pager(new Pagination(1, 2), countField: 'matching_rows')); + + self::assertCount(2, $rows); + foreach ($rows as $row) { + self::assertSame(6, (int) $row['matching_rows']); + self::assertArrayNotHasKey('total_results', $row); + } + } + + #[Test] + public function it_traverses_with_a_mapper(): void + { + $pager = self::pager(new Pagination(1, 2)); + + self::assertSame( + ['user1', 'user2'], + values($pager->traverse( + static fn (array $row): string => (string) $row[UsersTableColumns::Username->value], + )), + ); + } + + /** + * The iterator is cached, so reading the total and then the rows runs one statement and iterating + * twice yields the same page rather than an empty second pass. A user inserted between the two passes + * would change both the total and the first page, so its absence from the second pass is what proves + * no second statement ran. + */ + #[Test] + public function it_is_re_iterable_without_running_a_second_query(): void + { + $pager = self::pager(new Pagination(1, 2)); + + self::assertSame(['user1', 'user2'], self::usernames($pager)); + self::assertSame(6, $pager->totalResults()); + + TransactionManager::instance()->createSavepoint('extra_user'); + + try { + self::connection()->insert(UsersTable::name(), [ + UsersTableColumns::Id->value => Uuid::generate()->value, + UsersTableColumns::Username->value => 'user0', + UsersTableColumns::FirstName->value => 'odd', + UsersTableColumns::LastName->value => 'er0', + ]); + + self::assertSame(['user1', 'user2'], self::usernames($pager)); + self::assertSame(6, $pager->totalResults()); + self::assertSame(['user0', 'user1'], self::usernames(self::pager(new Pagination(1, 2)))); + } finally { + TransactionManager::instance()->rollbackSavepoint('extra_user'); + } + } + + #[Test] + public function it_does_not_alter_the_provided_query(): void + { + $composite = self::narrow(); + $originalSql = $composite->toSQL(); + + self::createPager($composite, new Pagination(2, 2))->totalResults(); + + self::assertSame($originalSql, $composite->toSQL()); + self::assertSame([], $composite->mainQuery()->getParameters()); + } + + /** + * A key with no table cannot yield a join condition, so it is refused rather than silently producing + * a cartesian product. + */ + #[Test] + public function it_refuses_a_key_that_is_not_table_qualified(): void + { + $this->expectExceptionMessage('The page key must be table qualified'); + + DeferredProjectionPager::create( + new Pagination(1, 2), + self::narrow(), + UsersTableColumns::Id->column()->from(null), + self::defaultOrder(), + self::hydrate(), + ); + } + + /** + * @param non-empty-string $countField + */ + private static function pager( + Pagination $pagination, + ?OrderBy $order = null, + string $countField = 'total_results', + ): DeferredProjectionPager { + return self::createPager(self::narrow(), $pagination, $order, $countField); + } + + /** + * @param non-empty-string $countField + */ + private static function createPager( + CompositeQuery $narrow, + Pagination $pagination, + ?OrderBy $order = null, + string $countField = 'total_results', + ): DeferredProjectionPager { + return DeferredProjectionPager::create( + $pagination, + $narrow, + UsersTableColumns::Id->column(), + $order ?? self::defaultOrder(), + self::hydrate(), + $countField, + ); + } + + private static function defaultOrder(): OrderBy + { + return new OrderBy(OrderBy::field(UsersTableColumns::Username->column(), OrderBy::ASC)); + } + + /** + * The narrow half: one column and the filters, with no join and no projection. + */ + private static function narrow(): CompositeQuery + { + return new CompositeQuery( + self::connection(), + self::connection()->createQueryBuilder() + ->select(UsersTableColumns::Id->select()) + ->from(UsersTable::name()), + [], + ); + } + + /** + * A fat projection standing in for a details builder: it is built from scratch and knows nothing about + * the page CTE, since the pager owns the join onto it. + * + * It aggregates, and therefore groups, without knowing the pager's count field: the pager reads the count + * as a scalar sub-query precisely so that an aggregating hydration query needs no group-by of its own for + * it. + * + * @return \Closure(CompositeQuery, non-empty-string): QueryBuilder + */ + private static function hydrate(): \Closure + { + return static fn (CompositeQuery $folded, string $pageAlias): QueryBuilder => self::connection() + ->createQueryBuilder() + ->select( + ...UsersTable::columns()->select(), + ...[new Alias( + JsonbAggStrict::onManyLeftJoinedJsonObjects( + new JsonbBuildObject([ + 'id' => PostsTableColumns::Id->column(), + 'post' => PostsTableColumns::Post->column(), + ]), + PostsTableColumns::Id->column(), + new OrderBy(OrderBy::field(PostsTableColumns::Post->column(), OrderBy::ASC)), + ), + 'posts', + )->toSQL()], + ) + ->from(UsersTable::name()) + ->leftJoin(...UsersTable::joinOntoPosts()) + ->groupBy(UsersTableColumns::Id->use()); + } + + /** + * @return list + */ + private static function usernames(DeferredProjectionPager $pager): array + { + return map( + values($pager), + static fn (array $row): string => (string) $row[UsersTableColumns::Username->value], + ); + } + + /** + * @return list + */ + private static function posts(array $row): array + { + /** @var list $posts */ + $posts = decode((string) $row['posts']); + + return map($posts, static fn (array $post): string => $post['post']); + } +} From 5e9d20bd0ab73da16d66df53e2da2cfa1457da7a Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 25 Sep 2026 06:13:22 +0000 Subject: [PATCH 2/6] Rename DeferredProjectionPager arguments to matchingKeys and projection 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. --- README.md | 34 ++--- src/Pager/DeferredProjectionPager.php | 32 ++--- .../Pager/DeferredProjectionPagerTest.php | 124 ++++++++++++++---- 3 files changed, 135 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index b1083cc..7194ea7 100644 --- a/README.md +++ b/README.md @@ -690,8 +690,8 @@ use Phpro\DbalTools\Pager\MappingPager; use Phpro\DbalTools\Pager\Pagination; use Phpro\DbalTools\Query\CompositeQuery; -// The narrow half: the key column and the filters. No projection, no join. -$narrowPage = new CompositeQuery( +// The key column and every filter. No projection, no join that only adds data. +$matchingKeys = new CompositeQuery( $connection, $connection->createQueryBuilder() ->select(UsersTableColumns::Id->select()) @@ -701,12 +701,12 @@ $narrowPage = new CompositeQuery( $usersPager = new MappingPager( DeferredProjectionPager::create( - new Pagination(page: $page, limit: $limit), - $narrowPage, - UsersTableColumns::Id->column(), - new OrderBy(OrderBy::field(UsersTableColumns::Username->column(), OrderBy::ASC)), - // The fat half: returned, not written in place, and it knows nothing about the page CTE. - static fn (CompositeQuery $folded, string $pageAlias): QueryBuilder => $connection->createQueryBuilder() + pagination: new Pagination(page: $page, limit: $limit), + matchingKeys: $matchingKeys, + key: UsersTableColumns::Id->column(), + order: new OrderBy(OrderBy::field(UsersTableColumns::Username->column(), OrderBy::ASC)), + // The wide select: returned, not written in place, and it knows nothing about the page CTE. + projection: static fn (CompositeQuery $folded, string $pageAlias): QueryBuilder => $connection->createQueryBuilder() ->select( ...UsersTable::columns()->select(), ...[new Alias( @@ -730,19 +730,23 @@ $usersPager = new MappingPager( Things worth knowing: -* The filters and the scope joins belong on the **narrow** query. A filter applied by the hydration closure - would narrow the page after the total was computed, so the total would over-report. -* Every CTE you registered on `$narrowPage` survives the fold, and so does a parameter bound on its main +* The filters and the scope joins belong on `$matchingKeys`. A filter applied by the projection would drop + rows after the total was computed, so the page would come back short and the total would over-report. +* To filter on a related table, for example users with a matching post, use `EXISTS` on `$matchingKeys` + rather than a join: a join yields the key once per matching post. To limit which related rows the + projection aggregates, put the condition in its `LEFT JOIN`, not in a `WHERE`, so rows without a match + stay on the page. +* Every CTE you registered on `$matchingKeys` survives the fold, and so does a parameter bound on its main query: that same query builder is moved into the `WITH` list, and `CompositeQuery::execute()` merges the parameters of every registered builder. -* The key must be table qualified (the join is derived from it) and unique in the narrow set. A duplicate - multiplies the hydrated rows and makes the total disagree with the page. +* The key must be table qualified (the join is derived from it) and unique among the matching keys. A + duplicate multiplies the projected rows and makes the total disagree with the page. * Aggregate freely. The pager reads its count as a scalar sub-query rather than as a column of the joined - CTE, so a hydration query that groups needs no group-by for it. + CTE, so a projection that groups needs no group-by for it. * The pager owns the join onto the page CTE, the count column, and the order - which it applies to both levels, since the narrow sort decides *which* rows the page holds and the outer one the order they come back in. The closure only supplies its projection. -* The hydration closure **returns** its query rather than writing onto the folded main query, because +* The projection closure **returns** its query rather than writing onto the folded main query, because `QueryBuilder` keeps its select, from and join parts private with no setters. * Rows are yielded verbatim, the count field included, exactly like `WindowCountPager`. diff --git a/src/Pager/DeferredProjectionPager.php b/src/Pager/DeferredProjectionPager.php index 5c862f5..ba17bc7 100644 --- a/src/Pager/DeferredProjectionPager.php +++ b/src/Pager/DeferredProjectionPager.php @@ -53,34 +53,34 @@ private function __construct( } /** - * `$narrowPage` is a composite whose main query selects only `$key` and carries the filters; every CTE - * registered on it survives the fold. + * `$matchingKeys` is a composite whose main query selects only `$key` and carries every filter, since it + * decides which rows exist and what the total counts; every CTE registered on it survives the fold. * - * `$key` is the column joining narrow to fat, and must be unique in the narrow set: a duplicate - * multiplies the hydrated rows and the total disagrees with the page. + * `$key` is the column joining the matching keys to the projection, and must be unique among them: a + * duplicate multiplies the projected rows and the total disagrees with the page. * * `$order` is applied to both levels, since the narrow sort decides which rows the page holds and the * outer one the order they come back in. * - * `$hydrate` returns the fat projection for the folded composite. The join onto the page and the total - * are the pager's job, not its. + * `$projection` returns the wide select for the folded composite. The join onto the page and the total + * are the pager's job, not its, and a filter here would drop rows after the total was counted. * - * @param \Closure(CompositeQuery, non-empty-string): QueryBuilder $hydrate + * @param \Closure(CompositeQuery, non-empty-string): QueryBuilder $projection * @param non-empty-string $countField */ public static function create( Pagination $pagination, - CompositeQuery $narrowPage, + CompositeQuery $matchingKeys, Column $key, OrderBy $order, - \Closure $hydrate, + \Closure $projection, string $countField = 'total_results', ): self { - invariant(null !== $key->from, 'The page key must be table qualified, so the hydration join can be derived.'); + invariant(null !== $key->from, 'The page key must be table qualified, so the projection join can be derived.'); // First clone the composite to avoid updates on the provided query. - $narrowPage = clone $narrowPage; - $narrow = $narrowPage->mainQuery(); + $matchingKeys = clone $matchingKeys; + $narrow = $matchingKeys->mainQuery(); $narrow->addSelect( new Alias( Over::aggregation(new Count(SqlExpression::int(1)), Over::fullWindow()), @@ -96,15 +96,15 @@ public static function create( * registered preserved. A parameter bound on the narrow query survives this: the very same builder * moves into the `WITH` list, and `execute()` merges every registered CTE builder's parameters. */ - $folded = $narrowPage->moveMainQueryToSubQuery(self::PAGE_ALIAS); + $folded = $matchingKeys->moveMainQueryToSubQuery(self::PAGE_ALIAS); - $fat = $hydrate($folded, self::PAGE_ALIAS); + $fat = $projection($folded, self::PAGE_ALIAS); /** * The count is read as a scalar sub-query rather than as a column of the joined CTE, so that a - * hydration query which aggregates does not have to group by it. A plain column reference would be + * projection which aggregates does not have to group by it. A plain column reference would be * neither aggregated nor functionally dependent on the group key, which PostgreSQL rejects, and - * wrapping it in `MAX()` is not an option either: that would turn a non-aggregating hydration query + * wrapping it in `MAX()` is not an option either: that would turn a non-aggregating projection * into an aggregate one and collapse the page to a single row. The value is identical on every row * of the page, so reading one is exact. The sub-query is the second reference to a CTE holding one * page of narrow rows, which costs nothing. diff --git a/tests/Integration/Pager/DeferredProjectionPagerTest.php b/tests/Integration/Pager/DeferredProjectionPagerTest.php index 0d78a5a..088c7b4 100644 --- a/tests/Integration/Pager/DeferredProjectionPagerTest.php +++ b/tests/Integration/Pager/DeferredProjectionPagerTest.php @@ -7,11 +7,15 @@ use Doctrine\DBAL\Query\QueryBuilder; use Phpro\DbalTools\Expression\Alias; use Phpro\DbalTools\Expression\Comparison; +use Phpro\DbalTools\Expression\Composite; use Phpro\DbalTools\Expression\Count; +use Phpro\DbalTools\Expression\Expression; use Phpro\DbalTools\Expression\Factory\NamedParameter; +use Phpro\DbalTools\Expression\ILike; use Phpro\DbalTools\Expression\JsonbAggStrict; use Phpro\DbalTools\Expression\JsonbBuildObject; use Phpro\DbalTools\Expression\OrderBy; +use Phpro\DbalTools\Expression\SqlExpression; use Phpro\DbalTools\Pager\DeferredProjectionPager; use Phpro\DbalTools\Pager\Pagination; use Phpro\DbalTools\Query\CompositeQuery; @@ -146,7 +150,7 @@ public function it_keeps_a_row_whose_aggregate_is_empty(): void #[Test] public function a_caller_registered_cte_survives_the_fold(): void { - $composite = self::narrow(); + $composite = self::matchingKeys(); $composite->addSubQuery( 'prolific', self::connection()->createQueryBuilder() @@ -177,7 +181,7 @@ public function a_caller_registered_cte_survives_the_fold(): void #[Test] public function a_parameter_bound_on_the_narrow_query_survives_the_fold(): void { - $composite = self::narrow(); + $composite = self::matchingKeys(); $group = NamedParameter::createForTableColumn( $composite->mainQuery(), UsersTableColumns::FirstName, @@ -194,6 +198,67 @@ public function a_parameter_bound_on_the_narrow_query_survives_the_fold(): void self::assertSame(3, $pager->totalResults()); } + /** + * A filter on related rows belongs in the matching keys, as `EXISTS` so each key stays unique, and the + * inner join onto the page is what carries it into the projection: the projection never repeats the + * filter, so it still aggregates every post of a matching user rather than only the matching one. + */ + #[Test] + public function a_related_row_filter_on_the_matching_keys_narrows_both_page_and_total(): void + { + $matchingKeys = self::matchingKeys(); + $search = NamedParameter::createForTableColumn( + $matchingKeys->mainQuery(), + PostsTableColumns::Post, + 'post 3 %', + ':search', + ); + $matchingKeys->mainQuery()->where(sprintf( + 'EXISTS (SELECT 1 FROM %s WHERE %s)', + PostsTable::name(), + Composite::and( + Comparison::equal(PostsTableColumns::UserId->column(), UsersTableColumns::Id->column()), + new ILike(PostsTableColumns::Post->column(), $search), + )->toSQL(), + )); + + $pager = self::createPager($matchingKeys, new Pagination(1, 1)); + $rows = values($pager); + + self::assertSame(['user4'], self::usernames($pager)); + self::assertSame(2, $pager->totalResults()); + self::assertSame(2, $pager->totalPages()); + self::assertSame(['post 1 of user 4', 'post 2 of user 4', 'post 3 of user 4'], self::posts($rows[0])); + } + + /** + * A condition on the projection's left join limits which related rows are aggregated without dropping a + * key, so a user without a match keeps its place on the page and in the total. Its parameter is bound + * on the projection, which reaches the statement because `execute()` merges the main query's parameters + * next to those of the CTEs. + */ + #[Test] + public function a_condition_on_the_projection_join_limits_the_aggregate_but_keeps_every_key(): void + { + $pager = self::createPager( + self::matchingKeys(), + new Pagination(1, 3), + projection: self::projection( + static fn (QueryBuilder $query): ILike => new ILike( + PostsTableColumns::Post->column(), + NamedParameter::createForTableColumn($query, PostsTableColumns::Post, 'post 2 %', ':post'), + ), + ), + ); + $rows = values($pager); + + self::assertSame(['user1', 'user2', 'user3'], self::usernames($pager)); + self::assertSame(6, $pager->totalResults()); + self::assertSame([], self::posts($rows[0])); + self::assertSame([], self::posts($rows[1])); + self::assertSame(['post 2 of user 3'], self::posts($rows[2])); + } + /** * The order is applied to the outer query too, not only to the narrow one: a join does not preserve * its input's order, so reading a deep page back in the requested direction is what pins the second @@ -217,7 +282,7 @@ public function it_orders_the_hydrated_page(int $page, string $direction, array #[Test] public function it_can_deal_with_empty_resultset(): void { - $composite = self::narrow(); + $composite = self::matchingKeys(); $composite->mainQuery()->where('false'); $pager = self::createPager($composite, new Pagination(2, 3)); @@ -305,7 +370,7 @@ public function it_is_re_iterable_without_running_a_second_query(): void #[Test] public function it_does_not_alter_the_provided_query(): void { - $composite = self::narrow(); + $composite = self::matchingKeys(); $originalSql = $composite->toSQL(); self::createPager($composite, new Pagination(2, 2))->totalResults(); @@ -325,10 +390,10 @@ public function it_refuses_a_key_that_is_not_table_qualified(): void DeferredProjectionPager::create( new Pagination(1, 2), - self::narrow(), + self::matchingKeys(), UsersTableColumns::Id->column()->from(null), self::defaultOrder(), - self::hydrate(), + self::projection(), ); } @@ -340,25 +405,26 @@ private static function pager( ?OrderBy $order = null, string $countField = 'total_results', ): DeferredProjectionPager { - return self::createPager(self::narrow(), $pagination, $order, $countField); + return self::createPager(self::matchingKeys(), $pagination, $order, $countField); } /** * @param non-empty-string $countField */ private static function createPager( - CompositeQuery $narrow, + CompositeQuery $matchingKeys, Pagination $pagination, ?OrderBy $order = null, string $countField = 'total_results', + ?\Closure $projection = null, ): DeferredProjectionPager { return DeferredProjectionPager::create( - $pagination, - $narrow, - UsersTableColumns::Id->column(), - $order ?? self::defaultOrder(), - self::hydrate(), - $countField, + pagination: $pagination, + matchingKeys: $matchingKeys, + key: UsersTableColumns::Id->column(), + order: $order ?? self::defaultOrder(), + projection: $projection ?? self::projection(), + countField: $countField, ); } @@ -370,7 +436,7 @@ private static function defaultOrder(): OrderBy /** * The narrow half: one column and the filters, with no join and no projection. */ - private static function narrow(): CompositeQuery + private static function matchingKeys(): CompositeQuery { return new CompositeQuery( self::connection(), @@ -386,16 +452,25 @@ private static function narrow(): CompositeQuery * the page CTE, since the pager owns the join onto it. * * It aggregates, and therefore groups, without knowing the pager's count field: the pager reads the count - * as a scalar sub-query precisely so that an aggregating hydration query needs no group-by of its own for - * it. + * as a scalar sub-query precisely so that an aggregating projection needs no group-by of its own for it. + * + * @param (\Closure(QueryBuilder): Expression)|null $postsCondition narrows the posts it aggregates * * @return \Closure(CompositeQuery, non-empty-string): QueryBuilder */ - private static function hydrate(): \Closure + private static function projection(?\Closure $postsCondition = null): \Closure { - return static fn (CompositeQuery $folded, string $pageAlias): QueryBuilder => self::connection() - ->createQueryBuilder() - ->select( + return static function (CompositeQuery $folded, string $pageAlias) use ($postsCondition): QueryBuilder { + $query = self::connection()->createQueryBuilder(); + $join = UsersTable::joinOntoPosts(); + if (null !== $postsCondition) { + $join['condition'] = Composite::and( + new SqlExpression($join['condition']), + $postsCondition($query), + )->toSQL(); + } + + return $query->select( ...UsersTable::columns()->select(), ...[new Alias( JsonbAggStrict::onManyLeftJoinedJsonObjects( @@ -409,9 +484,10 @@ private static function hydrate(): \Closure 'posts', )->toSQL()], ) - ->from(UsersTable::name()) - ->leftJoin(...UsersTable::joinOntoPosts()) - ->groupBy(UsersTableColumns::Id->use()); + ->from(UsersTable::name()) + ->leftJoin(...$join) + ->groupBy(UsersTableColumns::Id->use()); + }; } /** From 7b94e697157dac0e5b02887021e27b3c91f9d921 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 25 Sep 2026 06:14:44 +0000 Subject: [PATCH 3/6] Use projection wording in the remaining DeferredProjectionPager test names --- tests/Integration/Pager/DeferredProjectionPagerTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Integration/Pager/DeferredProjectionPagerTest.php b/tests/Integration/Pager/DeferredProjectionPagerTest.php index 088c7b4..2861455 100644 --- a/tests/Integration/Pager/DeferredProjectionPagerTest.php +++ b/tests/Integration/Pager/DeferredProjectionPagerTest.php @@ -94,7 +94,7 @@ public function it_pages_the_answer_while_counting_all_of_it(int $page, array $e } /** - * The total is the number of keys in the narrow set, not the number of rows the hydration join + * The total is the number of keys in the narrow set, not the number of rows the projection join * produces. That is the difference from counting over the fat query, where the one-to-many join * inflates every total. */ @@ -118,7 +118,7 @@ public function it_counts_keys_rather_than_joined_rows(): void * the two rows of the page rather than for all six. */ #[Test] - public function it_hydrates_every_row_of_the_page_with_its_aggregated_objects(): void + public function it_projects_every_row_of_the_page_with_its_aggregated_objects(): void { $rows = values(self::pager(new Pagination(2, 2))); @@ -269,7 +269,7 @@ public function a_condition_on_the_projection_join_limits_the_aggregate_but_keep #[TestWith([2, OrderBy::ASC, ['user4', 'user5', 'user6']])] #[TestWith([1, OrderBy::DESC, ['user6', 'user5', 'user4']])] #[TestWith([2, OrderBy::DESC, ['user3', 'user2', 'user1']])] - public function it_orders_the_hydrated_page(int $page, string $direction, array $expected): void + public function it_orders_the_projected_page(int $page, string $direction, array $expected): void { $pager = self::pager( new Pagination($page, 3), From a6d919c43694c3458280c2b1f19aea6d92f35d0f Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 25 Sep 2026 08:25:52 +0200 Subject: [PATCH 4/6] Suppress Symfony 8.1 psalm errors in DbalToolsBundle 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. --- src/DbalToolsBundle.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/DbalToolsBundle.php b/src/DbalToolsBundle.php index fd5d8ec..f44c684 100644 --- a/src/DbalToolsBundle.php +++ b/src/DbalToolsBundle.php @@ -8,8 +8,16 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; +/** + * @psalm-suppress DeprecatedInterface - Symfony 8.1 deprecates HttpKernel's BundleInterface, its replacement does not exist before 8.1. + * @psalm-suppress UnusedPsalmSuppress - Only needed for Symfony 8.1 and up. + */ final class DbalToolsBundle extends AbstractBundle { + /** + * @psalm-suppress ParamNameMismatch - Symfony 8.1 renamed these parameters, the names cannot match both 7.4/8.0 and 8.1. + * @psalm-suppress UnusedPsalmSuppress - Only needed for Symfony 8.1 and up. + */ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { $configDir = __DIR__.'/../config'; From 498eae056553e8e8533c4c1f6e003345187370cc Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 25 Sep 2026 08:42:20 +0200 Subject: [PATCH 5/6] Describe DeferredProjectionPager arguments in caller terms 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. --- README.md | 17 +++++++++++++++-- src/Pager/DeferredProjectionPager.php | 25 +++++++++++++++++-------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7194ea7..593b7b6 100644 --- a/README.md +++ b/README.md @@ -690,7 +690,7 @@ use Phpro\DbalTools\Pager\MappingPager; use Phpro\DbalTools\Pager\Pagination; use Phpro\DbalTools\Query\CompositeQuery; -// The key column and every filter. No projection, no join that only adds data. +// Which rows: the key column and every filter. No display columns, no join that only adds data. $matchingKeys = new CompositeQuery( $connection, $connection->createQueryBuilder() @@ -705,7 +705,8 @@ $usersPager = new MappingPager( matchingKeys: $matchingKeys, key: UsersTableColumns::Id->column(), order: new OrderBy(OrderBy::field(UsersTableColumns::Username->column(), OrderBy::ASC)), - // The wide select: returned, not written in place, and it knows nothing about the page CTE. + // What a row looks like: a new query from the table of the key. + // This one needs no CTE, so it ignores both arguments. projection: static fn (CompositeQuery $folded, string $pageAlias): QueryBuilder => $connection->createQueryBuilder() ->select( ...UsersTable::columns()->select(), @@ -728,6 +729,18 @@ $usersPager = new MappingPager( ); ``` +You describe the list twice: + +* `matchingKeys` decides which rows are in the list: a composite whose main query selects the key column + and nothing else, with every filter and scope join. No display columns, order or limit, the pager adds + those. +* `key` is that column, table qualified. The page is joined back to the projection on it. +* `projection` decides what a row looks like: a new query builder selecting everything a row shows from the + table of the key, with its joins, aggregates and group by, and no filters, order or limit. The closure + receives the composite that will run and the name of the page CTE. Use the composite to join a CTE + registered on `matchingKeys` or to register one of your own; a projection that needs no CTE ignores both + arguments. + Things worth knowing: * The filters and the scope joins belong on `$matchingKeys`. A filter applied by the projection would drop diff --git a/src/Pager/DeferredProjectionPager.php b/src/Pager/DeferredProjectionPager.php index ba17bc7..a26f097 100644 --- a/src/Pager/DeferredProjectionPager.php +++ b/src/Pager/DeferredProjectionPager.php @@ -53,17 +53,26 @@ private function __construct( } /** - * `$matchingKeys` is a composite whose main query selects only `$key` and carries every filter, since it - * decides which rows exist and what the total counts; every CTE registered on it survives the fold. + * You describe the list twice: which rows are in it, and what a row looks like. * - * `$key` is the column joining the matching keys to the projection, and must be unique among them: a - * duplicate multiplies the projected rows and the total disagrees with the page. + * `$matchingKeys` decides which rows are in the list. Its main query selects `$key` and nothing else, + * and carries every filter and scope join, for example `SELECT users.id FROM users WHERE ...`. Leave + * out display columns, order and limit: the pager adds the total, the order and the page. Every CTE + * registered on it stays available to the projection. * - * `$order` is applied to both levels, since the narrow sort decides which rows the page holds and the - * outer one the order they come back in. + * `$key` is the column that main query selects, table qualified (`users.id`). The page is joined back + * to the projection on it, so it must be unique per row: a duplicate multiplies the projected rows and + * the total disagrees with the page. * - * `$projection` returns the wide select for the folded composite. The join onto the page and the total - * are the pager's job, not its, and a filter here would drop rows after the total was counted. + * `$order` sorts the list. It is applied to both levels, since the narrow sort decides which rows the + * page holds and the outer one the order they come back in. + * + * `$projection` decides what a row looks like. Return a new query builder that selects everything a row + * shows `FROM` the table of `$key`, including any joins, aggregates and group by it needs. Leave out + * filters, order and limit: the pager joins it onto the page, and a filter here would drop rows after + * the total was counted. The closure receives the composite that will run and the name of the page + * CTE. A projection that needs a CTE joins one registered on `$matchingKeys` through the composite, or + * registers its own on it; one that needs none ignores both arguments. * * @param \Closure(CompositeQuery, non-empty-string): QueryBuilder $projection * @param non-empty-string $countField From bc38a92097d0b0c1732f5b1f365d9b7fbe9e957f Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 25 Sep 2026 08:47:57 +0200 Subject: [PATCH 6/6] Show the SQL DeferredProjectionPager runs in the README --- README.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 593b7b6..66cbf56 100644 --- a/README.md +++ b/README.md @@ -682,6 +682,8 @@ It remains a single statement, so there is one snapshot and no second round trip ```php use Doctrine\DBAL\Query\QueryBuilder; use Phpro\DbalTools\Expression\Alias; +use Phpro\DbalTools\Expression\Factory\NamedParameter; +use Phpro\DbalTools\Expression\ILike; use Phpro\DbalTools\Expression\JsonbAggStrict; use Phpro\DbalTools\Expression\JsonbBuildObject; use Phpro\DbalTools\Expression\OrderBy; @@ -691,11 +693,16 @@ use Phpro\DbalTools\Pager\Pagination; use Phpro\DbalTools\Query\CompositeQuery; // Which rows: the key column and every filter. No display columns, no join that only adds data. +$keys = $connection->createQueryBuilder(); $matchingKeys = new CompositeQuery( $connection, - $connection->createQueryBuilder() + $keys ->select(UsersTableColumns::Id->select()) - ->from(UsersTable::name()), + ->from(UsersTable::name()) + ->where(new ILike( + UsersTableColumns::Username->column(), + NamedParameter::createForTableColumn($keys, UsersTableColumns::Username, 'jo%', ':username'), + )->toSQL()), [], ); @@ -729,6 +736,32 @@ $usersPager = new MappingPager( ); ``` +For page 2 with a limit of 10, that runs as one statement. The main query of `$matchingKeys` became the +`deferred_page` CTE, with the count window, the order and the page added to it. The projection became the +new main query, joined onto that CTE: + +```sql +WITH deferred_page AS ( + SELECT users.user_id, COUNT(1) OVER() AS total_results + FROM users + WHERE users.username ILIKE :username + ORDER BY users.username ASC + LIMIT 10 OFFSET 10 +) +SELECT + users.user_id, users.username, users.first_name, users.last_name, + jsonb_agg_strict( + CASE WHEN posts.post_id IS NULL THEN NULL + ELSE jsonb_build_object('id', posts.post_id, 'post', posts.post) END + ) AS posts, + (SELECT deferred_page.total_results FROM deferred_page LIMIT 1) AS total_results +FROM users +LEFT JOIN posts posts ON users.user_id = posts.user_id +INNER JOIN deferred_page deferred_page ON users.user_id = deferred_page.user_id +GROUP BY users.user_id +ORDER BY users.username ASC +``` + You describe the list twice: * `matchingKeys` decides which rows are in the list: a composite whose main query selects the key column