Auxiliary builders for doctrine

Doctrine’s Query Builder works for most cases and offers a fluent interface that allows you to build your query step by step, following the builder pattern. However, it lacks some methods that are available in QueryDsl (Java), and inspired by that, I wrote some auxiliary builders that you can use in your PHP projects.

Select DTO Builder

This is a simple builder for the Doctrine SELECT new DTO statement. You can use it to build your SELECT query programmatically, give your code a better formatting, or if you want to avoid interpolating strings.

<?php

declare(strict_types=1);

namespace App\Builder\Doctrine;

use Ds\Set;
use Stringable;

use function implode;
use function sprintf;

final class SelectBuilder implements Stringable
{
    /**
     * @var class-string
     */
    private readonly string $className;

    /**
     * @var Set<string>
     */
    private Set $args;

    private function __construct(string $className)
    {
        $this->className = $className;
        $this->args = new Set();
    }

    public function __toString(): string
    {
        return sprintf(
            'new %s(%s)',
            $this->className,
            implode(',', $this->args->toArray()),
        );
    }

    /**
     * @param class-string $className
     */
    public static function new(string $className): static
    {
        return new self($className);
    }

    public function with(string ...$args): static
    {
        $clone = clone $this;
        $clone->args = $clone->args->merge(new Set($args));

        return $clone;
    }

    public function build(): string
    {
        return (string) $this;
    }
}

Usage:

<?php
// @var Doctrine\ORM\QueryBuilder $qb
$qb = $em->createQueryBuilder();
$qb->select(
    SelectBuilder::new(InvoiceListing::class)
        ->with(
            'invoice.id',
            'invoice.number',
            'invoice.emissionDate',
            'invoice.paidValue',
            'SUM(items.cents)'
        )
        ->build()
);

Case Builder

This is something that I missed a lot when I came back from QueryDsl. I don’t know or remember the syntax for CASE statements and having a builder for it makes it easier for you to:

<?php

declare(strict_types=1);

namespace App\Builder\Doctrine;

use Ds\Vector;
use InvalidArgumentException;

use function implode;

final class CaseBuilder
{
    /**
     * @var Vector<array{when: string, then: string}>
     */
    private Vector $whenClauses;

    private ?string $elseClause;
    private ?string $caseExpression;

    private function __construct()
    {
        $this->whenClauses = new Vector();
        $this->elseClause = null;
        $this->caseExpression = null;
    }

    public static function new(): static
    {
        return new self();
    }

    public static function expression(string $expression): static
    {
        $new = new self();
        $new->caseExpression = $expression;

        return $new;
    }

    public function when(string $condition): WhenClause
    {
        return new WhenClause(
            function (string $then) use ($condition) {
                $new = clone $this;
                $new->whenClauses = $this->whenClauses->copy();
                $new->whenClauses->push(
                    [
                        'when' => $condition,
                        'then' => $then
                    ]
                );
                return $new;
            }
        );
    }

    public function else(string $result): static
    {
        $new = clone $this;
        $new->elseClause = $result;

        return $new;
    }

    public function build(): string
    {
        if ($this->whenClauses->isEmpty()) {
            throw new InvalidArgumentException(
                'CASE statement must have at least one WHEN clause',
            );
        }

        $parts = [];

        if ($this->caseExpression !== null) {
            $parts[] = "CASE {$this->caseExpression}";
        } else {
            $parts[] = 'CASE';
        }

        foreach ($this->whenClauses as $clause) {
            $parts[] = "WHEN {$clause['when']} THEN '{$clause['then']}'";
        }

        if ($this->elseClause !== null) {
            $parts[] = "ELSE '{$this->elseClause}'";
        }

        $parts[] = 'END';

        return implode(' ', $parts);
    }
}
<?php

declare(strict_types=1);

namespace App\Builder\Doctrine;

use Closure;

final readonly class WhenClause
{
    /**
     * @param Closure(string): CaseBuilder $onThenCall
     */
    public function __construct(
        private Closure $onThenCall,
    ) {
    }

    public function then(string $result): CaseBuilder
    {
        return $this->onThenCall->__invoke($result);
    }
}

Usage:

<?php
// @var Doctrine\ORM\QueryBuilder $qb
$qb = $em->createQueryBuilder();
$qb->select(
    SelectBuilder::new(InvoiceListing::class)
        ->with(
            'invoice.id',
            CaseBuilder::new()
                ->when('invoice.paidValue = 0')
                ->then(InvoiceStatus::PENDING->value)
                ->when('invoice.paidValue >= SUM(items.cents)')
                ->then(InvoiceStatus::PAID->value)
                ->else(InvoiceStatus::PARTIALLY_PAID->value)
                ->build()
        )
        ->build()
);

Usage with an expression:

<?php
// @var Doctrine\ORM\QueryBuilder $qb
$qb = $em->createQueryBuilder();
$qb->select(
    SelectBuilder::new(UserDTO::class)
        ->with(
            'user.id',
            CaseBuilder::expression('user.status')
                ->when('1')
                ->then('Active')
                ->when('2')
                ->then('Suspended')
                ->when('3')
                ->then('Pending Verification')
                ->when('4')
                ->then('Deleted')
                ->else('Unknown')
                ->build()
        )
        ->build()
);

Function builder

In case you want to build a database function call.

<?php

declare(strict_types=1);

namespace App\Builder\Doctrine;

use Ds\Vector;

use function implode;
use function sprintf;

final class FunctionBuilder
{
    private string $funcName;

    /**
     * @var Vector<string>
     */
    private Vector $args;

    private function __construct()
    {
        $this->funcName = '';
        $this->args = new Vector();
    }

    public static function name(string $funcName): static
    {
        $new = new self();
        $new->funcName = $funcName;

        return $new;
    }

    public function args(string ...$args): static
    {
        $new = clone $this;
        $new->args = $this->args->merge(new Vector($args));

        return $new;
    }

    public function build(): string
    {
        return sprintf(
            '%s(%s)',
            $this->funcName,
            implode(',', $this->args->toArray()),
        );
    }
}

Usage:

<?php
// @var Doctrine\ORM\QueryBuilder $qb
$qb = $em->createQueryBuilder();
$qb->select(
    SelectBuilder::new(UserDTO::class)
        ->with(
            'user.id',
            FunctionBuilder::name('COALESCE')
                ->args(
                    'user.socialName',
                    'user.nickName',
                    'user.realName',
                )
                ->build()
        )
        ->build()
);

Json build object

A little abstraction over Postgres’ JSON_BUILD_OBJECT function 1.

<?php

declare(strict_types=1);

namespace App\Builder\Doctrine;

use Ds\Map;

use function implode;
use function sprintf;

final readonly class JsonBuildObjectBuilder
{
    /**
     * @param Map<string,string> $argsMap
     */
    private function __construct(private Map $argsMap)
    {
    }

    /**
     * @param array<string,string> $argsMap
     */
    public static function with(array $argsMap): static
    {
        return new self(new Map($argsMap));
    }

    public function build(): string
    {
        /**
         * @var string[] $dataMap
         */
        $dataMap = [];

        foreach ($this->argsMap as $key => $value) {
            $dataMap[] = sprintf("'%s'", $key);
            $dataMap[] = $value;
        }

        return sprintf('JSON_BUILD_OBJECT(%s)', implode(',', $dataMap));
    }
}

Usage:

<?php
// @var Doctrine\ORM\QueryBuilder $qb
$qb = $em->createQueryBuilder();
$qb->select(
    SelectBuilder::new(PostDTO::class)
        ->with(
            'post.id',
            FunctionBuilder::name('JSON_AGG')
                ->args(
                    JsonBuildObjectBuilder::with([
                        'id' => 'comment.id',
                        'description' => 'comment.description',
                        'likes' => 'SUM(comment.likes)',
                        'dislikes' => 'SUM(comment.dislikes)',
                    ])->build(),
                )
                ->build()
        )
        ->build()
);

Conclusion

Those are some builders that I created to help creating complex Doctrine queries. They were inspired by QueryDsl and are NOT a drop-in replacement for Doctrine’s Query Builder. I created them to make my queries a little more readable and predictable. Those auxiliary classes can help you make complicated queries in Doctrine look easier to maintain specially if you combine them with the builder pattern which is explained in my other articles.

Peace!