Tuesday, December 6, 2011

Fix problem in Fluent NHibernate Search with custom field bridges

NHibernate search is the project from NHibernate contrib which allows you to integrate NHibernate and NLucene search. The basic idea of NHibernate search is similar to the NHibernate at common: if NHibernate allows you to map entities to the database tables, then NHibernate search allows to map entities to NLucene index documents (if you are not familiar with NLucene basic concepts, I recommend you book Lucene in action which is written for Java, but all described concepts are also suitable for .Net). Briefly NLucene index is document database which contains documents and each document has several fields.

Fluent NHibernate library simplifies mapping configuration: it allows you to write mappings on C# instead of xml which is used in NHibernate. Similarly Fluent NHibernate search allows you to write mappings from your POCO to the NLucene index documents (the same POCO which are used for mapping to the database tables via Fluent NHibernate). Let’s consider example – model for ecommerce application.

Suppose that we have Product class:

   1: public class Product
   2: {
   3:     public virtual int Id { get; set; }
   4:     public virtual string Name { get; set; }
   5:     public virtual IList<Category> Categories { get; set; }
   6: }

Each product may belong to several categories. Each category may have parent category and several child categories:

   1: public class Category
   2: {
   3:     public virtual int Id { get; set; }
   4:     public virtual string Name { get; set; }
   5:     public virtual Category ParentCategory { get; set; }
   6:     public virtual IList<Category> ChildCategories { get; set; }
   7: }

We want to search by product names. Mapping will look like this:

   1: public class ProductMap : DocumentMap<Product>
   2: {
   3:     protected override void Configure()
   4:     {
   5:         Id(x => x.Id);
   6:         Name("Product");
   7:  
   8:         Map(x => x.Name)
   9:             .Store().Yes()
  10:             .Index().Tokenized();
  11:     }
  12: }

Here we specified map from Product class to the NLucene document. Store().Yes() means that index will store value of “Name” as is (so users will be able to search by whole name), Index().Tokenized() means that we want to tokenize name so users will be able to search by parts of the product name (full text search. Need to note here that for different languages it can be achieved by using different analyzers, but it is subject for another articles).

Everything is clear here. But then we need to add possibility for users to filter search by specific categories. In order to do this we need to store categories ids to the index somehow (add new field to the Product document). As you saw Product.Categories property has IList<Category>. The problem is that NLucene works with strings. So we need to convert IList<Category> to the string value. It can be done using custom field bridge. NHibernate search contains number of built-in bridges for dates, guids, strings. But in our case we need to write our own bridge – inheritor of IFieldBridge. It will retrieve all categories ids and concatenate them in string field:

   1: public class CategoriesToStringBridge : IFieldBridge
   2: {
   3:     public void Set(string name, object value, Document document,
   4:         Field.Store store, Field.Index index, float? boost)
   5:     {
   6:         var categories = value as IEnumerable<Category>;
   7:         if (categories == null)
   8:         {
   9:             return;
  10:         }
  11:  
  12:         var ids = new List<int>();
  13:         foreach (var category in categories)
  14:         {
  15:             ids.Add(category.Id);
  16:  
  17:             var cat = category.ParentCategory;
  18:             while (cat != null)
  19:             {
  20:                 ids.Add(cat.Id);
  21:                 cat = cat.ParentCategory;
  22:             }
  23:         }
  24:  
  25:         string val = string.Join(",", ids.Distinct());
  26:         var field = new Field(name, val, store, index);
  27:         if (boost.HasValue)
  28:         {
  29:             field.SetBoost(boost.Value);
  30:         }
  31:         document.Add(field);
  32:     }
  33: }

Note that for each category of the current product we also add all parent categories (lines 12-23). So if users will search by parent category id they will also see products from all child categories. On the lines 25-31 we create new field and add it to the document.

Now we need to configure search mapping. Fluent NHibernate search allows to do it like this:

   1: public class ProductMap : DocumentMap<Product>
   2: {
   3:     protected override void Configure()
   4:     {
   5:         Id(x => x.Id);
   6:         Name("Product");
   7:  
   8:         Map(x => x.Name)
   9:             .Store().Yes()
  10:             .Index().Tokenized();
  11:  
  12:         Map(x => x.Categories)
  13:             .Store().Yes()
  14:             .Bridge().Custom<CategoriesToStringBridge>()
  15:             .Index().UnTokenized();
  16:     }
  17: }

