DEV Community

Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on • Updated on

Adding criteria to your doctrine queries easily

In this post, I would like to show you how we can easily add criterias to our doctrine queries.
Let's start by creating a model which holds data we want to add as criteria to our query:

class ListContractsInput
{
    #[Assert\DateTime(message: 'Start at must be a valid datetime')]
    private ?string $startAt = null;

    #[Assert\DateTime(message: 'End at must be a valid datetime')]
    private ?string $endAt = null;

    public function getStartAt(): ?string
    {
        return $this->startAt;
    }

    public function setStartAt(?string $startAt): void
    {
        $this->startAt = $startAt;
    }

    public function getEndAt(): ?string
    {
        return $this->endAt;
    }

    public function setEndAt(?string $endAt): void
    {
        $this->endAt = $endAt;
    }
}
Enter fullscreen mode Exit fullscreen mode

As we can see in this model, it holds startAt and endAt values and it requires them to be valid datetimes. More information about symfony validation here.

Let's see now how we can process this input data to add it as criteria to our queries.

Creating a base class which process input data

abstract class QueryBuilderCriteriaManager
{
    private SerializerInterface $serializer;

    public function __construct()
    {
        $this->serializer = new Serializer([new ObjectNormalizer()]);
    }

    /**
     * @throws ExceptionInterface
     */
    public function addCriteria(QueryBuilder $qb, string $alias, iterable|object $filters): void
    {
        if(!is_object($filters)){
            foreach ($filters as $key => $value) {
                $this->addToQb($qb, $alias, $key, $value);
            }
        }
        else{
            $criteriaData = $this->serializer->normalize($filters);
            foreach ($criteriaData as $propName => $value) {
                $this->addToQb($qb, $alias, $propName, $value);
            }
        }
    }

    protected function addToQb(QueryBuilder $qb, string $alias, string $key, mixed $value): void
    {
        $method = u('get_' . $key . 'Criteria')->camel()->toString();

        if( !empty($value) || $value === 0 || $value === '0' || $value === false) {
            if(method_exists($this, $method)){
                $this->$method($qb, $alias, $value);
            }
            else{
                $qb
                    ->andWhere($qb->expr()->eq("{$alias}.{$key}", ':' . $key))
                    ->setParameter($key, $value)
                ;
            }
        }

    }

