Доменная модель
В посте о связях в NHibernate поступило пожелание увидеть пост о древовидных структурах и как с ними работать в NHiberante. Как правило такие структуры мне приходилось строить для категорий, на них и будем смотреть. Начнем с чистой доменной модели:
public class Category
{
private ISet<Category> childCategories;
private Category parentCategory;
public Category()
{
childCategories = new HashedSet<Category>();
}
public virtual int Id { get; protected set; }
public virtual string Name { get; set; }
public virtual ReadOnlyCollection<Category> ChildCategories
{
get
{
return new ReadOnlyCollection<Category>(new List<Category>(childCategories));
}
}
public virtual Category ParentCategory
{
get
{
return parentCategory;
}
}
public virtual void AddChildCategory(Category category)
{
if (category == null) throw new ArgumentNullException("category");
childCategories.Add(category);
}
public virtual void SetParentCategory(Category category)
{
if (category == null) throw new ArgumentNullException("category");
parentCategory = category;
}
}
Каждая категория содержит ссылку на своего родителя и содержит список дочерних категорий. На данный момент модель не представляет никакой гарантии того, что дерево действительно будет деревом. Что я имею ввиду: если есть следующая вложенность: “Parent” –> “Child” -> “Child of Child”. Доменная модель никаким образом не запретит следующий код: Category(“Child Of Child”).AddChildCategory(“Parent”). Это создаст цикл и никакие рекурсивные алгоритмы без дополнительных действий работать не будут.
Чтобы избежать подобной ситуации в методы SetParentCategory и AddChildCategory можно добавить логику проверки добавляемых категорий. Так добавляя подкатегорию можно проверить что она уже не содержится в данной иерархии и т.д.
Маппинг
Теперь обратимся к Nhibernate. Маппинг для данного класса будет следующим:
public class CategoryMap : ClassMap<Category>
{
public CategoryMap()
{
Id(x => x.Id).GeneratedBy.Native();
Map(x => x.Name)
.Length(200)
.Not.Nullable();
References(x => x.ParentCategory)
.Column("ParentCategoryId")
.Access.CamelCaseField();
HasMany(x => x.ChildCategories)
.Cascade.AllDeleteOrphan()
.AsSet()
.KeyColumn("ParentCategoryId")
.Access.CamelCaseField();
}
}
По маппингу можно сгенерировать следующую таблицу:
В общем ничего необычного. Для создания категорий можно использовать следующий код:
using (var transaction = Global.CurrentSession.BeginTransaction())
{
Category parent = new Category();
parent.Name = "parent";
Category child = new Category();
child.Name = "child";
parent.AddChildCategory(child);
Category childOfTheChild = new Category();
childOfTheChild.Name = "child of the child";
child.AddChildCategory(childOfTheChild);
Global.CurrentSession.SaveOrUpdate(parent);
transaction.Commit();
}
Поскольку в маппингах установлено каскадирование AllDeleteOrphan то достаточно только сохранения родительской категории. Так же это позволяет управлять поведением в случае удаления. Категория, родитель которой в данный момент удаляется будет удалена вместе с родительской. Если такое поведение вам не нужно, смените тип каскадирования на All.
Немного о производительности
Вопросы выборок иерархических структур сложны в самом SQL. Если оставить все как есть, то Nhibernate будет работать в режиме обычного lazy load и вытаскивать каждую коллекцию подкатегорий отдельным запросом. Для примера следующий код:
protected void Button1_Click(object sender, EventArgs e)
{
ICriteria criteria = Global.CurrentSession.CreateCriteria(typeof (Category));
criteria.Add(Restrictions.IsNull("ParentCategory"));
foreach (Category category in criteria.List<Category>())
{
EnumerateChilds(category);
}
}
protected void EnumerateChilds(Category category)
{
foreach (Category childCategory in category.ChildCategories)
{
EnumerateChilds(childCategory);
}
}
Тут вытаскиваются все родительские категории, и мы пробегаемся по всем дочерним. Если существует следующая вложенность: “Category”->”Child Category”->”Child of child category”, то данный код выполнит запросы:
-- Выбираем родительскую категорию
SELECT this_.Id as Id0_0_,
this_.Name as Name0_0_,
this_.ParentCategoryId as ParentCa3_0_0_
FROM [Category] this_
WHERE this_.ParentCategoryId is null
-- Выборка подкатегорий каждой из категорий
SELECT childcateg0_.ParentCategoryId as ParentCa3_1_,
childcateg0_.Id as Id1_,
childcateg0_.Id as Id0_0_,
childcateg0_.Name as Name0_0_,
childcateg0_.ParentCategoryId as ParentCa3_0_0_
FROM [Category] childcateg0_
WHERE childcateg0_.ParentCategoryId=@p0 /* @p0=4 */
SELECT childcateg0_.ParentCategoryId as ParentCa3_1_,
childcateg0_.Id as Id1_,
childcateg0_.Id as Id0_0_,
childcateg0_.Name as Name0_0_,
childcateg0_.ParentCategoryId as ParentCa3_0_0_
FROM [Category] childcateg0_
WHERE childcateg0_.ParentCategoryId=p0 /* @p0=5 */
SELECT childcateg0_.ParentCategoryId as ParentCa3_1_,
childcateg0_.Id as Id1_,
childcateg0_.Id as Id0_0_,
childcateg0_.Name as Name0_0_,
childcateg0_.ParentCategoryId as ParentCa3_0_0_
FROM [Category] childcateg0_
WHERE childcateg0_.ParentCategoryId=@p0 /* @p0=6 */
Как правило категории нужно доставать все сразу, чтобы построить красивое дерево для навигации. На своем блоге Oren Eini показал как можно оптимизировать выборку дерева. Сделать это можно следующим запросом:
var categories = Global.CurrentSession
.CreateQuery("select c from Category c join fetch c.ChildCategories")
.SetResultTransformer(new DistinctRootEntityResultTransformer())
.List<Category>();
Данный вариант кода выполнит меньше запросов, но выполнит по одному лишнему на каждую категорию, последнюю в иерархии. Т.е. категория, у которой нет подкатегорий сгенерирует еще один запрос к базе. В частности у меня было 2 такие категории, и были выполнены следующие запросы:
-- statement #1
select category0_.Id as Id0_0_,
childcateg1_.Id as Id0_1_,
category0_.Name as Name0_0_,
category0_.ParentCategoryId as ParentCa3_0_0_,
childcateg1_.Name as Name0_1_,
childcateg1_.ParentCategoryId as ParentCa3_0_1_,
childcateg1_.ParentCategoryId as ParentCa3_0__,
childcateg1_.Id as Id0__
from [Category] category0_
inner join [Category] childcateg1_
on category0_.Id = childcateg1_.ParentCategoryId
-- statement #2
SELECT childcateg0_.ParentCategoryId as ParentCa3_1_,
childcateg0_.Id as Id1_,
childcateg0_.Id as Id0_0_,
childcateg0_.Name as Name0_0_,
childcateg0_.ParentCategoryId as ParentCa3_0_0_
FROM [Category] childcateg0_
WHERE childcateg0_.ParentCategoryId = 8 /* @p0 */
-- statement #3
SELECT childcateg0_.ParentCategoryId as ParentCa3_1_,
childcateg0_.Id as Id1_,
childcateg0_.Id as Id0_0_,
childcateg0_.Name as Name0_0_,
childcateg0_.ParentCategoryId as ParentCa3_0_0_
FROM [Category] childcateg0_
WHERE childcateg0_.ParentCategoryId = 10 /* @p0 */
8я и 10я категории не содержат подкатегорий. Данный вопрос обсуждается в группе NHUsers. Ответов почему это происходит, равно как и решения задачи там пока не обнаружилось. Так что не факт что выполнив такой запрос Вы уменьшите нагрузку на базу.
Такое же поведение наблюдается если выключить lazy load и поставить fetch-mode=”join”.
Еще одна проблема с этим это то, что он исключает категории, у которых есть родитель, но нет подкатегорий. Соответственно если вложенность больше одного уровня, вы получите лишние результаты.
Поэтому я бы предпочел использовать стандартный lazy load до тех пор, пока это не станет реальной проблемой для производительности. Как только станет заметно тормозить, то оптимизировать, либо хранимкой, либо еще как.