Doctrine 2: práce s asociacemi

Dnes budeme pokračovat v tématu asociací v Doctrine 2. Představíme si možnosti kaskádového peristování, odpojování a mazání. Podíváme se podrobněji na kolekce a práci s nimi. Nejprve si ale ukážeme správné postupy při definicích getterů, setterů a dalších obslužných funkcí pro manipulaci s asociacemi.
Seriál: Doctrine 2 (12 dílů)
- Doctrine 2: úvod do systému 21. 7. 2010
- Doctrine 2: základní definice entit 4. 8. 2010
- Doctrine 2: pokročilá definice entit 11. 8. 2010
- Doctrine 2: načítání, ukládání a mazání 26. 8. 2010
- Doctrine 2:stavy entit a transakce 9. 9. 2010
- Doctrine 2: asociace 23. 9. 2010
- Doctrine 2: práce s asociacemi 14. 10. 2010
- Doctrine 2: DQL 3. 11. 2010
- Doctrine 2: Query Builder a nativní SQL 18. 11. 2010
- Doctrine 2: události 9. 12. 2010
- Doctrine 2: událostní handlery 13. 1. 2011
- Architektura aplikace nad Doctrine 2 23. 2. 2012
Základní gettery a settery
Abyste mohli s asociacemi nakládat zvenku entity, musíte jim definovat alespoň základní gettery a settery.
U jednosměrných asociací dává samozřejmě smysl definovat getter pouze na vlastnící straně, u obousměrných asociací pak můžeme gettery definovat na obou stranách:
/** @entity */ class Category { /** * @oneToMany(targetEntity="Article", mappedBy="category") */ private $articles; /** * @return DoctrineCommonCollectionsCollection; */ public function getArticles() { return $this->articles; } } /** @entity */ class Article { /** * @manyToOne(targetEntity="Category", inversedBy="articles") * @joinColumn(name="category_id", referencedColumnName="id") */ private $category; /** * @return Category */ public function getCategory() { return $this->category; } }
Použití je pak poměrně průhledné:
// na straně kategorie $category = $em->find('Category', 123); foreach ($category->getArticles() as $article) { echo $article->getTitle(); } // na straně článku $article = $em->find('Article', 123); echo $article->getCategory()->getName();
U definice setterů je strašně důležité rozlišovat vlastnící a inverzní stranu asociace. Důležitý je totiž vždy pouze setter na vlastnící straně! Toto pravidlo si velice dobře zapamatujte!
/** @Entity */ class Article { // ... public function setCategory(Category $category = NULL) { $this->category = $category; } }
Přiřazení článku do kategorie se pak provede jednoduše takto:
$category = $em->find('Category', 123); $article = $em->find('Article', 123); $article->setCategory($category); $em->flush();
Setter na inverzní straně
Proč je to tak důležité, aby byl setter vždy na vlastnící straně? Důvod je prostý – při ukládání změn v asociacích během zavolání $em->flush()
se Doctrine 2 dívá vždy jen a výhradně na vlastnící stranu a asociaci uloží podle toho, co je nastaveno právě na vlastnící straně.
Pokud byste místo toho měli nějaký setter (respektive v případě kolekcí spíše „adder“) na inverzní straně a provedli změnu v asociaci jen na inverzní straně, tak bude Doctrine 2 takovou změnu zcela ignorovat a vůbec ji při zavolání $em->flush()
do databáze nepromítne. Následující kód tedy nebude fungovat podle očekávání a provedená změna se do databáze vůbec neuloží:
/** @Entity */ class Category { // ... public function addArticle(Article $article) { $this->articles->add($article); } } // a samotné provedení změny, které nebude fungovat: $category = $em->find('Category', 123); $article = $em->find('Article', 123); $category->addArticle($article); $em->flush();
Co s tím? Máte vcelku dvě možnosti. První je udělat vše průhledné, setter definovat opravdu jen na vlastnící straně, zatímco inverzní stranu necháte bez jakéhokoliv setteru.
Pokud ale na inverzní straně z jakéhokoliv důvodu opravdu setter chcete (například abyste si v rámci aktuálního požadavku udrželi konzistenci v entitách na obou stranách asociace), musíte uvnitř daného setteru explicitně zajistit, aby se aktualizovala i asociace na vlastnící straně. Pak bude fungovat vše bez problémů:
/** @Entity */ class Category { // ... public function addArticle(Article $article) { $this->articles->add($article); $article->setCategory($this); } }
Kolekce
V příkladu výše stojí za povšimnutí, že kolekce v entitách, například $articles
v entitě Category
, jsou uloženy jako implementace rozhraní DoctrineCommonCollectionsCollection
, ve výchozím chování zpravidla jako instance třídy DoctrineCommonCollectionsArrayCollection
nebo DoctrineORMPersistentCollection
.
Tak je tomu samozřejmě ale jen v případě, kdy nám Doctrine 2 načte a vrátí nějakou již uloženou entitu. Naproti tomu pokud si sami vytvoříme úplně novou instanci přes operátor new
, je na začátku daná proměnná $articles
logicky úplně prázdná.
Silně proto doporučuji všechny kolekce v entitách důsledně explicitně inicializovat následujícím voláním v konstruktoru:
/** @Entity */ class Category { /** * @oneToMany(targetEntity="Article", mappedBy="category") */ private $articles; /** * V konstruktoru inicializujeme všechny kolekce. */ public function __construct() { $this->articles = new DoctrineCommonCollectionsArrayCollection; } }
Doctrine 2 provádí konstruktor pouze při vytváření úplně nové prázdné instance, nikdy pak už například při načítání uložené entity z databáze, proto je toto volání zcela v pořádku.
Zvykněte si hned automaticky psát takovou inicializaci do konstruktoru vždy, kdy v entitě definujete jakoukoliv asociační kolekci.
Metody pro práci s kolekcemi
Třída DoctrineCommonCollectionsArrayCollection
nabízí několik užitečných funkcí pro práci s danou kolekcí. Kromě běžných metod vyžadovaných od implementovaných rozhraní Countable
, IteratorAggregate
a ArrayAccess
jsou to zejména funkce pro přidávání, získávání a mazání jednotlivých prvků z kolekce.
Pokud jsem výše psal, že pro práci s kolekcí bych měl mít v dané entitě definovaný nějaký setter ve smyslu $category->addArticle($article)
, není to tak úplně pravda, protože teoreticky mi bohatě stačí mít pouze getter $category->getArticles()
:
$category = $em->find('Category', 123); $article = $em->find('Article', 123); // měníme kolekci skrz getter $category->getArticles()->add($article); $category->getArticles()->removeElement($article);
Tohle ale nikdy nedělejte, protože tím trochu narušujete pomyslné zapouzdření celé entity kategorie a můžete si tím potenciálně udělat v aplikaci čurbes. Navíc tím můžete obcházet některé další operace, které byste rádi ve svých setterech měli, jako je například výše ukázané promítnutí změny v asociaci i do vlastnící strany.
Takže si namísto toho pro těch pár nejčastějších operací radši definujte metody přímo v rámci entity $category
a používejte důsledně jen a pouze je:
/** @Entity */ class Category { // ... public function addArticle(Article $article) { $this->articles->add($article); $article->setCategory($this); } public function removeArticle(Article $article) { $this->articles->removeElement($article); $article->setCategory(NULL); } }
Pozdní inicializace
Na konci minulého dílu jsme si ukázali, že Doctrine 2 používá pro načítání odkazovaných entit pozdní inicializaci. Nejinak je tomu i u celých odkazovaných kolekcí.
Pokud si tedy v našem příkladu načteme nějakou kategorii přes $category = $em->find('Category', 123)
, neprovádí Doctrine 2 hned automaticky další SELECT na všechny články patřící do dané kategorie.
Namísto toho si do proměnné $category->articles
uloží instanci takové trochu zvláštní kolekce DoctrineORMPersistentCollection
. Ta nabízí všechny metody, jako jakákoliv jiná kolekce v Doctrine 2, ale vyčkává až do poslední chvíle, co to jde, a pak teprve sama na svém pozadí pošle do databáze potřebný SELECT:
// tady se provede jen SELECT na danou kategorii $category = $em->find('Category', 123); // dokonce ani tady se ještě nic dalšího nenačítá $articles = $category->getArticles(); // a teprve tady se načte seznam článků v kategorii foreach ($articles as $article) { echo $article->title; }
Řazení kolekcí
Odkazované entity se do asociačních kolekcí načítají obecně v náhodném řazení. Pokud chcete explicitně určit, podle čeho se mají entity seřadit, můžete pro to využít anotaci @orderBy
.
Pokud tedy například budu chtít řadit články v kategoriích podle času publikování sestupně a pak podle titulku vzestupně, definuji danou asociaci takto:
/** @Entity */ class Category { /** * @oneToMany(targetEntity="Article", mappedBy="category") * @orderBy({'published' = 'DESC', 'title' = 'ASC'}) */ private $articles; }
Pokud nějaké vlastní řazení definujete, je zpravidla dobrý nápad doprovodit to hned vytvořením odpovídajícího indexu v databázové struktuře.
Kaskádové persistování
Jak už bylo zmíněno v některém z dřívějších dílů, pokud chcete jakoukoliv nově vytvořenou entitu uložit do databáze, musíte na ni vždy zavolat $em->persist($entity)
:
$article = new Article; $category = new Category; $article->setCategory($category); $em->persist($article); $em->persist($category); $em->flush();
Nutnost persistovat každou jednu instanci zvlášť může být občas nepohodlná, hlavně při ukládání složitějších asociací a komplikovanějších struktur. Proto Doctrine 2 nabízí možnost kaskádového persistování.
Kaskádové persistování se nastavuje na inverzní straně asociace s pomocí atributu cascade
. Zdůrazňuji, že toto nastavení se dělá vždy na inverzní straně asociace, i když to na první pohled nemusí vypadat moc logicky. Mimo jiné to znamená, že pokud chcete použít kaskádování, musíte vždy definovat obousměrnou asociaci.
/** @Entity */ class Category { /** * @oneToMany(targetEntity="Article", mappedBy="category", * cascade={"persist"}) */ private $articles; }
Pak stačí persistovat jen hlavní entitu a persistování pak „probublá“ i do všech dalších navazujících entit:
$article = new Article; $category = new Category; $article->setCategory($category); $em->persist($article); $em->flush();
Kromě persistování je možné kaskádovat i další základní operace s entitami, tedy mazání, odpojování a slučování:
/** @Entity */ class Category { /** * @oneToMany(targetEntity="Article", mappedBy="category", * cascade={"persist", "remove", "detach", "merge"}) */ private $articles; }
Pokud chcete kaskádovat všechny čtyři operace, můžete místo jejich vyjmenovávání použít hodnotu "all"
:
/** @Entity */ class Category { /** * @oneToMany(targetEntity="Article", mappedBy="category", * cascade={"all"}) */ private $articles; }
Kaskádování velice zpříjemňuje práci s rozsáhlejšími asociacemi, na druhou stranu může mít zásadní dopad na celkovou výkonnost Doctrine 2. Pokaždé tedy zvažte, zda v daném případě kaskádování opravdu potřebujete a využijete. Určitě ale nezapínejte kaskádování mechanicky u všech asociací.
Zdravím,
díky za zajímavý seriál! Měl bych na autora nebo čtenáře dotaz k asociacím… U několika projektů používáme many-to-many asociace, kde bychom potřebovali k té asociaci přidat ještě nějakou poznámku – pokud použiju entity z příkladu, děláme many-to-many asociaci mezi Article a Category a do této asociace bychom potřebovali přidat např. výchozí kategorii článku, popř. pořadí jednotlivých kategorií. Jde tohle nějak pomocí Doctrine 2 zařídit nebo je potřeba udělat i entitu Article_Category a do ní tyhle doplňující informace dát?
Díky, Adam
V takovém případě se v Doctrine 2 opravdu musí pro spojovací tabulku vytvořit zvláštní entita a následně pak původní many-to-many asociace předělat na dvě one-to-many asociace.
Díky, toho jsem se obával. :)
Já si nemyslím, že je to špatně. Jak to totiž dělat jinak? Možností by mohl být nějaký mechanizmus „ohodnocené vazby“, kde by se k té relaci přidávaly nějaké další atributy. Což by ale byl zase další způsob nastavování a ukládání dat, ještě jeden navíc. Není lepší místo toho radši využít již existující standardní mechanizmy, tedy právě entity?
Dobrý den, když mám vazbu many-to.many mezi filmem a osobou, mam tam udelanou spojovaci entitu film_osoba a film_reziser a chtela bych se zeptat zda by mi stacilo tam jen zadavani id nebo mam udelat i promenou treba film nebo osoba v propojovaci entite? dekuji za radu zacinam s doctrine