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 to 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 Adjaceny 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 *<em>CONCAT(REPEAT(' ', page_depth </em> 2), location_text)**
     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, podkategorii, do których przypisane są artykuły i tematy na forum.

Wprowadzenie

W serwisie 4programmers.net, każda [#]_ podstrona w serwisie, 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 podjeścia do problemu. Obydwa mają swoje plusy i minusy. Chociaż te dwa rozwiązania nie są tematem tego artykułu, pokrótce je opiszę, aby dać Czytelnikowi pewien zarys.

Adjaceny list

Adjaceny 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ło na początku zaimplementowane w serwisie 4programmers.net. Model ten świetnie sobie radzi w przypadku wielu stron, 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 za prostym. Nested set bardzo wydajnym, lecz trudnym do wykorzystania. Należało wymyślić rozwiązanie, które połączy w sobie możliwości modelu adjacency list i nested set. Zainspirowany tym artykułem musiałem zaprojektować nowe rozwiązanie, które działałoby 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.

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. Dla uproszczenia, w tym artykule tabele będą posiadały kolumny, które naprawde są istotne w procesie prezentacji danych.

Najważniejszą tabelą jest page, która przechowuje 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. Np.:

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 (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. Np.:

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.

Wstawianie rekordów

Przed i po wstawieniu nowego rekordu do tabeli page, wykonywane będą instrukcje z triggerów.

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

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, jeżeli 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óry 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 (~1 sekunda) wykonania zapytania. Testowano na Intel Core i3 z 3 GB RAM.

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

Tabela path

Problem: jak wyświetlić ścieżkę do kategorii LCD (czyli de facto - listę 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łkuje się tutaj funkcją GET_LOCATION().

Funkcja GET_LOCATION()

Funkcja GET_LOCATION() w prosty sposób wyświetli nam ścieżkę do danego dokumentu, posiłkując się przy tym - a jakże - danymi z tabeli path, które umożliwiają 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:

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ń pozbywając się dodatkowych funkcji wykorzystywanych w zapytaniach SQL.

Trigger onBeforePageInsert

Aby dopełnić proces wstawiania rekordów, potrzebujemy jeszcze jednego triggera, który będzie 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 wartości kolumnom page_order, page_depth oraz page_matrix. 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, 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);

Zobacz, ż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() która służy 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 wkleić - np. w popularnej aplikacji phpmyadmin, aby odpowiednie triggery, tabele i procedury zostały utworzone w bazie danych. Można też zapisać je w pliku tekstowym i zaimportować, korzystając z konsoli, wykonując 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ą 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.

.. [#] Oprócz profilów użytkownika

2 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 :)