Showing posts with label Localization. Show all posts
Showing posts with label Localization. Show all posts

Thursday, September 4, 2014

There's one specific culture whose parent culture is not neutral

using System;
using System.Globalization;
using System.Linq;

public class SamplesCultureInfo
{

   public static void Main()
   {

      // Prints the header.
    Console.WriteLine("  {0} {1, 80}","SPECIFIC CULTURE", "PARENT CULTURE");
    
    
    Action<CultureTypes, Func<CultureInfo, bool>> display = (c, exp) => 
    {
        foreach (CultureInfo ci in CultureInfo.GetCultures(c).Where(exp).OrderBy(x => x.TwoLetterISOLanguageName))
        {
            Console.Write("0x{0} {1} {2, -70}", ci.LCID.ToString("X4"), ci.Name, ci.EnglishName);
            Console.WriteLine("0x{0} {1} {2} : {3}", ci.Parent.LCID.ToString("X4"), ci.Parent.Name, ci.Parent.EnglishName, ci.Parent.IsNeutralCulture);     
        }
    };
    
    Action<CultureTypes> displayAll = c => display(c, x => true);
    
    // displayAll(CultureTypes.SpecificCultures);
    // displayAll(CultureTypes.NeutralCultures);
    
    //// Only one specific culture which parent culture is not neutral:
    // CultureInfo.GetCultures(CultureTypes.SpecificCultures).All(x => x.Parent.IsNeutralCulture).Dump();        
    // CultureInfo.GetCultures(CultureTypes.SpecificCultures).Count(x => !x.Parent.IsNeutralCulture).Dump();
    //// This is that culture:        
    display(CultureTypes.SpecificCultures, x => !x.Parent.IsNeutralCulture);
    
    
        
    
    // CultureInfo.GetCultures(CultureTypes.NeutralCultures).Count().Dump();

   }

}

Live codes:
https://dotnetfiddle.net/PCq4vR
https://dotnetfiddle.net/17Bkny

Output:
SPECIFIC CULTURE                                                                    PARENT CULTURE
0x0803 ca-ES-valencia Valencian (Spain)                                             0x0403 ca-ES Catalan (Catalan) : False


Interesting: http://stackoverflow.com/questions/8354352/how-can-i-get-the-region-and-culture-info-for-bahamas

Monday, July 1, 2013

What's rawer than Razor's raw?

@{
    ViewBag.Title = "Index";
}

<h2>Index</h2>

@{
    System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("fr-FR");
    decimal d = 1234567.89M;
}


@d

<hr />

@Html.Raw(d)



The output is:

1234567,89 
1234567,89

I wanted to return these though:
1234567,89
1234567.89


Yeah I know, raw != English-centric, the world doesn't revolve around English culture. However, the programming language's raw(or culture if you may) is English-centric, i.e., we write one milllion and two hundred thirty four thousand five hundred sixty seven pesos and eighty nine centavos as 1234567.89 not 1234567,89

I want to use 1234567.89 even I'm in other culture. What's the use of that whimsical need? It's easier to bridge server-side values to javascript when the programming language format is preserved properly.

This view...

@{
    System.Threading.Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("fr-FR");
    decimal d = 1234567.8912M;
}

<script>    
    var x = @d;
    alert(x);    
</script>

...is materialized to html as the following, an invalid syntax, hence the alert will not pop-up:

<script>    
    var x = 1234567,89;
    alert(x);    
</script>


Any suggestion? I'll post this stackoverflow and will keep you posted when an answer come up


UPDATE: Feb 19, 2014


Some answers are just coming up when there's a damning need for a solution to a problem. A colleague has a problem on his Razor view. His problem is similar to above, he's passing a Model(with numeric values) from controller to view, and in turn his view is passing the model's numeric values to a JavaScript control, this is his razor view:


<script>
var c = chart.setup({ LowestScore : @Model.LowestScore, HighestScore : @Model.HighestScore });
</script>


However this is the HTML output of Razor view:
<script>
var c = chart.setup({ LowestScore : 12345,67 , HighestScore : 56789,12 });
</script>


Spot the error? It's not a valid JavaScript, the cents gets separated from the values. The problem stems from the razor being locale-aware, so if you set the language to French, the fraction will get emitted with comma separator. To fix the problem, we just serialize the values right there on View. Why on view and not on controller? If we do it on controller we are compelled to introduce another property on the model for the JSON string of the values we wanted to pass from the model, or worse yet we have to marshal it to ViewBag or ViewData from the controller. With that in mind, this is the best way to tackle the problem, serialize the values in View:

<script>
    @{
        string j = Newtonsoft.Json.JsonConvert.SerializeObject(
            new 
            { 
                LowestScore = Model.LowestScore,
                HighestScore = Model.HighestScore,                 
            });
    }

    var jsonObject = @Html.Raw(j);
    var c = chart.setup(jsonObject);
</script>



HTML generated:
<script>
    var jsonObject = {"LowestScore":12345.67,"HighestScore":56789.12};
    var c = chart.setup(jsonObject);
</script>



I hope I'm wrong with my approach above, if there's a shorter way to do it in Razor, i.e. its localization can be turned off programatically, I'll be more than glad to know.



