Доменная модель
В посте о связях в 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 до тех пор, пока это не станет реальной проблемой для производительности. Как только станет заметно тормозить, то оптимизировать, либо хранимкой, либо еще как.