Recently I’ve been developing an enterprise application with NHibernate which was required to fully support multi language (localization) on UI side as well as on data side. Whereas the former is easily implemented with .net resources the latter is not that straightforward as it seems. That articles talks about it and the solution I have chosen as I have never done it with NHibernate before.
Let’s start with a brief description of the problem before digging through the possible solutions. The applications architecture is based on the NHibernate Best Practices with , 1.2nd Ed. by Billy McCafferty. I guess it’s a must read for all who deal with Nhibernate and . So please read it before moving on, as it helps you to understand the approach I am talking about.
Problem:
- Many domain objects have different properties which are in need of translations. E.g. A
Product
may contain aName
and aCategory
property which needs to be translated into the applications supported languages. - It should be easily possible to update the translations of an object before persisting it.
- After getting a persistent entity it should be possible to get a given property in the applications current language.
- Querying entities according to the translation must not be a big problem. E.g. Getting a list of products ordered by its name in the current selected language.
1st Solution:
The first solution I tried was the approach from Ayende (Localizing NHibernate: Contextual Parameters) - whose Blog I strongly recommend for all NHibernaters. The approach he describes is having a simple string property on your domain object and mapping it using a filter with the current selected language (culture).
Pros:
- quickly to implement
- Domain objects property stays unchanged (still a string)
Cons:
- Each property would result in an own table
- “ugly” sql queries in your mapping files
- Can’t easily access all translations
- Querying entities is not straightforward (e.g. fulltext search)
2nd Solution (my preferred):
Due to the cons I could not really use the 1st approach within the project and decided to build an own solution. Here is my suggestions…
We create a Domain object called PhraseDictionary
acts as a generic container for translation phrases. It can be bound to any property of any of our domain objects. I have added its class diagram to show its members:
That type can be used now for each of our domain properties which require translation. The Name
property helps us to differentiate all the different phrases we will have in the DB (you can skip it if you want - though it’s nice if you browse the data directly). The SetPhrase()
method sets a phrase for a given LCID and getPhrase()
gets a phrase translation for the requested LCID. Both methods return the instance itself to make method chaining possible.
For easier usage there is a Phrase
property which does the same but always for the current domain culture (Domain culture could be handled in a DomainCulture
class - which gives us the ability to get the current culture and/or switch it). All Phrases are stored in a Dictionary with LCID as key and the actual translation as the value.
As we are going to persist it, thats the resulting mapping file:
<?xml version="1.0" encoding="utf-8" ?>
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2">
<class name="MyProject.Core.Domain.PhraseDictionary, MyProject.Core" table="dictionary">
<id name="ID" unsaved-value="0">
<generator class="identity" />
</id>
<property name="Name"></property>
<map name="Phrases"
access="nosetter.camelcase-underscore"
table="phrase"
cascade="all-delete-orphan">
<key column="dictionary_id"></key>
<index column="culture_id" type="Int32"></index>
<element column="phrase" type="String"></element>
</map>
</class>
</hibernate-mapping>
The most important part here is the Dictionary (map) which stores the LCID as key and the translation as value. Thoses “phrases” are fully cascaded as it makes no sense for them to live without the PhraseDictionary
.
Okay lets dig into the actual implementation…
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading;
namespace MyProject.Core.Domain {
public class PhraseDictionary : DomainObject<int> {
/// <summary>
/// required for NHiberante
/// </summary>
protected PhraseDictionary()
: base() {
Phrases = new Dictionary<int, string>();
}
/// <summary>
///
/// </summary>
/// <param name="name">internal name only</param>
public PhraseDictionary(string name)
: this() {
Name = name;
}
private IDictionary<int, string> _phrases;
private string _name;
/// <summary>
/// Name of the dictionary.
/// - only for internal use. Eases browsing data directly in database
/// </summary>
public virtual string Name {
get {
return _name;
}
set {
_name = value;
}
}
/// <summary>
/// Stores all phrases for a lcid
/// </summary>
public virtual IDictionary<int, string> Phrases {
get {
return _phrases;
}
private set {
_phrases = value;
}
}
/// <summary>
/// Adds a phrase for a given lcid
/// - if the lcid exists then the phrase is updated
/// - empty strings are considered as "not available".
/// Thus adding an empty phrase might remove an existing if already existed
/// </summary>
/// <param name="lcid"></param>
/// <param name="phrase"></param>
/// <returns>Returns itself. Useful for method chaining.</returns>
public virtual PhraseDictionary SetPhrase(string phrase, int lcid) {
if (Phrases.ContainsKey(lcid)) {
if (string.IsNullOrEmpty(phrase)) {
Phrases.Remove(lcid);
} else {
Phrases[lcid] = phrase;
}
} else if (!string.IsNullOrEmpty(phrase)) {
Phrases.Add(lcid, phrase);
}
return this;
}
public virtual PhraseDictionary SetPhrase(string phrase) {
return SetPhrase(phrase, Thread.CurrentThread.CurrentUICulture.LCID);
}
/// <summary>
/// Gets a phrase for a given lcid
/// - returns empty string if it does not exist
/// </summary>
/// <param name="lcid"></param>
/// <returns></returns>
public virtual string GetPhrase(int lcid) {
if (Phrases.ContainsKey(lcid)) return Phrases[lcid];
return string.Empty;
}
public virtual string GetPhrase() {
return GetPhrase(Thread.CurrentThread.CurrentUICulture.LCID);
}
/// <summary>
/// Gets the phrase for the current UI culrute
/// </summary>
public virtual string Phrase {
get {
return GetPhrase();
}
set {
SetPhrase(value);
}
}
public override int GetHashCode() {
return Phrases.GetHashCode();
}
public override object Copy() {
PhraseDictionary d = new PhraseDictionary();
d.Name = Name;
d.Phrases = new Dictionary<int, string>(Phrases);
return d;
}
public override string ToString() {
return Phrase;
}
}
}
And here is how we would hook it up onto e.g. the Name
property of a Product
domain object.
public interface IProduct {
// contains all name translations
// AllNames.Phrase gets/sets the translation for the current domain culture
PhraseDictionary AllNames { get; set; }
}
This would allow us to set/receive the translation of name property conveniently as follows:
// set the culture to US english
Thread.CurrentThread.CurrentUICulture = new CultureInfo("en-US");
IProduct p = new Product();
// sets product name in english
p.AllNames.Phrase = "Book";
// switch domain culture to german
Thread.CurrentThread.CurrentUICulture = new CultureInfo("de-DE");
// sets product name in german
p.AllNames.Phrase = "Buch";
// both translations have been stored
Debug.Assert(p.AllNames == 2);
// the current one should be german
Debug.Assert(p.AllNames.Phrase == "Buch");
To keep everything together. Here is the part you require in your Product mapping file:
<many-to-one name="AllNames"
cascade="all-delete-orphan">
</many-to-one>
One requirement was an easy use of the translations within HQL query. Here is an example of how we would fet all products sorted by the name of the current culture:
// Gets all products sorted by the name of the current culture
IList<Product> GetAllSortedByName() {
string query = @"
from Product product
left outer join product.AllNames allNames
where index(allNames) = :lcid
order by allNames";
IQuery q = NHibernateSession.CreateQuery(query);
q.SetParameter("lcid", Thread.CurrentThread.CurrentUICulture.LCID);
return q.List<Product>();
}
That was it. Thanks for having the read and I am looking forward to lots of feedback which could improve that approach.
Here are some quick remarks:
- I strongly recommend creating a DomainCulture
class which encapsulates the current culture and how it is stored.
- Probably it would be useful to make the PhraseDictionary.Phrases
readonly to the public.
Hi Michal
I would like to know your opinion about this other solution
Thanks.
Cheers Mike, this code was just what I was after. Regards
Great Post !
Could you give us the product mapping file and the description of the 3 tables Product, PhraseDictionary and Phrase ?
In fact We did not understand why the Id of the class PhraseDictionary is an identity and not the string dictionary Id
Regards
Hi there, everything is going sound here and ofcourse every one is sharing information, that’s truly excellent, keep up writing.