NHibernate: mapping class hierarchy

Эта статья погрузит Вас в тонкие материи работы с NHibernate, а именно mapping иерархии классов в смешанном стиле table-per-subclass + table-per-hierarchy + discriminator.

В стиле table-per-subclass, равно как и table-per-hierarchy, нет ничего особенного, это все довольно стандартные и банальные вещи, если речь идет об отображении иерархии классов. Интересное начинается тогда, когда задача выходит за рамки простых учебных примеров, и нужно, например, совместить эти два стиля в рамках отображения одной иерархии классов. Как раз в этих ситуациях и проявляется истинная мощь NHibernate. Об этом мы сегодня и поговорим.

Попутно будет раскрыт ряд интересных моментов, таких как:

  • Что такое implicit polymorphism в NHibernate.
  • В чем заключается сложность mapping'a интерфейсов.
  • [BONUS] Как в NHibernate вытянуть все данные из БД в рамках одного простого запроса.
  • Как выглядит sql запрос, который сгенерирует NHibernate для иерархии классов.
  • Использование любого sql-выражения в качестве discriminator, а не только фиксированной колонки.
Mapping будет описываться c помощью Fluent NHibernate, также будут даваться ссылки на raw xml-based конструкции.

Исходная задача

Object model

Предметной областью является интернет-магазин, который выполняет заказы по созданию эксклюзивных альбомов и отправке их заказчику. Задача состоит в необходимости отслеживания событий, которые произошли с альбомом в процессе его обработки и отправки. Сущность Album обладает понятием статуса (Created, Prepared, Cancelled, ...), однако исключительно статуса недостаточно для описания жизненного цикла заказа. Некоторые значимые события могут не переводить заказ в новый статус. Помимо этого с каждым событием потенциально могут быть ассоциированы некоторые данные, специфичные именно для этого события. Например, для события отмены заказа (cancellation) важно знать кем был отменен заказ (WhoCancels), и какая причина. Если произошла ошибка, то важно знать описание ошибки, и где она произошла. В итоге приходим к такой объектной модели. Object model

Данными, общими для всех событий, являются тип события (Type), время его возникновения (Timestamp), статус (AlbumStatus), в который это событие перевело или не перевело заказ. Некоторые события, такие как failure, cancellation, submit, содержат дополнительные данные. Другие события не содержат дополнительных данных, однако в объектной модели мы хотим представить их как отдельные классы, вместо того чтобы использовать базовый. Сущность Album имеет две ассоциации с событиями. Первая - это Events, то есть набор всех событий, произошедших с этим заказом, второй - LastEvent, то есть ссылка на последнее событие. Событие в свою очередь ссылается на заказ, к которому оно относится. То есть получается bidirectional association.

Relational model

Схема базы данных в свою очередь выглядит так. Database schema

С точки зрения БД, иерархия смоделирована в виде таблицы AlbumEvent, которая содержит данные, общие для всех событий. Данные расширенных событий (failure, cancellation, creation) помимо таблицы album_event содержатся в дополнительных таблицах (AlbumEventCancel, AlbumEventFail), которые связаны с AlbumEvent 1:1 по первичному ключу. Связи между Album и AlbumEvent представлена при помощи внешних ключей AlbumID и LastEventID.

Table-per-hierarchy

Стиль table-per-hierarchy состоит в том, что вся иерархия классов отображается на одну таблицу базы данных. Схема этой таблицы покрывает всё разнообразие данных всех классов иерархии. В нашем случае это могло бы выглядеть так.

Для того, чтобы отличить какому классу в иерархии соответствует та или иная строка, NHibernate'у необходимо указать так называемый discriminator value. В нашем случае - это Event type. Вот как бы выглядел mapping в table-per-hierarchy стиле.