Happy Coding! ツ

Sunday, June 30, 2013

Multilingual + Caching on NHibernate: Made Compatible

"There are two hard things in computer science: cache invalidation, naming things, and off-by-one errors" -- http://martinfowler.com/bliki/TwoHardThings.html


We will not create computer science today, we will just use one of the fruits of computer science. Today I will show you how to use NHibernate and its built-in caching mechanism and make it compatible with localization.



I've tried creating a seamless multilingual app in NHibernate before, it works even on brownfield projects. And I've tried a cache-enabled NHibernate app with Redis, it just works, it's simply amazing. I tried mixing the two, it has a problem though, localized fields must be mapped to their own classes, otherwise they can't be switched to another language when the master entity is cached already.



This post will show you how to make a multilingual app on NHibernate without compromising the entity and query cacheablity.



First, let's start with the domain model.

public class Product
{
    public virtual int ProductId { get; set; }
    public virtual int YearIntroduced { get; set; }    
}


[Serializable]
public class ProductLanguageCompositeKey
{
    public virtual int ProductId { get; set; }
    public virtual string LanguageCode { get; set; }


    public override bool Equals(object obj)
    {
        if (obj == null)
            return false;
        
        var t = obj as ProductLanguageCompositeKey;     
        if (t == null)
            return false;
            
        if (ProductId == t.ProductId && LanguageCode == t.LanguageCode)
            return true;
            
        return false;
    }
    
    public override int GetHashCode()
    {
        return (ProductId + "|" + LanguageCode).GetHashCode();
    }
}

public class ProductLanguage
{
    public virtual ProductLanguageCompositeKey ProductLanguageCompositeKey { get; set; }         

    public ProductLanguage()
    {
        this.ProductLanguageCompositeKey = new ProductLanguageCompositeKey();
    }

    public virtual string ProductName { get; set; }
    public virtual string ProductDescription { get; set; }

    // A guide for the user, so he/she could know the source language of the untranslated string came from
    public virtual string ActualLanguageCode { get; set; }
}   


For detailed explanation on Serializable on the composite key and the rationale for extracting the composite key of the product localization to its own class. Read on: http://devlicio.us/blogs/anne_epstein/archive/2009/11/20/nhibernate-and-composite-keys.aspx


To get the multi-lingual "table":

create function dbo.get_product_i18n(@language_code nvarchar(6))
returns table --  (product_id int, language_code nvarchar(6), product_name nvarchar(1000), product_description nvarchar(1000))
as
    return 
    with a as
    (
        select
            the_rank =
                row_number() 
                    over(partition by product_id
                    order by
                     case language_code
                     when @language_code then 1
                     when 'en' then 2
                     else 3
                     end) 

                ,* 
                ,actual_language_code = language_code

        from product_i18n 
    )
    select    

        -- composite key for ORM:
        a.product_id, language_code = @language_code
        -- ...composite key

        , a.product_name, a.product_description

        , a.actual_language_code
    from a 
    where the_rank = 1;


GO

That function will return the entity's localized fields(product_name and product_description in our example), if there's no matched found just return the English version of it, and if there's no English just return any localization that matches the entity.


Data Source:
product_id  year_introduced
----------- ---------------
1           2016
2           2007
3           1964
4           1994

(4 row(s) affected)


product_id  language_code product_name       product_description
----------- ------------- ------------------ -------------------------------
1           en            Apple I            First Personal Computer
1           zh            Pingguo Xian       Xian Dian Nao
2           en            iPhone             First Truly Smartphone
3           ph            Sarao              World's Top Jeepney Brand
4           zh            Anta               China's Top Shoe Brand

(5 row(s) affected)


Sample output of get_product_i18n:

select * from dbo.get_product_i18n('zh');


    product_id  language_code product_name       product_description          actual_language_code
    ----------- ------------- ------------------ ---------------------------- --------------------
    1           zh            Pingguo Xian       Xian Dian Nao                zh
    2           zh            iPhone             First Truly Smartphone       en
    3           zh            Sarao              World's Top Jeepney Brand    ph
    4           zh            Anta               China's Top Shoe Brand       zh

    (4 row(s) affected)



There's a zh translation for First Personal Computer, hence the tvf returns the localized version of First Personal Computer, i.e., Pingguo Xian

There's no zh translation for First Truly Smartphone, but there's an English(fallback language) version of it, hence the tvf returns the English version.

There's no zh translation for World's Top Jeepney Brand, and there's no English version of it, hence the tvf returns the native version, i.e., Sarao

There's a zh localization for China's Top Shoe Brand, hence the get_product_i18n will just return that.



On ProductLanguage mapping, you'll notice that we use merge, this will add the product + language pair if it doesn't exist yet, update if it already exist. If we look at the sample output of the query above, it also return a row for iPhone even if the zh language don't have a translation for it yet, the merge command will be able to INSERT the translation for iPhone if the zh user decided to change the product name and product description to something else. Then if the entity already exist on database, use the UPDATE command instead.


