QueryBuilder
of doctrine/orm has a method called addCriteria()
that allows you to build queries by combining Criteria
of doctrine/collections. This allows you to separate the concerns of "search conditions" into a Criteria
, improving the maintainability of your codebase.
However, Criteria
of doctrine/collections only has a very limited matching language because it is designed to work both on the SQL and the PHP collection level, and therefore cannot be used to build complex queries.
Rejoice! Doctrine ORM Criteria allows you to separate any complex "search condition" as a Criteria
with a specialized API for QueryBuilder
of doctrine/orm just like below.
$qb = (new CriteriaAwareness($fooRepository->createQueryBuilder('f')))
->addCriteria(new IsPublic(), 'f')
->addCriteria(new IsAccessibleBy($user), 'f')
->addCriteria(new CategoryIs($category), 'f')
->addCriteria(new OrderByRandom(), 'f')
->getQueryBuilder()
;
$foos = $qb->getQuery()->getResult();
// Or, using the Repository integration:
$foos = $fooRepository->findByCriteria([
new IsPublic(),
new IsAccessibleBy($user),
new CategoryIs($category),
new OrderByRandom(),
]);
final readonly class IsPublic implements CriteriaInterface
{
public ?\DateTimeInterface $at;
public function __construct(?\DateTimeInterface $at = null)
{
$this->at = $at ?? new \DateTimeImmutable();
}
public function apply(QueryBuilder $qb, string $alias): void
{
$qb
->andWhere("$alias.state = :state")
->andWhere($qb->expr()->andX(
$qb->expr()->orX(
"$alias.openedAt IS NULL",
"$alias.openedAt <= :at",
),
$qb->expr()->orX(
"$alias.closedAt IS NULL",
"$alias.closedAt > :at",
),
))
->setParameter('state', Foo::STATE_PUBLIC)
->setParameter('at', $this->at)
;
}
}
- PHP: ^8.1
- Doctrine ORM: ^2.8|^3.0
$ composer require ttskch/doctrine-orm-criteria
You can create your own Criteria
by implementing CriteriaInterface
and adding it to CriteriaAwareness
to build queries.
use App\Repository\Criteria\Foo\IsPublic;
use Ttskch\DoctrineOrmCriteria\CriteriaAwareness;
$qb = (new CriteriaAwareness($fooRepository->createQueryBuilder('f')))
->addCriteria(new IsPublic(), 'f')
->getQueryBuilder()
;
<?php
declare(strict_types=1);
namespace App\Repository\Criteria\Foo;
final readonly class IsPublic implements CriteriaInterface
{
public ?\DateTimeInterface $at;
public function __construct(?\DateTimeInterface $at = null)
{
$this->at = $at ?? new \DateTimeImmutable();
}
public function apply(QueryBuilder $qb, string $alias): void
{
$qb
->andWhere("$alias.state = :state")
->andWhere($qb->expr()->andX(
$qb->expr()->orX(
"$alias.openedAt IS NULL",
"$alias.openedAt <= :at",
),
$qb->expr()->orX(
"$alias.closedAt IS NULL",
"$alias.closedAt > :at",
),
))
->setParameter('state', Foo::STATE_PUBLIC)
->setParameter('at', $this->at)
;
}
}
There are some built-in Criteria
: OrderBy
, Andx
, and Orx
. Using Andx
and Orx
, you can combine multiple Criteria
to create a new Criteria
.
<?php
declare(strict_types=1);
namespace App\Repository\Criteria\Foo;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Ttskch\DoctrineOrmCriteria\Criteria\CriteriaInterface;
use Ttskch\DoctrineOrmCriteria\Criteria\Andx;
use Ttskch\DoctrineOrmCriteria\Criteria\Orx;
final readonly class IsViewable implements CriteriaInterface
{
public ?\DateTimeInterface $at;
public function __construct(
public User $me,
?\DateTimeInterface $at = null,
) {
$this->at = $at ?? new \DateTimeImmutable();
}
public function apply(QueryBuilder $qb, string $alias): void
{
(new Andx([
new Orx([
new IsPublic($this->at),
...array_map(fn (string $category) => new CategoryIs($category), Foo::PUBLIC_CATEGORIES),
]),
new IsAccessibleBy($this->me),
]))->apply($qb, $alias);
}
}
Additionally, when creating your own Criteria
, you can use AddSelectTrait
and JoinTrait
to ensure that the addSelect()
and join
are IDEMPOTENT even if the Criteria
is applied multiple times to the QueryBuilder
.
<?php
declare(strict_types=1);
namespace App\Repository\Criteria\Foo;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Ttskch\DoctrineOrmCriteria\Criteria\CriteriaInterface;
use Ttskch\DoctrineOrmCriteria\Criteria\Traits\JoinTrait;
final readonly class IsAccessibleBy implements CriteriaInterface
{
use JoinTrait;
private const string CRITERIA_KEY = 'Foo_IsAccessibleBy'; // some unique key
public function __construct(public User $me)
{
}
public function apply(QueryBuilder $qb, string $alias): void
{
$userAlias = sprintf('%s_%s_user', self::CRITERIA_KEY, $alias);
$this->leftJoin($qb, sprintf('%s.user', $alias), $userAlias);
$qb
->andWhere(sprintf('%s = :user', $userAlias))
->setParameter('user', $this->me)
;
}
}
You can also easily integrate with your repositories using CriteriaAwareRepositoryTrait
.
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Foo;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
+ use Ttskch\DoctrineOrmCriteria\Repository\CriteriaAwareRepositoryTrait;
/**
* @extends ServiceEntityRepository<Foo>
*/
class FooRepository extends ServiceEntityRepository
{
+ /** @use CriteriaAwareRepositoryTrait<Foo> */
+ use CriteriaAwareRepositoryTrait;
+
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Foo::class);
}
}
$foos = $fooRepository->findByCriteria([
new IsPublic(),
new IsAccessibleBy($user),
new CategoryIs($category),
new OrderByRandom(),
]);
\PHPStan\dumpType($foos); // Dumped type: array<App\Entity\Foo>
$foo = $fooRepository->findOneByCriteria([
new IsPublic(),
new IsAccessibleBy($user),
new CategoryIs($category),
new OrderByRandom(),
]);
\PHPStan\dumpType($foo); // Dumped type: App\Entity\Foo|null
$count = $fooRepository->countByCriteria([
new IsPublic(),
new IsAccessibleBy($user),
new CategoryIs($category),
]);
\PHPStan\dumpType($count); // Dumped type: int
$ composer install
# Develop...
$ composer tests