Управление ltree с использованием алгоритма Adjacency List

Обновлено: 14 февраля, 2025

Задача

«Прозрачное» управление, совместно с алгоритмом Adjacency List.

На самом деле, мне лично смешивание Materialized Path и Adjacency List при использовании ltree представляется сомнительным решением, особенно учитывая то, что в большинстве случаем материализованный путь составляется из ID узлов, и ID родителя получить не представляется сложной задачей. Тем не менее, определенные плюсы от этого есть, а именно:

  • Прямая связь с родителем и возможность навесить FOREIGN KEY;
  • Возможность просто указывать id родителя не тратя время на формирование материализованного пути изменяемого узла;

Универсальная хранимая процедура

Вообще идея подобной процедуры не моя, а моего коллеги, но он так до конца её и не реализовал. Эту реализацию я сделал в том числе и для него.

Таблица должна иметь следующие свойства:

  • материализованных путь формируется из id узлов;
  • имеется возможность хранения нескольких независимых деревьев;

В таблице должны присутствовать следующие обязательные поля:

CREATE TABLE "public"."section" (
    "id"            SERIAL, 
    "parent"        INTEGER,
    "root"          INTEGER             NOT NULL,
    "path"          "public"."ltree"    NOT NULL,
        CONSTRAINT "section__path__key" UNIQUE("path"), 
        CONSTRAINT "section__pkey" PRIMARY KEY("id"), 
        CONSTRAINT "section__section__fkey" FOREIGN KEY ("parent")
        REFERENCES "public"."section"("id")
            ON DELETE CASCADE
            ON UPDATE CASCADE
            NOT DEFERRABLE
) WITHOUT OIDS;

Где:

  • id — собственно id;
  • pid — id родителя, NULL если узел корневой;
  • root — идентификатор дерева, обязательное поле. Если в таблице будет храниться одно дерево, то для него можно поставить значение по умолчанию, например, 1;
  • path — материализованный путь;

Соответственно, внешний ключ на каскадное обновление и удаление для поля pid.

Сама хранимая процедура выглядит так:

CREATE OR REPLACE FUNCTION "public"."ltree_modify" (
)
RETURNS TRIGGER AS
$body$
DECLARE
    prnt            RECORD;
    ppath           ltree           := '';
BEGIN 
-- Операции использующие переменную NEW
    IF tg_op <> 'DELETE' THEN
        IF NEW.parent IS NOT NULL THEN
            EXECUTE 'SELECT * FROM ' || tg_table_schema || '.' || tg_table_name ||
                       ' WHERE id = ' || NEW.parent INTO prnt;
            IF prnt.id IS NULL THEN
                RAISE EXCEPTION 'parent is not exists: %', NEW.parent;
            END IF;
-- проверяем принадлежность к дереву в таблице
            IF NEW.root IS NULL THEN
                NEW.root := prnt.root;
            ELSE
                IF NEW.root <> prnt.root THEN
                    RAISE EXCEPTION 'different roots: % <=> %',
                        prnt.root, NEW.root;
                END IF;
            END IF;
            ppath := prnt.path;
        ELSE
            IF NEW.root IS NULL THEN
                RAISE EXCEPTION 'root is required';
            END IF;
        END IF;
        NEW.path := ppath || NEW.id::text;
    END IF;
-- Операции использующие переменную NEW и OLD
    IF tg_op = 'UPDATE' THEN
-- Проверяем что бы перенос узла был не пределах подчинения
        IF NEW.path <> OLD.path AND NEW.path <@ OLD.path THEN
            RAISE EXCEPTION 'can''t move node: % => %', OLD.path, NEW.path;
        END IF;
    END IF;
-- Возвращаем
    IF tg_op <> 'DELETE' THEN RETURN NEW; ELSE RETURN OLD; END IF;
END;
$body$
LANGUAGE 'plpgsql';

Ввиду того, что данная процедура будет использоваться при любом изменении таблицы, операции в ней разделены по использованию переменных NEW и OLD, так как при обращении к переменной NEW во время удаления даст ошибку, так как и обращение к переменной OLD во время вставки.

В процедуре предусмотрена работа с переменными таблицами и схемами, в зависимости от триггера её (процедуру) вызвавшего, что дает возможность использовать данную процедуру в нескольких таблицах одновременно. Но данное решение не совсем оптимально в так как запросы внутри процедуры не компилируются сразу а в процессе выполнения.

Создаем триггер для таблицы.

CREATE TRIGGER "section_tr" BEFORE INSERT OR UPDATE OR DELETE 
ON "public"."section" FOR EACH ROW 
EXECUTE PROCEDURE "public"."ltree_modify"();

Увы, при изменении подчиненности узла, вышеуказанный триггер не распространится на детей, соответственно потребуется произвести ручное обновление (внешний ключ не поможет так как id не обновляется):

CREATE OR REPLACE FUNCTION "public"."ltree_after_update" (
)
RETURNS TRIGGER AS
$body$
BEGIN 
    IF NEW.path <> OLD.path THEN
        EXECUTE 'UPDATE ' || tg_table_schema || '.' || tg_table_name ||
                   ' SET path = NULL
                     WHERE parent = ' || NEW.id;
    END IF;
    RETURN NEW;
END;
$body$
LANGUAGE 'plpgsql';

CREATE TRIGGER "section_tr1" AFTER UPDATE 
ON "public"."section" FOR EACH ROW 
EXECUTE PROCEDURE "public"."ltree_after_update"();

То что удаление подчиненных записей производится до удаления родительской тоже не совсем хорошо, но опять же, все зависит от того, насколько часто производится удаление.

Опубликовано: 14 февраля, 2025

ЕЩЕ СТАТЬИ ПО ДАННОЙ ТЕМЕ

Поддержка Postgre SQL

Поддержка — это когда у вас возникает техническая
проблема с существующей системой,
и вам необходимо некоторое руководство