Линки на остальные статьи
Изначально этот пост задумывался как описания каждого типа связи. Я собирался описать как мапить 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 будет управлять сохранением коллекций.
Спасибо
ReplyDeleteНаверное самая полезная статья
ReplyDeleteВот такое исключение выдает этот пример. Правда после тупого переноса на Oracle 10g.
ReplyDeleteSystem.NotSupportedException: Can't figure out what the other side of a many-to-many should be.
Статья правдо полезная, жаль пример не рабочий. "Can't figure out what the other side of a many-to-many should be" - полюбому.
ReplyDeleteНу так скажите, пример на который автор потратил время в принципе не рабочий или это криворукость моя сказывается? Если убрать оба HasManyToMany то ошибки нет. Но понятно, что это не выход.
ReplyDeleteДобавил пример работающего кода. Чтобы создать все необходимые таблицы раскомментируйте .ExposeConfiguration(BuildSchema) строку в файле Globa.asax. Это нужно сделать только при первом запуске.
ReplyDeleteСпасибо за готовый projrct. Причина оказалась как я и думал в моей криворукости. Но с другой стороны хорошо, что появились исходники не надо тратить время на "склеивание" проекта по частям и убита вероятность возникновения ошибки при этом неблагодарном занятии (как в моем случае).
ReplyDeleteЕще было бы неплохо рассмотреть вопрос древовидного Category (например добавим поле указателя на родителя - ParentID) как сюда правила прекрутить? Такой вариант будет более приближен к большинству реальных случаев.
В начале я хотел написать и об этом. Но перед тем как что-либо писать, я сначала ищу что уже написано. Касательно дерева категорий можно использовать вот эту ссылку http://blogs.hibernatingrhinos.com/nhibernate/archive/2008/05/14/how-to-map-a-tree-in-nhibernate.aspx. Ну а как дать возможность прикручивать туда логику - точно так же сделать ReadOnly коллекции дочерних, и в соответствующих методах (свойствах) писать свою логику.
ReplyDeleteНу понятно. Вообще там не русскоязычный ресурс, для некоторых это не очень удобно. Если есть возможность напишите, вам многие спасибо №2 скажут. :-)
ReplyDeletehttp://slynetblog.blogspot.com/2010/01/trees-and-nhibernate.html
ReplyDeleteКак и просили :)
О! Большое спасибо! Пойду разбираться.
ReplyDeleteСпасибо за пример! Очень интересно как выглядел бы мэппинг, если бы в заказе могло быть несколько продуктов (many-to-many отношение) с указанием количества каждого продукта?
ReplyDeleteЕсли нужна связь с некоторым атрибутом, то придется сделать отдельный объект, который будет содержать в себе составной ключ из идентификаторов объектов.
ReplyDeleteСсылка на проект умерла(
ReplyDeleteДа, и исходники к сожалению тоже потерялись :(
ReplyDeleteНашел исходники и полечил ссылку, спасибо.
ReplyDeleteCcылка на исходники опять побилась + я попробовал амапить many-to-many по какой-то приниче вставки в промежуточную таблицу не происходит. Всё сделано как в статье 1 в 1.
ReplyDeleteСсылку на исходники поправил. Вставка в связующую таблицу может не происходить из-за отсутствия транзакции.
ReplyDeleteИнтересная статья! Возник вопрос: а как будет выглядеть смена ссылки на продукцию у заказа, так чтобы заказ не удалялся, а выполнился апдейт? Заранее благодарен!
ReplyDeleteЕсли есть желание сменять продукт не удаляя заказ то у Order либо открыть сеттер для свойства Product, либо сделать метод ChangeProduct который будет делать присваивание. Вся операция будет сводиться к простой замене значения свойства:
ReplyDeleteOrder.Product = new Product();
Да, в таком случае пройдет простой апдейт, но при этом заказ останется в коллекции заказов старого продукта. Если попытаться его убрать оттуда Remove и добавить Add в коллекцию нового заказа, сработает Cascade.AllDeleteOrphan(), который попытается удалить этот заказ, но не сможет, т.к. на него есть ссылки с другого продукта (object would be resaved). Собственно вопрос был в том, как сделать корректную (с отработкой в коллекциях) смену продукта у заказа, не отказываясь от AllDeleteOrphan?
ReplyDeleteуже нашел решение: http://fabiomaulo.blogspot.com/2009/09/nhibernate-tree-re-parenting.html
ReplyDeleteинтересная ссылка, читаю :)
ReplyDeleteСкажите пожалуста, как можна сгенерировать схему связей классов, как в данной статье? Или там стрелочки вручную дорисованы.
ReplyDeleteСпасибо
Это стандартные диаграммы из Visual Studio. Чтобы добавить стрелочки нужно правой кнопкой клацнуть на нужном свойстве и выбрать Show as association или show as collection association
ReplyDeleteМы используем Conform и паттерн спецификацию и Linq запросов.
ReplyDeleteСуть проблемы - нельзя выполнить Linq запрос над ReadOnlyCollection.
Поэтому приходится передавать ISet или ICollection (пока не пробовали, но скоро попробуем). И тогда пропадает все инкапсуляция - так как мы обращаемся к коллекции напрямую.
Как ты обошел эту проблему?
Пост делался давно и я обычно уже не использую подход с ReadonlyCollection.
ReplyDeleteПросто ICollection {get; private set;} работает. + в кострукторе инициализая пустыми значениями чтобы не было NullReference
Вот тут пример http://slynetblog.blogspot.com/2011/06/getting-started-with-nhibernate-and.html
ReplyDelete