6 usprawnień korzystania z Doctrinowego Query Buildera

Znaki zapytania

Query Builder to narzędzie do budowania DQL w oparciu o obiektowe podejście do tematu. Jest ono bardzo wygodne, chociaż w samej oficjalnej dokumentacji jest wzmianka, że czytelniejsze nadal są czyste zapytania. Budowanie zapytań w ten sposób jest niezwykle proste dla osób, które znają składnie języka SQL. Sam mechanizm w swoich bebechach ma wzorzec budowniczego.

Szybko można zacząć korzystać z tego dobrodziejstwa, ale warto już na samym początku zwrócić uwagę na kilka szczegółów. Takich, które mogą usprawnić pracę z Query Builderem – i o tym cały wpis.

1. Kolejność ta sama co przy SQL

Budowanie zapytania za pomocą Query Buildera nie wymusza na programiście tak restrykcyjnej kolejności metod jak ma to miejsce w przypadku zwykłej klauzuli SELECT języka SQL. Jako, że więcej osób kojarzy właśnie język zapytań oraz ostatecznie do takiej składni doprowadzane jest zapytanie pochodzące z budowniczego to warto trzymać się narzuconej kolejności:

SELECT -> JOIN -> WHERE -> GROUP BY -> HAVING -> ORDER BY

W niektórych przypadkach reguła ta może zostać złamana, ale tylko wtedy kiedy są ku temu powody. U mnie zdarza się to na przykład w miejscach, gdzie złączenie tabel jest potrzebne tylko i wyłącznie dla jakiegoś konkretnego warunku. Oczywiście w celach optymalizacyjnych w głównym zapytaniu nie wrzucam joina, a robię to już wewnątrz instrukcji warunkowej.

W obiektowym mechanizmie budowania zapytania dochodzi jeszcze ustawienie wartości zdefiniowanym wcześniej parametrom. Ja robię to zawsze po metodzie andWhere(), a jeśli jest ich więcej to po ostatniej występującej w danym fragmencie zapytania. I co ważne, staram się w tej samej kolejności co występują w warunkach.

Parametry można też ustawiać metodą setParameters() – przyjmującą tablicę parametrów. Nigdy nie korzystam z tego rozwiązania, bo może prowadzić do nadpisania wcześniej zdefiniowanych, gdyż w całości podmienia wszystkie wartości. A czasem w wielu miejscach metody trzeba im przypisać wartość w zależności od warunku. Niemniej jednak na takie rozwiązania natrafiam, więc wspominam, że istnieje taka opcja.

W ogóle to powinienem zacząć od zachęcenia Was do definiowania i uzupełniania wszystkich parametrów. O ile te pochodzące od użytkownika to rzecz jasna, bo już chyba mało kto się nabierze dzisiaj na najprostsze SQL Injection, ale mówię o dosłownie wszystkich występujących w klauzulach where(). W poniższym kodzie zauważycie, że zamiast wykorzystania stałej STATUS_DELETED mogłem bezpośrednio użyć cyfry 0. Ale przyznacie, że nie jest to zbyt czytelne.

Powyższe rady w żaden sposób nie wpływają na działanie aplikacji, a ich jedynym celem jest poprawa czytelności kodu. Ktoś może powiedzieć, że drobiazg. Chociaż z drugiej strony większość zasad dobrego programowania rozchodzi się o czytelność. I ten legendarny argument, że 90% czasu kod czytamy…

2. Koniec z where(), orderBy() i groupBy()

Na początku swojej przygody z budowaniem zapytań w ten sposób używałem normalnie powyższych metod. Problem jaki widzę to ponowne użycie którejś z tych metod i nadpisanie wcześniej występującej. W takim razie śmiało używam odpowiednio andWhere(), addOrderBy() i addGroupBy(). I to od razu, bez wcześniejszego wystąpienia podstawowych, a Doctrine sobie świetnie z tym radzi. Już nie muszę się martwić, czy wcześniej została użyta.

Wyobrażam sobie, że mogą być zapytania w których pożądanym efektem ubocznym jest nadpisanie wcześniejszych warunków, sortowania czy grupowania. Wtedy jak najbardziej widzę zastosowanie dla tych metod. Jak zobaczycie poniżej w kodzie – w normalnych warunkach stosuję te bezpieczniejsze z punktu widzenia możliwości popełnienia błędu.

<?php

declare(strict_types=1);

use Doctrine\ORM\EntityRepository;

