Doctrine 2: asociace

Asociace jsou v terminologii ORM analogií ke vztahům mezi tabulkami u relačních databází. Je to jednoduše způsob, jak namapovat vazby mezi entitami na cizí klíče v databázových tabulkách. V článku si ukážeme, jak s těmito asociacemi pracovat v ORM Doctrine 2.
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
Zatímco v relačních databázích se pro vyjádření vztahů uměle šroubují cizí klíče nebo vazební tabulky, které často nemají v reálném světě svůj přirozený protějšek, na úrovni objektů je práce s asociacemi mezi entitami naprosto přirozená a logická:
$category = $em->find('Category', 123); $article = new Article; // první možnost - přiřadíme kategorii do článku $article->setCategory($category); // druhá možnost - přiřadíme článek do kategorie $category->addArticle($article); // získáme seznam všech článků z kategorie $articles = $category->getArticles();
Stejně jako je tomu u vztahů mezi relačními tabulkami, i u asociací mezi entitami se rozlišují typy 1:1, 1:N a M:N, ačkoliv v následné práci s entitami se rozdíly mezi nimi zásadně stírají. Nejprve je ale potřeba si vyjasnit pojem vlastnící a inverzní strany.
Vlastnící a inverzí strana
Pokud je mezi dvěma entitami vztah, ať už kteréhokoliv typu, vždy je jedna z nich vlastnící (owning side) a druhá inverzní (inverse side). Pro další práci s asociacemi je velice důležité vlastnící a inverzní stranu vztahu navzájem rozlišovat, a to z několika důvodů:
- Na každé straně se asociace definuje jinými anotacemi. Je třeba vědět, na kterou stranu jaké anotace patří.
- Navíc na vlastnící straně je definice asociace vždy povinná, zatímco na inverzní straně volitelná. Podle toho se pak rozlišují jednosměrné (unidirectional) a obousměrné (bidirectional) asociace.
- Rozdíl se pak projeví zejména v samotném používání entit. Do databáze se po zavolání
$em->flush()
promítnou jen změny asociací provedené na vlastnící straně, zatímco změny provedené na inverzní straně se ignorují!
Zpočátku budete mít možná trochu problém určit, která strana je která. Pomůže vám jednoduchá pomůcka, opět z oblasti relačních databází. U vazby 1:1 respektive 1:N je vlastnící vždy ta strana, u které je v databázi vazební sloupec s cizím klíčem. Předpokládejme pro příklad následující strukturu:
CREATE TABLE category ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY ); CREATE TABLE article ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, category_id INT NOT NULL REFERENCES category (id) );
Entita Article
je zde vždy vlastnící stranou, protože u její tabulky je cizí klíč category_id
, zatímco Category
je stranou inverzní.
U vazeb M:N s vazební tabulkou je to pak čistě na vaší volbě, jenom si ale musíte vždy jednu ze stran určit jako vlastnící a druhou jako inverzní. Pro toto rozhodování nejste prakticky ničím omezeni. Je ale vhodné zvážit, na které straně budete s asociací pracovat častěji a tuto stranu prohlásit za vlastnící.
Jednosměrné asociace
Jako příklad si teď vezměme 1:N asociaci – přiřazení článků do kategorií, pro které máme už výše strukturu databáze. Nejprve se podívejme na samotné vymezení daných sloupců pomocí příslušných anotací.
/** @Entity */ class Article { /** * @id @column(type="integer") * @generatedValue */ private $id; /** * @manyToOne(targetEntity="Category") * @joinColumn(name="category_id", referencedColumnName="id") */ private $category; } /** @Entity */ class Category { /** * @id @column(type="integer") * @generatedValue */ private $id; }
Příklad ukazuje definici jednosměrné 1:N vazby, kde se článek odkazuje na příslušnou kategorii. Definice vazby pomocí anotace @manyToOne
je na straně článku, protože článek je vlastnící stranou.
Názvy vazebního sloupce a sloupce s primárním klíčem kategorie uvedené v anotaci @joinColumn
jsou implicitní a pokud vám takto vyhovují, můžete celou anotaci @joinColumn
vynechat:
class Article { // ... /** * @manyToOne(targetEntity="Category") */ private $category; }
Vše uvedené platí i pro 1:1 vazbu, pouze se místo @manyToOne
použije anotace @oneToOne
.
Pro vazbu M:N je věc poněkud složitější, protože mezi dvěma základními entitami v takovém případě existuje ještě vazební tabulka:
CREATE TABLE article ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY ); CREATE TABLE tag ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY ); CREATE TABLE article_tag ( article_id INT NOT NULL REFERENCES article (id), tag_id INT NOT NULL REFERENCES tag (id), PRIMARY KEY (article_id, tag_id) );
Pokud dodržíte takovouto pojmenovávací konvenci pro názvy tabulek a atributů, kterou vyznává i Doctrine 2, je definice M:N asociace jednoduchá:
class Article { // ... /** * @manyToMany(targetEntity="Tag") */ private $tags; }
Při vlastním pojmenování spojovací tabulky či jednotlivých sloupců to musíte systému sdělit, a to s pomocí anotace joinTable
:
class Article { //... /** * @manyToMany(targetEntity="Tag") * @joinTable( * name="article_tag", * joinColumns={ * @joinColumn(name="article_id", referencedColumnName="id") * }, * inverseJoinColumns={ * @joinColumn(name="tag_id", referencedColumnName="id") * } * ) */ private $tags; }
Obousměrné asociace
Ve výše uvedených příkladech můžeme na vlastnící straně, tedy u konkrétního článku zjistit, do jaké kategorie a jakých štítků je článek zařazený. Prostě se jednoduše podíváme do jeho členských proměnných $category
a $tags
.
Velice často se nám to ale hodí i na inverzní straně – pro konkrétní kategorii či štítek si chceme z nějaké její proměnné $articles
vypsat seznam všech článků, které do nich patří. V takovém případě musíme definovat asociaci jako obousměrnou.
V praxi to znamená, že musíme do kategorie respektive tagu definovat správně anotovaný inverzní sloupec $articles
. Navíc musíme pomocí parametru inversedBy
přidat informaci o inverzním sloupci i do entity článku. Celá definice oboustranné 1:N (a analogicky tomu i oboustranné 1:1) asociace mezi článkem a kategorií by pak vypadala takto:
/** @Entity */ class Article { // ... /** * @manyToOne(targetEntity="Category", inversedBy="articles") * @joinColumn(name="category_id", referencedColumnName="id") */ private $category; } /** @Entity */ class Category { // ... /** * @oneToMany(targetEntity="Article", mappedBy="category") */ private $articles; }
Obdobně pro oboustrannou M:N vazbu mezi články a tagy by úplný zápis vypadal nějak takto:
class Article { //... /** * @manyToMany(targetEntity="Tag", inversedBy="articles") * @joinTable( * name="article_tag", * joinColumns={ * @joinColumn(name="article_id", referencedColumnName="id") * }, * inverseJoinColumns={ * @joinColumn(name="tag_id", referencedColumnName="id") * } * ) */ private $tags; } /** @Entity */ class Tag { // ... /** * @manyToMany(targetEntity="Article", mappedBy="tags") */ private $articles; }
Obousměrné asociace jsou zpravidla výkonově náročnější, než asociace jednosměrné. Kde to tedy jde, omezte se na jednosměrnou definici na vlastnící straně. Oboustranné asociace pak vytvářejte jen v opravdu odůvodněných případech.
Všechny typy a kombinace asociací
Výše jsou uvedeny jen základní nejčastější případy asociací. Různých variant a nuancí při definici vztahů mezi entitami je celá řada. Stojí za to si v dokumentaci Doctrine 2 pozorně pročíst popis všech typů asociací.
Zároveň určitě doporučuji si tento odkaz uložit do záložek nebo někam k ruce. Bude vám sloužit jako rychlý referenční přehled, do kterého budete ještě dlouho potřebovat nahlížet pokaždé, kdy budete nějakou asociaci definovat.
Pozdní inicializace
Chceme-li vložit článek do některé kategorie, jednoduše v instanci článku jakožto vlastnící strany do proměnné $category
přiřadíme instanci dané kategorie (samozřejmě skrze setter, který teď pro přehlednost v našich ukázkách chybí):
$article->setCategory($category); $em->flush();
Zavoláním $em->flush()
se pak daná vazba uloží do databáze. Při jakémkoliv dalším načtení článku přes $em->find()
se společně s článkem načte i instance jeho kategorie. Nemusíme ji tedy už nijak ručně donačítat, máme ji hned automaticky v proměnné $category
:
$article = $em->find('Article', 123); $category = $article->getCategory();
Přesněji řečeno Doctrine 2 zde důsledně využívá pozdní inicializaci. Takže kategorii si automaticky vyžádá z databáze teprve v okamžiku, kdy ji skutečně potřebujete. Do té doby je v proměnné uložena pouze takzvaná proxy entita, která zajišťuje právě ono pozdní načtení. Vše je ale zcela transparentní, takže se o to vůbec nemusíte starat.
Úplně stejně se chovají i asociační kolekce, v našem případě například proměnné $articles
v kategorii:
$category = $em->find('Category', 123); $articles = $category->getArticles();
I zde se příslušné články do kolekce $articles
automaticky načítají z databáze teprve ve chvíli, kdy je opravdu nutně potřebujete.
Doctrine 2 je tedy z tohoto pohledu poměrně efektivní, snaží se do databáze sahat jen v nezbytných případech.
Příští pokračování
Příště budeme v tématu asociací pokračovat. 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. Ukážeme si správné postupy při definicích getterů, setterů a dalších obslužných funkcí pro manipulaci s asociacemi.
Načítá Doctrine entity při přístupu jednotlivě, resp. jedinou entitu z vazby při přístupu? To by pak mohlo skončit bombardováním databáze dotazy, kdy při klasickém přístupu bychom měli jeden nebo hrstku dotazů…zkoušel jsem hodit ‚, fetch=“EAGER“‚, ale nepřišlo mi, že by to mělo vliv (zkoumal jsem přes logger, je možné že špatně).
I když Doctrine samo asi nemůže umět určit co vše nahrát, tak… leda nějak manuálně (možná při eventu)
Jinak potěší: http://www.doctrine-project.org/blog/dc2-experimental-associations-id-fields
Doctrine 2 opravdu standardně načítá navazující entity postupně. Navazující entity totiž často vůbec nepotřebuju a je tak zbytečné je načítat hned, stačí až lazy loading. Ušetří se tak hromada zbytečných dotazů do databáze.
Takové výchozí chování je naprosto v pořádku, je to jedna z killer vlastností Doctrine 2, která se standardně snaží používat lazy loading všude, kde to jde.
Pokud ale k navazujícím asociovaným entitám přistupuji, nastává pak zmiňovaná situace, tzv. N+1 problém, kdy Doctrine 2 pro každou z nich posílá další samostatný dodatečný dotaz.
K N+1 problému může dokonce dojít i v některých případech, kdy navazující entity vůbec nepotřebuji, ale kdy není lazy loading z různých důvodů možný:
fetch=EAGER
. To neznamená „načti vše v jednom dotazu“, ale právě jen a pouze „nedělej pozdní načítání“.Pro všechny tyto případy ale nabízí Doctrine 2 několik možností, jak to explicitně ošetřit a předejít tak nechtěnému N+1 problému:
SELECT p, a FROM Person JOIN p.auth a
. Což je asi nejlepší řešení, protože to zachová konzistenci a úplnost všech entit načtených v aplikaci, navíc takovýhle dotaz je u N:1 a 1:1 asociací výpočetně relativně nenáročný.$query->setHint(Query::HINT_FORCE_PARTIAL_LOAD, TRUE)
. To je ale u složitějších aplikací docela o ústa, protože si tam člověk zanáší neúplnosti, nekonzistence a může se později na jiném místě aplikace divit, proč mu ta asociace nefunguje. Takže tomu se obecně doporučuji radši vyhnout, výjimečně se to ale může hodit.$query->getArrayResult()
. V takovém případě opět nedochází k načítání navazujících asociací a nedochází tak ke zmiňovanému N+1 problému.NotORM umí načítání ze závislých tabulek řešit elegantně. U Doctrine by to mohl být trošku problém, protože používá jednu instanci entity na instanci aplikace, ale nějaký 20/80 přístup by zde mohl fungovat.
Na druhou stranu u většiny aplikací není úzkým místem časté instanciování objektů, ale přistupování do databáze. A v tomhle ohledu nabízí Doctrine 2 možnosti cachování. Takže ani tady bych se pádu výkonnosti nebál, myšleno alespoň v porovnání s ostatními ORM. Samozřejmě pokud to budu srovnávat pouze s ručním posíláním optimalizovaných SQL dotazů, tak budeme na konci možná někde jinde, ale zase na úkor přehlednosti, čistoty a spravovatelnosti aplikace. Takže to je každého vlastní rozhodnutí…
Děkuji za obsáhlou odpoveď … DQL na vhodném místě zní dobře
Jenom doplním příklad k mému dotazu, např. při:
foreach($item->attributes as $attribute){ ... }
je jasné, že budeme potřebovat vše a je zbytečné posílat 10 dotazů navíc. Zdá se mi, že to není až tak výjimečná situace…