public class AlbumEventMap : ClassMap<AlbumEventBase>
{
    public AlbumEventMap()
    {
        Table("AlbumEvent");

        Id(e => e.ID).Column("EventID").GeneratedBy.Native();
        Map(e => e.AlbumStatus).Not.Nullable().Length(32);
        Map(e => e.Timestamp).Not.Nullable();

        // tell NHibernate to use value from EventType column as a discriminator 
        DiscriminateSubClassesOnColumn("EventType");
    }
}

// usage of SubclassMap
public class FailedAlbumEventMap : SubclassMap<FailedAlbumEvent>
{
    public FailedAlbumEventMap()
    {
        // if EventType column has value FAIL, then map database row to FailedAlbumEvent class
        DiscriminatorValue("FAIL");

        // map additional properties
        // NOTE: all additional columns should be marked as nullable
        Map(e => e.Reason).Nullable().Length(40);
        Map(e => e.Origin).Nullable().Length(40);
        Map(e => e.Details).AsUnicodeString().Nullable().Length(1024);
    }
}

public class PreparedAlbumEventMap : SubclassMap<PreparedAlbumEvent>
{
    public PreparedAlbumEventMap()
    {
        DiscriminatorValue("PREPARED");
        // no additional properties to map
    }
}

Минус в том, что колонки, которые представляют дополнительные данные, должны быть nullable, даже если это и не соответсвует бизнес ограничениям. Это довольно неприятно. Помимо этого, таблица будет скорее всего полузаполненной, снова же из-за null значений. Из плюсов, это то, что данные находятся в одной таблице, а значит выполнение запросов будет быстрым.

Я бы использовал этот стиль только в том случае, если количество различных данных в иерархии довольно мало по отношению к количеству общих данных. Если это не так, то стоит рассмотреть другой вариант - table-per-subclass.

Table-per-subclass

Каждый класс в иерархии отображается на отдельную таблицу. То есть, если бы в нашей иерархии были только классы: CancelledAlbumEvent, FailedAlbumEvent, CreatedAlbumEvent, а в базе данных - таблицы: AlbumEvent, AlbumEventCancel, AlbumEventFail, AlbumEventSubmit, то можно было бы смело заявить, что это стиль table-per-subclass.

Mapping по сравнению с предыдущим сценарием отличается не сильно. В данном случае не нужно указывать discriminator, так как таблицы БД сами по себе являются теми отличительными значениями, которые нужны NHibernate'у.

public class AlbumEventMap : ClassMap<AlbumEventBase>
{
    public AlbumEventMap()
    {
        Table("AlbumEvent");
        
        Id(e => e.ID).Column("EventID").GeneratedBy.Native();
        Map(e => e.AlbumStatus).Not.Nullable().Length(32);
        Map(e => e.Timestamp).Not.Nullable();

        // no discriminator column
    }
}

// use SubclassMap once again
public class FailedAlbumEventMap : SubclassMap<FailedAlbumEvent>
{
    public FailedAlbumEventMap()
    {
        Table("AlbumEventFail");

        Map(e => e.Reason).Not.Nullable().Length(40);
        Map(e => e.Origin).Nullable().Length(40);
        Map(e => e.Details).AsUnicodeString().Not.Nullable().Length(1024);
    }
}

public class CreatedAlbumEventMap : SubclassMap<CreatedAlbumEvent>
{
    public CreatedAlbumEventMap()
    {
        Table("AlbumEventSubmit");
        Map(e => e.AlbumOrigin).Not.Nullable().Length(40);
    }
}
В классическом xml-based mapping используется конcтрукция joined-subclass.

Table-per-subclass, using a discriminator

Однако NHibernate не ограничивает нас, и дает возможность даже в случае table-per-subclass указать discriminator value. В нашем случае это выглядит довольно разумно, в качестве discriminator используем колонку EventType. Mapping несколько меняется, а в xml-based формате используется конструкция subclass + discriminator-value + join.

