Tak więc ostatnio gdy starałem się o pracę w pewnej firmie, dostałem jako zlecenie stworzenie menu o strukturze drzewa z nieograniczonymi gałęziami, liśćmi. Każdy kto programuje chwilkę w końcu natknie się na ten problem i będzie szukał rozwiązania w internecie. Tak samo było zemną, no więc założenia do projektu:
- struktura drzewa
- nieograniczona ilość gałęzi i liści
- przenoszenie gałęzi wraz z zawartością
- usuwanie gałęzi wraz z zawartością
Są to podstawowe założenia do projektu jakie są nam potrzebne, tak więc wyruszając w przygodę z google i znalezieniem jakiejś ciekawej struktury dla sql, na ircu od adi^R dostałem ciekawego linka depesz.com. Gościu opisuje jak rozwiązać ten problem z poziomu sql’a. No tak więc mamy już jeden problem z głowy (poniżej opiszę wszystko po kolei), został nam drugi, algorytm który będzie tą strukturę drzewa tworzył w php i zrobi z nam tego tablicę taką żeby móc ja ładnie użyć z smartami (tak przeszedłem na smarty z ITX, dlaczego? a tak jakoś). No i tu były schody, ponieważ twórcy smartów nie przewidzieli, lub nie chcieli przewidzieć rekurencji w ich skrypcie. Co nam pozostaje? W rekurencji includowanie tego samego pliku przy każdym kolejnym wykonaniu operacji, lub znaleźć plugin który to zrobi za nas.
Dobra po kolei, zacznijmy od kodu sql który jest na potrzebny i niezbędny do życia, więc na pewno potrzebne nam są dwie tabele oto one:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | CREATE TABLE "kategorie" ( "id" serial PRIMARY KEY, "name" text, -- ważne name nie jest unique "parent_id" integer, FOREIGN KEY ("parent_id") REFERENCES "kategorie" ("id") ON DELETE cascade ); CREATE TABLE "powiazania" ( "id" serial PRIMARY KEY, "parent_id" integer NOT NULL, "child_id" integer NOT NULL, "depth" integer NOT NULL, FOREIGN KEY ("parent_id") REFERENCES "kategorie" ("id") ON DELETE cascade, FOREIGN KEY ("child_id") REFERENCES "kategorie" ("id") ON DELETE cascade ); |
Poprawiłem tutaj trochę po fabryce, czyli po Hubercie Lubaczewskim, autorem tego skryptu. Bo tak mi pasowało zresztą gdy porównacie mój kod sql (mowa o tych 2 tabelach) zobaczycie różnice.
Następne co nam będzie potrzebne to funkcja oraz triger która dodaje za nas powiązania:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | CREATE LANGUAGE plpgsql; CREATE OR REPLACE FUNCTION tree_objects_ai() RETURNS TRIGGER AS $BODY$ DECLARE BEGIN INSERT INTO powiazania (parent_id, child_id, depth) VALUES (NEW.id, NEW.id, 0); INSERT INTO powiazania (parent_id, child_id, depth) SELECT x.parent_id, NEW.id, x.depth + 1 FROM powiazania x WHERE x.child_id = NEW.parent_id; RETURN NEW; END; $BODY$ LANGUAGE 'plpgsql'; -- Dzięki tej procedurze, nie ważne czy mamy zagnieżdżenie 2, 3, 9, 231 stopniowe, -- i tak za każdym razem wykonujemy tylko 1 insert CREATE TRIGGER tree_objects_ai AFTER INSERT ON kategorie FOR EACH ROW EXECUTE PROCEDURE tree_objects_ai(); -- Tworzymy trigera który po każdym INSERT'cie wykona procedure trg_powiazania_i, dlaczego trigger i procedura -- mają taką samą nazwę? Ponoć ułatwia analizę bazy danych... Ja tak tak to czemu nie ;] |
Nie będę tutaj się rozpisywał ocb z tymi procedurami, trigerami i powiązaniami pomiędzy tabelami, wszystko możecie sobie doczytać na stronie Huberta.
To był funkcja która tworzy nam powiązania a teraz funkcja która przerzuca gałęzie drzewa pomiędzy sobą.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | CREATE OR REPLACE FUNCTION tree_objects_au() RETURNS TRIGGER AS $BODY$ DECLARE BEGIN IF NOT OLD.parent_id IS DISTINCT FROM NEW.parent_id THEN RETURN NEW; END IF; IF OLD.parent_id IS NOT NULL THEN DELETE FROM powiazania WHERE id IN ( SELECT r2.id FROM powiazania r1 JOIN powiazania r2 ON r1.child_id = r2.child_id WHERE r1.parent_id = NEW.id AND r2.depth > r1.depth ); END IF; IF NEW.parent_id IS NOT NULL THEN INSERT INTO powiazania (parent_id, child_id, depth) SELECT r1.parent_id, r2.child_id, r1.depth + r2.depth + 1 FROM powiazania r1, powiazania r2 WHERE r1.child_id = NEW.parent_id AND r2.parent_id = NEW.id; END IF; RETURN NEW; END; $BODY$ LANGUAGE 'plpgsql'; CREATE TRIGGER tree_objects_au AFTER UPDATE ON kategorie FOR EACH ROW EXECUTE PROCEDURE tree_objects_au(); -- triger i procedura dzięki której przerzucamy bez problemu gałęzie |
Dobra mamy już cały SQL potrzebny i niezbędny do zbudowania takiego menu opartego na wielu poziomach.
A teraz trochę php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 09.07.09 * @file controlers/tpl.php **/ require_once('models/view.php'); class ShowData { private $tpl; private $obj; public function __construct($tepel) { $this->tpl = $tepel; $this->obj = new GetData; $this->parseKat(); $this->parseTree(); } private function parseKat() { $data = $this->obj->_get_kat(); $this->tpl->assign('kategorie',$data); } private function setSort(&$sort) { if($sort != 'ASC' && $sort != 'DESC') $sort = 'ASC'; } private function parseTree() { $sort_kat = $_POST['sort_kat']; $sort_list = $_POST['sort_list']; $this->setSort($sort_kat); $this->setSort($sort_list); $mast = $this->obj->_get_master_kat($sort_kat); $n = count($mast); for($i = 0; $i < $n; $i++) { $tablica['element'][$i]['name'] = $mast[$i]['name']; $tablica['element'][$i] = $this->obj->_get_tree($mast[$i]['id'], $tablica['element'][$i], $sort_list); } $this->tpl->assign('tree',$tablica); } } ?> |
Co to jest? Jest to kontroler templatki, nie chciało mi się tworzyć jakiegoś MVC to zrobiłem pseudo MVC.
Pasowało by Wam pokazać jak to tworzy te drzewo co? No to jeszcze jeden plik z modelu, odpowiedzialny za widok.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 09.07.09 * @file models/view.php **/ class GetData { public function _get_kat() { $kat = Doctrine::getTable('Kategoria')->findAll(); return $kat->toArray(); } public function _get_master_kat($sort) { $kat = Doctrine_Query::create()->query("SELECT k.name FROM Kategoria k WHERE NOT EXISTS (SELECT * FROM Powiazanie p WHERE k.id = p.child_id AND p.depth = 1) ORDER BY k.id $sort"); return $kat->toArray(); } public function _get_sub_kat($id_kat, $sort) { $kat = Doctrine_Query::create()->query("SELECT k.name FROM Kategoria k JOIN k.Powiazania p ON k.id = p.child_id WHERE p.parent_id = $id_kat AND p.depth = 1 ORDER BY k.id $sort"); return $kat->toArray(); } public function _get_tree($id, $tab, $sort) { $sub = $this->_get_sub_kat($id, $sort); $nn = count($sub); for($j = 0; $j < $nn; $j++) { $tab['element'][$j]['name'] = $sub[$j]['name']; $tab['element'][$j] = self::_get_tree($sub[$j]['id'],$tab['element'][$j], $sort); } return $tab; } } ?> |
No i tak, teraz jeszcze przydałby by się modele dla doctrine, już pokazuję:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 07.07.09 * @file models/doctrine/kategorie.php **/ class Kategoria extends Doctrine_Record { public function setTableDefinition() { $this->setTableName('kategorie'); $this->hasColumn('name','string'); $this->hasColumn('parent_id','integer'); } public function setUp() { $this->hasMany('Powiazanie as Powiazania', array ( 'local' => 'id', 'foreign' => 'parent_id', ) ); $this->hasMany('Powiazanie as Powiazanias', array ( 'local' => 'id', 'foreign' => 'child_id', ) ); $this->hasMany('Kategoria as Kategorie', array ( 'local' => 'parent_id', 'foreign' => 'id' ) ); } } ?> |
Nie ma tu nic ciekawego, trochę małe zamieszanie wprowadziłem z powiązaniami… przez co nie wiadomo z jakiej przyczyny nie chciał mi działać joinLeft w doctrine, dlatego zmuszony byłem użyć query().
Kolejny model:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 07.07.09 * @file models/doctrine/powiazania.php **/ class Powiazanie extends Doctrine_Record { public function setTableDefinition() { $this->setTableName('powiazania'); $this->hasColumn('parent_id','integer'); $this->hasColumn('child_id','integer'); $this->hasColumn('depth','integer'); } public function setUp() { $this->hasMany('Kategoria as Kategorie', array ( 'local' => 'parent_id', 'foreign' => 'id', ) ); $this->hasMany('Kategoria as Kategories', array ( 'local' => 'child_id', 'foreign' => 'id', ) ); } } ?> |
Jak będę dopisywał to do swojego „cms’a” to obiecuję poprawić powiązania
Dobra, no to mamy praktycznie wszystko co nam było potrzebne, jeszcze dorzucę dwie rzeczy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 07.07.09 * @file models/create.php **/ class Dodawanie { public function MasterKat($kat) { $query = new Kategoria; $query->name = $kat; $query->save(); } public function SubKat($kat,$parent) { $query = new Kategoria; $query->name = $kat; $query->parent_id = $parent; $query->save(); } } ?> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.3 * @author Przemysław Czekaj * @date 09.07.09 * @file models/delete.php **/ class Niszczenie { public function czyIstniejeKat($kat) { $query = Doctrine_Query::create() ->from('Kategoria') ->where('name = ?',$kat) ->orWhere('id = ?',(int)$kat); $result = $query->execute(); $result = $result->toArray(); if(is_array($result[0])) return true; else return false; } public function czyDoPotomka($id_kat,$id_pod) { $query = Doctrine_Query::create()->query("SELECT k.id, k.name, k.parent_id FROM Kategoria k JOIN k.Powiazania p ON k.id = p.child_id WHERE p.parent_id = $id_kat AND p.depth <> 0"); $result = $query->toArray(); $n = count($result); for($i = 0; $i < $n; $i++) { if($result[$i]['id'] == (int)$id_pod) throw new Exception ('Nie można przenosić głównej kategorii do sub kategorii danej kategorii'); } } //nie chce działać... public function usunKategorie($kat) { $query = Doctrine_Query::create() ->delete('Kategoria') ->where('name = ?',$kat) ->orWhere('id = ?',(int)$kat); $result = $query->execute(); return $result; } } ?> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 09.07.09 * @file models/move.php **/ class Posuwanie // xD { public function MoveKat($kat,$parent) { $query = Doctrine_Query::create() ->update('Kategoria') ->set('parent_id','?',(int)$parent) ->where('id = '.(int)$kat); $result = $query->execute(); return $result; } } ?> |
Tak więc mamy tutaj 3 modele, do tworzenia kategorii, do usuwania kategorii, oraz do przenoszenia kategorii.
Teraz pora na kontrolery, tak więc zobaczmy:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 07.07.09 * @file controlers/dodaj.php **/ require_once('controlers/validate.php'); final class HandlerDodaj { private $handle; private $ref; public function __construct($handle) { $this->handle = $handle; } public function handled_event($ref) { $this->ref = $ref; $this->ref = 'valAdd'.ucfirst($this->ref); if(method_exists('Validate',$this->ref)) { $val = new Validate; if(is_callable(array($val,$this->ref),true)) { $val->{$this->ref}(); return true; } throw new Exception ('Nie można wywołać zdarzenia'); return true; } throw new Exception ('Brak obsługi zdarzenia'); } } ?> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 07.07.09 * @file controlers/przenies.php **/ require_once('controlers/validate.php'); final class HandlerPrzenies { private $handle; private $ref; public function __construct($handle) { $this->handle = $handle; } public function handled_event($ref) { $this->ref = $ref; $this->ref = 'valPrzenies'.ucfirst($this->ref); if(method_exists('Validate',$this->ref)) { $val = new Validate; if(is_callable(array($val,$this->ref),true)) { $val->{$this->ref}(); return true; } throw new Exception ('Nie można wywołać zdarzenia'); return true; } throw new Exception ('Brak obsługi zdarzenia'); } } ?> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 07.07.09 * @file controlers/usun.php **/ require_once('controlers/validate.php'); final class HandlerUsun { private $handle; private $ref; public function __construct($handle) { $this->handle = $handle; } public function handled_event($ref) { $this->ref = $ref; $this->ref = 'valDelete'.ucfirst($this->ref); if(method_exists('Validate',$this->ref)) { $val = new Validate; if(is_callable(array($val,$this->ref),true)) { $val->{$this->ref}(); return true; } throw new Exception ('Nie można wywołać zdarzenia'); return true; } throw new Exception ('Brak obsługi zdarzenia'); return true; } } ?> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 07.07.09 * @file controlers/validate.php **/ require_once('models/delete.php'); require_once('models/create.php'); require_once('models/move.php'); final class Validate { private $check; private $add; public function __construct() { $this->check = new Niszczenie; } public function valDeleteKat() { $nazwa_kat = trim($_POST['nazwa_kategorii']); $nazwa_pod = trim($_POST['nazwa_podkategorii']); if($nazwa_kat != '') $this->doIt($nazwa_kat); else $this->doIt($nazwa_pod); } public function valPrzeniesKat() { $nazwa_kat = trim($_POST['nazwa_kategorii']); $nazwa_pod = trim($_POST['nazwa_podkategorii']); if($nazwa_kat == 0 || $nazwa_pod == 0) throw new Exception ('Musisz wybrać skąd dokąd ma być przeniesiona kategoria'); if ($nazwa_kat == $nazwa_pod) throw new Exception ('Nie ma sensu przenoszenia kategorii na ją samą'); $this->check->czyDoPotomka($nazwa_kat,$nazwa_pod); if($this->check->czyIstniejeKat($nazwa_kat) && $this->check->czyIstniejeKat($nazwa_pod)) { $przenies = new Posuwanie; $przenies->MoveKat($nazwa_kat,$nazwa_pod); return true; } throw new Exception ('Nie istnieje podana kategoria'); } private function doIt($what) { if($this->check->czyIstniejeKat($what)) { $this->check->usunKategorie($what); return true; } throw new Exception ('Nie ma podanej kategorii'); } private function doItAgain($what,$i = 1,$child = '') { $this->valKat($what); switch($i) { case 1: $this->add->MasterKat($what); break; case 2: $this->add->SubKat($what,$child); break; } } private function valKat($string) { if($string == '') throw new Exception ('Nazwa kategorii nie może być pusta'); elseif(!mb_check_encoding($string,'UTF-8')) throw new Exception ('Należy ustawić kodowanie w przeglądarce na UTF-8'); elseif(strlen($string) < 3) throw new Exception ('Nazwa kategorii nie może być krótsza niż 3 znaki'); elseif(!preg_match('/[a-zA-Z0-9ĄąĆćĘꣳŃńÓóŚśŹźŻż]/',$string)) throw new Exception ('Nazwa kategorii może składać się wyłącznie z liter i cyfr'); else return true; } public function valAddKat() { $nazwa_kat = trim($_POST['nazwa_kategorii']); $nazwa_pod = trim($_POST['nazwa_podkategorii']); $this->add = new Dodawanie; if($nazwa_pod != 0) { $this->doItAgain($nazwa_kat,2,$nazwa_pod); } else { $this->doItAgain($nazwa_kat); } } } ?> |
Wszystkie powyższe kontrolery zostały oparte o dyspozytora:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | <?php /** * @li gnu/agpl v3 or leter * @code utf8 * @version 0.1 * @author Przemysław Czekaj * @date 07.07.09 * @file controlers/dispatcher.php **/ require_once('controlers/dodaj.php'); require_once('controlers/usun.php'); require_once('controlers/przenies.php'); require_once('controlers/tpl.php'); final class Dispatcher { private $handle; private $tpl; public function __construct($tepel, $link) { $this->tpl = $tepel; $this->handle = $link; $this->handle_the_event(); $this->display(); } public function handle_the_event() { try // posprawdzajamy co nam tam chce uzytkownik powysyłać { $name = "Handler".ucfirst($this->handle); if ($this->handle != '') { if (class_exists("$name")) { $handlerObj = new $name($tpl->handle); $handlerObj->handled_event($_GET['co']); return true; } throw new Exception ('Nie można obsłużyć'); } } catch(Exception $e) // połapiemy błędy { $this->tpl->assign('typ_msg','error'); $this->tpl->assign('wiadomosc',$e->getMessage()); } } private function display() // poparsujmy troche templatke { $disTpl = new ShowData($this->tpl); } } ?> |
Najważniejsze pliki zostały Wam przedstawione, a teraz troche opisu, żeby móc utworzyć drzewo, musimy użyć Rekurencji, została ona użyta w pliku: models/view.php, linia 39. Odwołanie się do samego siebie przez self::, dlaczego po co i jak? Już tłumaczę:
Do stworzenia drzewa użyłem dwóch rzeczy:
1) compiler defun – plugin do smartów – link
2) gotowego przykładu z jquery z budową drzewiastą – link
Jak musi wyglądać tablica w php żeby mogła być przerobiona za pomocą tego pluginu do smartów? Otóż tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | $tree = array('element'=>array(array('name' => 'test1', 'element' => array(array('name' => 'test1.1'), array('name' => 'test1.2', 'element' => array(array('name' => 'test1.2.1'), array('name' => 'test1.2.2') ) ), array('name' => 'test1.3', 'element' => array(array('name' => 'test1.3.1') ) ) ) ) ) ); |
Prawda że nie fajna co? Ni i właśnie za pomocą tej rekurencj którą przedstawiłem powyżej stworzyliśmy taką o to tablice, dzięki której piknie nam wszystko działa.
Teraz jak powinna wyglądać w tpl cała struktura? o tak:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | <ul id="black" class="treeview-black"> {defun name="testrecursion" list=$tree.element} {foreach from=$list item=element} {if $element.element} <li> <span> {$element.name}</span> <ul> {fun name="testrecursion" list=$element.element} </ul> </li> {else} <li> {$element.name}</li> {/if} {/foreach} {/defun} </ul> |
Dlaczego tak a nie inaczej? Ponieważ użyłem 3 przykładu z struktury drzewiastej w jQuery. Nie wiem co tu jeszcze opisywać, dam Wam do pobrania wszystkie pliki, link poniżej.
Do pobrania całość: link


%H:%i
Ostro zwalone, masz n zapytań dla n poziomów zagnieżdżeń. Nigdzie nie napisałeś o cacheowaniu takiej kobyły – masz wtedy n zapytań na każdym requeście. Niezły killer
Tym bardziej, że można to robić cronowo – jedno zapytanie o wszystkie rekordy, potem tworzenie w php z tego drzewa i zapis do pamięci podręcznej. No i wtedy SQL sprowadza się do jednej tabelki, bez dodatkowych procedur czy triggerów.
BTW pisanie tu dłuższych komentarzy jest mordercze – textarea na komentarz się nie przewija i jest za mały.