Zaawansowane podejście do drzew w modelu Adjacency list w MySQL

Adam Boduch

Treści na stronach WWW często grupowane są w kategorie oraz podkategorie. Przy większej ilości kategorii tworzą one pewnego rodzaju strukturę.

Wyobraźmy sobie sklep internetowy. W sklepie, drzewo kategorii może prezentować się następująco:

- Sprzęt RTV
	- TV
		- LCD
		- Plazma
	- Głośniki
	- DVD
		- Przenośne
- Sprzęt AGD
	- Lodówki
	- Pralki

Problemem jest zaimplementowanie odpowiedniego mechanizmu w naszej bazie danych, który umożliwi prostą manipulację kategoriami (przenoszenie, usuwanie, dodawanie) i prezentację (w tym sortowanie).

Za Wikipedią:

Drzewo – w informatyce: struktura danych reprezentująca drzewo matematyczne. W naturalny sposób reprezentuje hierarchię danych (obiektów fizycznych i abstrakcyjnych, pojęć itp.), jest więc stosowane głównie do tego celu. Drzewa ułatwiają i przyspieszają wyszukiwanie, a także pozwalają w łatwy sposób operować na posortowanych danych.

     1 Wprowadzenie
          1.1 Adjacency list
          1.2 Nested set
     2 Założenia
     3 Struktura tabel
     4 Wstawianie rekordów
          4.3 Sortowanie
          4.4 Tabela path
               4.4.1 Funkcja GET_LOCATION()
               4.4.2 Funkcja GET_CHILDREN()
          4.5 Trigger onBeforePageInsert
     5 Wyświetlanie drzewa kategorii
     6 <strong>CONCAT(REPEAT(' ', page_depth * 2), location_text)</strong>
     7 Sortowanie
          7.6 Funkcja GET_MATRIX()
     8 Kasowanie kategorii
     9 Przenoszenie kategorii
     10 Podsumowanie

W tym artykule opiszę mechanizm, jaki jest używany w serwisie 4programmers.net. Drzewa kategorii stanowią w tym serwisie trzon, podstawę działania. Mamy tutaj wiele kategorii oraz podkategorii, do których przypisane są artykuły i tematy na forum.

Wprowadzenie

W serwisie 4programmers.net każda podstrona odwzorowana jest w bazie danych (w tabeli page). Z punktu widzenia architektury nie ma znaczenia, czy dana strona jest kategorią, czy stroną bez rodzica (np. wątkiem na forum). Mamy więc (w uproszczeniu) takie drzewo stron/kategorii:

- Logowanie
- Rejestracja
- Delphi
	- FAQ
	- Artykuły
- Forum
	- Webmastering
		- Wątek w dziale webmasteringu
		...
	- C# i .NET
		- Wątek w dziale C# i .NET
	...

Implementacja drzewa stron może opierać się o model nested set lub adjacency list. Są to dwa najpopularniejsze podejścia do problemu. Obydwa mają swoje plusy i minusy. Chociaż te dwa rozwiązania nie są tematem tego artykułu, to pokrótce je opiszę, aby dać Czytelnikowi pewien zarys.

Adjacency list

Adjacency list jest bardzo prostym rozwiązaniem. Polega na tym, iż w tabeli kolumna parent przechowuje ID rodzica (czyli strony macierzystej). Struktura tabeli page mogłaby więc prezentować się w sposób następujący:

Kolumna Type
page_id int
page_parent int
page_subject varchar

Domyślną wartością dla pola page_parent jest NULL. Czyli dany rekord (strona) nie posiada kategorii macierzystej. W przypadku wartości różnej od NULL wiemy, że dana strona posiada kategorię macierzystą:

page_id page_parent page_subject
1 NULL Sprzęt RTV
2 1 TV
3 NULL Sprzęt AGD
4 3 Lodówki
5 2 LCD

Dzięki temu w dość prosty sposób możemy wyświetlać zależności pomiędzy kategoriami. Takie rozwiązanie ma swoje wady i zalety.

Zalety:

  • Prostota
  • Szybkość dodawania/usuwania danych
  • Łatwa prezentacja listy kategorii

Wady:

  • Nieoptymalne działanie w przypadku wielu zagnieżdżeń kategorii
  • Trudna prezentacja posortowanej listy w przypadku wielu podkategorii

Nested set

Nested set został zaimplementowany w serwisie 4programmers.net na początku. Model ten świetnie sobie radzi w przypadku wielu stron czy kategorii. Jedynym warunkiem jest, aby owe drzewo nie było zbyt często modyfikowane. Szybko okazało się, że w przypadku serwisu 4programmers.net nie sprawdza się najlepiej. Główną przyczyną był fakt, że częsta modyfikacja drzewa nie jest zbyt dobra dla modelu nested set.

Model ten jest o wiele bardziej zaawansowany niż zwykły, poczciwy adjacency list. Umożliwia jednak proste wyświetlanie teoretycznie nieograniczonej liczby zagnieżdżeń.

Nested set wymaga dodania do tabeli dwóch kolumn left_id oraz right_id. Pola te powinny zawierać unikalne wartości w obrębie całej tabeli:

page_id page_parent page_subject left_id right_id
1 NULL Sprzęt RTV 1 7
2 1 TV 2 5
3 NULL Sprzęt AGD 8 11
4 3 Lodówki 9 10
5 2 LCD 3 6
6 2 32" 4 5

Aby wyświetlić drzewo kategorii w odpowiedniej kolejności, należy sortować dane po wartości kolumny left_id:

SELECT * FROM page ORDER BY left_id

Zalety:

  • Bardzo wydajny przy odczycie
  • Teoretycznie nieograniczona ilość zagłębień kategorii
  • Prosty odczyt drzewa, wraz z odczytaniem poziomu zagnieżdżenia danego drzewa

Wady:

  • Trudny do zrozumienia
  • Mało wydajny przy aktualizowaniu drzewa
  • Bardzo trudne przenoszenie gałęzi z jednej do drugiej

Adjacency list był algorytmem zbyt prostym, za to nested set bardzo wydajnym, lecz trudnym do wykorzystania. Należało wymyślić rozwiązanie, które połączy w sobie możliwości modelów adjacency list i nested set. Zainspirowany tym artykułem zaprojektowałem nowe rozwiązanie, które w zamierzeniu miało działać poprawnie na silniku bazy MySQL.