public class AlbumEventMap : ClassMap<AlbumEventBase>
{
    public AlbumEventMap()
    {
        Table("AlbumEvent");

        Id(e => e.ID).Column("EventID").GeneratedBy.Native();
        Map(e => e.AlbumStatus).Not.Nullable().Length(32);
        Map(e => e.Timestamp).Not.Nullable();

        DiscriminateSubClassesOnColumn("EventType");
    }
}

public class FailedAlbumEventMap : SubclassMap<FailedAlbumEvent>
{
    public FailedAlbumEventMap()
    {
        DiscriminatorValue("FAIL");
        Join("AlbumEventFail", j =>
        {
            j.KeyColumn("EventID");
            j.Map(e => e.Reason).Not.Nullable().Length(40);
            j.Map(e => e.Origin).Nullable().Length(40);
            j.Map(e => e.Details).AsUnicodeString().Not.Nullable().Length(1024);
        });
    }
}
Можно сказать, что "table-per-subclass, using discriminator" - это мост, который позволяет связать два стиля: table-per-subclass и table-per-hierarchy. Так как table-per-hierarchy основан на использовании discriminator value, то table-per-subclass тоже должен поддерживать эту опцию.

2-in-1: table-per-subclass + table-per-hierarchy

Соединим два стиля, изложенных выше, вместе и применим их для решения нашей задачи. Простые классы событий будут отображены на таблицу album_event в стиле table-per-hierarchy, события же с дополнительными данными - на собственные таблицы в стиле table-per-subclass.

// base class mapping
public class AlbumEventMap : ClassMap<AlbumEventBase>
{
    public AlbumEventMap()
    {
        Table("AlbumEvent");

        Id(e => e.ID).Column("EventID").GeneratedBy.Native();
        Map(e => e.AlbumStatus).Not.Nullable().Length(32);
        Map(e => e.Timestamp).Not.Nullable();

        DiscriminateSubClassesOnColumn("EventType")
          
        // NOTE: this cannot be done, we've already used EventType as a discriminator column
        // NOTE: mapping the same column twice will cause update conflicts
        Map(e => e.Type).Column("EventType").Length(40);

    }
}

// simple event w/o additional data
// map it in a table-per-hierarchy style
public class PreparedAlbumEventMap : SubclassMap<PreparedAlbumEvent>
{
    public PreparedAlbumEventMap()
    {
        DiscriminatorValue("PREPARED");
    }
}

// event with custom data
// use table-per-subclass, using discriminator
public class CancelledAlbumEventMap : SubclassMap<CancelledAlbumEvent>
{
    public CancelledAlbumEventMap()
    {
        DiscriminatorValue("CANCEL");
        Join("AlbumEventCancel", j =>
        {
            j.KeyColumn("EventID");
            j.Map(e => e.WhoCancels).Not.Nullable().Length(40);
            j.Map(e => e.Comment).AsUnicodeString().Nullable().Length(1024);
        });
    }
}

Formula as a discriminator

Первая проблема, с которой мы сталкиваемся здесь, это то, что мы не можем отобразить свойство EventType на соответствующую колонку, так как она уже используется в качестве discriminator. Посмотрим как можно решить эту проблему.

Корень проблемы лежит в том, что если дважды отобразить одну и ту же колонку таблицы БД (как свойство сущности и как discriminator value), то могут возникнуть конфликты обновления, и NHibernate не сможет корректно разрешить, что именно использовать в качестве значения для этой колонки. Одно из решений - пометить discriminator как read-only.

...
<property name = "Type" column = "EventType" />
<discriminator column = "EventType" insert = "false" />
...

В отличии от xml-based syntax, fluent API не позволяет установить значение "insert = false". В качестве альтернативного варианта, можно указать формулу для discriminator value, а не фиксированную колонку.
Map(e => e.Type).Column("EventType").Length(40);
DiscriminateSubClassesOnColumn("").Formula("EventType");

В качестве формулы допускается любой валидный sql.

Map(e => e.Type).Column("EventType").Length(40);
DiscriminateSubClassesOnColumn("")
    .Formula("case when LEFT(EventType, 8) = 'Contract' then 'Contract' else EventType end");

