Oct 20, 2009

NHibernate для начинающих. Часть 3. Связи

Линки на остальные статьи

Изначально этот пост задумывался как описания каждого типа связи. Я собирался описать как мапить many-to-many, many-to-one, one-to-many и т.д. Но начав писать, я понял что такой информации и так достаточно в сети. Поэтому пост больше получился про то, как построить доменную модель для связанных сущностей. Данный подход наверняка можно распространить и на любое другое нормальное ORM средство.

В предидущих постах были рассмотрены базовые возможности NHibernate. В них рассматрился один класс Product, который соответствовал одной таблице в базе данных. Но естественно для полноценных бизнес приложений требуется набор объектов, которые взаимодействуют между собой. Например рассмотрим модель, которую можно было бы увидеть у какого нибуть электронного магазина. Нужен список товаров, категории товаров и возможность выполнить заказ. Пусть это приведет к следующей структуре классов:

  Диаграмма классов

На этой схеме изображено следующее:

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

Для хранения данной доменной модели была построена следующая база данных:

 Схема базы данных

Исходный текст классов таков:

public class Category  
{     
    private ISet<Product> products;     
    public Category()     
    {         
        products = new HashedSet<Product>();     
    }
    public virtual string DisplayName { get; set; }     
    public virtual int Id { get; protected set; }     
    public virtual ReadOnlyCollection<Product> Products     
    {        
        get { return new ReadOnlyCollection<Product>(new List<Product>(products));  }     
    }     
    public virtual void AddProduct(Product product)     
    {        
        if(product == null) throw new ArgumentNullException("product");     
        if(!products.Contains(product))         
        {
            product.AddCategory(this);            
            products.Add(product);        
        }     
    }     
    public virtual void RemoveProduct(Product product)     
    {        
        if(product == null) throw new ArgumentNullException("product");
        if(products.Contains(product))
        {            
            products.Remove(product);
            product.RemoveFrom(this);
        }
     }
}
 

Следует обратить внимание на следующие вещи:

  • Поле Id – protected. Это необходимо для защиты данных. Если нечайно поменять Id то можно обновить данные другого объекта, а не ожиемого
  • Все поля и методы должны быть виртуальными. Это необходимо для работы NHiberante
  • В конструкторе инициализируется колекция продуктов, таким образом никогда не будет NullReferenceException
  • Колекция продуктов представлена как ReadOnlyCollection. Такой подход оставляет нам возможность получить полный контроль над логикой добавления и удаления продуктов внутри категории
  • При добавлении продукта в категорию продукту автоматически добавляется эта категория. Это обеспечивает согласованность доменной модели (не может быть такого случая, что в колекции у категории продукт есть, а у продукта нет информации о том, что он содержится в категории)
  • Проверка приходящих параметров. Нельзя пробовать добавлять пустые ссылки на продукты
public class Product
{
     private ISet<Category> categories;
     public Product()
     {
        categories = new HashedSet<Category>();
        Orders = new HashedSet<Order>();
     }
     public virtual int Id { get; protected set; }
     public virtual string Name { get; set; }
     public virtual string Description { get; set; }
     public virtual double Price { get; set; }
     public virtual ReadOnlyCollection<Category> Categories
     {
        get
        {
           return new ReadOnlyCollection<Category>(new List<Category>(categories));
        }
     }
     public virtual ISet<Order> Orders { get; protected set; }
     public virtual void AddCategory(Category category)
     {
         if(category == null)
             throw new ArgumentNullException("category");
         if(!categories.Contains(category))
         {
             categories.Add(category);
             category.AddProduct(this);
         }
      }
      public virtual void RemoveFrom(Category category)
      {
         if(category == null)
             throw new ArgumentNullException("category");
         if(categories.Contains(category))
         {
             categories.Remove(category);
             category.RemoveProduct(this);
         }
       }
}  

Так же как и у категории мы обеспечиваем полный контроль над добавлением и удалением элементов из колекции. Но мы не делаем коллекцию заказов ReadOnly (но по прежнему инициализируем пустой колекцией для отсутствия NullReferenceException в нашем коде). Далее будет рассмотрено почему.