    /**
     * @throws \Exception
     */
    protected function getAsDateTime(string|\DateTimeImmutable $date): \DateTimeImmutable
    {
        return ($date instanceof \DateTimeImmutable)
            ? $date
            : new \DateTimeImmutable($date)
            ;
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's analyze this code step by step:

private SerializerInterface $serializer;

public function __construct()
{
    $this->serializer = new Serializer([new ObjectNormalizer()]);
}
Enter fullscreen mode Exit fullscreen mode

First of all, we build a symfony serializer in the constructor since we'll need it in the next lines.

public function addCriteria(QueryBuilder $qb, string $alias, iterable|object $filters): void
{
    if(!is_object($filters)){
         foreach ($filters as $key => $value) {
            $this->addToQb($qb, $alias, $key, $value);
         }
    }
    else{
         $criteriaData = $this->serializer->normalize($filters);
         foreach ($criteriaData as $propName => $value) {
            $this->addToQb($qb, $alias, $propName, $value);
         }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now we create method addCriteria which receives the following parameters:

  • $qb: QueryBuilder object to which we will add criteria.
  • $alias: Query main entity alias
  • $filters: Criteria we're going to add. It can be an object (like our model) or an iterable

If $filters parameter is an iterable, it loops over the iterable as key / values and use method addToQb to add criteria.

If $filters parameter is an object, it first serializes the object to an array and then loops it as key / value and also use addToQb to add criteria.

protected function addToQb(QueryBuilder $qb, string $alias, string $key, mixed $value): void
{
    $method = u('get_' . $key . 'Criteria')->camel()->toString();

    if( !empty($value) || $value === 0 || $value === '0' || $value === false) {
        if(method_exists($this, $method)){
            $this->$method($qb, $alias, $value);
        }
        else{
            $qb
               ->andWhere($qb->expr()->eq("{$alias}.{$key}", ':' . $key))
               ->setParameter($key, $value)
            ;
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

Method addToQb receives the following parameters:

  • $qb: Query builder to which we will add criteria
  • $alias: Query main entity alias
  • $key: Field (entity property) we're going to filter
  • $value: Value by which we want to filter

Before explaining addToQb, I would like to highlight that this class QueryBuilderCriteriaManager is an abstract class and it will be extended by other classes which will define the logic for each criteria we want to add.

Each of this child classes (we will see an example later) will define a method for each criteria following this format: get{KeyName}Criteria

For instance, according to the model we did show at the begining of this post, we should define methods getStartAtCriteria() and getEndAtCriteria()

Knowing this, let's see how the method works

  • First, it builds the method's name following the format we've just seen.
  • Second, if value is not null:
    • if method exists in child class, it executes the method which receives QueryBuider,alias, and value and finally adds the criteria.
    • if method does not exist, it adds the criteria as an equal condition.

Creating the child class

Let's see now how our child class looks like:

class ContractsCriteriaManager extends QueryBuilderCriteriaManager
{
    /**
     * @throws \Exception
     */
    public function getStartAtCriteria(QueryBuilder $qb, string $alias, string|\DateTimeImmutable $value): void
    {
        $qb
            ->andWhere($qb->expr()->gte("{$alias}.createdAt",':start_at'))
            ->setParameter('start_at', $this->getAsDateTime($value))
        ;
    }

    /**
     * @throws \Exception
     */
    public function getEndAtCriteria(QueryBuilder $qb, string $alias, string|\DateTimeImmutable $value): void
    {
        $qb
            ->andWhere($qb->expr()->lte("{$alias}.createdAt",':end_at'))
            ->setParameter('end_at', $this->getAsDateTime($value))
        ;
    }
}
Enter fullscreen mode Exit fullscreen mode

As we can see, child class define two methods following the last format:

  • getStartAtCriteria(): It adds a criteria so that createdAt must be greater than or equal to startAt value
  • getEndAtCriteria(): It adds a criteria so that createdAt must be less than or equal to endAt value

Using it in the repository

Let's take a look to the following repository method:

public function getList(array|object $criteria, ?int $limit): array
{
     $criteriaManager = new ContractsCriteriaManager();
     $qb = $this->createQueryBuilder(self::ALIAS);

     if($limit){
         $qb->setMaxResults($limit);
     }

     $qb->orderBy(self::ALIAS . '.createdAt', 'desc');
     $criteriaManager->addCriteria($qb, self::ALIAS, $criteria);
     return $qb->getQuery()->getResult();
}
Enter fullscreen mode Exit fullscreen mode

As you can see, after adding limit and order by to the query builder instance, we use criteria manager to fill our query builder with the criteria data holded in $criteria object.

If we would execute this getList() method and would debug query we would see the following DQL:

SELECT c FROM App\Entity\Contract c WHERE c.startAt >= :start_at AND c.endAt <= :end_at ORDER BY c.createdAt desc
Enter fullscreen mode Exit fullscreen mode

If we would dump query parameters, they would look like this:

Doctrine\Common\Collections\ArrayCollection {#721
  -elements: array:2 [
    0 => Doctrine\ORM\Query\Parameter {#682
      -name: "start_at"
      -value: DateTimeImmutable @1683128700 {#671
        date: 2023-05-03 15:45:00.0 UTC (+00:00)
      }
      -type: "datetime_immutable"
      -typeSpecified: false
    }
    1 => Doctrine\ORM\Query\Parameter {#681
      -name: "end_at"
      -value: DateTimeImmutable @1683304255 {#670
        date: 2023-05-05 16:30:55.0 UTC (+00:00)
      }
      -type: "datetime_immutable"
      -typeSpecified: false
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

That's all, I hope it can be useful to create your custom criteria behaviour and can create complex queries in a more decoupled way.

You can download this code from my github repository

If you enjoy my content and like the Symfony framework, consider reading my book: Building an Operation-Oriented Api using PHP and the Symfony Framework: A step-by-step guide

Top comments (0)