Управление «чистым» алгоритмом Materialized Path

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

Задача

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

Исходные данные

Создаем простую таблицу без излишеств:

CREATE TABLE "public"."mp_tree" (
  "id"          INTEGER             DEFAULT nextval('mpath_id_seq'::regclass) NOT NULL, 
  "name"        TEXT, 
  "mpath"       "public"."ltree"    NOT NULL, 
  CONSTRAINT "mpath_pkey" PRIMARY KEY("id")
) WITHOUT OIDS;

CREATE UNIQUE INDEX "mp_tree_idx" ON "public"."mp_tree"
  USING btree ("mpath" "public"."ltree_ops");

CREATE INDEX "mp_tree_idx1" ON "public"."mp_tree"
  USING gist ("mpath" "public"."gist_ltree_ops");

Правда, что плохо в ltree — приходится создавать 2 индекса на поле path, для того, что бы можно было осуществлять как по ltree типу так и по text:

В качестве элементов материализованного пути возьмем id.

Традиционно, для поддержания целостности данных я использую триггера. Соответственно их и буду описывать.

Триггер на вставку

Основной сложностью вставки материализованного пути на базе id является то, что id у нас не известен до момента вставки, поэтому собрать путь заранее не представляется возможным. Эту проблему можно решить создав триггер на вставку записи, в котором мы будем переопределять материализованный путь вставляемого узла относительно входных данных. Входные данные у нас могут быть двух видов: ID родительского узла или материализованный путь родительского узла.

CREATE OR REPLACE FUNCTION "public"."_mpath_insert_before_trigger" (
)
RETURNS TRIGGER AS
$body$
DECLARE
    new_mpath       ltree;
BEGIN
    IF NEW.mpath IS NOT NULL THEN
--- передан, какой-то материализованный путь
------- обрезаем в материализованном пути id вставляемого узла, если он есть
        new_mpath := CASE WHEN NEW.id::text = subpath(NEW.mpath, -1, 1)::text
                          THEN subpath(NEW.mpath, 0, -1)
                          ELSE NEW.mpath
                     END;
------- проверяем существование родителя
        SELECT mp.mpath INTO new_mpath 
            FROM mp_tree AS mp
            WHERE mp.mpath = new_mpath OR mp.id::text = new_mpath::text;
------- родителя не нашли
        IF new_mpath IS NULL OR new_mpath = '' THEN
            NEW.mpath := NEW.id::text;
        ELSE -- родителя нашли
            NEW.mpath := new_mpath || NEW.id::text;
        END IF;
    ELSE
        NEW.mpath := NEW.id::text;
    END IF;
    RETURN NEW;
END;
$body$
LANGUAGE 'plpgsql';

CREATE TRIGGER "mpath_insert_before" BEFORE INSERT 
ON "public"."mp_tree" FOR EACH ROW 
EXECUTE PROCEDURE "public"."_mpath_insert_before_trigger"();

Сразу бросается в глаза постоянное принудительное приведение значений переменных к определенному типу (::text). Это важно, так как ltree может работать только со своим типом данных и типом text.

Триггер на обновление

С обновлением тоже есть определенные сложности, так как при перемещении узла, нам потребуется обновить все подчиненные ветви, что приведет к повторному вызову триггера на обновление.

Так как нет прямой связи родитель-потомок, то FOREIGN KEY навесить не получится, но можно производить обновление рекурсивно, если применять изменения не на всех потомков, а только потомков следующего уровня вложенности:

CREATE OR REPLACE FUNCTION "public"."_mpath_update_after_trigger" (
)
RETURNS TRIGGER AS
$body$
DECLARE
    tid         INTEGER;  
BEGIN
--- Принудительно запрещаем изменять ID
    NEW.id := OLD.id;
--- Приводим NULL значение материализованного пути к ID
    NEW.mpath := CASE WHEN NEW.mpath IS NULL 
                      THEN NEW.id::text
                      ELSE NEW.mpath::text 
                 END;
--- Есть ли у нас изменения материализованного пути
    IF NEW.mpath <> OLD.mpath THEN
------- Проверяем что новое значение материализованного пути
------- лежит не пределах подчинения и что на его конце ID
        IF 
            NEW.mpath ~ ('*.' || NEW.id::text || '.*{1,}')::lquery OR
            NEW.mpath ~ ('*.!' || NEW.id::text)::lquery
        THEN
            RAISE EXCEPTION 'Bad mpath!';
        END IF;
------- Если уровень больше 1 то стоит проверить родителя
        IF nlevel(NEW.mpath) > 1 THEN
            SELECT m.id INTO tid
                FROM mp_tree AS m
                WHERE m.mpath = subpath(NEW.mpath, 0, nlevel(NEW.mpath) - 1);
            IF tid IS NULL THEN
                RAISE EXCEPTION 'Bad parent!';
            END IF;
        END IF;
------- Обновляем детей узла следующего уровня
        UPDATE mp_tree
            SET mpath = NEW.mpath || id::text
            WHERE mpath ~ (OLD.mpath::text || '.*{1}')::lquery;
    END IF;
    RETURN NEW;
END;
$body$
LANGUAGE 'plpgsql';

CREATE TRIGGER "mpath_update_after" AFTER UPDATE
ON "public"."mp_tree" FOR EACH ROW 
EXECUTE PROCEDURE "public"."_mpath_update_after_trigger"();

Так же как и в триггере на вставку производится жесткая типизация данных.

Триггер на удаление

Тут вообще все просто: рекурсивное удаление всех детей:

CREATE OR REPLACE FUNCTION "public"."_mp_tree_delete_after_trigger" (
)
RETURNS TRIGGER AS
$body$
BEGIN
    DELETE FROM mp_tree
        WHERE mpath ~ (OLD.mpath::text || '.*{1}')::lquery;
    RETURN OLD; 
END;
$body$
LANGUAGE 'plpgsql';

CREATE TRIGGER "mp_tree_delete_after" AFTER DELETE 
ON "public"."mp_tree" FOR EACH ROW 
EXECUTE PROCEDURE "public"."_mp_tree_delete_after_trigger"();

На этом все. Никаких сложностей, даже при отсутствии прямого наследования (Adjacency List).

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

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

Поддержка Postgre SQL

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