SqlInsert and SqlUpdate don't have a named parameter capability yet, the order of the parameters (denoted by the question mark) is simple, the fields just follows the exact order of its corresponding properties on the class. With minor caveat, primary key(s) are on the last part of the database command. Hence this is the order of the parameter: product_name, product_description, actual_language_code, pk_product_id, pk_language_code. pk_product_id and pk_language_code being the composite keys.


using NHibernate.Mapping.ByCode.Conformist;
using NHibernate.Mapping.ByCode;

using LocalizationWithCaching.Models;

namespace LocalizationWithCaching.ModelMappings
{
    public class ProductLanguageMapping : ClassMapping<ProductLanguage>
    {

        string save =
"merge product_i18n as dst
using( values(?,?,?, ?,?) ) 
    as src(product_name, product_description, actual_language_code, pk_product_id, pk_language_code)
on
    src.pk_product_id = dst.product_id and src.pk_language_code = dst.language_code

when matched then
    update set dst.product_name = src.product_name, dst.product_description = src.product_description
when not matched then
    insert (product_id, language_code, product_name, product_description)
    values (src.pk_product_id, src.pk_language_code, src.product_name, src.product_description);";


        public ProductLanguageMapping()
        {            
            // When the query from this mapping is run on different languages, they will have their isolated copy of query caching.
            // That behavior comes from NHibernate filters. 
            
            Table("dbo.get_product_i18n(:lf.LanguageCode)"); // lf is an NHibernate filter
            // Hence the following behavior:
                // TestQueryCache("en"); // database hit
                // TestQueryCache("zh"); // database hit
                // TestQueryCache("en"); // cached query hit
                // TestQueryCache("zh"); // cached query hit
                // TestQueryCache("ca"); // database hit
                
            // If we don't use NHibernate filters(e.g. using CONTEXT_INFO technique instead), identical queries run from different languages will get the same query cache.
            // Thus this mapping:
            //      Table("dbo.get_product_i18n(convert(nvarchar, substring(context_info(), 5, convert(int, substring(context_info(), 1, 4)) )  ))");
            // Will have this behavior:            
                // TestQueryCache("en"); // database hit
                // TestQueryCache("zh"); // cached query hit
                // TestQueryCache("en"); // cached query hit
                // TestQueryCache("zh"); // cached query hit
                // TestQueryCache("ca"); // cached query hit


            // Need to be turned on, so N+1 won't happen
            // http://stackoverflow.com/questions/8761249/how-do-i-make-nhibernate-cache-fetched-child-collections
            Cache(x => x.Usage(CacheUsage.ReadWrite));


            ComponentAsId(key => key.ProductLanguageCompositeKey, m =>
            {
             m.Property(x => x.ProductId, c => c.Column("product_id"));
             m.Property(x => x.LanguageCode, c => c.Column("language_code"));
            });


            SqlInsert(save);
            SqlUpdate(save);

            Property(x => x.ProductName, c => c.Column("product_name"));
            Property(x => x.ProductDescription, c => c.Column("product_description"));
            Property(x => x.ActualLanguageCode, c => c.Column("actual_language_code"));            
        }
    }
}    

The product mapping:


using NHibernate.Mapping.ByCode.Conformist;
using NHibernate.Mapping.ByCode;

using LocalizationWithCaching.Models;

namespace LocalizationWithCaching.ModelMappings
{
    public class ProductMapping : ClassMapping<Product>
    {
        public ProductMapping()
        {
            Table("product");
            Id(x => x.ProductId, c =>
            {
                c.Column("product_id");
                c.Generator(Generators.Identity);
            });


            // Need to be turned on, so N+1 won't happen
            // http://stackoverflow.com/questions/8761249/how-do-i-make-nhibernate-cache-fetched-child-collections
            Cache(x => x.Usage(CacheUsage.ReadWrite));


            Property(x => x.YearIntroduced, c => c.Column("year_introduced"));
        }
    }
}    

Now on the interesting part, when mapping a table-valued function...

create function dbo.tvf_get_product_sold() 
returns table
as
 return
  select p.product_id, ordered_count = coalesce(sum(o.qty), 0)
  from dbo.product p          
  left join dbo.ordered_product o on p.product_id = o.product_id
  group by p.product_id
go



namespace LocalizationWithCaching.Models
{
    public class GetProductSold
    {
        public virtual int ProductId { get; set; }        
        public virtual int Sold { get; set; }
    }
}


...we must have a mechanism to invalidate the query cache whenever there's a change on ordered_product. NHibernate just have that, just specify Synchronize(new[] { "ordered_product" }); on GetProductSold mapping:

public class GetProductSoldMapping : ClassMapping<GetProductSold>
{
    public GetProductSoldMapping()
    {
        Table("dbo.tvf_get_product_sold()");

        Cache(x => x.Usage(CacheUsage.ReadOnly));

        Synchronize(new[] { "ordered_product" });

        Id(x => x.ProductId, c => c.Column("product_id"));
        Property(x => x.Sold, c => c.Column("ordered_count"));
    }
}


Thus we will get this behavior when we specify synchronize:

TestTvfGetProductSoldQueryCache("en"); // database hit
TestTvfGetProductSoldQueryCache("en"); // cached query hit
UpdateOrderedProduct(orderedProductId: 1, languageCode: "en"); // database hit on entity get. database hit on update. refresh entity cache. invalidates GetProductSold query cache
TestOrderedProductEntityCache(orderedProductId: 1, languageCode: "en"); // cached entity hit on entity get
TestTvfGetProductSoldQueryCache("en"); // was invalidated on line 3. database hit
TestTvfGetProductSoldQueryCache("en"); // cached query hit


private void TestTvfGetProductSoldQueryCache(string languageCode)
{
    using (var session = Mapper.TheMapper.GetSessionFactory().OpenSession())
    using (var tx = session.BeginTransaction().SetLanguage(session, languageCode))
    {
        var x = 
            (from q in 
                  from ps in session.Query<GetProductSold>()
                  join pl in session.Query<ProductLanguage>() on ps.ProductId equals pl.ProductLanguageCompositeKey.ProductId
                  select new { ps, pl }
            where q.pl.ProductLanguageCompositeKey.LanguageCode == languageCode
            select q).Cacheable();

        // Rationale for Cacheable at the end:
        // http://www.ienablemuch.com/2013/06/nhibernate-query-caching.html

        var l = x.ToList();
    }
}


private void UpdateOrderedProduct(int orderedProductId, string languageCode)
{
    using (var session = Mapper.TheMapper.GetSessionFactory().OpenSession())
    using (var tx = session.BeginTransaction().SetLanguage(session, languageCode))
    {
        var x = session.Get<OrderedProduct>(orderedProductId);
        x.Quantity = x.Quantity + 1;
        session.Save(x);
        tx.Commit();
    }
}


private void TestOrderedProductEntityCache(int orderedProductId, string languageCode)
{
    using (var session = Mapper.TheMapper.GetSessionFactory().OpenSession())
    using (var tx = session.BeginTransaction().SetLanguage(session, languageCode))
    {
        var x = session.Get<OrderedProduct>(orderedProductId);                
    }
} 

With the right modelling, multilingual with caching can be easily achieved on NHibernate.


Here's the detailed behavior of NHibernate caching:

TestProductAndLanguageQueryCache("en"); // database hit
TestProductAndLanguageQueryCache("zh"); // database hit
TestProductAndLanguageQueryCache("en"); // cached query hit
TestProductAndLanguageQueryCache("zh"); // cached query hit
TestProductAndLanguageQueryCache("ca"); // database hit


TestTvfGetOrderInfoQueryCache("en"); // database hit
TestTvfGetOrderInfoQueryCache("en"); // cached query hit
TestTvfGetOrderInfoQueryCache("zh"); // database hit
TestTvfGetOrderInfoQueryCache("zh"); // cached query hit

   
TestTvfGetOrderInfoQueryCache("en"); // cached query hit
UpdateProduct(productId: 1, languageCode: "en"); // cached entity hit on entity get. database hit on update. refresh entity cache. Invalidates GetOrderInfo query cache
TestTvfGetOrderInfoQueryCache("en"); // database hit
TestTvfGetOrderInfoQueryCache("en"); // cached query hit
UpdateProductLanguage(productId: 1, languageCode: "zh"); // cached entity hit on entity get. database hit on update. refresh entity cache. Invalidates GetOrderInfo query cache
TestTvfGetOrderInfoQueryCache("en"); // database hit. even we only touch the Chinese language above
TestTvfGetOrderInfoQueryCache("en"); // cached query hit



TestTvfGetProductSoldQueryCache("en"); // database hit
UpdateProduct(productId: 1, languageCode: "en"); // cached entity hit on entity get. database hit on update. refresh entity cache. does not invalidates GetProductSold query cache
TestTvfGetProductSoldQueryCache("en"); // was not invalidated. cached query hit. GetProductSold query cache is Synchronized with ordered_product only
TestTvfGetProductSoldQueryCache("en"); // cached query hit
UpdateProductLanguage(productId: 1, languageCode: "zh"); // cached hit on entity get. database hit on update. refresh entity cache. invalidates GetProductSold query cache as it joins on ProductLanguage entity
TestTvfGetProductSoldQueryCache("en"); // cached query was invalidated. database hit
TestTvfGetProductSoldQueryCache("en"); // cached query hit


TestTvfGetProductSoldQueryCache("en"); // cached query hit
UpdateOrderedProduct(orderedProductId: 1, languageCode: "en"); // database hit on entity get. database hit on update. refresh entity cache. invalidates GetProductSold query cache
TestOrderedProductEntityCache(orderedProductId: 1, languageCode: "en"); // cached entity hit on entity get
TestTvfGetProductSoldQueryCache("en"); // database hit
TestTvfGetProductSoldQueryCache("en"); // cached query hit
UpdateOrderedProduct(orderedProductId: 1, languageCode: "zh"); // cached entity hit on entity get. database hit on update. refresh entity cache. invalidates GetProductSold query cache
TestTvfGetProductSoldQueryCache("en"); // database hit. even we only touch the Chinese language above
TestTvfGetProductSoldQueryCache("en"); // cached query hit
UpdateOrderedProduct(orderedProductId: 1, languageCode: "en"); // cached entity hit on entity get. database hit on update. refresh entity cache
TestOrderedProductEntityCache(orderedProductId: 1, languageCode: "en"); // cached entity hit on entity get



