Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Identity map a clearDoctrine

Identity mapa a clearDoctrine()#

Nejzávažnější landmina celého systému. Kdo ji nezná, narazí do týdne — a stráví den hledáním chyby v místě, kde není.

Mechanismus#

Api repository vyprazdňuje Doctrine identity mapu na začátku každého čtení:

// app/Core/Traits/Api/Models/Repositories/GetAllTrait.php
public function getAll(…): array
{
    $this->clearDoctrine();   // ← ÚPLNĚ PRVNÍ řádek

}

protected function clearDoctrine(): void
{
    $this->db->getUnitOfWork()->clear();       // celá identity mapa pryč
    $cache = $this->db->getConfiguration()->getResultCache();
    if ($cache) { $cache->clear(); }
}

getAll() je jediné hrdlo celého čtecího stacku. get() i getBy() na něj jen delegují (GetTrait a GetByTrait volají getAll() s limitem 1), takže find(), findBy() i findAll() mapu vyprázdní úplně stejně.

Platí to i pro cizí managery a pro Admin i Front

Admin i Front jdou přes most do téhož Api stacku. Manager, který si zavolá jiný manager kvůli číselníku nebo nastavení, spustí clearDoctrine() úplně stejně. „Jen si přečtu jedno id“ není nevinná operace.

Objekty žijí dál, jen přestanou být spravované

Právě proto to není vidět. Kód po clearDoctrine() čte i zapisuje gettery bez chyby a rozbije se až nejbližší flush() — často o několik vrstev jinde.

Tři projevy#

1. Odpojená reference#

Doctrine\ORM\ORMInvalidArgumentException:
A new entity was found through the relationship 'X#owner' that was not
configured to cascade persist operations

Hook zavolal cizí findAll() a odpojil referenci. Při flush() vypadá jako nová entita bez kaskády.

Náprava: znovupřipojit přes $em->getReference() až za všemi cizími voláními, nebo cizí volání nahradit raw DBAL.

2. Líná kolekce inicializovaná až za clearDoctrine()#

Zákeřnější, protože nestačí „nevolat find() v hoocích“:

  1. Služba natáhne entitu s includes — kolekce, která v nich není, zůstane líná.
  2. Cestou se zavolá cizí manager → clearDoctrine() → vlastník je odpojený.
  3. Teprve teď se iteruje líná kolekce → děti jsou spravované, ale jejich zpětná reference ukazuje na odpojeného vlastníka.
  4. flush() → stejná hláška.

includes musí pokrývat každou kolekci, které se dotkne cokoli spuštěné z ní

Ne jen to, co čte orchestrační služba sama. Kde to nejde, dohydratujte kolekce explicitním „touch“, dokud je vlastník ještě spravovaný.

3. Duplicitní instance#

While adding an entity of class …\OrderShipper with an ID hash of "1" to the
identity map, another object of class …\OrderShipper was already present for
the same ID.

Graf se načetl, mapa se vyprázdnila, a tatáž entita se do ní dostala podruhé jinou cestou.

Postup: píšu hook, který potřebuje cizí data#

  1. Načti všechno PŘED tím, než začneš skládat graf. Číselníky, nastavení, cizí klíče — všechno na začátku, ne uprostřed.
  2. Uvnitř hooku nevolej find() ani findAll() — ani na vlastním manageru.
  3. Použij bezpečnou náhradu:
Potřebuji Náhrada
jeden řádek číselníku raw DBAL dotaz přes $em->getConnection()
nastavení SettingContext (in-memory, nesahá na DB)
cizí klíč do asociace $em->getReference()
ověřit existenci SELECT COUNT přes DBAL
  1. Když se cizímu volání nedá vyhnout, udělej ho první a teprve potom načti entitu, kterou budeš ukládat.
  2. Ověř uložením, ne čtením. Chyba se projeví až při flush().

Raw DBAL v hooku není obcházka vrstev, je to nutnost

Proto to dělá i InvoiceNumberGenerator: čte řadu i MAX(order) čistě přes getConnection(), aby nesáhl na identity mapu uprostřed rozestavěného grafu faktury.

Postup: dostal jsem „A new entity was found through the relationship“#

Od nejlevnějšího:

  1. Nedívej se na místo, kde spadl flush(). Výjimka vzniká až při ukládání; viník je čtení, které proběhlo předtím — někdy o několik vrstev výš.
  2. Projdi cestu od načtení entity ke flush() a hledej každé volání find() / findBy() / findAll(), včetně cizích managerů.
  3. Podívej se na includes. Chybí tam kolekce, kterou někdo iteruje až později?
  4. Zkontroluj lifecycle hooky manageru — a taky hooky manažerů, které z nich voláš.
  5. Změř to, nehádej: Doctrine umí událost onClear. Zaregistruj posluchače, který vypíše zásobník volání, a uvidíš přesně, které volání mapu vyprázdnilo.
  6. Teprve pak sahej na getReference() jako na náplast — nejdřív musíš vědět, co mapu vyčistilo.

Hláška ukazuje na flush(), viník je jinde

Tohle je hlavní důvod, proč se na to hledá tak dlouho. Zásobník volání ukazuje ukládání, ne čtení — a čtení může být v úplně jiném modulu.

Postup: přidávám čtení do už fungujícího procesu#

Typicky „potřebuju sem doplnit ještě jméno uživatele“:

  1. Zjisti, jestli jsi uvnitř rozestavěného grafu. Jsi-li v beforeSave, afterSave, v generátoru nebo v orchestrační službě mezi načtením a uložením — ano.
  2. Když ano, vezmi data raw DBAL dotazem, nebo je natáhni před začátek procesu a předej si je parametrem.
  3. Když ne (presenter, komponenta, čtecí cesta), je běžné findAll() v pořádku.
  4. Nikdy nespoléhej na to, že „to je jen jedno id“ — velikost dotazu s tím nemá nic společného, mapu vyprázdní i SELECT na jeden řádek.

Pravidlo#

Nejdřív všechna čtení, pak teprve stavba grafu

Jediná věta, která tuhle landminu shrnuje. Potřebujete-li číselník, nastavení nebo cizí data, načtěte je před tím, než začnete skládat objektový graf k uložení. Ne uprostřed.

Kam sáhnout#

Chci Kde
samotné vyprázdnění app/Core/Traits/Api/Models/Repositories/GetAllTrait.phpclearDoctrine()
proč to get() a getBy() dělá taky …/Repositories/{Get,GetBy}Trait.php — delegují na getAll()
hotový příklad obejití přes DBAL app/UI/Api/Invoicer/Models/Generators/InvoiceNumberGenerator.php
lifecycle hooky, kterých se to týká app/Core/Traits/Api/Models/Managers/SaveTrait.php

Navazující kapitoly: Životní cyklus manageru · Hydrátory · Volání API · Pětivrstvý model