class ClientRepository extends EntityRepository
{
    public function getMostRecentRemoved(): array
    {
        return $this
            ->createQueryBuilder('c')
            ->andWhere('c.status = :clientStatus')
            ->andWhere('c.deletedAt > :minDate')
            ->setParameter('clientStatus', Client::STATUS_DELETED)
            ->setParameter('minDate', (new \DateTime())->modify('-2 hours'))
            ->addOrderBy('c.deletedAt', 'DESC')
            ->getQuery()
            ->getResult();
    }
}

3. addSelect() tam gdzie trzeba

O problemie n+1 i rozwiązaniach stworzyłem osobny wpis. Jest to jednak bardzo ważna sprawa, dlatego nie mogło jej tutaj zabraknąć. Poniżej krótki przykład, gdzie widać wykorzystanie tej funkcji. Metoda getMostRecentRemoved() zwraca klientów od razu z uzupełnionymi informacjami o miejscu pracy. Dla lepszego zrozumienia tego mechanizmu odsyłam Was do wpisu: https://koddlo.pl/doctrine-problem-n1-i-mozliwe-rozwiazania/

<?php

declare(strict_types=1);

use Doctrine\ORM\EntityRepository;

class ClientRepository extends EntityRepository
{
    public function getMostRecentRemoved(): array
    {
        return $this
            ->createQueryBuilder('c')
            ->join('c.workplace', 'w')
            ->addSelect('w')
            ->andWhere('c.status = :clientStatus')
            ->andWhere('c.deletedAt > :minDate')
            ->setParameter('clientStatus', Client::STATUS_DELETED)
            ->setParameter('minDate', (new \DateTime())->modify('-2 hours'))
            ->addOrderBy('c.deletedAt', 'ASC')
            ->getQuery()
            ->getResult();
    }
}

Przy okazji – do metody sortowania dorzucam kierunek sortowania nawet jeśli jest on domyślny, czyli rosnący. Jasno widać, że wykorzystano ASC lub DESC i dzięki temu nie trzeba się nad tym zastanawiać.

4. Konkretne repozytorium dla encji

Nie powinno być sytuacji, że w klasie ClientRepository istnieje metoda zwracająca wszystkie miejsca pracy. Z klasy ClientRepository powinno się zwracać tylko obiekt bądź kolekcję obiektów Client. Ewentualnie dane w formie tablicy, czy pojedynczy wynik jakiegoś obliczenia typu COUNT. Ale to zawsze musi dotyczyć właśnie encji klienta.

Żeby łatwiej było się trzymać tej zasady w przypadku dziedziczenia po Doctrine\ORM\EntityRepository można skorzystać z uproszczonej wersji tworzenia Query Buildera.

$queryBuilder = $this->createQueryBuilder('c');

W przypadku używania kompozycji w swoich repozytoriach niestety trzeba będzie skorzystać z poniższego rozwiązania. W dalszym ciągu zachęcam do trzymania się zasady o której wspomniałem powyżej. Skrótowy zapis to tylko pewność, że nie pokusi o nic głupiego.

$queryBuilder = $this
    ->getEntityManager()
    ->createQueryBuilder()
    ->select('c')
    ->from(Client::class, 'c');

5. Warunkowanie bez Expression Buildera

Do budowania warunków dla klauzuli WHERE można wykorzystać Expression Buildera. Ja jakoś nie mogę się do niego przekonać, ale w projektach z którymi mam do czynienia widzę, że też nie jest za często wykorzystywany. I dobrze – dla mnie jest zupełnie nieczytelny. Zdecydowanie wolę proste warunki w postaci tekstowej tak jak w przykładach z tego wpisu.

Znam jednak przypadki, gdzie bez zastanowienia go używam i nie muszę tworzyć jakiś swoich magii konkatenacji warunków. Prezentuję go w poniższym kodzie, gdzie budowanie warunków zależy od jakiejś logiki i w tym przypadku dla klienta firmowego wyszukiwarka działa po nazwie miejsca pracy, a w przypadku indywidualnego po imieniu i nazwisku. Wspólnie wszystkich klientów da się też wyszukiwać po identyfikatorze. W łatwy sposób można to właśnie zbudować Expression Builderem.

Bo od razu Wam powiem – nie używajcie metody orWhere(). Więcej z nią kłopotów niż pożytku, a użycie jej w środku listy wherów zadziała tak jakby nie dodać nawiasów, czyli słabo i nieprzewidywalnie. Dlatego lepiej zbudować pełny jeden warunek oparty o metodę orX() i na samym końcu ją wrzucić do znanej i lubianej addWhere().

