Doctrine ORM QueryBuilder
QueryBuilderAdapter
takes a Doctrine ORM QueryBuilder
instance. It supports
keyset and offset pagination.
Installation
composer require rekalogika/rekapager-doctrine-orm-adapter
If you need SQL row values support, you need to register the necessary DQL function:
- Symfony
- Manual Wiring
If you are using Symfony, add the following to your configuration:
doctrine:
orm:
dql:
string_functions:
REKAPAGER_ROW_VALUES: Rekalogika\Rekapager\Doctrine\ORM\RowValuesFunction
If you wire Doctrine manually, use the following code:
use Doctrine\ORM\Configuration;
use Rekalogika\Rekapager\Doctrine\ORM\RowValuesFunction;
/** @var Configuration $configuration */
$configuration
->addCustomStringFunction('REKAPAGER_ROW_VALUES', RowValuesFunction::class);
Usage
use Doctrine\DBAL\LockMode;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Rekalogika\Rekapager\Doctrine\ORM\QueryBuilderAdapter;
use Rekalogika\Rekapager\Keyset\KeysetPageable;
use Rekalogika\Rekapager\Offset\OffsetPageable;
/** @var EntityRepository $postRepository */
$queryBuilder = $postRepository
->createQueryBuilder('p')
->where('p.group = :group')
->setParameter('group', $group)
->addOrderBy('p.date', 'DESC') // a date field that accepts DateTime
->addOrderBy('p.title', 'ASC')
->addOrderBy('p.id', 'ASC');
$adapter = new QueryBuilderAdapter(
queryBuilder: $queryBuilder,
lockMode: LockMode::PESSIMISTIC_WRITE, // optional
typeMapping: [
'p.date' => Types::DATE_MUTABLE // the type of the date field
],
indexBy: 'id' // optional
);
$pageable = new KeysetPageable($adapter);
// or
$pageable = new OffsetPageable($adapter);
If you don't provide a type mapping, the adapter will try to look it up from Doctrine's class metadata. If it fails, it will use heuristics to detect the type for some common objects.
The QueryBuilderAdapter
does not support QueryBuilder's indexBy
(the third
parameter of from()
, or the second parameter of a repository's
createQueryBuilder()
). If you need the feature, use the indexBy
parameter of
QueryBuilderAdapter
as the above example.
Notes
With keyset pagination, there are additional prerequisites:
- The underlying
QueryBuilder
object must have a sort order. Be sure to callorderBy()
oraddOrderBy()
on the query builder before passing it to the adapter. - If a field in a sort order uses a non-scalar type, you should provide a
typeMapping
option. The adapter will use it in thesetParameter()
method of theQueryBuilder
. The example above shows how to provide a type mapping for a date field.
Limitations
One-to-many and many-to-many joins are not supported. Many-to-one joins are OK.
// supported because a post has only one author
$queryBuilder
->from(Post::class, 'p')
->leftJoin('p.author', 'a')
->select('p');
// not supported because a post has many comments
$queryBuilder
->from(Post::class, 'p')
->leftJoin('p.comments', 'c')
->select('p');
If you have an entity with a one-to-many relationship, you can usually omit the join and Doctrine will fetch the related entities lazily.
Transactions
If you use the lockMode
option, the adapter will pass the option to the
resulting Query
object. In a batch processing, this is how to wrap the
processing of each page in a transaction:
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\QueryBuilder;
use Rekalogika\Rekapager\Keyset\KeysetPageable;
use Rekalogika\Rekapager\Doctrine\ORM\QueryBuilderAdapter;
/** @var EntityManagerInterface $entityManager */
/** @var QueryBuilder $queryBuilder */
$adapter = new QueryBuilderAdapter(
queryBuilder: $queryBuilder,
lockMode: LockMode::PESSIMISTIC_WRITE,
);
/** @var PageableInterface<int,Post> */
$pageable = new KeysetPageable(
adapter: $adapter,
itemsPerPage: 10,
);
// using explicit begin, commit and rollback
foreach ($pageable->getPages() as $page) {
$entityManager->beginTransaction();
try {
foreach ($page as $post) {
// do something with the post
}
} catch (\Throwable $e) {
$entityManager->rollback();
throw $e;
}
$entityManager->flush();
$entityManager->commit();
}
// using wrap
foreach ($pageable->getPages() as $page) {
$entityManager->wrapInTransaction(function () use ($page) {
foreach ($page as $post) {
// do something with the post
}
});
}
The above can work because PageInterface
is lazy. The content of the page is
fetched when you iterate over it, not when you iterate over getPages()
.
Overriding the Boundary Fields
By default, the adapter uses the fields in the ORDER BY
clause as the boundary
fields, and it should work in most cases. This is an example scenario that
necessitates overriding the boundary fields.
Suppose you have this table:
CREATE TABLE post (
id INT PRIMARY KEY NOT NULL,
category VARCHAR(255),
title VARCHAR(255)
);
CREATE INDEX post_category_id ON post (category, id);
To efficiently paginate over the posts of a specific category, you might want to
use QueryBuilderAdapter
like this:
use Doctrine\ORM\EntityRepository;
use Rekalogika\Rekapager\Doctrine\ORM\QueryBuilderAdapter;
use Rekalogika\Rekapager\Keyset\KeysetPageable;
/** @var EntityRepository $postRepository */
$queryBuilder = $postRepository->createQueryBuilder('p');
if ($category === null) {
$queryBuilder->where('p.category IS NULL');
} else {
$queryBuilder->where('p.category = :category')
->setParameter('category', $category);
}
// forces the database to use the same index for filtering and ordering:
$queryBuilder
->orderBy('p.category', 'ASC')
->addOrderBy('p.id', 'ASC');
$adapter = new QueryBuilderAdapter(
queryBuilder: $queryBuilder,
boundaryFields: ['id'],
);
$pageable = new KeysetPageable($adapter);
Without the boundaryFields
argument, the adapter would use category
and id
as the boundary fields. And it would work correctly, unless you have posts
with a NULL category, like in the above example.