Эта статья посвящена работе с деревьями в Oracle. В большинстве современных СУБД нет встроенных средств для работы с иерархическими структурами, для построения дерева на основе таблицы приходится писать громоздкие процедуры, или разносить данные по нескольким таблицам.
Oracle предлагает достаточно средств, для удовлетворения практически любых желаний при работе с иерархиями. В это статье я попытаюсь на примерах показать, как это применять, и какие результаты достигаются.
Классическим примером дерева является иерархия сотрудников на предприятии. Для демонстрации работы с деревьями создадим таблицу и заполним ее данными:
CREATE TABLE EMPL ( ID INTEGER PRIMARY KEY, NAME VARCHAR(50), PARENT_ID REFERENCES EMPL );
Добавим данные в таблицу:
INSERT INTO EMPL VALUES (1, 'Директор', NULL);
INSERT INTO EMPL VALUES (2, 'Заместитель по экономике', 1);
INSERT INTO EMPL VALUES (3, 'Заместитель по ИТ', 1);
INSERT INTO EMPL VALUES (4, 'Программист', 3);
INSERT INTO EMPL VALUES (5, 'Программист-стажер', 4);
INSERT INTO EMPL VALUES (6, 'Главный бухгалтер', 1);
INSERT INTO EMPL VALUES (7, 'Бухгалтер 1', 6);
INSERT INTO EMPL VALUES (8, 'Бухгалтер 2', 6);
Проверяем:
SQL> SELECT * FROM EMPL;
ID NAME PARENT_ID
---------- -------------------------------------------------- ----------
1 Директор
2 Заместитель по экономике 1
3 Заместитель по ИТ 1
4 Программист 3
5 Программист-стажер 4
6 Главный бухгалтер 1
7 Бухгалтер 1 6
8 Бухгалтер 2 6
8 rows selected.
Значения столбца PARENT_ID, реально указывают на другие строки в таблице EMPL. Для отображения получившийся иерархии имея в распоряжении стандартный SQL и любой язык программирования, такой как C++, Delphi или C# придется написать достаточно громоздкий код. Отобрать сначала узлы верхнего уровня, далее в зависимости от выбранного узла запрашивать подчиненные записи и т.д.
В распоряжение пользователя, Oracle предоставляет предложение языка PL/SQL - CONNECT BY. Оно позволяет строить иерархию одним запросом, просто и изящно:
SELECT NAME, ID, PARENT_ID
FROM EMPL
CONNECT BY PRIOR ID = PARENT_ID;
NAME ID PARENT_ID
-------------------------------------------------- ---------- ----------
Заместитель по экономике 2 1
Заместитель по ИТ 3 1
Программист 4 3
Программист-стажер 5 4
Главный бухгалтер 6 1
Бухгалтер 1 7 6
Бухгалтер 2 8 6
Программист 4 3
Программист-стажер 5 4
Программист-стажер 5 4
Бухгалтер 1 7 6
Бухгалтер 2 8 6
Директор 1
Заместитель по экономике 2 1
Заместитель по ИТ 3 1
Программист 4 3
Программист-стажер 5 4
Главный бухгалтер 6 1
Бухгалтер 1 7 6
Бухгалтер 2 8 6
20 rows selected.
Полученный результат кажется не совсем понятным, но если внимательно посмотреть, то видно, что выстроены все возможные деревья и поддеревья. Теперь добавим в запрос конструкцию START WITH:
SELECT NAME, ID, PARENT_ID
FROM EMPL
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID IN (SELECT ID
FROM EMPL
WHERE PARENT_ID IS NULL);
NAME ID PARENT_ID
-------------------------------------------------- ---------- ----------
Директор 1
Заместитель по экономике 2 1
Заместитель по ИТ 3 1
Программист 4 3
Программист-стажер 5 4
Главный бухгалтер 6 1
Бухгалтер 1 7 6
Бухгалтер 2 8 6
8 rows selected.
Обратите внимание, что в предложении START WITH использован вложенный запрос для определения кто стоит на самом верху. Обычно, в поле PARENT_ID для узлов, используют NULL или -1. Естественно, что их может быть один и более. Сама конструкция START WIDTH указывает, откуда начинать строить дерево.
Теперь, наведем немного порядок, упорядочим записи, и покажем кто находится на каком уровне иерархии. Для этого, Oracle предоставляет псевдоколонку LEVEL. Она может быть использована только в том случае, если в запросе присутствует CONNECT BY. Для упрощения укажем ID =1:
SELECT NAME, ID, PARENT_ID, LEVEL
FROM EMPL
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID = 1;
NAME ID PARENT_ID LEVEL
---------------------------------------- ---------- ---------- ----------
Директор 1 1
Заместитель по экономике 2 1 2
Заместитель по ИТ 3 1 2
Программист 4 3 3
Программист-стажер 5 4 4
Главный бухгалтер 6 1 2
Бухгалтер 1 7 6 3
Бухгалтер 2 8 6 3
8 rows selected.
Колонка LEVEL может быть использована для отметки записи. Используем оператор конкатенации (//)для добавления пробелов в начале каждой строки:
SELECT
LPAD(' ', (LEVEL - 1) * 2) // NAME AS H_NAME,
ID,
PARENT_ID,
LEVEL
FROM EMPL
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID = 1;
H_NAME ID PARENT_ID LEVEL
----------------------------------- ---------- ---------- ----------
Директор 1 1
Заместитель по экономике 2 1 2
Заместитель по ИТ 3 1 2
Программист 4 3 3
Программист-стажер 5 4 4
Главный бухгалтер 6 1 2
Бухгалтер 1 7 6 3
Бухгалтер 2 8 6 3
8 rows selected.
Для ограничения вывода можно использовать стандартное условие WHERE. Уберем из вывода сотрудников, у которых уровень меньше, либо равен 3:
SELECT
LPAD(' ', (LEVEL - 1) * 2) // NAME AS H_NAME,
ID,
PARENT_ID,
LEVEL
FROM EMPL
WHERE LEVEL <=3
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID = 1;
H_NAME ID PARENT_ID LEVEL
----------------------------------- ---------- ---------- ----------
Директор 1 1
Заместитель по экономике 2 1 2
Заместитель по ИТ 3 1 2
Программист 4 3 3
Главный бухгалтер 6 1 2
Бухгалтер 1 7 6 3
Бухгалтер 2 8 6 3
7 rows selected.
Если вы хотите произвести сортировку, то стоит учитывать, ORDER BY работает не совсем так, как в случае с простыми данными, без иерархии. Продемонстрируем это:
SELECT
LPAD(' ', (LEVEL - 1) * 2) // NAME AS H_NAME,
ID,
PARENT_ID,
LEVEL
FROM EMPL
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID = 1
ORDER BY LEVEL, NAME;
H_NAME ID PARENT_ID LEVEL
----------------------------------- ---------- ---------- ----------
Директор 1 1
Главный бухгалтер 6 1 2
Заместитель по ИТ 3 1 2
Заместитель по экономике 2 1 2
Бухгалтер 1 7 6 3
Бухгалтер 2 8 6 3
Программист 4 3 3
Программист-стажер 5 4 4
8 rows selected.
Как видно, сортировка прошла по колонке LEVEL, и затем уже по имени, но замете, что самое важное, иерархия сохранена, и внутри каждого уровня иерархии уже идет сортировка по имени. А что же будет, если из условия сортировки убрать поле LEVEL?
SELECT
LPAD(' ', (LEVEL - 1) * 2) // NAME AS H_NAME,
ID,
PARENT_ID,
LEVEL
FROM EMPL
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID = 1
ORDER BY NAME;
H_NAME ID PARENT_ID LEVEL
----------------------------------- ---------- ---------- ----------
Бухгалтер 1 7 6 3
Бухгалтер 2 8 6 3
Главный бухгалтер 6 1 2
Директор 1 1
Заместитель по ИТ 3 1 2
Заместитель по экономике 2 1 2
Программист 4 3 3
Программист-стажер 5 4 4
8 rows selected.
Как видно вся иерархия поломалась. Чтобы указать Oracle, что сортировать надо только в пределах одного уровня иерархии, поможет маленькая добавка в виде оператора SIBLINGS. Достаточно изменить условие сортировки на ORDER SIBLINGS BY <поле> - и все встанет на свои места.
SELECT
LPAD(' ', (LEVEL - 1) * 2) // NAME AS H_NAME,
ID,
PARENT_ID,
LEVEL
FROM EMPL
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID = 1
ORDER SIBLINGS BY NAME;
H_NAME ID PARENT_ID LEVEL
----------------------------------- ---------- ---------- ----------
Директор 1 1
Главный бухгалтер 6 1 2
Бухгалтер 1 7 6 3
Бухгалтер 2 8 6 3
Заместитель по ИТ 3 1 2
Программист 4 3 3
Программист-стажер 5 4 4
Заместитель по экономике 2 1 2
8 rows selected.
Еще одна очень полезная функция - SYS_CONNECT_BY_PATH().Она принимает два параметра через запятую: название колонки и строку с символом-разделителем. Для иллюстрации ее работы выполним такой запрос:
SELECT SYS_CONNECT_BY_PATH(NAME, '/') AS PATH
FROM EMPL
WHERE ID=5
START WITH PARENT_ID IS NULL
CONNECT BY PRIOR ID = PARENT_ID;
PATH
-------------------------------------------------
/Директор/Заместитель по ИТ/Программист/Программист-стажер
В результате получаем путь от большого босса до стажера. Сейчас это выглядит как путь в файловом менеджере, но эта функция может быть вам очень полезна, к тому же разделитель можно сменить.
Топаем дальше. Псевдоколонка CONNECT_BY_ISLEAF. Ее можно использовать так же, как LEVEL. В этой колонке напротив каждой строки проставляется 0 или 1. Если есть потомки - то 0. Если потомков нет, такой узел в дереве называется "листом", тогда и значение в поле CONNECT_BY_ISLEAF будет равно 1.
Помните такую конструкцию PRIOR, которая позволяла ссылаться на родительскую запись? Так вот, есть такой оператор, CONNECT_BY_ROOT, который ссылается на корень дерева. Для демонстрации работы выполним:
SELECT ID, NAME, PARENT_ID, LEVEL,
CONNECT_BY_ISLEAF AS ISLEAF,
PRIOR NAME AS PARENT,
CONNECT_BY_ROOT NAME AS ROOT
FROM EMPL
START WITH PARENT_ID IS NULL
CONNECT BY PRIOR ID = PARENT_ID
ORDER SIBLINGS BY NAME;
Если при построении дерева вы получаете ошибку, о том, что найдена петля (цикл), то это означает - дерево неверно спроектировано. На такой случай, есть NOCYCLE. Это позволит вам избежать бесконечных циклов. Для иллюстрации работы, выполним:
UPDATE EMPL SET PARENT_ID=5 WHERE ID=5;
COMMIT;
Теперь, программист-стажер подчиняется сам себе. Выполняем:
SELECT
LPAD(' ', (LEVEL - 1) * 2) // NAME AS H_NAME,
ID,
PARENT_ID,
LEVEL
FROM EMPL
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID = 1
ORDER BY LEVEL, NAME;
H_NAME ID PARENT_ID LEVEL
------------------------------ ---------- ---------- ----------
Директор 1 1
Главный бухгалтер 6 1 2
Заместитель по ИТ 3 1 2
Заместитель по экономике 2 1 2
Бухгалтер 1 7 6 3
Бухгалтер 2 8 6 3
Программист 4 3 3
7 rows selected.
И видим, что стажера нет, он выпал из дерева. Oracle не видит пути, и не включает элемент в иерархию. Попробуем заставить его начать со стажера. Для этого немного поменяем условия запроса:
SELECT
LPAD(' ', (LEVEL - 1) * 2) // NAME AS H_NAME,
ID,
PARENT_ID,
LEVEL
FROM EMPL
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID = 5
ORDER BY LEVEL, NAME;
FROM EMPL
*
error at line 6:
ORA-01436: CONNECT BY loop in user data
Что бы избежать таких неприятных ситуаций, изменим запрос, что бы он выглядел так:
SELECT
LPAD(' ', (LEVEL - 1) * 2) // NAME AS H_NAME,
ID,
PARENT_ID,
LEVEL
FROM EMPL
CONNECT BY NOCYCLE PRIOR ID = PARENT_ID
START WITH ID = 5
ORDER BY LEVEL, NAME;
H_NAME ID PARENT_ID LEVEL
------------------------------ ---------- ---------- ----------
Программист-стажер
JOIN не работает с CONNECT BY
Например, построим отчет в котором укажем сотрудника и его непосредственного начальника:
SELECT
LPAD(' ', (LEVEL - 1) * 2) // A.NAME AS Н_NAME,
B.NAME AS BOSS_NAME
FROM EMPL A, EMPL B
WHERE A.PARENT_ID = B.ID(+)
CONNECT BY PRIOR A.ID = A.PARENT_ID
START WITH A.ID = 1;
На старых версиях Oracle, можно получить сообщение об ошибке:
ERROR at line 4: ORA-01437: cannot have join with CONNECT BY
Обойти эту проблему можно создав представление:
CREATE OR REPLACE VIEW V_EMPL
AS
SELECT
LPAD(' ', (LEVEL - 1) * 2) // NAME AS H_NAME,
ID,
PARENT_ID,
LEVEL AS THE_LEVEL
FROM EMPL
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID = 1;
Колонку LEVEL переименовали, чтобы представление не заканчивалось на зарезервированное слово.
SQL> SELECT * FROM V_EMPL;
Сейчас можно выполнить JOIN
SELECT
A.H_NAME,
B.NAME AS BOSS_NAME
FROM V_EMPL A, EMPL B
WHERE A.PARENT_ID = A.ID(+);
Если обратите внимание, то увидите что выполнено OUTER JOIN, потому что в списке нет большого босса.
Подзапросы, списки и CONNECT BY
Вместо VIEW и JOIN можно использовать вложенные запросы в списке выборки:
SELECT
LPAD(' ', (LEVEL - 1) * 2) // NAME AS H_NAME,
(SELECT NAME
FROM EMPL B
WHERE B. ID = A.PARENT_ID) AS BOSS_NAME
FROM EMPL A
CONNECT BY PRIOR ID = PARENT_ID
START WITH ID = 1;
H_NAME BOSS_NAME
----------------------------------- -----------------------------------
Директор
Заместитель по экономике Директор
Заместитель по ИТ Директор
Программист Заместитель по ИТ
Программист-стажер Программист
Главный бухгалтер Директор
Бухгалтер 1 Главный бухгалтер
Бухгалтер 2 Главный бухгалтер
8 rows selected.
Производительность
Для увеличения производительности, вам потребуется создать индексы на таблицу, которые позволят Oracle быстрее получать ответ на вопрос, "кто является детьми некого родителя Х?":
CREATE INDEX EMPL_IDX1 ON EMPL (ID, PARENT_ID);
CREATE INDEX EMPL_IDX2 ON EMPL (PARENT_ID, ID); |