Założenia

Założenia nowego algorytmu były dosyć proste:

  • Łatwe wyświetlanie drzewa przy wielu poziomach zagnieżdżenia
  • Prosta manipulacja danymi
  • Wydajne dodawanie/usuwanie i przenoszenie gałęzi
  • Możliwość ustalania kolejności wyświetlania danej gałęzi (sortowanie)
  • Możliwość odczytania "zagnieżdżenia" danej strony
  • Możliwość odczytania ilości podstron w danej kategorii

Zadanie jednak o tyle trudne, iż musiałem sobie poradzić z "ułomnościami" bazy MySQL. Nie pozwała ona bowiem na rekurencyjne wywołania w triggerach, stąd musiałem część instrukcji SQL przenieść do funkcji i procedur SQL, a inne dane wydzielić do osobnej tabeli.

Zakładam, że czytelnik zaznajomiony jest z pojęciem trigger, stąd nie wyjaśniam go w tym artykule.

Do zaimplementowania algorytmu posłużę się następującymi mechanizmami SQL:

  • klucze obce
  • triggery
  • procedury
  • funkcje
  • tworzenie tabel tymczasowych
  • widoki (opcjonalnie)

Struktura tabel

W serwisie 4programmers.net tabele są nieco bardziej zaawansowane, ale – dla uproszczenia – w tym artykule tabele będą posiadały jedynie te kolumny, które są istotne w procesie prezentacji danych.

Najważniejszą tabelą jest page. Przechowuje ona każdą kategorię/podkategorię oraz stronę w serwisie 4programmers.net. Z punktu widzenia architektury systemu każda podkategoria czy kategoria jest po prostu stroną. Niezależnie, czy owa strona ma potomków (tak będę nazywał strony potomne, czyli np. podkategorie), czy też nie.

Struktura tabeli page przedstawia się następująco:

Kolumna Typ Opis
page_id int Unikalna wartość - ID strony
page_parent int ID rodzica. Może być wartością pustą (NULL)
page_subject varchar Tytuł strony - np. Artykuły AGD lub TV
page_path varchar Ścieżka strony. np. Artykuły_AGD lub TV
pgae_depth smallint Poziom "zagnieżdżenia". Np. kategoria główna ma zagnieżdżenie 0
page_order smallint Liczba służąca do sortowania drzewa
page_matrix text Wartość tekstowa służaca do sortowania całego drzewa

Dzięki polu page_order możemy sterować kolejnością wyświetlania drzew na danym poziomie zagnieżdżenia. Na przykład:

SELECT * FROM page WHERE page_parent IS NULL ORDER BY page_order

Znaczenie pozostałych kolumn wyjaśni się w dalszej części artykułu.

Podsumowując: zapytanie SQL tworzące tabelę page może wyglądać tak:

CREATE TABLE `page` (
	`page_id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
	`page_parent` INT(10) UNSIGNED NULL DEFAULT NULL,
	`page_subject` VARCHAR(200) NOT NULL,
	`page_path` VARCHAR(200) NOT NULL,
	`page_depth` SMALLINT(5) UNSIGNED NOT NULL DEFAULT '0',
	`page_order` MEDIUMINT(8) UNSIGNED NOT NULL DEFAULT '0',
	`page_matrix` TEXT NOT NULL DEFAULT '',
	PRIMARY KEY (`page_id`),
	INDEX `page_parent` (`page_parent`),
	INDEX `page_order` (`page_order`),
	CONSTRAINT `page_ibfk_1` FOREIGN KEY (`page_parent`) REFERENCES `page` (`page_id`) ON DELETE CASCADE
)
COLLATE='utf8_general_ci'
ENGINE=InnoDB
ROW_FORMAT=DEFAULT

Zwróć uwagę, że kolumna page_parent jest kluczem obcym do kolumny page_id. Dzięki temu baza danych sama dba o integralność danych. Usuwając gałąź macierzystą (główną), usunięte zostaną wszelkie gałęzie potomne.

W serwisie 4programmers.net każda strona jest identyfikowana po unikalnej ścieżce (np. Forum/Off-Topic, Delphi/FAQ itd.). Potrzebujemy również tabeli, która będzie przechowywać tego typu informacje; jest to tabela location:

Kolumna Typ Opis
location_page int ID strony (klucz obcy do pola page_id z tabeli page)
location_text text Ścieżka identyfikująca daną stronę (np. Foo/Bar/A/B/C)
location_children smallint Liczba stron potomnych

Zapytanie SQL:

CREATE TABLE `location` (
	`location_page` INT(10) UNSIGNED NOT NULL,
	`location_text` TEXT NOT NULL,
	`location_children` SMALLINT(5) UNSIGNED NOT NULL DEFAULT '0',
	PRIMARY KEY (`location_page`),
	CONSTRAINT `location_ibfk_1` FOREIGN KEY (`location_page`) REFERENCES `page` (`page_id`) ON DELETE CASCADE
)
COLLATE='utf8_general_ci'
ENGINE=InnoDB
ROW_FORMAT=DEFAULT

Dzięki tym dwóm tabelom, wyświetlenie listy stron i interesujących nas informacji będzie bardzo proste. Na przykład:

SELECT page_subject, page_depth, location_text, location_children
FROM page
INNER JOIN location_page = page_id

Przykładowy rezultat może wyglądać tak:

page_subject page_depth location_text location_children
Artykuły RTV 0 Artykuły_RTV 2
TV 1 Artykuły_RTV/TV 1
LCD 2 Artykuły_RTV/TV/LCD 0
Artykuły AGD 0 Artykuły AGD 0

Potrzebna nam będzie jeszcze jedna tabela, dzięki której w prosty sposób, bez zbędnej rekurencji, będziemy mogli odczytać listę kategorii macierzystych lub potomnych. Nazwijmy ją path:

Kolumna Typ Opis
path_id int Unikalna liczba (AUTO_INCREMENT)
parent_id int ID kategorii macierzystej
child_id int ID kategorii potomnej
length int "Dystans" pomiędzy kategorią potomną, a macierzystą

Objaśniania znaczenia istnienia tabeli path pozwolę sobie zostawić na koniec artykułu.

Wstawianie rekordów

Przed i po wstawieniu nowego rekordu do tabeli page wykonywane będą instrukcje z triggerów. Jeżeli mamy już utworzone tabele, przyszedł czas na dane. Zanim zaprezentuję kod triggerów, proszę o ręczne wstawienie danych, na których będę opierał dalsze przykłady:

INSERT INTO `page` (`page_id`, `page_parent`, `page_subject`, `page_path`, `page_depth`, `page_order`, `page_matrix`) VALUES
(1, NULL, 'Artykuły RTV', 'Artykuły_RTV', 0, 1, '000000001'),
(2, 1, 'TV', 'TV', 1, 1, '000000001/000000001'),
(3, 2, 'LCD', 'LCD', 2, 1, '000000001/000000001/000000001'),
(4, NULL, 'Artykuły AGD', 'Artykuły_AGD', 0, 2, '000000002');

INSERT INTO `path` (`path_id`, `parent_id`, `child_id`, `length`) VALUES
(3, 1, 1, 0),
(4, 2, 2, 0),
(5, 1, 2, 1),
(6, 3, 3, 0),
(7, 2, 3, 1),
(8, 1, 3, 2),
(10, 4, 4, 0);

INSERT INTO `location` (`location_page`, `location_text`, `location_children`) VALUES
(1, 'Artykuły_RTV', 2),
(2, 'Artykuły_RTV/TV', 1),
(3, 'Artykuły_RTV/TV/LCD', 0),
(4, 'Artykuły_AGD', 0);

Po utworzeniu triggerów dane w tabelach path i location będą wstawiane automatycznie.

Proste zapytanie SQL na tabeli page zwróci więc taki rezultat:

page_id page_parent page_subject page_path page_depth page_order page_matrix
1 NULL Artykuły RTV Artykuły_RTV 0 1 000000001
2 1 TV TV 1 1 000000001/000000001
3 2 LCD LCD 2 1 000000001/000000001/000000001
4 NULL Artykuły AGD Artykuły_AGD 0 2 000000002

Na tym etapie mamy już praktycznie działające drzewo kategorii. Prosto możemy wyświetlić nasze drzewo, a korzystając z kolumny page_depth, możemy zaprezentować wcięcia – oznaczające, że mamy do czynienia z kategorią potomną:

SELECT CONCAT(REPEAT(' ', page_depth * 2), page_subject)
FROM page
ORDER BY page_matrix

Takie zapytanie powinno wyświetlić drzewo:

Artykuły RTV
	TV
		LCD
Artykuły AGD

Sortowanie

W powyższym zapytaniu sortowanie odbyło się po wartości kolumny page_matrix. Na tym etapie należy wyjaśnić, do czego właściwie to pole służy. Każdemu rekordowi nadawana jest wartość pola page_order. Jest to liczba całkowita, dzięki której możemy sortować rekordy według kolejności wyświetlania gałęzi drzew.

Co z tego, skoro taką samą wartość może posiadać wiele rekordów w tabeli, w zależności od stopnia ich zagnieżdżenia? Ta kolumna przyda się tylko, jeżeli chcemy posortować gałęzie przynależące do danej kategorii nadrzędnej:

SELECT * FROM page WHERE page_parent IS NULL ORDER BY page_order

page_matrix to kolumna tekstowa, która zawiera ciąg liczb oznaczających kolejność wyświetlania stron w systemie.

MySQL nie umożliwia niestety nadawania indeksów na typ typu text. Stąd, chcąc posortować naprawdę duże drzewo kategorii (>= 100k), należy się liczyć w wydłużonym czasem wykonania zapytania (~1 sekunda). Testowano na Intel Core i3 z 3 GB RAM.

O sortowaniu powiem jeszcze w dalszej części artykułu.

Tabela path

Problem: wyświetlenie ścieżki do kategorii LCD (czyli de facto – listy kategorii macierzystych), korzystając z jednego zapytania.

Rozwiązaniem tego problemu jest tabela path. Jej kolumny parent_id oraz child_id łączą rekordy z tabeli page, umożliwiając wyświetlenie ścieżki.

Przykładowe zapytanie:

SELECT page.*
FROM page
INNER JOIN path ON child_id = 3 # tutaj należy wstawić ID kategorii
WHERE page_id = parent_id
ORDER BY `length` DESC

wyświetli taki rezultat:

page_id | page_parent | page_subject | page_path | page_depth | page_order | page_matrix
1 | NULL | Artykuły RTV | Artykuły_RTV | 0 | 1 | 000000001
2 | 1 | TV | TV | 1 | 1 | 000000001/000000001
3 | 2 | LCD | LCD | 2 | 1 | 000000001/000000001/000000001

Oczywiście nie będziemy ręcznie wprowadzać danych do tej tabeli. Uczyni to za nas trigger, który będzie wykonywany po wstawieniu nowego rekordu do tabeli page:

DELIMITER //
CREATE TRIGGER `onAfterPageInsert` AFTER INSERT ON `page`
 FOR EACH ROW BEGIN
	INSERT INTO path (parent_id, child_id, `length`) VALUES(NEW.page_id, NEW.page_id, 0);
	
	INSERT INTO path (parent_id, child_id, `length`) 
	SELECT parent_id, NEW.page_id, `length` + 1 
	FROM path
	WHERE child_id = NEW.page_parent;
		
	INSERT INTO location (location_page, location_text, location_children) VALUES(NEW.page_id, GET_LOCATION(NEW.page_id), 0);
	
	IF NEW.page_parent IS NOT NULL THEN
	
		UPDATE location
		INNER JOIN path ON child_id = NEW.page_parent
		SET location_children = GET_CHILDREN(location_page)
		WHERE location_page = parent_id;
	END IF;
END
//

Pierwsze instrukcje z tego triggera tworzą rekordy w tabeli path, które tak naprawdę wiążą nasz nowy rekord z kategorią macierzystą. Dzięki temu powstaje drzewo kategorii. Tabela path spełnia w systemie bardzo ważną funkcję, lecz nie będziesz musiał właściwie nigdy modyfikować danych w niej zawartych. Będą to robiły triggery, które przerzucą ten obowiązek na bazę danych i zapewnią integralność danych.

Druga instrukcja SQL wstawia nowy rekord do tabeli location. Posiłkuję się tutaj funkcją GET_LOCATION().

Funkcja GET_LOCATION()

Funkcja GET_LOCATION() w prosty sposób wyświetli nam ścieżkę do danego dokumentu, korzystając przy tym – a jakże – danymi z tabeli path, umożliwiającymi wygenerowanie ścieżki:

SELECT GET_LOCATION(3); // zwróci: Artykuły_RTV/TV/LCD

Kod tej funkcji prezentuje się w ten sposób:

DELIMITER //
CREATE FUNCTION `GET_LOCATION`(`pageId` INT)
	RETURNS text
	LANGUAGE SQL
	NOT DETERMINISTIC
	READS SQL DATA
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN
	RETURN (	
		SELECT GROUP_CONCAT(page_path ORDER BY `length` DESC SEPARATOR '/')
		FROM path
		INNER JOIN page ON page_id = parent_id
		WHERE child_id = pageId
	); 
END//

Funkcja GET_CHILDREN()

W tabeli location oprócz ścieżki znajduje się również informacja o liczbie stron potomnych w stosunku do danej ścieżki. Innymi słowy, funkcja GET_CHILDREN() zwraca ilość dokumentów znajdujących się w danej kategorii:

SELECT GET_CHILDREN(1); // zwróci cyfrę 2

Jej kod wygląda tak:

DELIMITER //
CREATE FUNCTION `GET_CHILDREN`(`pageId` INT)
	RETURNS smallint(6)
	LANGUAGE SQL
	NOT DETERMINISTIC
	CONTAINS SQL
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN
	RETURN (
	
		SELECT COUNT(*) -1
		FROM path
		WHERE parent_id = pageId
	);
END//

Jak zauważyłeś, w triggerze onAfterPageInsert, posiłkując się funkcjami GET_LOCATION() i GET_CHILDREN(), uaktualniamy pewne wartości w tabelach MySQL. Gdyby nie zależało nam w serwisie 4programmers.net na takiej wydajności, moglibyśmy spokojnie usunąć tabelę location, oraz kolumnę page_depth z tabeli page – ponieważ informacje o ścieżce, czy też o liczbie potomków danej strony, możemy odczytywać na bieżąco, bazując jedynie na informacjach z tabeli path. Na przykład, następujące zapytanie:

SELECT page_subject, 
		 GET_LOCATION(page_id) AS page_location, 
		 GET_CHILDREN(page_id) AS page_children
FROM page

zwróci:

page_subject page_location page_children
Artykuły RTV Artykuły_RTV 2
TV Artykuły_RTV/TV 1
LCD Artykuły_RTV/TV/LCD 0
Artykuły AGD Artykuły_AGD 0

Zapisując te dane w tabeli location, oraz w kolumnie page_depth, zwiększamy wydajność zapytań poprzez pozbycie się dodatkowych funkcji wykorzystywanych w zapytaniach SQL.

Trigger onBeforePageInsert

Aby zakończyć proces wstawiania rekordów, potrzebujemy jeszcze jednego triggera. Będzie on tym razem wykonywany przed faktycznym wstawieniem rekordu do tabeli page. Jego kod jest następujący:

DELIMITER //
CREATE TRIGGER `onBeforePageInsert` BEFORE INSERT ON `page`
 FOR EACH ROW BEGIN
	IF (NEW.page_parent IS NULL OR NEW.page_parent = 0) THEN
		SET NEW.page_depth = 0;
		
		SET NEW.page_order = (SELECT IFNULL(MAX(page_order), 0) FROM page WHERE page_parent IS NULL) + 1;
		SET NEW.page_matrix = LPAD(NEW.page_order, 9, '0');
	ELSE
		SELECT page_depth, page_matrix INTO @pageDepth, @pageMatrix
		FROM page
		WHERE page_id = NEW.page_parent;
		
		SET @pageOrder = (SELECT IFNULL(MAX(page_order), 0) FROM page WHERE page_parent = NEW.page_parent) + 1;
		
		SET NEW.page_order = @pageOrder;
		SET NEW.page_depth = @pageDepth + 1;
		SET NEW.page_matrix = CONCAT_WS('/', @pageMatrix, LPAD(NEW.page_order, 9, '0'));		
	END IF;
END
//

Instrukcje zawarte w tym triggerze mają za zadanie nadanie kolumnom page_order, page_depth oraz page_matrix ich wartości. Dzięki nim sortowanie gałęzi w drzewie będzie banalnie proste. Myślę, że instrukcje zawarte w tym triggerze nie wymagają specjalnego omówienia.

Możesz teraz usunąć dane z tabeli page:

TRUNCATE page;

Wszystko po to, aby przetestować działanie naszych funkcji i triggerów. Od tej pory operacje INSERT, DELETE czy UPDATE należy dokonywać na tabeli page, ponieważ triggery zrealizują za nas pozostałą pracę:

INSERT INTO page (page_parent, page_subject, page_path) VALUES(NULL, 'Artykuły RTV', 'Artykuły_RTV');
INSERT INTO page (page_parent, page_subject, page_path) VALUES(1, 'TV', 'TV');
INSERT INTO page (page_parent, page_subject, page_path) VALUES(2, 'LCD', 'LCD');
INSERT INTO page (page_parent, page_subject, page_path) VALUES(NULL, 'Artykuły AGD', 'Artykuły_AGD');

Wyświetlanie drzewa kategorii

Ze względu na ograniczenia bazy MySQL, ścieżki poszczególnych stron oraz pozostałe dane znajdują się w dwóch różnych tabelach. Jeżeli chcesz, możesz utworzyć widok, który będzie grupował te dane:

CREATE VIEW page_v AS 
SELECT *
FROM page
INNER JOIN location ON location_page = page_id

Dzięki temu proste zapytanie SELECT wyświetli nam drzewo kategorii:

SELECT * FROM page_v ORDER BY page_matrix
page_id page_parent page_subject page_path page_depth page_order page_matrix location_page location_text location_children
1 NULL Artykuły RTV Artykuły_RTV 0 1 000000001 1 Artykuły_RTV 2
2 1 TV TV 1 1 000000001/000000001 2 Artykuły_RTV/TV 1
3 2 LCD LCD 2 1 000000001/000000001/000000001 3 Artykuły_RTV/TV/LCD 0
4 NULL Artykuły AGD Artykuły_AGD 0 2 000000002 4 Artykuły_AGD 0

Inny przykład wyświetli kategorię z odpowiednimi "wcięciami":

SELECT CONCAT(REPEAT(' ', page_depth * 2), location_text)
FROM page_v
ORDER BY page_matrix

CONCAT(REPEAT(' ', page_depth * 2), location_text)

Artykuły_RTV
Artykuły_RTV/TV
Artykuły_RTV/TV/LCD
Artykuły_AGD

Sortowanie

Wstawmy do naszego drzewa nowy rekord (nową kategorię):

INSERT INTO page (page_parent, page_subject, page_path) VALUES(NULL, 'Meble', 'Meble');

Powiedzmy, że chcielibyśmy, aby nasza nowa kategoria – Meble – była wyświetlona na stronie jako pierwsza (tj. przed pozostałymi, utworzonymi wcześniej). Czyli kategoria meble musi być wyświetlona pierwsza w kolejności, przed artykułami RTV. Należy zmienić wartość pola page_order zarówno dla Artykuły AGD, jak i Meble.

Przyda się do tego procedura PAGE_ORDER() która zmieni kolejność wyświetlania stron:

DELIMITER //
CREATE PROCEDURE `PAGE_ORDER`(IN `pageId` INT, IN `pageOrder` SMALLINT)
	LANGUAGE SQL
	NOT DETERMINISTIC
	CONTAINS SQL
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN	
	-- pobranie aktualnej pozycji danej strony
	SELECT page_parent, page_order INTO @pageParent, @pageOrder
	FROM page
	WHERE page_id = pageId;
	
	IF !(pageOrder <=> @pageOrder) THEN
		
		-- strona na ktora zamienimy sie miejscami
		SELECT page_id INTO @currPageId
		FROM page
		WHERE page_parent <=> @pageParent AND page_order = pageOrder;
		
		IF @currPageId IS NOT NULL THEN
		
			START TRANSACTION;
			
			UPDATE page AS p1, page AS p2                   
			SET p1.page_order = pageOrder, p2.page_order = @pageOrder
			WHERE	p1.page_parent <=> @pageParent 
				AND p2.page_parent <=> @pageParent
					AND p1.page_id = pageId 
						AND p2.page_id = @currPageId;
			
			UPDATE page 
			INNER JOIN path ON parent_id = pageId
			SET page_matrix = GET_MATRIX(child_id)
			WHERE page_id = child_id;
			
			UPDATE page
			INNER JOIN path ON parent_id = @currPageId
			SET page_matrix = GET_MATRIX(child_id)
			WHERE page_id = child_id;
			
			COMMIT;
		
		
		END IF;
	END IF; 
END//

Jej działanie polega na podaniu w parametrze ID strony, jak i numeru pozycji, jaki chcemy nadać dla tej kategorii. Przykład użycia:

CALL PAGE_ORDER(5, 1);

Zauważ, że zawartość tabeli page uległa zmianie. Konkretnie, zmieniły się wartości kolumn page_order oraz page_matrix:

page_id page_parent page_subject page_path page_depth page_order page_matrix location_page location_text location_children
5 NULL Meble Meble 0 1 000000001 5 Meble 0
4 NULL Artykuły AGD Artykuły_AGD 0 2 000000002 4 Artykuły_AGD 0
1 NULL Artykuły RTV Artykuły_RTV 0 3 000000003 1 Artykuły_RTV 2
2 1 TV TV 1 1 000000003/000000001 2 Artykuły_RTV/TV 1
3 2 LCD LCD 2 1 000000003/000000001/000000001 3 Artykuły_RTV/TV/LCD 0

Funkcja GET_MATRIX()

W powyższym przykładzie użyta została funkcja GET_MATRIX(), służąca do generowania nowej wartości tekstowej dla pola page_matrix:

DELIMITER //
CREATE FUNCTION `GET_MATRIX`(`pageId` INT)
	RETURNS text
	LANGUAGE SQL
	DETERMINISTIC
	READS SQL DATA
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN
	RETURN (
		SELECT GROUP_CONCAT(LPAD(page_order, 9, '0') ORDER BY `length` DESC SEPARATOR '/')
		FROM path
		INNER JOIN page ON page_id = parent_id
		WHERE child_id = pageId
	);
END//

Kasowanie kategorii

Kasowanie kategorii jest stosunkowo proste. Klucze obce zapewnią nam integralność danych. Innymi słowy, usunięcie rekordu z tabeli page spowoduje usunięciem niepotrzebnych danych także z tabel path oraz location.

Jedyny szczegół jest taki, iż usuwając daną gałąź chcielibyśmy, aby w kategorii macierzystej pomniejszona została wartość pola location_children w tabeli location. Należy więc dodać kolejny trigger:

DELIMITER //
CREATE TRIGGER `onAfterPageDelete` AFTER DELETE ON `page`
 FOR EACH ROW BEGIN
	IF OLD.page_depth > 0 THEN 
	
		UPDATE location
		INNER JOIN path ON child_id = OLD.page_parent
		SET location_children = GET_CHILDREN(location_page)
		WHERE location_page = parent_id;
	END IF;
END
//

Przenoszenie kategorii

Należy uwzględnić sytuację, w której konieczne będzie przeniesienie kategorii (wraz z podkategoriami!) do innej kategorii. Czyli innymi słowy – przepięcie całej gałęzi pod inną gałąź macierzystą. Nim do tego dojdę, chciałbym zaprezentować jeszcze jeden istotny element projektu – trigger onAfterPageUpdate:

DELIMITER //
CREATE TRIGGER `onAfterPageUpdate` AFTER UPDATE ON `page`
 FOR EACH ROW BEGIN
 
 	IF NEW.page_path != OLD.page_path THEN
 
 		UPDATE location
		INNER JOIN path ON parent_id = NEW.page_id
		SET location_text = GET_LOCATION(location_page)		
		WHERE location_page = child_id; 
 	END IF;
 END
//

Zmieniając ścieżkę w kategorii potomnej, ścieżka w kategoriach macierzystych również musi ulec zmianie. Na szczęście taka zmiana jest dość łatwa dzięki triggerowi, który zostanie wykonany po zapytaniu typu UPDATE na tabeli page.

Samo przeniesienie gałęzi z jednej do drugiej zrealizuje procedura PAGE_MOVE():

DELIMITER //
CREATE PROCEDURE `PAGE_MOVE`(IN `pageId` INT, IN `parentId` INT)
	LANGUAGE SQL
	NOT DETERMINISTIC
	MODIFIES SQL DATA
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN	
	-- pobranie ID rodzica oraz polozenia (order) strony
	SELECT page_parent, page_order INTO @pageParent, @pageOrder
	FROM page
	WHERE page_id = pageId;
	
	-- jezeli ID rodzicow jest rozne, wiemy, ze mamy przenisc a nie zmienic kolejnosc
	IF (!(@pageParent <=> parentId)) THEN
		
		-- pobranie nowej wartosci order
		SELECT MAX(page_order) INTO @maxOrder
		FROM page 
		WHERE page_parent <=> parentId;
		
		-- uaktualnienie wiersza
		UPDATE page 
		SET page_parent = parentId, page_order = IFNULL(@maxOrder, 0) + 1
		WHERE page_id = pageId;
		
		IF @pageParent IS NOT NULL THEN
			
			CREATE TEMPORARY TABLE temp_tree AS
			SELECT t2.path_id FROM path t1 
			JOIN path t2 on t1.child_id = t2.child_id
			WHERE t1.parent_id = pageId AND t2.`length` > t1.`length`;
			
			DELETE FROM path WHERE path_id IN (SELECT * FROM temp_tree);
			DROP TABLE temp_tree;
			
			-- uaktualnienie liczby okreslajacej "dzieci" w galeziach macierzystych
			UPDATE location
			INNER JOIN path ON child_id = @pageParent
			SET location_children = GET_CHILDREN(location_page)			
			WHERE location_page = parent_id;
	     
		END IF;
			
		IF parentId IS NOT NULL THEN -- przenosimy galaz do innej galezi glownej
			
			INSERT INTO path (parent_id, child_id, `length`)
			SELECT t1.parent_id, t2.child_id, t1.`length` + t2.`length` + 1
			FROM path t1, path t2
			WHERE t1.child_id = parentId AND t2.parent_id = pageId;
			
			UPDATE location
			INNER JOIN path ON child_id = parentId
			SET location_children = GET_CHILDREN(location_page)			
			WHERE location_page = parent_id;
		END IF;
				
		-- uaktualnienie danych w galeziach - dzieciach
		UPDATE page, location
		INNER JOIN path AS t ON t.parent_id = pageId
		SET location_text = GET_LOCATION(page_id), page_depth = GET_DEPTH(page_id), page_matrix = GET_MATRIX(page_id)
		WHERE page_id = t.child_id AND location_page = t.child_id;
			
	END IF;	
END//

Jeżeli chcielibyśmy przenieść np. całą kategorię Artykuły RTV do kategorii Meble, wystarczy wykonać takie zapytanie SQL:

CALL PAGE_MOVE(1, 5);
page_id page_parent page_subject page_path page_depth page_order page_matrix location_page location_text location_children
5 NULL Meble Meble 0 1 000000001 5 Meble 3
1 5 Artykuły RTV Artykuły_RTV 1 1 000000001/000000001 1 Meble/Artykuły_RTV 2
2 1 TV TV 2 1 000000001/000000001/000000001 2 Meble/Artykuły_RTV/TV 1
3 2 LCD LCD 3 1 000000001/000000001/000000001/000000001 3 Meble/Artykuły_RTV/TV/LCD 0
4 NULL Artykuły AGD Artykuły_AGD 0 2 000000002 4 Artykuły_AGD 0

Na koniec załączam pełen zrzut instrukcji SQL, które wykorzystywałem w tym artykule:

SET FOREIGN_KEY_CHECKS=0;
SET SQL_MODE="NO_AUTO_VALUE_ON_ZERO";

CREATE TABLE `location` (
  `location_page` int(10) unsigned NOT NULL,
  `location_text` text NOT NULL,
  `location_children` smallint(5) unsigned NOT NULL DEFAULT '0',
  PRIMARY KEY (`location_page`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO `location` (`location_page`, `location_text`, `location_children`) VALUES
(1, 'Meble/Artykuły_RTV', 2),
(2, 'Meble/Artykuły_RTV/TV', 1),
(3, 'Meble/Artykuły_RTV/TV/LCD', 0),
(4, 'Artykuły_AGD', 0),
(5, 'Meble', 3);

CREATE TABLE `page` (
  `page_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `page_parent` int(10) unsigned DEFAULT NULL,
  `page_subject` varchar(255) NOT NULL,
  `page_path` varchar(255) NOT NULL,
  `page_depth` smallint(5) unsigned NOT NULL DEFAULT '0',
  `page_order` mediumint(8) unsigned NOT NULL DEFAULT '0',
  `page_matrix` text NOT NULL DEFAULT '',
  PRIMARY KEY (`page_id`),
  KEY `page_parent` (`page_parent`),
  KEY `page_path` (`page_path`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 AUTO_INCREMENT=6 ;

INSERT INTO `page` (`page_id`, `page_parent`, `page_subject`, `page_path`, `page_depth`, `page_order`, `page_matrix`) VALUES
(1, 5, 'Artykuły RTV', 'Artykuły_RTV', 1, 1, '000000001/000000001'),
(2, 1, 'TV', 'TV', 2, 1, '000000001/000000001/000000001'),
(3, 2, 'LCD', 'LCD', 3, 1, '000000001/000000001/000000001/000000001'),
(4, NULL, 'Artykuły AGD', 'Artykuły_AGD', 0, 2, '000000002'),
(5, NULL, 'Meble', 'Meble', 0, 1, '000000001');

DROP TRIGGER IF EXISTS `onBeforePageInsert`;
DELIMITER //
CREATE TRIGGER `onBeforePageInsert` BEFORE INSERT ON `page`
 FOR EACH ROW BEGIN
	IF (NEW.page_parent IS NULL OR NEW.page_parent = 0) THEN
		SET NEW.page_depth = 0;
		
		SET NEW.page_order = (SELECT IFNULL(MAX(page_order), 0) FROM page WHERE page_parent IS NULL) + 1;
		SET NEW.page_matrix = LPAD(NEW.page_order, 9, '0');
	ELSE
		SELECT page_depth, page_matrix INTO @pageDepth, @pageMatrix
		FROM page
		WHERE page_id = NEW.page_parent;
		
		SET @pageOrder = (SELECT IFNULL(MAX(page_order), 0) FROM page WHERE page_parent = NEW.page_parent) + 1;
		
		SET NEW.page_order = @pageOrder;
		SET NEW.page_depth = @pageDepth + 1;
		SET NEW.page_matrix = CONCAT_WS('/', @pageMatrix, LPAD(NEW.page_order, 9, '0'));		
	END IF;
END
//
DELIMITER ;
DROP TRIGGER IF EXISTS `onAfterPageInsert`;
DELIMITER //
CREATE TRIGGER `onAfterPageInsert` AFTER INSERT ON `page`
 FOR EACH ROW BEGIN
	INSERT INTO path (parent_id, child_id, `length`) VALUES(NEW.page_id, NEW.page_id, 0);
	
	INSERT INTO path (parent_id, child_id, `length`) 
	SELECT parent_id, NEW.page_id, `length` + 1 
	FROM path
	WHERE child_id = NEW.page_parent;
		
	INSERT INTO location (location_page, location_text, location_children) VALUES(NEW.page_id, GET_LOCATION(NEW.page_id), 0);
	
	IF NEW.page_parent IS NOT NULL THEN
	
		UPDATE location
		INNER JOIN path ON child_id = NEW.page_parent
		SET location_children = GET_CHILDREN(location_page)
		WHERE location_page = parent_id;
	END IF;
END
//
DELIMITER ;
DROP TRIGGER IF EXISTS `onAfterPageUpdate`;
DELIMITER //
CREATE TRIGGER `onAfterPageUpdate` AFTER UPDATE ON `page`
 FOR EACH ROW BEGIN
 
 	IF NEW.page_path != OLD.page_path THEN
 
 		UPDATE location
		INNER JOIN path ON parent_id = NEW.page_id
		SET location_text = GET_LOCATION(location_page)		
		WHERE location_page = child_id; 
 	END IF;
 END
//
DELIMITER ;
DROP TRIGGER IF EXISTS `onAfterPageDelete`;
DELIMITER //
CREATE TRIGGER `onAfterPageDelete` AFTER DELETE ON `page`
 FOR EACH ROW BEGIN
	IF OLD.page_depth > 0 THEN 
	
		UPDATE location
		INNER JOIN path ON child_id = OLD.page_parent
		SET location_children = GET_CHILDREN(location_page)
		WHERE location_page = parent_id;
	END IF;
END
//
DELIMITER ;
CREATE TABLE `page_v` (
`page_id` int(10) unsigned
,`page_parent` int(10) unsigned
,`page_subject` varchar(255)
,`page_path` varchar(255)
,`page_depth` smallint(5) unsigned
,`page_order` mediumint(8) unsigned
,`page_matrix` text
,`location_page` int(10) unsigned
,`location_text` text
,`location_children` smallint(5) unsigned
);
CREATE TABLE `path` (
  `path_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `parent_id` int(10) unsigned NOT NULL,
  `child_id` int(10) unsigned NOT NULL,
  `length` int(10) unsigned NOT NULL,
  PRIMARY KEY (`path_id`),
  UNIQUE KEY `tree_parent` (`parent_id`,`child_id`),
  KEY `child_id` (`child_id`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 AUTO_INCREMENT=15 ;

INSERT INTO `path` (`path_id`, `parent_id`, `child_id`, `length`) VALUES
(3, 1, 1, 0),
(4, 2, 2, 0),
(5, 1, 2, 1),
(6, 3, 3, 0),
(7, 2, 3, 1),
(8, 1, 3, 2),
(10, 4, 4, 0),
(11, 5, 5, 0),
(12, 5, 1, 1),
(13, 5, 2, 2),
(14, 5, 3, 3);
DROP TABLE IF EXISTS `page_v`;

CREATE VIEW `page_v` AS select `page`.`page_id` AS `page_id`,`page`.`page_parent` AS `page_parent`,`page`.`page_subject` AS `page_subject`,`page`.`page_path` AS `page_path`,`page`.`page_depth` AS `page_depth`,`page`.`page_order` AS `page_order`,`page`.`page_matrix` AS `page_matrix`,`location`.`location_page` AS `location_page`,`location`.`location_text` AS `location_text`,`location`.`location_children` AS `location_children` from (`page` join `location` on((`location`.`location_page` = `page`.`page_id`)));


ALTER TABLE `location`
  ADD CONSTRAINT `location_ibfk_1` FOREIGN KEY (`location_page`) REFERENCES `page` (`page_id`) ON DELETE CASCADE;

ALTER TABLE `page`
  ADD CONSTRAINT `FK_page_page` FOREIGN KEY (`page_parent`) REFERENCES `page` (`page_id`) ON DELETE CASCADE ON UPDATE NO ACTION;

ALTER TABLE `path`
  ADD CONSTRAINT `path_ibfk_1` FOREIGN KEY (`parent_id`) REFERENCES `page` (`page_id`) ON DELETE CASCADE,
  ADD CONSTRAINT `path_ibfk_2` FOREIGN KEY (`child_id`) REFERENCES `page` (`page_id`) ON DELETE CASCADE;
  
DELIMITER //
CREATE FUNCTION `GET_CHILDREN`(`pageId` INT)
	RETURNS smallint(6)
	LANGUAGE SQL
	NOT DETERMINISTIC
	CONTAINS SQL
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN
	RETURN (
	
		SELECT COUNT(*) -1
		FROM path
		WHERE parent_id = pageId
	);
END//

CREATE FUNCTION `GET_DEPTH`(`pageId` INT)
	RETURNS mediumint(9)
	LANGUAGE SQL
	DETERMINISTIC
	READS SQL DATA
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN
	RETURN (
		SELECT COUNT(*) -1
		FROM path
		WHERE child_id = pageId
	);
END//

CREATE FUNCTION `GET_LOCATION`(`pageId` INT)
	RETURNS text
	LANGUAGE SQL
	NOT DETERMINISTIC
	READS SQL DATA
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN
	RETURN (	
		SELECT GROUP_CONCAT(page_path ORDER BY `length` DESC SEPARATOR '/')
		FROM path
		INNER JOIN page ON page_id = parent_id
		WHERE child_id = pageId
	); 
END//

CREATE FUNCTION `GET_MATRIX`(`pageId` INT)
	RETURNS text
	LANGUAGE SQL
	DETERMINISTIC
	READS SQL DATA
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN
	RETURN (
		SELECT GROUP_CONCAT(LPAD(page_order, 9, '0') ORDER BY `length` DESC SEPARATOR '/')
		FROM path
		INNER JOIN page ON page_id = parent_id
		WHERE child_id = pageId
	);
END//

CREATE PROCEDURE `PAGE_MOVE`(IN `pageId` INT, IN `parentId` INT)
	LANGUAGE SQL
	NOT DETERMINISTIC
	MODIFIES SQL DATA
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN	
	-- pobranie ID rodzica oraz polozenuia (order) strony
	SELECT page_parent, page_order INTO @pageParent, @pageOrder
	FROM page
	WHERE page_id = pageId;
	
	-- jezeli ID rodzicow jest rozne, wiemy, ze mamy przenisc a nie zmienic kolejnosc
	IF (!(@pageParent <=> parentId)) THEN
		
		-- pobranie nowej wartosci order
		SELECT MAX(page_order) INTO @maxOrder
		FROM page 
		WHERE page_parent <=> parentId;
		
		-- uaktualnienie wiersza
		UPDATE page 
		SET page_parent = parentId, page_order = IFNULL(@maxOrder, 0) + 1
		WHERE page_id = pageId;
		
		IF @pageParent IS NOT NULL THEN
			
			CREATE TEMPORARY TABLE temp_tree AS
			SELECT t2.path_id FROM path t1 
			JOIN path t2 on t1.child_id = t2.child_id
	      WHERE t1.parent_id = pageId AND t2.`length` > t1.`length`;
			
			DELETE FROM path WHERE path_id IN (SELECT * FROM temp_tree);
			DROP TABLE temp_tree;
			
			-- uaktualnienie liczby okreslajacej "dzieci" w galeziach macierzystych
			UPDATE location
			INNER JOIN path ON child_id = @pageParent
			SET location_children = GET_CHILDREN(location_page)			
			WHERE location_page = parent_id;
	     
		END IF;
			
		IF parentId IS NOT NULL THEN -- przenosimy galaz do innej galezi glownej
			
			INSERT INTO path (parent_id, child_id, `length`)
			SELECT t1.parent_id, t2.child_id, t1.`length` + t2.`length` + 1
			FROM path t1, path t2
			WHERE t1.child_id = parentId AND t2.parent_id = pageId;
			
			UPDATE location
			INNER JOIN path ON child_id = parentId
			SET location_children = GET_CHILDREN(location_page)			
			WHERE location_page = parent_id;
		END IF;
				
		-- uaktualnienie danych w galeziach - dzieciach
		UPDATE page, location
		INNER JOIN path AS t ON t.parent_id = pageId
		SET location_text = GET_LOCATION(page_id), page_depth = GET_DEPTH(page_id), page_matrix = GET_MATRIX(page_id)
		WHERE page_id = t.child_id AND location_page = t.child_id;
			
	END IF;	
END//

CREATE PROCEDURE `PAGE_ORDER`(IN `pageId` INT, IN `pageOrder` SMALLINT)
	LANGUAGE SQL
	NOT DETERMINISTIC
	CONTAINS SQL
	SQL SECURITY DEFINER
	COMMENT ''
BEGIN	
	-- pobranie aktualnej pozycji danej strony
	SELECT page_parent, page_order INTO @pageParent, @pageOrder
	FROM page
	WHERE page_id = pageId;
	
	IF !(pageOrder <=> @pageOrder) THEN
		
		-- strona na ktora zamienimy sie miejscami
		SELECT page_id INTO @currPageId
		FROM page
		WHERE page_parent <=> @pageParent AND page_order = pageOrder;
		
		IF @currPageId IS NOT NULL THEN
		
			START TRANSACTION;
			
			UPDATE page AS p1, page AS p2                   
			SET p1.page_order = pageOrder, p2.page_order = @pageOrder
			WHERE	p1.page_parent <=> @pageParent 
				AND p2.page_parent <=> @pageParent
					AND p1.page_id = pageId 
						AND p2.page_id = @currPageId;
			
			UPDATE page 
			INNER JOIN path ON parent_id = pageId
			SET page_matrix = GET_MATRIX(child_id)
			WHERE page_id = child_id;
			
			UPDATE page
			INNER JOIN path ON parent_id = @currPageId
			SET page_matrix = GET_MATRIX(child_id)
			WHERE page_id = child_id;
			
			COMMIT;
		
		
		END IF;
	END IF; 
END//

DELIMITER ;

SET FOREIGN_KEY_CHECKS=1;

Powyższe zapytania można przekleić np. do popularnej aplikacji phpmyadmin, aby odpowiednie triggery, tabele i procedury zostały utworzone w bazie danych. Można też zapisać to zapytanie w pliku tekstowym i zaimportować, korzystając z konsoli. W tym celu należy wykonać polecenie:

mysql -u <nazwa użytkownika> -p <nazwa bazy danych> < /lokalizacja/pliku.sql

Podsumowanie

Rozwiązanie, które zaprezentowałem w tym artykule, nie jest pozbawione wad. Główną z nich jest pole page_matrix, które zwiększa rozmiar tabeli page przez użycie dodatkowego pola typu text. W dodatku, na to pole nie może być nałożony indeks (który przyspieszyłby sortowanie). Jeżeli jednak potrzebujesz szybkiego sortowania całego drzewa, a nie posiadasz wielu poziomów zagnieżdżeń, możesz zmienić typ tego pola z text na varchar(2000) i dopiero wówczas założyć indeks.

Pole page_matrix zakłada również, że w systemie będzie istniała ograniczona ilość poziomów zagnieżdżeń oraz ograniczona liczba rekordów. Lecz ten limit jest tak duży, że myślę, że spokojnie wystarczy do zaspokojenia zdecydowanej większości wymagań w projektach informatycznych.

Myślę, że przedstawione tutaj rozwiązanie jest dobrą alternatywą dla modelów adjacency list i nested set.

3 komentarzy

Wygląda to profesjonalnie. Przeczytałem z zainteresowaniem, szkoda tylko że połowy się domyślałem (nie, nie wina artykułu - ja po prostu słabo znam SQL ;) )

ciekawy art :)