Линки на остальные статьи
Изначально этот пост задумывался как описания каждого типа связи. Я собирался описать как мапить 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 будет управлять сохранением коллекций.
Пример кода