TestProductEntityCache(productId: 1,languageCode: "en"); // cached entity hit
TestProductLanguageEntityCache(productId: 1, languageCode: "en"); // cached entity hit
TestProductEntityCache(productId: 2, languageCode: "zh"); // cached entity hit
TestProductLanguageEntityCache(productId: 2, languageCode: "en"); // cached entity hit

UpdateProduct(productId: 1, languageCode: "en"); // cached entity hit on entity get. database hit on update. refresh entity cache
TestProductEntityCache(productId: 1, languageCode: "en"); // cached entity hit

UpdateProduct(productId: 1, languageCode: "en"); // cached entity hit on entity get. database hit. refresh entity cache. invalidates cached query
TestProductAndLanguageQueryCache("en"); // cached query was invalidated. database hit
TestProductAndLanguageQueryCache("en"); // cached query hit

TestProductQueryCache("en"); // cached query was invalidated. database hit
TestProductQueryCache("ca"); // cached query was invalidated. database hit

TestProductQueryCache("en"); // cached query hit
TestProductQueryCache("ca"); // cached query hit

UpdateProduct(productId: 1, languageCode: "en"); // cached entity hit on entity get. database hit on update. refresh entity cache. invalidates cached query 
TestProductEntityCache(productId: 1, languageCode: "en"); // cached entity hit
TestProductAndLanguageQueryCache("en"); // cached query was invalidated. database hit
TestProductAndLanguageQueryCache("ca"); // database hit

TestProductLanguageEntityCache(productId: 1, languageCode: "ca"); // cached entity hit

TestProductLanguageEntityCache(productId: 1, languageCode: "es"); // database hit

TestProductLanguageEntityCache(productId: 1, languageCode: "es"); // cached entity hit

// cached entity hit on entity get. database hit on update. entity cache is refreshed. invalidates *ALL* language version of ProductLanguage query cache
UpdateProductLanguage(productId: 1, languageCode: "es");


TestProductAndLanguageQueryCache("zh"); // was invalidated. database hit
TestProductAndLanguageQueryCache("es"); // was invalidated. database hit
TestProductAndLanguageQueryCache("es"); // cached query hit           
TestProductAndLanguageQueryCache("en"); // was invalidated. database hit            

TestProductAndLanguageQueryCache("zh"); // cached query hit
TestProductAndLanguageQueryCache("en"); // cached query hit


TestProductLanguageEntityCache(productId: 1, languageCode: "es"); // cached entity hit


Full code: https://github.com/MichaelBuen/DemoLocalizationWithCaching/tree/optimized

Monday, December 24, 2012

Making your application multilingual with NHibernate. Laugh in the face of change

An existing application that become a success and later needed be bolted with multilingual support is where NHibernate adaptability on brownfield projects shines. How well does the application that uses NHibernate can adapt to this change? It handles it seamlessly, in fact there's virtually no changes in your application code needed be made. The localization on both retrieving and saving are being done automatically


Changes needed be made to your domain classes to support multiple languages? Zero, zilch, nada


public class Order
{
    public virtual int OrderId { get; set; }
    public virtual Product Product { get; set; }
    public virtual DateTime OrderDateTaken { get; set; }
    public virtual int Qty { get; set; }
    public virtual string EmailAccount { get; set; }
}

public class Product
{
    public virtual int ProductId { get; set; }
    
    public virtual string ProductName { get; set; }
    public virtual string ProductDescription { get; set; }      

    public virtual int YearIntroduced { get; set; }

    public virtual IList<Order> Orders { get; set; }
}     


Changes needed be made to your persistence code to support multiple languages? Zero, zilch, nada

var pz = 
        new Product
        {
            ProductName = "PC",
            ProductDescription = "Personal Computer",
            YearIntroduced = 1981
        };
    s.Merge(pz);
    s.Flush();


Changes needed be made to your object retrieval code to support multiple languages? Zero, zilch, nada
var ps = 
        from p in s.Query<Product>()
        orderby p.ProductDescription descending
        select p;
        
    
    
    foreach (var p in ps)
    {
        Console.WriteLine(
             "Name: {0}\nDescription: {1}\nYear Introduced: {2}\n\n", 
             p.ProductName, p.ProductDescription, p.YearIntroduced);
    }


When we get to the bottom of things, multilingual support is something that should not leak to your domain classes. This is where a capable ORM shines, it insulates your domain classes against infrastructure changes needed be made, everything just works automagically. The infrastructure to support multiple languages might be relational, but the domain classes don't have to reflect the underlying infrastructure. Object-wise, your domain class is still the same cohesive and atomic object your app knows. This is what Object-Relational Mapping is all about. Your object truly mimics the real-world object/entity, not a mere one-to-one mapping of object to relational. The beauty of abstraction


To cut to the chase, these are the infrastructure changes needed be made to enable multilingual support for your app.

  1. Move language-specific fields to another table
  2. Create a function that fetches the language-specific information
  3. Adjust the mapping
  4. Set the language upon database connection


1. Move language-specific fields to another table:
create table Product_Translation
(   
    ProductId int not null references Product(ProductId),
    LanguageCode varchar(5) not null,
    ProductName nvarchar(max) not null,
    ProductDescription nvarchar(max) not null,
    constraint pk_Product_Translation primary key(ProductId, LanguageCode)
);



