Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Stromy (nested set)

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)

  1. php bin/regions-rebuild-hrefs.php --region=<id> — suchý běh, vypíše změny a nic nezapíše.
  2. Zkratku státu nastav --href=<region>:<jazyk>=<href> (jen u státu nebo kořene).
  3. Spusť znovu s --apply; každý --region běží ve vlastní transakci a po commitu se zvednou cache tagy regionů.
  4. 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).

  1. V presenteru use TreePositionHostTrait; a implementuj treePositionConfig() (manager, popisek, editovaný uzel, scope). Metoda musí být čistá, volá ji i AJAX signál siblings!.
  2. Ve formuláři zavolej $this->addTreePositionFields($form) vedle skrytého parentId.
  3. V uložení existujícího uzlu vezmi $meta z $this->buildTreeMoveMeta($values, $id, $puvodniRodic, $novyRodic). null znamená neplatnou volbu (flash je už nastavený), vrať false; [] znamená, že se nic nepřesouvá.
  4. 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ři false nastav vlastní obecnou hlášku.
  5. 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#

  1. Entita implementuje TreeNodeInterface (getParentId(), getLft(), getRgt(), getLevel()). Víc stromů v tabulce: ScopedTreeNodeInterface s getTreeScope().
  2. Repository dostane Repositories\TreeTrait.
  3. Mapper dostane Mappers\TreeTrait a implements TreeMapperInterface.
  4. Service dostane Services\TreeTrait a implements TreeServiceInterface.
  5. Manager dostane Managers\TreeTrait. Mění-li formulář rodiče, zavolej applyMetaSubtreeMove() v afterSave().
  6. 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

Nastavení cookies