<?php

declare(strict_types=1);

use Doctrine\ORM\EntityRepository;

class ClientRepository extends EntityRepository
{
    public function getMostRecentRemoved(string $searchPhrase, int $clientType): array
    {
        $queryBuilder = $this
            ->createQueryBuilder('c')
            ->join('c.workplace', 'w')
            ->addSelect('w')
            ->andWhere('c.status = :clientStatus')
            ->andWhere('c.deletedAt > :minDate')
            ->setParameter('clientStatus', Client::STATUS_DELETED)
            ->setParameter('minDate', (new \DateTime())->modify('-2 hours'));

        $searchConditions = $queryBuilder->expr()->orX();
        $searchConditions->add('c.uuid LIKE :searchPhrase');

        switch ($clientType) {
            case ClientType::TYPE_B2B:
                $searchConditions->add('w.name LIKE :searchPhrase');
                break;
            case ClientType::TYPE_INDIVIDUAL:
                $searchConditions->add('c.firstName LIKE :searchPhrase');
                $searchConditions->add('c.lastName LIKE :searchPhrase');
                break;
            default:
        }

        $queryBuilder
            ->andWhere($searchConditions)
            ->setParameter('searchPhrase', '%' . $searchPhrase . '%');

        return $queryBuilder
            ->addOrderBy('c.deletedAt', 'DESC')
            ->getQuery()
            ->getResult();
    }
}

6. Jeśli paginacja to dedykowany paginator

Właściwym, ale niekoniecznie zrozumiałym dla mnie na początku zachowaniem było działanie metody setMaxResults(). Jest to najzwyklejszy w świecie sqlowy LIMIT. Jaki z tym problem? W bazie istnieje 10 klientów i każdy z nich ma po 2 miejsca pracy. Stronicowanie zakłada 5 rekordów na stronę, więc setMaxResults(5). Tyle, że rekordów w tabeli wynikowej zapytania jest 20, bo miejsce pracy je podwaja. Więc założony limit spowoduje, że na stronie pojawi się 3 klientów, a nie 5 jak można było by pomyśleć.

O ile w samym SQL jest to czytelne, o tyle tutaj działa się na encji klient, więc założyć można, że oczekuje się 5 klientów. I taki wynik można uzyskać używając właśnie Doctrine\ORM\Tools\Pagination\Paginator. Jedyna różnica jest taka, że zwraca się właśnie ten obiekt, a nie tablicę. Dalej można normalnie iterować po wynikach, więc w kodzie klienckim nic się nie zmieni.

<?php

declare(strict_types=1);

use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Tools\Pagination\Paginator;

class ClientRepository extends EntityRepository
{
    public function getMostRecentRemoved(Pagination $pagination): Paginator
    {
        $query = $this
            ->createQueryBuilder('c')
            ->join('c.workplace', 'w')
            ->addSelect('w')
            ->andWhere('c.status = :clientStatus')
            ->andWhere('c.deletedAt > :minDate')
            ->setParameter('clientStatus', Client::STATUS_DELETED)
            ->setParameter('minDate', (new \DateTime())->modify('-2 hours'))
            ->addOrderBy('c.deletedAt', 'DESC')
            ->setFirstResult($pagination->getStartRow())
            ->setMaxResults($pagination->getLimitRow())
            ->getQuery();

        return new Paginator($query, true);
    }
}

Miało być 6 porad, ale podczas pisania tego wpisu jeszcze kilka mniejszych mi się nasunęło. Sześć głównych, którymi chciałem się podzielić na pewno usprawni Wam pracę z Query Builderem jeśli w ten sposób w projektach budujecie zapytania bazodanowe. Ja bardzo lubię ten mechanizm i od razu się do niego przekonałem.

Korzystam z niego wszędzie tam, gdzie wyciągane są obiekty do operacji biznesowych. Po czyste zapytania prędzej sięgam w modelu odczytowym, czyli wszelkiego rodzaju prezentowaniu danych w postaci widoków, raportów i tak dalej. Na pewno nie korzystam w ogóle z metod magicznych typu findBy(), findOneBy() i tak dalej. Piszę tylko swoje własne w oparciu o Query Buildera. I na koniec – korzystajcie z Fluent Interface przy budowaniu zapytań.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.