Then move the language-specific content of your Product table to Product_Translation table.
insert into Product_Translation(ProductId,LanguageCode,ProductName,ProductDescription)
select ProductId,'en',ProductName,ProductDescription from Product;


Then drop the language-specific fields from the main table, your table should not have any language-specific fields left in it, e.g.
create table Product
(
    ProductId int identity(1,1) not null primary key,
    YearIntroduced int not null
);



2. Create a function that fetches the language-specific information:


create function GetProductTranslation(@LanguageCode varchar(5))
returns table
as
    return
        with a as
        (
            select 
                
                TheRank = 
                    rank() over(
                        partition by ProductId 
                
                        order by    
                        case LanguageCode 
                        when @LanguageCode then 1
                        when 'en' then 2 -- fallback language
                        else 3
                        end
                    ),

                ProductId, ProductName, ProductDescription

            from Product_Translation t
        )
        select ProductId, ProductName, ProductDescription 
        from a 
        where TheRank = 1;       
go




To verify if things are working accordingly:
select p.ProductId, t.ProductName, t.ProductDescription, p.YearIntroduced
from Product p
join dbo.GetProductTranslation('en') t
on t.ProductId = p.ProductId


3. Adjust the mapping

Do note that the parameter substitution on SqlInsert and SqlUpdate is order-dependent. If you declare properties in your class in this order: ProductName, ProductDescription; the ProductName will be passed to the first parameter, and the ProductDescription to the second parameter, the last parameter is where NHibernate passes your entities joining key.

public class ProductMapping : NHibernate.Mapping.ByCode.Conformist.ClassMapping<Product>
{
    string updateCommand =
@"
merge Product_Translation as target
using (values({0},?, ?, ?)) as source(LanguageCode, ProductName, ProductDescription, ProductId)
on source.ProductId = target.ProductId and source.LanguageCode = target.LanguageCode

when matched then 
    update set ProductName = source.ProductName, ProductDescription = source.ProductDescription
when not matched then 
    insert (LanguageCode, ProductName, ProductDescription, ProductId) 
    values(LanguageCode, ProductName, ProductDescription, ProductId);";


    public ProductMapping()
    {

        Table("Product");

        Id(x => x.ProductId, map =>
        {
            map.Column("ProductId");
            map.Generator(NHibernate.Mapping.ByCode.Generators.Identity);
        });

        Property(x => x.YearIntroduced);


        string sessionLanguage = "(select cast(CONTEXT_INFO() as varchar(5)))";

        Join("dbo.GetProductTranslation(:lf.LanguageCode)", j =>
        {
            j.Inverse(false);
            j.Fetch(NHibernate.Mapping.ByCode.FetchKind.Join);
            j.Key(k => k.Column("ProductId"));

            j.Property(p => p.ProductName);
            j.Property(p => p.ProductDescription);

            j.SqlInsert(string.Format("insert into Product_Translation(LanguageCode, ProductName, ProductDescription, ProductId) values({0}, ?, ?, ?)", sessionLanguage));
            j.SqlUpdate(string.Format(updateCommand, sessionLanguage));
        }); // join

        Bag<Order>(
            list => list.Orders,
            rel => rel.Key(y => y.Column("ProductId")),
            relType => relType.OneToMany());
    }

} // product mapping

Point of interest on the mapping is the dbo.GetProductTranslation function, it receives its values from the filter. Which is defined while building the session factory:

var filterDef = 
       new NHibernate.Engine.FilterDefinition(
            "lf", null, 
            new Dictionary<string, NHibernate.Type.IType> 
            { 
                { "LanguageCode", NHibernateUtil.String }
            }, useManyToOne: false);
        cfg.AddFilterDefinition(filterDef);



You'll notice too that the SqlInsert and SqlUpdate doesn't use filters, this is a limitation of NHibernate filters, filters are applied on functions and conditions only. For that limitation, we'll just set Sql Server's CONTEXT_INFO to desired language upon database connection. For SqlUpdate command, we use Sql Server's merge statement, if the ProductId+LanguageCode is existing on Product_Translation it will update the existing row; if not, then it will insert it.



4. Set the language upon database connection
using (var s = Mapper.GetSessionFactory().OpenSession())
using (var tx = s.BeginTransaction().SetLanguage(s,"zh-CHS"))    
{   
     // Your beautiful code goes here        
}


public static class TransactionHelper
{
    public static NHibernate.ITransaction SetLanguage(this NHibernate.ITransaction tx, NHibernate.ISession session, string languageCode)
    {            
        var cmd = session.Connection.CreateCommand();
        tx.Enlist(cmd);
        cmd.CommandType = System.Data.CommandType.StoredProcedure;
        cmd.CommandText = "SetLanguage";
        var prm = cmd.CreateParameter();
        prm.ParameterName = "@LanguageCode";
        prm.Value = languageCode;
        cmd.Parameters.Add(prm);
        cmd.ExecuteNonQuery();

        session.EnableFilter("lf").SetParameter("LanguageCode", languageCode);

        return tx;
    }
}

