Stromy (nested set)#
Jak modelová vrstva drží stromová data — kategorie, regiony, položky menu, stránky, příspěvky diskuze. Kapitola je pro toho, kdo zakládá novou stromovou entitu, píše formulář se změnou rodiče nebo hledá, proč přesun vrátil chybu. Jak strom vypadá v administraci, popisuje DataGrid.
Jak strom vzniká a mění se#
Řádek nese parent_id a meze lft/rgt/level. Meze nikdo nepíše ručně: počítá je
set-based SQL v Repositories\TreeTrait, nad kterým jsou tři tenké vrstvy (service →
mapper → repository). Mapper převede názvy vlastností z TreeConfig (lft, rgt,
parentId, level a scope) na sloupce podle metadat Doctrine. Neznámá vlastnost je chyba,
do SQL se nikdy nedostane název z požadavku.
| Operace | Metoda manageru | Co udělá |
|---|---|---|
| založení | save() sám |
po flushi zavolá insertTreeNode() uvnitř transakce save(): uzel jde za posledního sourozence, bez sourozenců hned za lft rodiče, kořen za poslední kořen; level = rodič + 1, kořen 1 |
| posun mezi sourozenci | moveUp() / moveDown() |
prohodí uzel se sousedem i s oběma podstromy; bez souseda TREE_NO_SIBLING |
| nový rodič | moveSubtree($id, $parentId) |
celý podstrom pod nového rodiče jako poslední dítě (null = kořenová úroveň) |
| nový rodič a místo | moveSubtreeTo($id, $parentId, $afterId, …, $expectation) |
$afterId: -1 = první dítě, 0 = poslední, >0 = za sourozence s tímto id |
| přeskládání dětí | reorderChildren($parentId, $orderedIds) |
jeden UPDATE uvnitř intervalu rodiče; data.changed = počet dětí, které se pohnuly |
| smazání | deleteNode($id) |
smaže celý podstrom a zacelí mezeru v mezích |
Víc stromů v jedné tabulce#
Položky menu sdílejí tabulku, ale každé menu má vlastní číslování od 1. Odliší je scope:
entita implementuje ScopedTreeNodeInterface a getTreeScope() vrátí vlastnosti, které
strom vymezují (MenuItem: ['menuId' => $this->menuId]). Manager si scope vezme sám
(treeConfigForEntity()), a to i při ukládání z administrace, kde se persistuje admin
varianta entity. Tabulka s jediným stromem (stránky, kategorie blogu) rozhraní neimplementuje
a jede s prázdným scope.
Scope jde do WHERE všech dotazů (null-safe <=>) i do jména zámku.
Scope patří na entitu, ne do přepsané metody manageru
Bez scope se přepočet mezí rozjede do cizích stromů téže tabulky. Projeví se to rozsypaným pořadím v jiném menu, ne chybou.
Změna rodiče z formuláře#
Formulář přesun nevolá sám. Pošle nového rodiče v $meta a manager ho provede v afterSave(),
tedy ve stejné transakci jako uložení parent_id:
protected function afterSave(bool $isInsert, MenuItem &$entity, ?Result $result = null, array &$meta = []): ?Result
{
if ($result === null || !$result->isSuccess()) {
return null;
}
return $this->applyMetaSubtreeMove($isInsert, $entity, $entity->getId(), $meta);
}
Klíč v $meta |
Význam |
|---|---|
TreeTrait::META_MOVE_SUBTREE_PARENT (moveSubtreeToParentId) |
nový rodič; null nebo 0 = kořen. Bez klíče se nic nepřesouvá |
TreeMovePlan::META_AFTER_ID (moveSubtreeAfterId) |
místo mezi sourozenci; bez něj jde uzel na konec |
TreeMovePlan::META_NEIGHBOURS (moveSubtreeNeighbours) |
sousedé prevId/nextId, jak je formulář vykreslil; nesedí-li, TREE_STALE |
Při založení se nepřesouvá nic: nový uzel dostane meze už v save().
Přesun jako samostatný krok po save() rozbije strom
Selže-li přesun až po úspěšném uložení, zůstane v databázi nový parent_id
s nepřepočítanými lft/rgt a strom se z administrace opravit nedá. Z afterSave()
se neúspěch vrátí jako Result a SaveTrait vrátí i parent_id.
Manager, jehož odvozená data závisí na cestě ve stromu (regiony a jejich adresy), přepíše
afterMoveSubtree($id, $result). Běží v transakci i pod zámkem přesunu, takže jeho
neúspěch vrátí i přesun. Přeskládání dětí cestu nemění a hook nevolá.
Slugy regionů#
Href regionu je cesta rodičů v témž jazyce. Pravidlo počítá jen
App\UI\Api\System\Models\Helpers\RegionHrefBuilder (bez I/O), zapisuje
RegionManager::rebuildSubtreeHrefs():
| Uzel | Href |
|---|---|
stát (type = country) a kořen |
ruční, prázdný = slug názvu; potomci ho dědí |
| okres | href(kraj)-okres-slug(název bez „Okres “) → cr-stredocesky-kraj-okres-nymburk |
| obec, kraj, uzel bez typu | href(rodič)-slug(název) → …-okres-nymburk-nymburk (opakování se nevynechává) |
| kolize v jazyce | přípona -{official_code}, jinak -{region_id}; holý href si ponechá uzel nejblíž kořeni, shoda na stejné úrovni = přípona všem |
Po uložení regionu se přegeneruje podstrom (změna názvu, ručního hrefu státu, nový uzel), při
přesunu to udělá afterMoveSubtree(). Obojí běží v téže transakci, takže neúspěch vrátí
i uložení. Výsledek nezávisí na pořadí: přegenerování jedné obce dá totéž jako celého státu.
Postup: hromadně přegeneruji slugy (import, změna zkratky státu)
php bin/regions-rebuild-hrefs.php --region=<id>— suchý běh, vypíše změny a nic nezapíše.- Zkratku státu nastav
--href=<region>:<jazyk>=<href>(jen u státu nebo kořene). - Spusť znovu s
--apply; každý--regionběží ve vlastní transakci a po commitu se zvednou cache tagy regionů. - Druhý běh nesmí nic změnit.
Staré adresy se nepřesměrují
Změna hrefu mění URL filtrů a výpisů podle regionu. Indexované a sdílené odkazy pak vedou na neznámý slug. Na ostrém webu hrefy hromadně neměň bez přesměrování.
cms_system_regions nejsou platební regiony
Skript i pravidlo pracují jen s geografickým stromem; cms_system_payment_regions nemění.
Postup: pozice uzlu v admin formuláři#
Volbu „kam pod rodiče“ a sestavení $meta nepiš ručně. Sdílí ji komponenta TreePosition
s modálem „Přesunout do…“ v gridu a do presenteru ji přinese TreePositionHostTrait
(dnes ji má 10 presenterů: stránky, menu, regiony, kategorie Blogu, Bazaaru, Comcatu, Company,
Discussion a e-shopu a příspěvky diskuze; spočítáno grep -l TreePositionHostTrait app/UI/Admin/*/Presenters/*.php).
- V presenteru
use TreePositionHostTrait;a implementujtreePositionConfig()(manager, popisek, editovaný uzel, scope). Metoda musí být čistá, volá ji i AJAX signálsiblings!. - Ve formuláři zavolej
$this->addTreePositionFields($form)vedle skrytéhoparentId. - V uložení existujícího uzlu vezmi
$metaz$this->buildTreeMoveMeta($values, $id, $puvodniRodic, $novyRodic).nullznamená neplatnou volbu (flash je už nastavený), vraťfalse;[]znamená, že se nic nepřesouvá. - Při neúspěšném
save()zavolej$this->flashTreeMoveFailure($result, $meta). Vrátítrue, když vypsal hlášku stromového důvodu (TREE_STALE,TREE_LOCKED, neplatný cíl, poškozený uzel); přifalsenastav vlastní obecnou hlášku. - Do šablony dej
{control treePosition}pod picker rodiče. U nového uzlu se nevykreslí.
Bez volby („ponechat současné místo“) se uzel při změně rodiče zařadí jako poslední dítě.
Souběh: zámek stromu a zamykací čtení#
Každá zápisová operace běží pod advisory zámkem stromu (GET_LOCK, jméno z tabulky a scope)
a meze čte s FOR UPDATE. Potřeba je obojí:
| Pojistka | Proti čemu |
|---|---|
| zámek stromu | druhá operace nad týmž stromem počká, po 10 s dostane TREE_LOCKED místo deadlocku |
FOR UPDATE |
operace vnořená do starší transakce (save() → insertTreeNode()) by jinak počítala ze snímku transakce, tedy ze starých mezí |
Samostatnou operaci manager obalí zámkem i transakcí a zámek pustí až po commitu.
Běží-li už transakce (přesun z afterSave()), vlastní neotevře a výsledek jen vrátí:
commit i rollback udělá volající a uživatel dostane konkrétní důvod, ne obecné „uložení se
nezdařilo".
Zámek je re-entrantní na úrovni spojení (MariaDB drží GET_LOCK s počítadlem), takže
vnořené volání (manager → repository, smazání → zacelení mezery) jen páruje získání
a uvolnění. Jméno zámku normalizuje scope, takže ['menu_id' => 5] a ['menu_id' => '5']
z HTTP zamknou tentýž strom.
Výsledky, které stromová operace vrací#
| Kód | Kdy |
|---|---|
TREE_LOCKED |
zámek stromu drží jiná operace déle než 10 s; nic se nezačalo |
TREE_STALE |
klient viděl jiný stav: nesedí starý rodič, sousedé nebo množina dětí; nic se nezapsalo |
TREE_NODE_CORRUPTED |
uzel má rgt <= lft (typicky 0/0, meze se mu nikdy nespočítaly) nebo děti nevyplňují interval rodiče souvisle |
TREE_NO_SIBLING |
moveUp()/moveDown() na okraji |
INVALID_ARGUMENT |
afterId nesouhlasí s prevId v očekávání přesunu |
Poškozený uzel se neopravuje posunem
Operace nad uzlem s nulovou nebo zápornou šířkou selže schválně. Kdyby ho „opravovala", posunula by intervaly cizích uzlů a rozbila celý strom.
Přepočet stromu stránek z parent_id#
Strom stránek jde přepočítat celý: ruční úloha /cron/system/page-tree/rebuild
(PageManager::rebuildTree() → PageRepository::rebuildTree()). Pod zámkem stromu stránek
(stejný GET_LOCK jako přesun, vložení a mazání) načte page_id a parent_id všech stránek,
projde je do hloubky v PHP (nested set nejde spočítat jedním set-based UPDATE) a v jedné
transakci zapíše nové lft, rgt a level. Vrátí pages (počet), orphans (horní stránky
podstromů, jejichž rodič neexistuje) a cycles (stránky v cyklu parent_id nebo pod ním). Obojí
se zařadí jako kořeny a parent_id zůstane. Po úspěchu zvedne verzi tagu Page, takže se obnoví
menu, routování i drobečky. Opakované spuštění dá stejný výsledek. Drží-li strom jiná operace,
úloha skončí kódem 409 (TREE_LOCKED) a nic nezapíše.
Přepočet zahodí ruční pořadí sourozenců
Sourozence řadí podle page_id vzestupně. Stránky přeskládané stromovými operacemi (posun mezi
sourozenci, přetažení) se po přepočtu vrátí do pořadí, v jakém vznikly. Pouštějte ho jen po
hromadné změně parent_id mimo stromové operace, ne jako běžnou údržbu.
Cyklus v parent_id přepočet obejde, ale neopraví
Průchod si pamatuje navštívené stránky, takže se na cyklu nezacyklí. Stránky v cyklu postaví
jako kořeny, vrátí je v cycles a zapíše varování do warning.log. parent_id nemění:
rodiče opravte ručně a úlohu pusťte znovu.
Postup: přidávám stromovou entitu#
- Entita implementuje
TreeNodeInterface(getParentId(),getLft(),getRgt(),getLevel()). Víc stromů v tabulce:ScopedTreeNodeInterfacesgetTreeScope(). - Repository dostane
Repositories\TreeTrait. - Mapper dostane
Mappers\TreeTraitaimplements TreeMapperInterface. - Service dostane
Services\TreeTraitaimplements TreeServiceInterface. - Manager dostane
Managers\TreeTrait. Mění-li formulář rodiče, zavolejapplyMetaSubtreeMove()vafterSave(). - Ověř založení (nový uzel má nenulové
lft/rgt), přesun pod jiného rodiče a smazání uzlu s dětmi.
Chybějící traita na jedné vrstvě shodí uložení
Stromové traity se z base tříd nedědí. Entita s TreeNodeInterface bez insertTreeNode()
na service vyhodí LogicException a save() se vrátí. Je to schválně: jinak by uzel
tiše zůstal s lft = rgt = 0 a strom by se rozbil až při prvním přesunu.
Smazání a přesun nespouštějí hooky ani běžnou invalidaci cache
deleteNode() maže přes deleteAll(), takže afterDelete() neběží (viz
Životní cyklus manageru). Obrázky
podstromu manager uvolní sám, pokud deklaruje ownedAssetIds(). Cache zneplatní tag
třídy po commitu; tag nezávislý na entitě vrať z classCacheTags(), viz
Cache.
Kam sáhnout#
| Chci | Kde |
|---|---|
| veřejné operace, transakci a zámek | app/Core/Traits/Api/Models/Managers/TreeTrait.php |
| výpočet mezí v SQL, zámek, kódy výsledků | app/Core/Traits/Api/Models/Repositories/TreeTrait.php |
| převod vlastností na sloupce | app/Core/Traits/Api/Models/Mappers/TreeTrait.php |
| smazání podstromu | app/Core/Traits/Api/Models/Services/TreeTrait.php |
| názvy vlastností a scope | app/Core/Utils/ValueObjects/TreeConfig.php |
| místo a sousedé z formuláře | app/Core/Utils/ValueObjects/TreeMovePlan.php, TreeMoveExpectation.php |
| strom se scope | app/Core/Base/Shared/System/Models/Entities/MenuItem.php, app/UI/Api/System/Models/Managers/MenuItemManager.php |
| test souběhu a re-entrance zámku | tests/Manual/System/tree_move_subtree_harness.php |
Navazující kapitoly: DataGrid · Picker · Životní cyklus manageru · Cache