Doctrine – różne obiekty w jednej tabeli

Dziś zajmiemy się tematem przechowywania różnych rodzajów obiektów na jednej liście i w jednej tabeli. Nauczymy się definiować takie struktury w Doctrine.

Zdarza się, że chcemy mieć poliformiczną listę obiektów. Przykładowo użytkownik może posiadać zwierzęta. Zwierzak może być kotem lub psem. Z czasem dodamy kanarki. Chcemy pobrać całą listę zwierząt, np tak:

// pobieramy użytkownika z bazy
$user = $em->getRepository('User')->find(1);

// listujemy posiadane zwierzaki
foreach ($user->getPets() as $pet) {
   echo get_class($pet);
}

Taki kod powinien nam wypisać obiekty klasy kot i pies. Obiekty są różne. Pies szczeka, kot miauczy i mruczy. Jak stworzymy taką bazę?

Jak stworzyć bazę?

Myśląc bazodanowo stworzymy zapewne tabelę. Jedno pole będzie przeznaczone na rozróżnienie klasy (typu) rekordu. Nazwiemy je discriminator:

CREATE TABLE pets (
   id INT PRIMARY KEY auto_increment,
   discriminator VARCHAR(255) NOT NULL,
   purr TINYINT,
   meyow TINYINT,
   bark TINYINT
);

W polu discriminator wpiszemy wartości „dog” lub „cat”. Zależnie od tego pola albo szczekanie albo miauczenie i mruczenie będzie miało wartość NULL. Ale jak to zapisać w Doctrine?

Definiujemy klasy

Zacznijmy od początku. Napiszemy kod dla osoby i ogólnego zwierzaka (pomijam oczywiste pola takie jak id):

use Doctrine\Orm\Mapping as ORM;

/** @ORM\Entity */
class User {
   ...
   /** @ORM\OneToMany(targetEntity="Pet", mappedBy="owner") */
   protected $pets;
}

/** @ORM\Entity */
class Pet {
   ...
   /** @ORM\ManyToOne(targetEntity="User") */
   protected $owner;
}

Po uruchomieniu komendy doctrine:generate:entities będziemy mieć gotowe encje z setterami i getterami.

Klasa Pet jest naszą klasą bazową. Pies i kot będą dziedziczyć po tej klasie. Kot będzie miał pola związane z mruczeniem i miauczeniem:

/** @ORM\Entity */
class Cat extends Pet {

   /** @ORM\Column(type="boolean") */
   private $meyow;

   /** @ORM\Column(type="boolean") */
   private $purr;
}

Podobnie pies będzie miał pole definiujące szczekanie:

/** @ORM\Entity */
class Dog extends Pet {

   /** @ORM\Column(type="boolean") */
   private $bark;
}

Single Table Inheritance w Doctrine

Mamy dobrze zdefiniowane obiekty. Ale jak poinformować Doctrine aby dobrze ułożyła je w bazie? Sprawa okazuje się bardzo prosta. Cała logika będzie w klasie bazowej (Pet).  Musimy poinformować Doctrine, że jest to klasa, która ma encje dziedziczące:

/**
 * @ORM\Entity
 * @ORM\InheritanceType("SINGLE_TABLE")
 * @ORM\DiscriminatorColumn(name="discriminator")
 */
class Pet {
   ...

Co tu się dzieje? Zobaczmy po kolei:

/**
 * @ORM\InheritanceType("SINGLE_TABLE")
 */

Ta definicja mówi dwie rzeczy:

  1. Nasza encja bierze udział w dziedziczeniu. Doctrine ma wziąć pod uwagę klasy dziedziczące.
  2. Doctrine ma wszystkie encje umieszczać w jednej wspólnej tabeli (SINGLE_TABLE).

Dalej mamy:

/**
 * @ORM\DiscriminatorColumn(name="discriminator")
 */

Tu mówimy po prostu jaka kolumna w tabeli służy do rozróżnienia klasy. Doctrine automatycznie weźmie nazwę podklasy i umieści w tej kolumnie. Dla „Cat” zapisze „cat”, dla „Dog” wpisze „dog”.

Po wywołaniu doctrine:schema:update i doctrine:generate:entities możemy wykonać nasz kod z początku artykułu:

// pobieramy użytkownika z bazy
$user = $em->getRepository('User')->find(1);

// listujemy posiadane zwierzaki
foreach ($user->getPets() as $pet) {
   echo get_class($pet);
}

Jeśli wypełniliśmy bazę danymi, dostaniemy w odpowiedzi coś w stylu:

\AppBundle\Entity\Cat
\AppBundle\Entity\Dog
\AppBundle\Entity\Cat

Złe identyfikatory tekstowe

Kolumna discriminator ma typ tekstowy. Doctrine zapisuje w niej nazwę klasy. Ale my nie lubimy tekstowych identyfikatorów. Klas mamy tylko kilka, można je ponumerować i szukać za pomocą wartości integer. Zmienimy trochę definicję dyskryminatora:

/**
 * @ORM\DiscriminatorColumn(name="discriminator", type="integer")
 */

Ale skąd Doctrine ma wiedzieć jakie liczby przypisać podklasom? Sam się nie domyśli. Musimy mu powiedzieć dodając słownik:

/**
 * @DiscriminatorMap({"0" = "Cat", "1" = "Dog"})
 */

Wywołujemy doctrine:schema:update i… error. Dostajemy błąd, który wygląda tak:

[Doctrine\ORM\Mapping\MappingException]
Entity ‚AppBundle\Entity\Pet’ has to be part of the discriminator map of ‚AppBundle\Entity\Pet’ to be properly mapped in the inheritance hierarchy. Alternatively you can make ‚AppBundle\Entity\Pet’ an abstract class to avoid this exception from occurring.

No tak. Doctrine uważa, że powinniśmy też tworzyć encję „Pet” a nie dodaliśmy dla niej mapowania. Jeśli nigdy nie chcemy mieć obiektu klasy Pet a jedynie konkretne psy, koty, kanarki itp musimy zdefiniować główną klasę jako abstrakcyjną:

abstract class Pet {
  ...
}

Teraz komenda doctrine:schema:update przejdzie bez problemów. W bazie będziemy mieć ładne mapowanie według liczb a nie tekstu.

Wada – jeśli planujemy w przyszłości dodawać więcej zwierzaków (krowy, króliki, nietoperze…) to za każdym razem będziemy musieli dopisać je do mapowania w klasie Pet. Trochę kłóci się to z zasadą pojedynczej odpowiedzialności – dodając zwierzaka musimy zawsze zmieniać klasę nadrzędną. Trzeba przemyśleć czy bardzo przeszkadza nam tekstowy dyskryminator i czy gra jest warta świeczki.

Marnowanie miejsca na dużo pustych pól

W naszym przykładzie zawsze będziemy mieć w tabeli puste pola. Im więcej zwierząt, im bardziej rozbudowana struktura tym większa nasza tabela. A co gdy jedno zwierzę będzie wymagać całego zestawu pól? Czy liczba kolumn jest skazana na wieczny wzrost?

Na szczęście jest na to rada – Class Table Inheritance. Doctrine stworzy trzy tabele:

  • Tabela Pet będzie miała tylko kolumny wspólne dla wszystkich zwierzaków.
  • Nowa tabela Dog będzie miała pola charakterystyczne dla psów.
  • Nowa tabela Cat będzie miała tylko kocie kolumny.

Doctrine powiąże te tabele za pomocą identyfikatorów. Pole „id” w Pet i w Dog bedzie miało tą samą wartość dla tego samego wpisu.

Zmiana jest bardzo prosta. Zmieniamy jedynie typ dziedziczenia w encji Pet:

/**
 * @ORM\InheritanceType("JOINED")
 */

Jak zwykle wywołujemy doctrine:schema:update. Od teraz będziemy mieć trzy tabele. Jeśli zechcemy dodać bardzo skomplikowane zwierzę, np. ławicę ryb – stworzymy nową encję dziedziczącą po klasie Pet. Nowa tabela w bazie będzie mogła przechować wszystkie skomplikowane cechy nowej encji.

Wada – każde pobieranie danych polega na wykonaniu operacji JOIN. Musimy więc sami zadecydować czy liczy się szybkość bazy czy jej rozmiar.

Do czego się to przyda?

Podobne struktury spotkamy w wielu projektach. Tworząc np. system do quizów mamy pytania jednokrotnego wyboru (radio buttony), wielokrotne (checkboxy), numeryczne, opisowe i wiele innych. Wszystkie one mają wspólne cechy (np. sprawdzenie poprawności). Wszystkie też są na liście pytań powiązanych z danym quizem. Table Inheritance pozwala nam ułożyć je w bazie.

Tak samo możemy rozwiązać problem różnego rodzaju dokumentów przechowowyanych przez jednego użytkownika w tworzonym przez nas systemie CMS. Historia zdarzeń w systemie CRM też pasuje do tego rozwiązania. Przykłady można mnożyć.

Co dalej?

Warto poczytać dokumentację mapowania dziedziczenia w Doctrine. Class Table Inheritance jest też opisany przez Martina Fowlera.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *