Model pro práci se stránkami

Vložení stránky

Minule jsme si vyrobili tabulku a vložili do ní už jeden záznam. Také máme už fasádu pro práci s tabulkou Page. Metoda traverzování nás nutí mít jednu stránku jako hlavní, které bude obsahovat všechny další stránky, které jí budou podřízeny.

Jakmile přidávám novou stránku, musím přepočítat indexy lft a rgt u všech stránek, které tím budou ovlivněny. Pro jednoduchost budeme řadit novou stránku vždy jako poslední ve skupině. Každá nová stránka musí mít nějakého rodiče, takže jejímu rodiči a vlastně všem v řadě výš až k rootu, musím zvýšit jejich rgt o 2. Navíc ale také musím všem stránkám, které následují za novou stránkou zvýšit oba indexy, lft i rgt o 2. Zařídí to funkce prepareBeforeInsert. Vlož ji tedy do souboru PageFacade.php:

    /**
     * Method prepareBeforeInsert
     *
     * @param \PageModule\Model\Entity\Page $parent
     *
     * @throws \Kdyby\Doctrine\DBALException
     * @throws \Doctrine\DBAL\DBALException
     */
    private function prepareBeforeInsert(Page $parent)
    {
        $sql = /** @lang text */
            'UPDATE `page`
	        SET
	        lft = lft + IF(lft > ' . $parent->getRgt() . ', 2, 0),
	        rgt = rgt + IF(rgt >= ' . $parent->getRgt() . ', 2, 0)
	         ';

        $conn = $this->em->getConnection();
        $conn->executeQuery($sql);
    }

Metoda přijímá jako argument entitu Page, která by měla být rodičem nové stránky. Poté zjistí všechny stránky, které následují za rodičovskou stránkou (a mají tedy lft větší než rgt této rodičovské stránky) a přičte k jejich lft i rgt číslo 2. Dvojku proto, že nově vznikající stránka dostane první volné lft a o jedno vyšší rgt, čili bude o 2 čísla delší index.

Metodu voláme z metody insert, která se postará ještě o přečíslování indexu rgt u rodičovské stránky, a pak už může vložit přidávanou stránku:

    /**
     * Method insert
     *
     * @param Page $item
     *
     * @throws \Kdyby\Doctrine\DBALException
     * @throws \Exception
     */
    public function insert(Page $item)
    {
        $pageParent = $this->find($item->getParent());

        if ($pageParent === null) {
            $pageParent = $this->find(1);
        }

        $this->prepareBeforeInsert($pageParent);

        $item->setLevel($pageParent->getLevel() + 1);
        $item->setLft($pageParent->getRgt());
        $item->setRgt($pageParent->getRgt() + 1);
        $item->setCreateDate(new DateTime());
        $item->setUpdateDate(new DateTime());

        $this->em->persist($item);
        try {
            $this->em->flush($item);
        } catch (\Exception $exc) {
            throw new \Exception;
        }
        $this->em->flush();
    }

Pro zjednodušení budeme každou stránku přidávat jako poslední prvek ve skupině podstránek daného rodiče. Jinak bychom museli přečíslovávat ještě případné sourozence, což by byla práce navíc.

Mazání stránky

Další metoda, která se týká přečíslování indexů, se používá při mazání. Redakční sytém jsem navrhl tak, aby se daly mazat jen takové stránky, které nemají žádného potomka. Takové stránky mají vždy rgt o jedna větší než lft. Stačí tedy najít všechny stránky, které mají větší lft než mazaná stránka a zmenšit jejich lft a rgt o 2. Následně můžu samotnou stránku smazat.

Abychom ale mohli bez obav mazat, musíme mít jistotu, že mazaná stránka nemá žádné potomky. Mohli bychom testovat, zda je rgt o jedna větší než lft, ale já si na to připravil malou metodu:

    /**
     * Method haveChild
     *
     * @param \PageModule\Model\Entity\Page $page
     *
     * @return int
     */
    private function haveChild(Page $page)
    {
        return $page->getRgt() - $page->getLft() - 1;
    }

Pokud mi vrátí 0, je to v pohodě a můžeme mazat. Jinak mazání nebude povoleno. Opět tedy potřebujeme metodu pro přepočet indexů. Tentokráte budeme odečítat:

    /**
     * Method prepareBeforeDelete
     *
     * @param \PageModule\Model\Entity\Page $page
     *
     * @throws \Doctrine\DBAL\DBALException
     */
    private function prepareBeforeDelete(Page $page)
    {
        $sql = /** @lang text */
            'UPDATE `page`
	        SET 
	        lft = lft - IF(lft > ' . $page->getRgt() . ', 2, 0),
	        rgt = rgt - IF(rgt > ' . $page->getRgt() . ', 2, 0)
	         ';
        $conn = $this->em->getConnection();
        $conn->executeQuery($sql);
    }

Samotná metoda pro mazání si nejprve ověří, že stránka nemá potomky, pak přepočítá indexy a stránku smaže:

    /**
     * @param Page $page
     * @throws DBALException
     * @throws \Exception
     */
    public function delete(Page $page)
    {
        if (!$this->haveChild($page)) {
            try {
                $this->prepareBeforeDelete($page);
            } catch (DBALException $e) {
                throw $e;
            }
            $this->em->remove($page);
            try {
                $this->em->flush();
            } catch (\Exception $e) {
                throw $e;
            }
        }
    }

Přesunutí stromu stránek

Přesouvání je spolu se změnou rodiče jedna z nejnáročnějších operací. Tady musíme umět přečíslovat nejen samotnou stránku, ale i jejich případné potomky. Na začátku vím, jakou stránku přesouvám a jakým směrem. Pokud má stránka nějaké potomky, pak přesouvám celou skupinu stránek. Podle směru přesouvání zjistím předchozí, nebo následující stránku, případně zase celou skupinu stránek. A pak musím přečíslovat obě skupiny stránek. Pro přesun nahoru, či dolů, slouží metoda move:

    /**
     * @param Page $item
     * @param $direction
     * @throws \Doctrine\DBAL\DBALException
     */
    public function move(Page $item, $direction)
    {
        if ($direction === 'dn') {
            /** @var Page $neighborPage */
            $neighborPage = $this->repository->findOneBy(
                ['lft' => $item->getRgt() + 1],
                ['lft' => 'ASC']
            );
        } else {
            /** @var Page $neighborPage */
            $neighborPage = $this->repository->findOneBy(
                ['rgt' => $item->getLft() - 1],
                ['lft' => 'ASC']
            );
        }

        switch ($direction === 'dn') {
            case true:
                $lft = $item->getLft();
                $rgt = $item->getRgt();
                $differentPage = $item->getRgt() - $item->getLft() + 1;
                $differentNeighbor = $neighborPage->getRgt() - $neighborPage->getLft() + 1;
                break;
            case false:
            default:
                $lft = $neighborPage->getLft();
                $rgt = $neighborPage->getRgt();
                $differentPage = $neighborPage->getRgt() - $neighborPage->getLft() + 1;
                $differentNeighbor = $item->getRgt() - $item->getLft() + 1;
                break;
        }

        $min_lft = min($item->getLft(), $neighborPage->getLft());
        $max_rgt = max($item->getRgt(), $neighborPage->getRgt());
        $sql = "UPDATE page
	        SET lft = lft + IF(@subtree := lft >= $lft 
	        AND rgt <= $rgt, $differentNeighbor, IF(lft >= $min_lft, -$differentPage, 0)),
	            rgt = rgt + IF(@subtree, $differentNeighbor, IF(rgt <= $max_rgt, -$differentPage, 0))
	        WHERE rgt >= $min_lft AND lft <= $max_rgt";

        $conn = $this->em->getConnection();
        $conn->executeQuery($sql);
    }

Jde to jedním dotazem, což je fajn. Je ale trochu pokročilejší, takže vysvětlím. Metoda přijímá jako argumenty entitu přesouvané stránky a směr přesunu. Přesouvaná stránka může mít nějaké potomky a pak přesouvám celou skupinu. Ať už je sama, nebo je to celá skupina, budu ji označovat jako přesouvaná skupina. Přesouvám buď nahoru, nebo dolu a vždy daným směrem musí být nějaká sousední stránka, která také může obsahovat potomky. Budu ji označovat jako sousední skupina.

Nejdříve zjistím první sousední stránku. To je ta $neighborPage. Pokud přesun probíhá směrem dolů, je sousední stránka ta, jejíž lft je o jedna vyšší, než rgt přesouvané stránky. Pokud přesouváme vzhůru, je sousední stránka ta, jejíž rgt je naopak o jedna menší, než lgt přesouvané stránky.

Potom zjistím, kolik stránek tvoří obě skupiny. To jsou ty proměnné $differentPage a $differentNeighbor. Opět rozhoduje směr přesouvání. Pak také zjistím, kolik stránek vlastně budu potřebovat přečíslovat. To zjistím tak, že najdu nejmenší lft a největší rgt z obou skupin. Tyto hodnoty pak použijeme v podmínce SQL dotazu.

Pak naplním proměnné $lft a $rgt. V případě, že se přesouvá nahoru, ještě přehodím hodnoty tak, jako bych vlastně přesouval sousední skupinu dolů.

V tom SQL dotazu nejprve naplním proměnnou @subtree. To bude vždy skupina, která se přesouvá dolů. Stránkám v @subtree pak k jejich lft přičtu hodnotu $differentNeighbor. Stránkám, které nejsou v @subtree od jejich lft odečtu hodnotu $differentPage. No a stejnou operaci provedu s indexem rgt.

Změna rodiče

Speciální případ přesunu stránek je změna rodiče stránky. Při použití traverzování okolo stromu je nutné, aby každá stránka nějakého rodiče měla. Výjimkou je kořenová stránka, která rodiče nemá a sama obsahuje celý strom. Při změně rodiče se musí pamatovat na spousty věcí a je to asi nejsložitější úkon v traverzování.

    /**
     * Method changeParent
     *
     * @param \PageModule\Model\Entity\Page $page
     * @param \PageModule\Model\Entity\Page $newParent
     *
     * @throws \Doctrine\DBAL\DBALException
     */
    public function changeParent(Page $page, Page $newParent)
    {
        $different = $page->getRgt() - $page->getLft() + 1;
        $lft = $newParent->getRgt();
        $level = $newParent->getLevel() + 1;
        if ($lft > $page->getLft()) {
            $lft -= $different;
        }

        $min_lft = min($lft, $page->getLft());
        $max_rgt = max($lft + $different - 1, $page->getRgt());
        $move = $lft - $page->getLft();
        if ($lft > $page->getLft()) {
            $different = -$different;
        }

        $sql = "UPDATE `page`
	        SET level = 
	        level + IF(@subtree := lft >= $page->getLft() AND rgt <= $page->getRgt(), ($level - $page->getLevel()), 0),
	        parent = IF(id = $page->getId(), $newParent->getId(), parent),
	        lft = lft + IF(@subtree, $move, IF(lft >= $min_lft, $different, 0)),
	        rgt = rgt + IF(@subtree, $move, IF(rgt <= $max_rgt, $different, 0))
	        WHERE rgt >= $min_lft AND lft <= $max_rgt";

        $conn = $this->em->getConnection();
        $conn->executeQuery($sql);
    }

Metoda přijímá jako entitu přesouvané stránky a entitu nového rodiče. Nejprve zjistíme rozdíl mezi rgt a lft a tím dostaneme celý přesouvaný strom. Hodnotu uložíme do proměnné $different. Do proměnné $lft si dočasně uložíme hodnotu rgt nového rodiče a také si řekneme, že přesouvaná stránka bude mít $level o jedna vyšší než její budoucí rodič.

Pak je tam podmínka, kdy porovnávám to dočasné $lft s lft přesouvané stránky. Pokud je větší, leží nový rodič pod přesouvanou stránkou a jakmile přesouvaná skupina zmizne (aby se pak objevila jinde), musím všechny stránky, co byly pod ní, posunout nahoru. To znamená zmenšit jejich lft a rgt. Proto tedy říkám, že proměnná $lft je dočasná, protože tady se může změnit, když od ní odečítám hodnotu $different:

        if ($lft > $page->getLft()) {
            $lft -= $different;
        }

Zjistím rozsah stránek, kterých se přečíslování dotkne. Budou to stránky, které sahají od nejmenšího lft po největší rgt. O kolik posunu indexy v přesouvaném stromu, to uložím do proměnné $move. Nakonec, pokud je $lft stále ještě menší než lft přesouvané stránky, bude mít hodnota $different opačné znaménko.

        if ($lft > $page->getLft()) {
            $different = -$different;
        }

SQL dotaz pak už jen do proměnné @subtree vloží rozsah přesouvaných stránek a upraví jim level a parent. Indexy lft a rgt přepočítá tak, že přičte hodnotu $move pro stránky v @subtree. U ostatních stránek použije hodnotu $different.

Jak říkám - celkem složité na vysvětlení, možná i na pochopení, ale jestli chceš, zkus si chvilku hrát s debugem a uvidíš proč a jak to funguje.

Mimochodem - je to asi můj nejsložitější dotaz v MySQL, tak očekávám oslavné poklony a projevy uznání :-)

Další metody

Samozřejmě, že do třídy můžeš přidat jakoukoli smysluplnou metodu pro práci se stránkami. Například metodu pro výběr stránek, které budou v nějakém menu:

    /**
     * @param $menuType
     * @return array
     */
    public function getForMenu($menuType)
    {
        $qb = $this->em->createQueryBuilder();
        $qb->select('p')
            ->from(Page::class, 'p')
            ->orderBy('p.lft');
        $qb->andWhere('p.inMenu LIKE :m')->setParameter('m', "%" . $menuType . "%");
        $qb->andWhere('p.status = :s')->setParameter('s', 1);

        return $qb->getQuery()->getResult();
    }

Tam je podmína LIKE, takže používám QueryBuilder. Nebo jiná metoda pro výběr potenciálních nových rodičů, když přesovám stránku:

    /**
     * @param Page $page
     * @return array|\stdClass
     */
    public function getPosibleParentsTree(Page $page)
    {
        $qb = $this->em->createQueryBuilder();

        $qb->select('p.id, p.name')
            ->from(Page::class, 'p')
            ->orderBy('p.lft');
        $qb->andWhere('p.lft < :lft OR p.rgt > :rgt')
            ->setParameter('lft', $page->getLft())
            ->setParameter('rgt', $page->getRgt());

        return \Nette\Utils\Arrays::associate($qb->getQuery()->getArrayResult(), 'id=name');
    }

Mělo by to být pole stránek, které leží mimo uzel přesouvané stránky, takže nám jde o všechny stránky, které mají lft menší nebo rgt větší, než přesouvaná stránka. Opět tedy přes QueryBuilder. Na konci potřebuji převést entity na pole, aby se celý výsledek dal předhodit Nette formuláři jako SelectBox.

Další příklad je metoda, která vrátí počet stránek, které mají dané URL. To je proto, abychom mohli při ukládání článku automaticky doplnit jeho URL, které by mělo být jedinečné. Pokud už stránka s daným URL existuje, upozorníme uživatele, aby ho změnil ručně (to ale později až v prezenteru):

    /**
     * @param $url
     * @return int
     */
    public function countUrl($url)
    {
        return $this->repository->countBy(['url' => $url]);
    }

Samozřejmě, pokud tě napadne cokoli smysluplného, můžeš si to do Mapperu přidat.

Příště si začneme hrát s administrací stránek.

To, co jsme dnes udělali, si můžeš opět stáhnout: rsrs-kapitola-3.

Celý seriál o vývoji redakčního systému

Komentáře

O mně

Jmenuji se Rudolf Svátek - lektor výpočetní techniky, trochu PHP programátor a SEO konzultant na volné noze.

Adresa

Příčná 326/3
736 01 Havířov

Kontakty

Email: office@rudolfsvatek.cz
Telefon: +420 777 828 353
Skype: svatekr