Mapping against interface

При наличии иерархии AlbumEventBase : IAlbumEvent, возникает резонный вопрос почему таблица "album_event" отображается на базовый класс AlbumEventBase вместо интерфейса IAlbumEvent. Ведь другие классы ссылаются на интерфейс, а не на базовый класс. Базовый класс - детали реализации, исключительно для удобства, его могло бы и не быть. Чтож, попробуем переписать меппинг для интерфейса вместо базового класса.

public interface IAlbumEvent
{
    int ID { get; }
    EventType Type { get; }
    AlbumStatus Status { get; }
    Album Album { get; }
    DateTime Timestamp { get; }

    void Raise(Album album);
}

// change AlbumEventBase to IAlbumEvent
public class AlbumEventMap : ClassMap<IAlbumEvent>
//public class AlbumEventMap : ClassMap<AlbumEventBase>
{
    // the rest of the mapping is the same
}
NHibernate скажет:
NHibernate.PropertyNotFoundException: Could not find a setter for property 'ID' in class 'Events.IAlbumEvent'

Если модель допускает поднятие setter'ов на уровне интерфейса, то тогда все хоккей. Если же, нет, то тогда можно попытаться указать использовать backing field в качестве access strategy, чтобы NHibernate искал field, а не property setter.

Id(e => e.ID).Column("EventID")
    .GeneratedBy.Native()
    .Access.ReadOnlyPropertyThroughLowerCaseField(Prefix.Underscore);
И теперь:
NHibernate.PropertyNotFoundException: NHibernate.PropertyNotFoundException: Could not find field '_id' in class 'Events.IAlbumEvent'
Несмотря на то, что NHibernate больше не ищет property setter, а ищет backing field, однако ищет он его по-прежнему в интерфейсе, а не в базовом классе. Здесь обсуждается подобная проблема. Остается либо поднять property setter на уровень интерфейса, либо описывать меппинг по отношению к базовому классу. Однако это привносит другую проблему: можно ли будет использовать IAlbumEvent, если для него не описан меппинг? Ответ на этот вопрос: implicit polymorphism.

Implicit polymorphism

В качестве описания что такое implicit polymorphism, можно процитировать эту статью.

Implicit polymorphism means that instances of the class will be returned by a query that names any superclass or implemented interface or the class and that instances of any subclass of the class will be returned by a query that names the class itself. For most purposes the default, polymorphism=”implicit”, is appropriate.
А теперь попроще. Если запрос описан в терминах базового класса, или интерфейса, то помимо экземпляров этого класса запрос вернет экземпляры всех его подклассов, для которых описан меппинг.

Для нашего примера это значит, что нет необходимости явно описывать меппинг по отношению к интерфейсу IAlbumEvent, более того, используя интерфейс к примеру в ассоциации ISet<IAlbumEvent>, мы делаем такую коллекцию полиморфной, то есть результатом загрузки такой коллекции будет вся наша иерархия классов событий. Вот как будет выглядеть меппинг класса Album.

public class Album
{
    // associations are expressed in terms of IAlbumEvent, not AlbumEventBase
    public virtual IEnumerable<IAlbumEvent> Events { get; }
    public virtual IAlbumEvent LastEvent { get; private set; }
}

public class AlbumMap : ClassMap<Album>
{
    // note, explicit specification of AlbumEventBase as a type-parameter
    o.HasMany<AlbumEventBase>(o => o.Events).AsSet()
        .Inverse().Cascade.AllDeleteOrphan()
        .KeyColumn("AlbumID")
        .Access.ReadOnlyPropertyThroughCamelCaseField(Prefix.Underscore);
    
    o.References<AlbumEventBase>(o => o.LastEvent)
        .Nullable().Fetch.Join()
        .ForeignKey("FK_Album_Album_event_LastEventID");
}
И последний штрих, для того, чтобы все это работало. Нужно явно указать AlbumEventBase в качестве типа-параметра в конструкции o.HasMany<AlbumEventBase>(o => o.Events), иначе будет такая ошибка.
NHibernate.MappingException: Association references unmapped class: Events.IAlbumEvent.

