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
.
super
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 :)