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