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ů)

  1. Doctrine 2: úvod do systému 21.7.2010
  2. Doctrine 2: základní definice entit 4.8.2010
  3. Doctrine 2: pokročilá definice entit 11.8.2010
  4. Doctrine 2: načítání, ukládání a mazání 26.8.2010
  5. Doctrine 2:stavy entit a transakce 9.9.2010
  6. Doctrine 2: asociace 23.9.2010
  7. Doctrine 2: práce s asociacemi 14.10.2010
  8. Doctrine 2: DQL 3.11.2010
  9. Doctrine 2: Query Builder a nativní SQL 18.11.2010
  10. Doctrine 2: události 9.12.2010
  11. Doctrine 2: událostní handlery 13.1.2011
  12. 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í.

Provozuje vývojářskou firmu Medio Interactive. Vystudoval informační a znalostní inženýrství na VŠE, kde stále příležitostně přednáší o tvorbě webů.

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 5

Přehled komentářů

Adam many-to-many asociace s dodatkem...
Jan Tichý Re: many-to-many asociace s dodatkem...
Adam Re: many-to-many asociace s dodatkem...
Jan Tichý Re: many-to-many asociace s dodatkem...
Lenka many to many
Zdroj: https://www.zdrojak.cz/?p=3345