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ń.
w następnym artykule napisze pan taką oczywistość że w controllerze mogą być tylko akcje, czy że w encji mają być tlyko pola getery i setery?
Uważam, że niektóre oczywiste dla nas rzeczy mogą być nieoczywiste dla innych.
Btw. na pewno nie napiszę czegoś takiego, że w encji powinny być gettery i setery. Powiem więcej, encje powinny mieć zachowania, a nie akcesory.
Bardziej zachowania do agregatów i to raczej w kontekstach, które mają predyspozycje do modelu bogatego.
Dlaczego nie korzystasz z gotowych metod Doctrinowych? Jeśli na przykład potrzebujesz wyciągnąć obiekt na podstawie identyfikatora to nadpisujesz metodę find w repozytorium?
Po pierwsze nie wstrzykuję Entity Managera, a konkretne repozytorium. Jeśli dziedziczę po EntityRepository to wówczas dostęp do tych metod jest i tak nadpisuję je. Jeśli używam kompozycji czyli nie dziedziczę, a wstrzykuję EM to nie mam takich metod by default.
Teraz, dlaczego nadpisuję:
– zyskuję typ zwracany w postaci mojej encji;
– w momencie jak zmieni się nazwa pola w Encji, to jedyne miejsce gdzie powinienem szukać kodu do zmiany to repozytorium tej klasy, a nie gdzieś po kontrolerach/serwisach, gdzie to zostało użyte;
– poprawiam w jednym miejscu, a nie wielu.
Takie nadpisywanie powinno się wiązać z tym, że trzeba zachować domyślne zachowanie, żeby inny programista nie użył tej metody myśląc, że działa jak ta doctrinowa. Zdarzyło mi się też wewnątrz repozytorium opakować swoją metodą tę doctrinową magię – taki kompromis. ?
Krystian – pochwalam 😀