Как удобно делать выборки из деревьев типа Nested Sets, и как не удобно им управлять. Как удобно
управлять деревьями типа id->parent_id, но как не удобно и накладно использовать рекурсии при выборках. Понятно, что при
использовании модулей для управления деревьями часть проблемы снимается, но при этом процесс работы с базой данных не
совсем прозрачен т.е. для изменения данных мы используем одни методы, для изменения расположения узла в дереве - другие,
плюс еще транзакции не помешали бы. Эту нестыковку можно решить двумя способами:
Использовать для работы с таблицей хранимые процедуры, в которой объединить оба метода обновления (вставки, удаления);
Использовать триггеры, для исключения вообще каких-либо нестандартных методов работы;
Первый способ неудобен тем, что при изменении структуры таблицы, нам потребуется еще изменять процедуру, а так же быть
максимально внимательным, при работе с таблицей, что бы все изменения данных проходили через наши процедуры, а не прямыми
запросами. Второй способ несколько утяжеляет тяблицу введением дополнительных булевых полей, а так же приходится делать
некоторые "финты ушами", хотя позволяет добиться максимальной прозрачности работы.
Первый способ - в топку, тем более где-то интернетах уже есть подобное решение.
База данных - PostgreSQL, как актуальная мне на данный момент, дополнения для MySQL здесь.
Таблица
Итак, какие триггеры нам понадобятся:
До вставки записи - для формирования разрыва в дереве и ключей для создаваемого узла;
До обновления - для перестроения дерева и формирования ключей для обновляемого узла;
После удаления - удаление разрыва в дереве оставшееся после удаления узла;
Грабли:
На время работы триггеров, требуется лочить таблицу, или отдельное дерево, если у нес в одной таблице деревьев несколько;
В PostgreSQL и MySQL в триггерах нельзя отключить рекурсию, вот так;
Пункт второй подробнее: В триггере до обновления, могут обновляться записи из той же таблицы, что повлечет за собой повторый
вызов триггера и так далее, так же и для триггера, вызываемого после удаления. Для того что бы понять вызван у нас запрос
из триггера или нет, введем два дополнительных булевых поля, которые мы будем передавать в запросе как параметр (флаг) для
триггера, а не как данные. Почему именно два - расскажу позднее.
Структуру таблицы сформируем сразу с учетом того, что у нас в ней будет храниться несколько деревьев.
Объясню почему. Мне смешно до слез слушать глупых разработчиков, которые с пеной у рта доказывают, что мол, ай-ай-ай, при
большом количестве узлов обновление узлов может затронуть все дерево, и это так тяжело для базы. Да, именно так. Не спорю.
Только вот у меня еще ниразу не было огромного количества узлов в одном дереве потому, что:
я не использую общий корневой узел;
я разделяю деревья по узлам нулевого уровня;
Пример: Есть некоторый форум. В разделе форума 1'000 постов, у каждого поста 1'000 комментариев. Всего комментариев - 1'000'000.
Так вот, раздел форума - НЕ является корневым узлом комментариев, так же как и посты НЕ являются узлами одного дерева, а
являются только разделителями деревьев. В итоге, у меня 1'000 раздельных деревьев по 1'000 комментариев. Обновление происходит
только лишь в пределах максимум 1'000 записей. В некоторых случаях, если и этого много, разделителем деревьев являются комментарии
первого уровня. В итоге, перестроение дерева не является такой уж нагрузкой на базу. Изучайте мат часть.
TTTSSSaaa111
Не будем о грустном, структура таблицы:
Собственно - _trigger_lock_update и _trigger_for_delete, являются нашими вспомогательными полями.
Сразу сделаем функцию блокирующую дереве на изменение, пока транзакция не закончена:
Создание записи
У нас есть 3 варианта добаления узла в дерево:
Добавление в подчинение определенному узлу, тогда мы передаем parent_id;
Добавление в определенную точку дерева, тогда мы передаем left_key;
Добавление в конец дерева, тогда нам не требуется ничего дополнительно передавать;
В такой же последовательности мы будем определять место назначения создаваемного узла.
Теперь некоторые пояснения:
_trigger_lock_update и _trigger_for_delete - управляющие поля, поэтому их сразу сбрасываем не зависимо от пожеланий пользователя;
Даже если мы указали parent_id - не факт, что такой узел у нас есть и то, что он в соответсвующем дереве. В текущем случае, если я не
нахожу узла в данном дереве, то parent_id сбрасывается, и узел вставляется в конец дерева. Как вариант, можно фильтровать по дереву, а
просто искать узел по id, тогда нужно будет обновлять поле tree создаваемого узла. Все зависит от приоритетности полей и конкретной реализации;
Если мы указали левый ключ, то нам, как минимум, нужно вычислить родителя создаваемого узла, родителя определяем по принципу: если мы нашли узел по левому
ключу, то родитель будет таким же как и у найденого узла, иначе если по правому, то родителем будет найденный нами узел, так же выстраиваем и уровень.
Если же узел, не найден, то тогда вставляем в конец дерева, left_key при этом - сбрасывается;
Во время формирования разрыва, дополнительно передается поле _trigger_lock_update - как раз таки для того, что бы триггер для UPDATE
знал, что это обновление связано исключительно со структурой дерева, и никаких дополнительных вычислений и изменений структуры не требуется,
так как передаются уже конечные значения ключей;
Изменение записи
Точнее данный триггер будет работать исключительно со структурой дерева, а не с изменяемыми данными.
Основными параметрами принуждающие его к каким либо действиям являются parent_id или left_key
(помня, конечно, о _trigger_lock_update как об управляющем параметре для триггера).
Алгоритм простой: сначала координаты перемещения, потом перестраиваем дерево.
Пояснения:
Изначально, кроме параметра _trigger_lock_update мы проверяем так же параметр _trigger_for_delete.
Это сделано потому, что во время удаления, мы не передавать параметры, как изменение поля, поэтому удаление записей триггером
будем производить через UPDATE определенных записей. Впрочем более понятно станет далее;
Опять же в данном случае, parent_id у нас боле приоритетный чем left_key, поэтому его проверяем первым;
При проверке left_key мы выбираем изначально либо узел, который будет перед перемещаемым узлом (right_key = _left_key + 1),
либо узел в который мы перемещаем узел (right_key = _left_key). При этом, у некоторых случаях результатом запроса будет возвращаться
2 узла, как будущий сосед, так и будущий родитель, что на логику никак не влияет, по этому LIMIT 1 установлен, что бы не просто не
выбирать лишние данные, сортировка не важна, так как даже если результатом выборки будет 2 узла, они оба будут корректны, поэтому нам совершенно
безразлично какой из них нам вернется. Но хочу обратить внимание, на то, что если мы указываем у перемещаемого узла left_key = 1, то естественно,
что ни впередистоящего, ни родительского узла у нас не будет, для чего используем дополнительное условие "ELSIF NEW.left_key = 1";
При перестроении дерева, дополнительным условием является "id <> OLD.id", это сделано потому, что мы не можем в триггере изменять запись,
которую и так сейчас меняем.
Удаление записи
Вот с удалением сложнее всего. Во-первых, потому, что существует 2 принципа удаления узлов:
Удаление узла с потомками;
Удаление узла без потомков, при этом дочерние узлы смещаются по дереву на уровень вверх;
Во-вторых, мы не можем в запросе на удаление передавать параметры, что бы ограничить рекурсивный вызов триггера, впрочем, рекурсивный вызов триггера актуален
только для случая удаления узла с потомками.
Делать универсальный триггер для обоих принципов удаления будет накладно, слишком много логики будет. Лучше все-таки два разных решения.
В первом решении (удаление узла с потомками) у нас будет следующий алгоритм:
Обновляем дочерние узлы на предмет установки поля (параметра) _trigger_for_delete;
Удаляем дочерние узлы;
Удаляем разрыв в ключах в дереве (перестаиваем дерево);
Во втором решении просто смещаем дочернее дерево на уровень вверх, и удаляем разрыв ключей.
Собственно все. Осталось только проставить индексы (мне лениво сюда писать SQL команды, поэтому просто их озвучу):
Композитный не уникальный на поля (left_key, right_key, level, tree);