How to get everything from DB?

Один интересный побочный эффект применения "implicit polymorphism". Каков будет результат выполнения этого запроса:

Session.Query<object>().ToList();
Да-да, вот так просто можно вытянуть всю базу данных. Хотя врядли это Вам понадобится. :)

Result sql query

Ну и сейчас наверное самое интересное, особенно для тех, кого беспокоит как будет выглядеть конечный sql запрос и его производительность. А это на самом деле не может не беспокоить, потому что если на проекте есть DBA, то он врядли будет рад километровым запросам с 32-мя уровнями вложенных select'ов, и рано или поздно он к Вам придет.

Итак, сделаем запрос на получение всех заказов вместе с его последним событием.

Session.Query<Album>().ToList();
И вот, что мы имеем на выходе.
SELECT
        album0_.AlbumID as AlbumID9_3_,
        album0_.AlbumStatusCode as AlbumSta4_9_3_,
        -- and more album related columns

        client1_.ClientID as ClientID0_0_,
        -- and more client related columns 

        -- all event related columns
        albumevent2_.EventID as EventID3_1_,
        albumevent2_.AlbumStatus as AlbumSta2_3_1_,
        albumevent2_.Timestamp as Timestamp3_1_,
        albumevent2_.EventType as EventType3_1_,
        albumevent2_.AlbumID as AlbumID3_1_,
        albumevent2_1_.WhoCancels as Actor4_1_,
        albumevent2_1_.Comment as Comment4_1_,
        albumevent2_2_.Reason as Reason5_1_,
        albumevent2_2_.Origin as Origin5_1_,
        albumevent2_2_.Details as Details5_1_,
        albumevent2_4_.AlbumOrigin as AlbumOri2_7_1_,
        case 
            when LEFT(albumevent2_.EventType, 8) = 'Contract' 
            then 'Contract' 
            else albumevent2_.EventType 
        end as clazz_1_
    FROM [Album] album0_ inner join Client client1_ 
        on album0_.ClientID=client1_.ClientID 
    left outer join
        AlbumEvent albumevent2_ 
            on album0_.LastEventID=albumevent2_.EventID 
    left outer join
        AlbumEventCancel albumevent2_1_ 
            on albumevent2_.EventID=albumevent2_1_.EventID 
    left outer join
        AlbumEventFail albumevent2_2_ 
            on albumevent2_.EventID=albumevent2_2_.EventID 
    left outer join
        AlbumEventCreated albumevent2_4_ 
            on albumevent2_.EventID=albumevent2_4_.EventID 
    WHERE
        album0_.AlbumID=@p0;
И здесь NHibernate потрясает нас чистотой запроса. На самом деле запрос близок к написанному человеком, и отличается разве что именованием.

Лирика

И напоследок, небольшое лирическое отступление на тему возможностей NHibernate.

NHibernate - мощная технология. Но чтобы овладеть ёё возможностями и заставить её работать, нужны труд и терпение, нужно осознать идею и вникнуть в детали. Поверхностных знаний недостаточно, и книжка "NHibernate за 12 часов" - тут не поможет. Да, высокий порог вхождения, но в этом в то же время и прелесть. Тем больше вероятность, что код будет писаться более опытными разработчиками, и тем меньше вероятность появления косяков.

NHibernate - как хороший автомобиль, на плохом бензине даже не заведется!

1 comments:

Романовский Евгений said...

Интересная статья, спасибо.
У меня появились вопросы:
1) Все-таки по типу IOrderEvent.Type чем является - обычной строкой или каким-то другим типом? По ходу она как будто бы строка, но в интерфейсе задана как тип EventType.
2) Было бы очень здорово, если бы вы куда-нибудь выложили исходники к статье. Будет проще понять, что и куда.
Спасибо

Post a Comment