SQL

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

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.

Spis treści

          1 Wprowadzenie
               1.1 Adjaceny list
               1.2 Nested set
          2 Założenia
          3 Struktura tabel
          4 Wstawianie rekordów
               4.1 Sortowanie
               4.2 Tabela path
                    4.2.1 Funkcja GET_LOCATION()
                    4.2.2 Funkcja GET_CHILDREN()
               4.3 Trigger onBeforePageInsert
          5 Wyświetlanie drzewa kategorii
          6 Sortowanie
               6.1 Funkcja GET_MATRIX()
          7 Kasowanie kategorii
          8 Przenoszenie kategorii
          9 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 1 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:

KolumnaType
page_idint
page_parentint
page_subjectvarchar


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_idpage_parentpage_subject
1NULLSprzęt RTV
21TV
3NULLSprzęt AGD
43Lodówki
52LCD


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_idpage_parentpage_subjectleft_idright_id
1NULLSprzęt RTV17
21TV25
3NULLSprzęt AGD811
43Lodówki910
52LCD36
6232"45


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:

KolumnaTypOpis
page_idintUnikalna wartość - ID strony
page_parentintID rodzica. Może być wartością pustą (NULL)
page_subjectvarcharTytuł strony - np. Artykuły AGD lub TV
page_pathvarcharŚcieżka strony. np. Artykuły_AGD lub TV
pgae_depthsmallintPoziom "zagnieżdżenia". Np. kategoria główna ma zagnieżdżenie 0
page_ordersmallintLiczba służąca do sortowania drzewa
page_matrixtextWartość 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):

KolumnaTypOpis
location_pageintID strony (klucz obcy do pola page_id z tabeli page)
location_texttextŚcieżka identyfikująca daną stronę (np. Foo/Bar/A/B/C)
location_childrensmallintLiczba 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_subjectpage_depthlocation_textlocation_children
Artykuły RTV0Artykuły_RTV2
TV1Artykuły_RTV/TV1
LCD2Artykuły_RTV/TV/LCD0
Artykuły AGD0Artykuły AGD0


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:

KolumnaTypOpis
path_idintUnikalna liczba (AUTO_INCREMENT)
parent_idintID kategorii macierzystej
child_idintID kategorii potomnej
lengthint"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_idpage_parentpage_subjectpage_pathpage_depthpage_orderpage_matrix
1NULLArtykuły RTVArtykuły_RTV01000000001
21TVTV11000000001/000000001
32LCDLCD21000000001/000000001/000000001
4NULLArtykuły AGDArtykuły_AGD02000000002


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_idpage_parentpage_subjectpage_pathpage_depthpage_orderpage_matrix
1_NULL_Artykuły RTVArtykuły_RTV01000000001
21TVTV11000000001/000000001
32LCDLCD21000000001/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_subjectpage_locationpage_children
Artykuły RTVArtykuły_RTV2
TVArtykuły_RTV/TV1
LCDArtykuły_RTV/TV/LCD0
Artykuły AGDArtykuły_AGD0


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_idpage_parentpage_subjectpage_pathpage_depthpage_orderpage_matrixlocation_pagelocation_textlocation_children
1_NULL_Artykuły RTVArtykuły_RTV010000000011Artykuły_RTV2
21TVTV11000000001/0000000012Artykuły_RTV/TV1
32LCDLCD21000000001/000000001/0000000013Artykuły_RTV/TV/LCD0
4_NULL_Artykuły AGDArtykuły_AGD020000000024Artykuły_AGD0


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_idpage_parentpage_subjectpage_pathpage_depthpage_orderpage_matrixlocation_pagelocation_textlocation_children
5_NULL_MebleMeble010000000015Meble0
4_NULL_Artykuły AGDArtykuły_AGD020000000024Artykuły_AGD0
1_NULL_Artykuły RTVArtykuły_RTV030000000031Artykuły_RTV2
21TVTV11000000003/0000000012Artykuły_RTV/TV1
32LCDLCD21000000003/000000001/0000000013Artykuły_RTV/TV/LCD0


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_idpage_parentpage_subjectpage_pathpage_depthpage_orderpage_matrixlocation_pagelocation_textlocation_children
5_NULL_MebleMeble010000000015Meble3
15Artykuły RTVArtykuły_RTV11000000001/0000000011Meble/Artykuły_RTV2
21TVTV21000000001/000000001/0000000012Meble/Artykuły_RTV/TV1
32LCDLCD31000000001/000000001/000000001/0000000013Meble/Artykuły_RTV/TV/LCD0
4_NULL_Artykuły AGDArtykuły_AGD020000000024Artykuły_AGD0


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.

[1] Oprócz profilów użytkownika

2 komentarze

msm 2010-12-18 19:46

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 ;) )

Coldpeer 2010-12-17 15:13

ciekawy art :)