public class Order
{
      protected Order() { }
      public Order(Product product)
      {
         if(product == null)
             throw new ArgumentNullException("product");
         product.Orders.Add(this);
         Product = product;
      }
      public virtual int Id { get; protected set; }
      public virtual Product Product{get;protected set;}
      public virtual int NumberOfItems { get; set; }
      public virtual string Customer { get; set; }
}  

Важно выделить следующее

  • protected констуктор необходим для работы NHiberante
  • Тут можно увидеть почему колекция заказов осталась открытой. Чтобы добавить туда новый элемент необходимо сначала его создать. Но заказ не может существовать без продукта, что и отражено единственном открытом конструкторе
  • Product для заказа содержит protected сеттер. Это сделано для того, чтобы нельзя было у созданного заказа заменить продукт, который покупался.
  • Заказов без продукта существовать не должно. Соответственно при удалении продукта или же удалении связи между продуктом и заказом заказ должен быть удален. Как этого достичь будет показано далее

Для правильной работы рекомендуется всегда оборачивать запросы в транзацкии. Для этого следует изменить соответствующие методы в global.asax:

private ITransaction Transaction;
protected void Application_BeginRequest(object sender, EventArgs e)
{
      CurrentSession = SessionFactory.OpenSession();
      Transaction = CurrentSession.BeginTransaction();
}
protected void Application_EndRequest(object sender, EventArgs e)
{
      if (Server.GetLastError() == null)
      {
         Transaction.Commit();
      }
      else
      {
         Transaction.Rollback();
      }
      if (CurrentSession != null)
         CurrentSession.Dispose();
}   

И наконец маппинги:

public class CategoryMap: ClassMap<Category>
{
      public CategoryMap()
      {
          Table("Categories");
          Id(x => x.Id).GeneratedBy.Native();
          Map(x => x.DisplayName);
          HasManyToMany(x => x.Products)
                             .Access.CamelCaseField()
                             .Cascade.SaveUpdate()
                             .AsSet()
                             .ParentKeyColumn("CategoryId")
                             .ChildKeyColumn("ProductId")
                             .Table("[Products.Categories]");
      }
 }
public class ProductMap : ClassMap<Product>  
{
      public ProductMap()
      {
         Table("Products");
         Id(x => x.Id).GeneratedBy.Native();
         Map(x => x.Description);
         Map(x => x.Name);
         Map(x => x.Price);
         HasManyToMany(x => x.Categories)
                           .Access.CamelCaseField()
                           .Table("[Products.Categories]")
                           .ParentKeyColumn("ProductId")
                          .ChildKeyColumn("CategoryId")
                          .AsSet()
                          .Inverse()
                          .Cascade.SaveUpdate();
         HasMany(x => x.Orders)     
                      .KeyColumn("ProductId")     
                      .Inverse() 
                      .Cascade.AllDeleteOrphan();
      }
  }
  public class OrderMap:ClassMap<Order>
  {
   public OrderMap()
   {
    Table("Orders");
    Id(x => x.Id).GeneratedBy.Native();
    Map(x => x.NumberOfItems);
    Map(x => x.Customer);
    References(x => x.Product, "ProductId");
   }
  }

На что обратить внимание

  • У продукта и категории стоит Cascade.SaveUpdate() для колекции категорий и продукта соответственно. Это значит что сохранение продукта вызывает сохранение категории и наоборот. Чуть более подробно про каскадирование можно почитать тут
  • У колекций продукта стоит Inverse. Это означает что за сохранение связи между категорией и продуктом будет отвечать категория. Более подробно тут
  • У продукта на список заказов стоит Cascade.AllDeleteOrphan(). Это обязывает Nhibernate удалять записи, которые лишились родителя (дополнительно тут)
  • Для many-to-many установлен Access.CamelCaseField(). Это означает, что NHibernate будет использовать приватное поле для работы, это позволяет нам сделать коллекцию продуктов ReadOnlyCollection

Итак теперь можно поработать с полученной моделью:

Category category = new Category
    {
        DisplayName = "Our first category"
    };
Product product = new Product
    {
        Description = "First product description",
        Name = "First product",
        Price = 1
    };
category.AddProduct(product);
Global.CurrentSession.SaveOrUpdate(category); 

Эта часть кода заставит NHiberante выполнить следующий набор sql запросов:

INSERT INTO Categories (DisplayName) VALUES (@p0); select SCOPE_IDENTITY() @p0=N'Our first category'  
INSERT INTO Products (Description, Name, Price) VALUES (@p0, @p1, @p2); select SCOPE_IDENTITY() @p0=N'First product description',@p1=N'First product', @p2=1  
INSERT INTO [Products.Categories] (CategoryId, ProductId) VALUES (@p0, @p1) @p0=1,@p1=1  

Как можно видеть NHiberante сам позаботился о порядке сохранения объектов, и заполнении связующей таблицы.

Теперь проверим связь продуктов и заказов:

Product product = Global.CurrentSession.Get<Product>(1); 
var order = new Order(product)
 {
    NumberOfItems = 5,
    Customer = "customer"
 }; 
Global.CurrentSession.SaveOrUpdate(product); 

Это заставит NHiberante выполнить следующие запросы:

SELECT product0_.Id as Id2_0_, product0_.Description as Descript2_2_0_, product0_.Name as Name2_0_, product0_.Price as Price2_0_  
FROM Products product0_ 
WHERE product0_.Id=@p0,@p0=1  

SELECT orders0_.ProductId as ProductId1_, orders0_.Id as Id1_, orders0_.Id as Id3_0_, orders0_.NumberOfItems as NumberOf2_3_0_, orders0_.Customer as Customer3_0_, orders0_.ProductId as ProductId3_0_  
FROM Orders orders0_  
WHERE orders0_.ProductId=@p0,@p0=1  

INSERT INTO Orders (NumberOfItems, Customer, ProductId) VALUES (@p0, @p1, @p2); select SCOPE_IDENTITY(), @p0=5,@p1=N'customer',@p2=1  

(id могут быть другими).

Проверим сценарий в котором удаление связи между продуктом и заказом должно вызывать удаление заказа (это достигается при помощи all-delete-orphan). Например рассмотрим следующий код:

Product product = Global.CurrentSession.Get<Product>(1);
product.Orders.Clear(); 
Global.CurrentSession.SaveOrUpdate(product);

Он выполнит следующие запросы:

SELECT product0_.Id as Id2_0_, product0_.Description as Descript2_2_0_, product0_.Name as Name2_0_, product0_.Price as Price2_0_  
FROM Products product0_ 
WHERE product0_.Id=@p0, @p0=1  

SELECT orders0_.ProductId as ProductId1_, orders0_.Id as Id1_, orders0_.Id as Id3_0_, orders0_.NumberOfItems as NumberOf2_3_0_, orders0_.Customer as Customer3_0_, orders0_.ProductId as ProductId3_0_ 
FROM Orders orders0_  
WHERE orders0_.ProductId=@p0, @p0=1  

DELETE FROM Orders WHERE Id = @p0 @p0=2  
DELETE FROM Orders WHERE Id = @p0 @p0=3 
DELETE FROM Orders WHERE Id = @p0 @p0=4  

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

Пример кода

