Localizing DB content with valueForKeyPath();

It is fairly straight forward to localize a WebObjects application using either localized components or localized strings as I wrote about here. But things get a little more complex when you need to localize content coming from a database.

Usually multilingual database content is modeled something like this:

product_model.gif

Regardless of your model though, your task is usually to identify the correct object from an array based on its language key. You might do that in your Product EO:

public ProductDescription englishDescription() {
   NSArray descriptions = CBArrayUtilities.filteredArrayWithKeyValueEqual(
						descriptions(),
						"language",
						"English");
   if (descriptions != null && descriptions.count() > 0 ) {
      return descriptions.lastObject();
   }
   return null;
}

Note: The source for the filteredArrayWithKeyValueEqual method is here

Or you might move that logic into your components. Either way these kinds of solutions tend to result in:

  1. Lots of code in lots of places (Entities, Components, etc.).
  2. Adding supported languages require massive code changes. New accessors, component level changes, etc… yuck.

I prefer a more flexible approach. I like to get my components to recognize a new @localized key path operator by overriding their valueForKeyPath() method. Since my components usually inherit from a custom WOComponent subclass (ie. CBLocalizedComponent) this is easy to do. This example code shows one way you might do this:

/*
* Overriding WOComponent's valueForKeyPath method to 
* get it to recognize the @localized operator.
*/ 
public Object valueForKeyPath(String keypath) {
   String localizedOperator = "@localized";
   int locOpLength = localizedOperator.length();
   String sourceKey, valueKey;
   int index = keypath.indexOf(localizedOperator);
   if (index > -1) { 
      /*
       * If the index of the localizedOperator is > -1 then it is contained
       * in the keyPath, and we should proceed.
       * First, extract the parts of the keypath before and after the 
       * localizedOperator
       */
      sourceKey = keypath.substring(0, index - 1);
      valueKey = keypath.substring(index + locOpLength + 1, keypath.length());
      /*
       * Use the sourceKey to give us our array of objects
       */
      NSArray source = (NSArray)this.valueForKeyPath(sourceKey);
      /*
       * Filter the source array for the current language, we're assuming 
       * it only contains one object so get it.
       */
      NSArray filtered = (NSArray)this.filteredArrayForCurrentLanguage(source);
      NSKeyValueCodingAdditions object = 
		(NSKeyValueCodingAdditions)filtered.lastObject();
      if (object != null) {
         if (valueKey != null &&  valueKey.length() > 0) {
            /*
             * If the object and the valueKey isn't null, use the valueKey 
             * to get the requested value
             */
            return object.valueForKeyPath(valueKey);
         }
         /* 
          * Otherwise, return the object
          */
         return object;
      }
      return null;
   }
   /*
    * If the index of the localizedOperator is -1 then we can just
    * let super deal with it
    */
   return super.valueForKeyPath(keypath);
}
 
/*
* Utility method to filter an array returning the objects that match the
* current selected language. If no objects exist for that language return
* the default language.
* 
* It is assumed that the objects contained in the array will all implement
* a "language" attribute.
*/  
public NSArray filteredArrayForCurrentLanguage(NSArray array) {
   NSArray filteredArray = new NSArray();
   NSArray availableLanguages = (NSArray)array.valueForKeyPath("language");
   String currentLanguage = ((Session)session()).currentSelectedLanguage();
   if (availableLanguages.containsObject(currentLanguage)) {
      filteredArray = CBArrayUtilities.filteredArrayWithKeyValueEqual(array, 
							"language", 
							currentLanguage);
   } else {
      String defaultLanguage = Session.DEFAULTLANGUAGE;
      filteredArray = CBArrayUtilities.filteredArrayWithKeyValueEqual(array, 
							"language", 
							defaultLanguage);
   }
   return filteredArray;
}

The code in the WOComponents rely on some values being available from the Session. This is an example of that code:

protected NSArray _requestedLanguages;
protected String _currentSelectedLanguage;
public static String DEFAULTLANGUAGE = "English";
 
/* 
 * Get the array of requested languages from the browser request. 
 * I'm using ERXSession, ERContext, and ERXRequest from ProjectWONDER
 * The ERXRequest appends a "NonLocalized" value at the end of this array
 * that will not exist if you use the standard WORequest. You will need
 * modify the code accordingly.
 */
public NSArray requestedLanguages() {
   if (_requestedLanguages == null) {
      _requestedLanguages = this.context().request().browserLanguages();
   }
   return _requestedLanguages;
}
   
public void setRequestedLanguages(NSArray array) {
   _requestedLanguages = array;
}
  
/*
 * Identify the first requestedLanguage that matches the available languages
 * for this application. If none of them match, use the default language.
 * For performance reasons we cache this once per Session.
 *
 * Notes:
 * The ERXSession.availableLanguagesForTheApplication() returns an array
 * of the languages currently supported by the application. If you are not
 * using ProjectWONDER:
 *    - You will need to do identify the languages your application supports 
 *    - You will need to handle the "NonLocalized" browser request differently.
 */
public String currentSelectedLanguage() {
   if (_currentSelectedLanguage == null) {
      int rlc = requestedLanguages().count();
      for (int i = 0; i < rlc; i ++ ) {
         String lang = (String)requestedLanguages().objectAtIndex(i);
         if (lang.equals("Nonlocalized")) {
            _currentSelectedLanguage = DEFAULTLANGUAGE;
            return _currentSelectedLanguage;
         } else if (availableLanguagesForTheApplication().containsObject(lang)){
            _currentSelectedLanguage = lang;
            return _currentSelectedLanguage;
         }
      }
      _currentSelectedLanguage = DEFAULTLANGUAGE;
   }
   return _currentSelectedLanguage;
}
       
public void setCurrentSelectedLanguage(String value) {
   _currentSelectedLanguage = value;
}

With this code in place we can bind values in our WOComponants with key paths that look something like this:

eman.dezilacolnull@.snoitpircsed.tcudorp

Our modified components will recognize the @localized operator, and return only the description that matches the current selected language. We can easily add additional languages to our application without having to change any of the code in any of our WOComponents or EOs, and new EOs in our model only need to implement a language attribute to support full localization.

Note: Like any example code, this shows only one possible way you might chose to implement this. Feel free to use it as a starting point. The code was tested before I started marking it up, if you find any errors please let me know. No warrantee implied, blah, blah, blah. 🙂

5 thoughts on “Localizing DB content with valueForKeyPath();

  1. I am curious if there are issues wth using operators as much as you do, especially in regards performance.

    thanx – ray

  2. So far I’ve been pretty happy with the performance of my apps. I haven’t done a lot of performance profiling, mainly because I haven’t had to.

    Obviously this is dependent on the app type and expected volume of visitors. Speed and flexibility of development ranks higher in my clients needs then raw performance at this point.

    YMMV 🙂

  3. Thanks a lot for that article. Makes life so much easier.

    Only thing I was missing in the article is what “name” does in a key path like “eman.dezilacolnull@.snoitpircsed.tcudorp”. I guess this is because you’re using projectWonder, where you can put dicitonaries into the .strings file, but the example is a bit confusing to the new-bee.

    Inside valueForKeyPath() there’s a key.length() that should be a keypath.length() if I’m not mistaken.

    Also you’re using CBArrayUtilities 😉

    /Daniel

  4. Daniel,

    1. “name” is an attribute from ProductDescripiton Entity (there will be an array of these, the code filters to the correct one for the current language). See the model view at the top of the post.

    2. That was a typo, it should have been keypath.length() – fixed now 🙂

    3. The code for CBArrayUtilities is here.

Comments are closed.