create procedure SetLanguage(@LanguageCode varchar(5)) as
begin
    declare @binvar varbinary(128);
    set @binvar = cast(@LanguageCode as varbinary(128));
    set context_info @binvar;
end;

That's it! Your application will do business as usual, without it being aware that the infrastructure underneath was somehow changed radically.

Nothing shall change on how the application get and persist objects.


Take that curmudgeoned ADO.NET developers!


Happy Coding! ツ


One of C#'s selling points: One-stop-shop. Expand to see the full code:

C# code:
using System;
using System.Collections.Generic;


namespace BillionDollarBusinessApp
{    
    using BusinessEntities;

    // If we are using repository pattern, these doesn't need to turn up here:
    using TheMapper;
    using NHibernate.Linq;    

    class Program
    {
        static void Main(string[] args) 
        {

            using (var s = Mapper.GetSessionFactory().OpenSession())
            using (var tx = s.BeginTransaction().SetLanguage(s, "zh"))
            {

                Action showProducts = delegate
                {
                    foreach (var product in s.Query<Product>())
                    {
                        Console.WriteLine("Product Name: {0}\nDescription: {1}\nYear Introduced: {2}\n", product.ProductName, product.ProductDescription, product.YearIntroduced);
                    }
                };

                Console.WriteLine("***Prior to localizing Apple name***\n");
                showProducts();

                var prodApple = s.Get<Product>(2);
                prodApple.ProductName = "苹果";
                s.Merge(prodApple);

                Console.WriteLine("***After localizing Apple name***\n");
                showProducts();

                tx.Commit();
            }

            Console.ReadKey();
        }
    }

}

namespace TheMapper
{
    using NHibernate.Cfg;
    using NHibernate.Linq;

    using BusinessEntities;

    public static class TransactionHelper
    {
        public static NHibernate.ITransaction SetLanguage(this NHibernate.ITransaction tx, NHibernate.ISession session, string languageCode)
        {
            var cmd = session.Connection.CreateCommand();
            tx.Enlist(cmd);
            cmd.CommandType = System.Data.CommandType.StoredProcedure;
            cmd.CommandText = "SetLanguage";
            var prm = cmd.CreateParameter();
            prm.ParameterName = "@LanguageCode";
            prm.Value = languageCode;
            cmd.Parameters.Add(prm);
            cmd.ExecuteNonQuery();

            session.EnableFilter("lf").SetParameter("LanguageCode", languageCode);

            return tx;
        }
    }

    public static class Mapper
    {
        private static NHibernate.ISessionFactory _isf = null;
        public static NHibernate.ISessionFactory GetSessionFactory()
        {
            var cfg = new NHibernate.Cfg.Configuration();
            var mapper = new NHibernate.Mapping.ByCode.ModelMapper();
            mapper.AddMappings(new[] { typeof(ProductMapping), typeof(OrderMapping) });

            cfg.DataBaseIntegration(c =>
                {
                    c.Driver<NHibernate.Driver.Sql2008ClientDriver>();
                    c.Dialect<NHibernate.Dialect.MsSql2008Dialect>();
                    c.ConnectionString = "Server=localhost; Database=TheLocalized; Trusted_Connection=true; MultipleActiveResultSets=true";
                    // c.LogSqlInConsole = true;
                });

            NHibernate.Cfg.MappingSchema.HbmMapping domainMapping = mapper.CompileMappingForAllExplicitlyAddedEntities();
            cfg.AddMapping(domainMapping);

            var filterDef = new NHibernate.Engine.FilterDefinition(
                "lf", null,
                new Dictionary<string, NHibernate.Type.IType> 
                { 
                    { "LanguageCode", NHibernate.NHibernateUtil.String }
                }, useManyToOne: false);
            cfg.AddFilterDefinition(filterDef);

            NHibernate.ISessionFactory sessionFactory = cfg.BuildSessionFactory();



            _isf = sessionFactory;

            return _isf;
        }

    }


    public class ProductMapping : NHibernate.Mapping.ByCode.Conformist.ClassMapping<Product>
    {
        string updateCommand =
@"
merge Product_Translation as target
using (values({0},?, ?, ?)) as source(LanguageCode, ProductName, ProductDescription, ProductId)
on source.ProductId = target.ProductId and source.LanguageCode = target.LanguageCode

when matched then 
    update set ProductName = source.ProductName, ProductDescription = source.ProductDescription
when not matched then 
    insert (LanguageCode, ProductName, ProductDescription, ProductId)   
    values(LanguageCode, ProductName, ProductDescription, ProductId);";


        public ProductMapping()
        {
            Table("Product");

            Id(x => x.ProductId, map =>
            {
                map.Column("ProductId");
                map.Generator(NHibernate.Mapping.ByCode.Generators.Identity);
            });

            Property(x => x.YearIntroduced);

            string sessionLanguage = "convert(nvarchar, substring(context_info(), 5, convert(int, substring(context_info(), 1, 4)) )  )";

            Join("dbo.GetProductTranslation(:lf.LanguageCode)", j =>
            {
                j.Inverse(false);
                j.Fetch(NHibernate.Mapping.ByCode.FetchKind.Join);
                j.Key(k => k.Column("ProductId"));

                j.Property(p => p.ProductName);
                j.Property(p => p.ProductDescription);

                j.SqlInsert(string.Format("insert into Product_Translation(LanguageCode, ProductName, ProductDescription, ProductId) values({0}, ?, ?, ?)", sessionLanguage));
                j.SqlUpdate(string.Format(updateCommand, sessionLanguage));
            }); // join

            Bag<Order>(
                list => list.Orders,
                rel => rel.Key(y => y.Column("ProductId")),
                relType => relType.OneToMany());
        }

    } // product mapping