28 comments:

  1. Наверное самая полезная статья

    ReplyDelete
  2. Вот такое исключение выдает этот пример. Правда после тупого переноса на Oracle 10g.
    System.NotSupportedException: Can't figure out what the other side of a many-to-many should be.

    ReplyDelete
  3. Статья правдо полезная, жаль пример не рабочий. "Can't figure out what the other side of a many-to-many should be" - полюбому.

    ReplyDelete
  4. Ну так скажите, пример на который автор потратил время в принципе не рабочий или это криворукость моя сказывается? Если убрать оба HasManyToMany то ошибки нет. Но понятно, что это не выход.

    ReplyDelete
  5. Добавил пример работающего кода. Чтобы создать все необходимые таблицы раскомментируйте .ExposeConfiguration(BuildSchema) строку в файле Globa.asax. Это нужно сделать только при первом запуске.

    ReplyDelete
  6. Спасибо за готовый projrct. Причина оказалась как я и думал в моей криворукости. Но с другой стороны хорошо, что появились исходники не надо тратить время на "склеивание" проекта по частям и убита вероятность возникновения ошибки при этом неблагодарном занятии (как в моем случае).
    Еще было бы неплохо рассмотреть вопрос древовидного Category (например добавим поле указателя на родителя - ParentID) как сюда правила прекрутить? Такой вариант будет более приближен к большинству реальных случаев.

    ReplyDelete
  7. В начале я хотел написать и об этом. Но перед тем как что-либо писать, я сначала ищу что уже написано. Касательно дерева категорий можно использовать вот эту ссылку http://blogs.hibernatingrhinos.com/nhibernate/archive/2008/05/14/how-to-map-a-tree-in-nhibernate.aspx. Ну а как дать возможность прикручивать туда логику - точно так же сделать ReadOnly коллекции дочерних, и в соответствующих методах (свойствах) писать свою логику.

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

    ReplyDelete
  9. http://slynetblog.blogspot.com/2010/01/trees-and-nhibernate.html

    Как и просили :)

    ReplyDelete
  10. О! Большое спасибо! Пойду разбираться.

    ReplyDelete
  11. Спасибо за пример! Очень интересно как выглядел бы мэппинг, если бы в заказе могло быть несколько продуктов (many-to-many отношение) с указанием количества каждого продукта?

    ReplyDelete
  12. Если нужна связь с некоторым атрибутом, то придется сделать отдельный объект, который будет содержать в себе составной ключ из идентификаторов объектов.

    ReplyDelete
  13. Ссылка на проект умерла(

    ReplyDelete
  14. Да, и исходники к сожалению тоже потерялись :(

    ReplyDelete
  15. Нашел исходники и полечил ссылку, спасибо.

    ReplyDelete
  16. Ccылка на исходники опять побилась + я попробовал амапить many-to-many по какой-то приниче вставки в промежуточную таблицу не происходит. Всё сделано как в статье 1 в 1.

    ReplyDelete
  17. Ссылку на исходники поправил. Вставка в связующую таблицу может не происходить из-за отсутствия транзакции.

    ReplyDelete
  18. Интересная статья! Возник вопрос: а как будет выглядеть смена ссылки на продукцию у заказа, так чтобы заказ не удалялся, а выполнился апдейт? Заранее благодарен!

    ReplyDelete
  19. Если есть желание сменять продукт не удаляя заказ то у Order либо открыть сеттер для свойства Product, либо сделать метод ChangeProduct который будет делать присваивание. Вся операция будет сводиться к простой замене значения свойства:

    Order.Product = new Product();

    ReplyDelete
  20. Да, в таком случае пройдет простой апдейт, но при этом заказ останется в коллекции заказов старого продукта. Если попытаться его убрать оттуда Remove и добавить Add в коллекцию нового заказа, сработает Cascade.AllDeleteOrphan(), который попытается удалить этот заказ, но не сможет, т.к. на него есть ссылки с другого продукта (object would be resaved). Собственно вопрос был в том, как сделать корректную (с отработкой в коллекциях) смену продукта у заказа, не отказываясь от AllDeleteOrphan?

    ReplyDelete
  21. уже нашел решение: http://fabiomaulo.blogspot.com/2009/09/nhibernate-tree-re-parenting.html

    ReplyDelete
  22. интересная ссылка, читаю :)

    ReplyDelete
  23. Скажите пожалуста, как можна сгенерировать схему связей классов, как в данной статье? Или там стрелочки вручную дорисованы.

    Спасибо

    ReplyDelete
  24. Это стандартные диаграммы из Visual Studio. Чтобы добавить стрелочки нужно правой кнопкой клацнуть на нужном свойстве и выбрать Show as association или show as collection association

    ReplyDelete
  25. Мы используем Conform и паттерн спецификацию и Linq запросов.

    Суть проблемы - нельзя выполнить Linq запрос над ReadOnlyCollection.

    Поэтому приходится передавать ISet или ICollection (пока не пробовали, но скоро попробуем). И тогда пропадает все инкапсуляция - так как мы обращаемся к коллекции напрямую.

    Как ты обошел эту проблему?

    ReplyDelete
  26. Пост делался давно и я обычно уже не использую подход с ReadonlyCollection.

    Просто ICollection {get; private set;} работает. + в кострукторе инициализая пустыми значениями чтобы не было NullReference

    ReplyDelete
  27. Вот тут пример http://slynetblog.blogspot.com/2011/06/getting-started-with-nhibernate-and.html

    ReplyDelete