We don’t want to analyze Categories field – so we specified Index().UnTokenized(). However when you will compile and run the program, you will get the following exception:

NHibernate.HibernateException: Unable to guess IFieldBridge for Categories
  at NHibernate.Search.Bridge.BridgeFactory.GuessType(String fieldName, Type fieldType, IFieldBridgeDefinition fieldBridgeDefinition, IDateBridgeDefinition dateBridgeDefinition)
  at FluentNHibernate.Search.Mapping.Parts.FluentMappingPart..ctor(PropertyInfo propertyInfo)
  at FluentNHibernate.Search.Mapping.Parts.FieldMappingPart..ctor(PropertyInfo propertyInfo)
  at FluentNHibernate.Search.Mapping.DocumentMap`1.Map(Expression`1 property)

Exception occurs in FluentNHibernate.Search.Mapping.Parts.FluentMappingPart constructor:

   1: protected FluentMappingPart(PropertyInfo propertyInfo)
   2: {
   3:     this.Name(propertyInfo.Name);
   4:  
   5:     // set the default getter
   6:     this.Getter = new BasicPropertyAccessor.BasicGetter(propertyInfo.DeclaringType,
   7:         propertyInfo, propertyInfo.Name);
   8:  
   9:     // set the default bridge
  10:     var bridge = BridgeFactory.GuessType(propertyInfo.Name, propertyInfo.PropertyType,
  11:         null, null);
  12:     (this as IHasBridge).FieldBridge = bridge;
  13: }

On the line 10 it calls BridgeFactory.GuessType() method. Note that 3rd parameter (fieldBridgeDefinition) definition is null here, and as we have property of IList<Categories>, NHibernate search can’t find appropriate bridge at this moment and throws exception. As it shown in the search map configuration Bridge().Custom<CategoriesToStringBridge>() is called after Map() method:

   1: Map(x => x.Categories)
   2:     .Store().Yes()
   3:     .Bridge().Custom<CategoriesToStringBridge>()
   4:     .Index().UnTokenized();

So when FluentMappingPart constructor is called it doesn’t know yet about custom bridge.

In order to fix it, we can use the following simple workaround: enclose call to BridgeFactory.GuessType() with try/catch block:

   1: protected FluentMappingPart(PropertyInfo propertyInfo)
   2: {
   3:     this.Name(propertyInfo.Name);
   4:  
   5:     // set the default getter
   6:     this.Getter = new BasicPropertyAccessor.BasicGetter(propertyInfo.DeclaringType,
   7:         propertyInfo, propertyInfo.Name);
   8:  
   9:     // asadomov: need to add try/catch here - otherwise it fails 
  10:     // for the values with custom bridge (e.g. Product.Categories)
  11:     try
  12:     {
  13:         // set the default bridge
  14:         var bridge = BridgeFactory.GuessType(propertyInfo.Name, propertyInfo.PropertyType,
  15:             null, null);
  16:         (this as IHasBridge).FieldBridge = bridge;
  17:     }
  18:     catch
  19:     {
  20:     }
  21: }

Now the exception won’t be re-thrown and our custom bridge will be successfully applied for the Categories properties. When you will re-build your search index – you can use e.g. Luke tool (you need to install Java to use it. However it works with index built by NLucene) to check that Product documents contain Categories field which contains categories ids, separated by comma. So you will be able to filter search results by categories (how to do it is the subject for another post).

2 comments:

  1. I am using c#.net and we are using fluent nhibernate. It was fine until i was connecting to a single database. Now in the same project i am suppose to use multiple database, how can this be achieved. Configuration of the fluent nhibernate is lies in web/app.config file. How to specify multiple connection string over there and how to create multiple session factories. Please suggest.

    ReplyDelete
  2. hello Raj,
    you need to configure nh in the code instead of config file and replace connection string. See example here: http://sadomovalex.blogspot.com/2011/07/configure-nh-in-code-instead-of.html.

    ReplyDelete