NHibernate: как хранить иерархические сущности (деревья) в базе

Многие из вас, скорее всего, сталкивались с простой на первый взгляд задачей: сохранение иерархических данных в базу и последующая работа с ними. Кажется, что нет ничего проще: создадим в таблице колонку PARENT_ID и будем записывать туда, собственно, идентификатор нашей вышестоящей сущности.

    class Tree {
        int Id;
        Tree Parent;
    }

Но, это только на первый взгляд.

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

C такой задачей ни одна ORM уже не справится: в лучшем случае вы получите SELECT N+1. Для решения этой проблемы вам придется написать кастомный зависящий от конкретной базы запрос: рекурсивные запрос с WITH в Microsoft Sql Server; запрос с CONNECT BY PRIOR в Oracle; либо специальную хранимую процедуру.

В статье “How to map a tree in NHibernate" Gabriel Schenker предлагает альтернативный вариант: необходимо добавить таблицу, в которой для каждой сущности мы будем хранить ссылки на всех ее предков и всех ее потомков. Потомки будут отображаться на коллекцию Descendants, а предки на коллекцию Ancestors. Обе коллекции many-to-many:

    class Tree {
        int Id;
        Tree Parent;
        IEnumerable<Tree> Children;
        IEnumerable<Tree> Ancestors;
        IEnumerable<Tree> Descendant;
    }

С такой структурой очень легко обращаться.

Но, плюсы не бывают без минусов. Из минусов могу отметить то, что вам необходимо следить за состоянием таблицы иерархических связей: это можно делать из кода, либо с помощью триггера\запроса\хранимой процедуры. К счастью, если это делать в коде, то этот код нужно написать лишь раз и использовать его везде, где необходимо, что я собственно и сделал.

Brandy.Grapes

Brandy.Grapes - это небольшой (всего 3) набор библиотек, который позволяет легко и непринужденно работать с сохраняемыми иерархическими сущностями в NHibernate.

  • Необходимо установить библиотеку через nuget (поддерживается NHibernate By Code и FluentNHibernate):

    > install-package Brandy.Grapes.NHibernate
    

    или

    > install-pacakge Brandy.Grapes.FluentNhibernate
    
  • Унаследовать вашу сущность от TreeEntry`1

    public class MySuperTree : TreeEntry<MySuperTree> {
        public virtual int Id { get; set; }
    
        public virtual string Name { get; set; }
    }
    
  • Наконец, написать маппинг, к примеру, для FluentNHibernate:

    using Brandy.Grapes.FluentNHibernate;
    public class MySuperTreeMap : ClassMap {
        public MySuperTreeMap() {
            Id(x => x.Id);
            Map(x => x.Name);
    
            this.MapTree("MySuperTreeHierarchy"); // вся магия происходит здесь
        }
    }
    
  • Наслаждаться: теперь Brandy.Grapes будет отслеживать изменения в иерархии и корректно сохранять их в базу.

Как справедливо заметил Денис Боровнев, при изменении иерархии необходимо из базы подгрузить всю иерархию для данного элемента, чтобы правильно обновить связи. Если у вас в проекте иерархические сущности изменяются достаточно часто, то можно отключить изменение иерархии из кода и обновлять связи через базу. Существует несколько способов:

  • Вызывать хранимую процедуру (по триггеру, или из кода), для обновления иерархических связей:

    -- пример для Microsoft Sql Server
    CREATE PROCEDURE [dbo].[FillHierarchy] (@table_name nvarchar(MAX), @hierarchy_name nvarchar(MAX))
    AS
    BEGIN
        DECLARE @sql nvarchar(MAX), @id_column_name nvarchar(MAX)
        SET @id_column_name = '[' + @table_name + '_ID]'
        SET @table_name = '[' + @table_name + ']'
        SET @hierarchy_name = '[' + @hierarchy_name + ']'
    
        SET @sql = ''
        SET @sql = @sql + 'WITH Hierachy(CHILD_ID, PARENT_ID) AS ( '
        SET @sql = @sql + 'SELECT ' + @id_column_name + ', [PARENT_ID] FROM ' + @table_name + ' e '
        SET @sql = @sql + 'UNION ALL '
        SET @sql = @sql + 'SELECT e.' + @id_column_name + ', e.[PARENT_ID] FROM ' + @table_name + ' e '
        SET @sql = @sql + 'INNER JOIN Hierachy eh ON e.' + @id_column_name + ' = eh.[PARENT_ID]) '
        SET @sql = @sql + 'INSERT INTO ' + @hierarchy_name + ' ([CHILD_ID], [PARENT_ID]) ( '
        SET @sql = @sql + 'SELECT [CHILD_ID], [PARENT_ID] FROM Hierachy WHERE [PARENT_ID] IS NOT NULL '
        SET @sql = @sql + ') '
    
        EXECUTE (@sql)
    END
    GO
    
  • Для каждой иерархии создать View (тот же запрос, что и в хранимой процедуре) и отобразить связи Ancestors и Descendants на эту View:

    -- Пример для Microsoft Sql Server
    CREATE VIEW [MySuperTreeHierarchy]
    AS
        WITH Hierachy (CHILD_ID, PARENT_ID) 
        AS 
        (
            SELECT [MySuperTree_ID], [PARENT_ID] FROM [MySuperTree] AS e
            UNION ALL
            SELECT e.[MySuperTree_ID], e.[PARENT_ID] FROM [MySuperTree] AS e 
                INNER JOIN Hierachy AS eh ON e.[MySuperTree_ID] = eh.[PARENT_ID]
        )
    
        SELECT [CHILD_ID], [PARENT_ID] FROM Hierachy WHERE [PARENT_ID] IS NOT NULL
    GO
    

Оба этих подхода обладают большей гибкостью и надежностью, чем иерархические запросы на чистом SQL из кода.

PS: интерфейс абстрактного класса TreeEntry`1:

    public abstract class TreeEntry<T> where T : TreeEntry {
        public virtual T Parent { get; set; }

        public virtual IEnumerable<T> Children { get; }

        public virtual IEnumerable<T> Ancestors { get; }

        public virtual IEnumerable<T> Descendants { get; }

        public virtual void AddChild(T child);

        public virtual void RemoveChild(T child);
    }

PPS: для EF такое сделать также возможно, но т.к. EF не поддерживает скрытие коллекций за интерфейсом IEnumerable`1, я не стал выкладывать реализацию для EF в открытый доступ.

comments powered by Disqus