    public class OrderMapping : NHibernate.Mapping.ByCode.Conformist.ClassMapping<Order>
    {
        public OrderMapping()
        {
            Table("[Order]");
            Id(x => x.OrderId, map =>
            {
                map.Column("OrderId");
                map.Generator(NHibernate.Mapping.ByCode.Generators.Identity);
            });
            Property(x => x.Qty);
            Property(x => x.OrderDateTaken);
            Property(x => x.EmailAccount);

            ManyToOne(refr => refr.Product, m => m.Column("ProductId"));

        }
    } // order mapping


}

namespace BusinessEntities
{

    public class Product
    {
        public virtual int ProductId { get; set; }

        // SqlInsert's parameter should correspond to this order
        public virtual string ProductName { get; set; }
        public virtual string ProductDescription { get; set; }

        public virtual int YearIntroduced { get; set; }

        public virtual IList<Order> Orders { get; set; }
    }

    public class Order
    {
        public virtual int OrderId { get; set; }
        public virtual Product Product { get; set; }
        public virtual DateTime OrderDateTaken { get; set; }
        public virtual int Qty { get; set; }
        public virtual string EmailAccount { get; set; }
    }
}


Database:
/*
drop table [Order];
drop table Product_Translation;
drop table Product;

drop function GetProductTranslation;
drop procedure SetLanguage;
*/


create table Product
(
    ProductId int identity(1,1) not null primary key,
    YearIntroduced int not null
);  


create table Product_Translation
(   
    ProductId int not null references Product(ProductId),
    LanguageCode varchar(5) not null,
    ProductName nvarchar(max) not null,
    ProductDescription nvarchar(max) not null,
    constraint pk_Product_Translation primary key(ProductId, LanguageCode)
);



create table [Order]
(
OrderId int identity(1,1) not null primary key,
EmailAccount nvarchar(100) not null,
ProductId int not null references Product(ProductId),
Qty int not null,
OrderDateTaken datetime not null,
);



insert into Product(YearIntroduced) values(1981);
declare @Computer int = SCOPE_IDENTITY();
insert into Product_Translation(ProductId, LanguageCode, ProductName, ProductDescription) 
values(@Computer, 'en','PC', 'Personal Computer');
insert into Product_Translation(ProductId, LanguageCode, ProductName, ProductDescription) 
values(@Computer, 'zh', N'电脑', N'电脑');


insert into Product(YearIntroduced) values(1984);
declare @Apple int = SCOPE_IDENTITY();
insert into Product_Translation(ProductId, LanguageCode, ProductName, ProductDescription) 
values(@Apple, 'en', 'Apple', 'Truly Personal Computer');


insert into Product(YearIntroduced) values(1886);
declare @CocaCola int = SCOPE_IDENTITY();
insert into Product_Translation(ProductId, LanguageCode, ProductName, ProductDescription) 
values(@CocaCola, 'en', 'Coca Cola', 'Refreshing');
insert into Product_Translation(ProductId, LanguageCode, ProductName, ProductDescription) 
values(@CocaCola, 'zh', N'可口可乐', N'可口可乐');




insert into Product(YearIntroduced) values(1903);
declare @TsingTao int = SCOPE_IDENTITY();
insert into Product_Translation(ProductId, LanguageCode, ProductName, ProductDescription) 
values(@TsingTao, 'zh', N'青岛', N'青岛');


insert into [Order](EmailAccount, ProductId, OrderDateTaken, Qty) values('a@a.com', @Apple, CURRENT_TIMESTAMP, 3);

go

create function GetProductTranslation(@LanguageCode varchar(max))
returns table
as
    return
        with a as
        (
            select 
                
                TheRank = 
                    rank() over(
                        partition by ProductId 
                
                        order by    
                        case LanguageCode 
                        when @LanguageCode then 1
                        when 'en' then 2 -- fallback language
                        else 3
                        end
                    ),

                ProductId, ProductName, ProductDescription

            from Product_Translation t
        )
        select ProductId, ProductName, ProductDescription 
        from a where TheRank = 1;       
go

create procedure [dbo].[SetLanguage](@LanguageCode nvarchar(5)) as
begin
    declare @binvar varbinary(128);
    set @binvar = cast(datalength(@LanguageCode) as varbinary) + cast(@LanguageCode as varbinary);
    set context_info @binvar;
end;
    

/*


select *
from Product p
join dbo.GetProductTranslation('en') t
on t.ProductId = p.ProductId;



select *
from Product p
join dbo.GetProductTranslation('zh') t
on t.ProductId = p.ProductId;


select * from Product_Translation;

*/