Multilingual models

If you develop a multilingual website your most likely will have to translate your content as well (not just the interface with the normal I18N mechanisms). One way of doing that is to partition our all translatable attributes from a table out to another table. The ActiveRecord pattern breaks as soon as you partition the tables.

Let’s say I have a table product and product_trans, in product I store all locale aware attributes like price, sizes etc, while in product_trans I store name, description etc.

It may be a solution to create a MultilingualActiveRecord class which extends CActiveRecord and are able to assemble a localized version of the Product instance based on a provided locale.

Anyone doing this already? Maybe this is such a general feature that it could be supported in the framework?

Any other ideas to accomplish the same result?

I’m in need of something like that. Just today I was scratching my head again how to implement it in the best possible way.

why don’t use behaviours?

As of Yii 1.0.9 we could add relations to the Product model


    /**

     * @return array relational rules.

     */

    public function relations()

    {

        // NOTE: you may need to adjust the relation name and the related

        // class name for the relations automatically generated below.

        return array(

            'en' => array(self::HAS_ONE,'productTrans','productId','condition'=>"??.`language`='en'",'alias'=>'ProductEN'),

            'ru' => array(self::HAS_ONE,'productTrans','productId','condition'=>"??.`language`='ru'",'alias'=>'ProductRU'),

            'es' => array(self::HAS_ONE,'productTrans','productId','condition'=>"??.`language`='es'",'alias'=>'ProductES'),

        );

    }

Then if you need products with spanish translation, you do

$models=Product::model()->with(‘es’)->findAll();

and your title now is

$models[0]->es->title

Could anyone think of a better solution?

@phpdevmd: good idea :) but I think the ultimate solution would be even nicer than this. I think of a more transparent solution where you don’t have to declare language relations. It should be possible to add another language without touching business logic. In larger multilingual applications this is a must-have I think.

If I’m thinking loud, I would like to write something like:


$products = Product::model()->local('es')->findAll();

or:


$products = Product::model()->localize('es')->findAll();

When defining a multilingual active record model you’ll have to specify the table name and which attributes that are locale aware.

In the product-table all attributes are present. In product_trans-table all locale aware attributes is added. Let’s say my default language is English (en) and that my data in the product table is in English. Then this query should try to find translated attributes in Spanish (es) and if no translation is found it should fall back to the default language and use the default attribute values from the product-table.

I think this could be a killer feature :)

Good thoughts. We will need this in 1 or 2 months also. If you come to an implementation of your idea, please let us know. I think everyone will be interesting to use the Multilingual Active Record in such an easy way :)

I’ve made something, please let me know what you think.

I’ll post here the Model and ActiveRecordBehavior class (should give you a quick idea of what’s going on). Check attachement for a small test app (and the other classes of course).

I don’t use any additional db-tables. There are “multilanguage”-fields that have to be suffixed with a language-code (eg “title_en”). If one of these special fields are NULL, it means the content is not available in the defined language. If that’s the case, a fallback-language may be used.

You can manually override the fallback-setting which is defined in the Model by doing for example:


Model::model()->fallback(false)->findAll();

The supported languages are defined in the app-config (I18n app-component class).

Well the whole thing is not complete of course, but it’s something at least. Check test-app to see live (make sure to import database-dump). Use /index.php?language=it&fallback=1 to see the fallback-functionality in action.

This works by the way:


$postItem = Post::model()->findAll();


echo $postItem[0]['title'];



So there’s no need to mess around with any language codes when doing queries.


class Post extends I18nActiveRecord

{


	public $title;

	public $text;


	public static function model($className=__CLASS__)

	{

		return parent::model($className);

	}


	public function behaviors()

	{


		return array(

			'I18n' => array(

				'class' => 'I18nActiveRecordBehavior',

				'fallback' => false,

				'fields' => array('title', 'text'),

			),

		);


	}


}


class I18nActiveRecordBehavior extends CActiveRecordBehavior

{


	public $fallback;

	public $fields;


	private function _getFallback()

	{


		if (null !== ($fallback = $this->owner->getFallback()))

		{

			return $fallback;

		}


		return $this->fallback;


	}


	private function _setFallback()

	{

		if (null === $this->owner->getFallback())

		{

			$this->owner->setFallback($this->fallback);

		}

	}


	public function beforeFind()

	{


		$this->_setFallback();


		foreach ($this->fields as $field)

		{

			$this->owner->dbCriteria->mergeWith(array(

				'condition' => $this->_getCondition($field),

			));

		}


	}


	public function afterFind()

	{


		foreach ($this->fields as $field)

		{


			if (true === $this->_getFallback() && null === $this->owner->fallbackActive)

			{

				if (null === $this->owner->__get("{$field}_" . Yii::app()->i18n->activeLanguage['code']))

				{

					$this->owner->fallbackActive = true;

				}

				else

				{

					$this->owner->fallbackActive = false;

				}

			}


			$languageCode = (true === $this->owner->fallbackActive) ? Yii::app()->i18n->fallbackLanguage['code'] : Yii::app()->i18n->activeLanguage['code'];


			$this->owner->$field = $this->owner->__get("{$field}_{$languageCode}");


		}


	}


	private function _getCondition($field)

	{


		if (true === $this->_getFallback() && Yii::app()->i18n->activeLanguage['code'] !== Yii::app()->i18n->fallbackLanguage['code'])

		{

			return sprintf("{$field}_%s IS NOT NULL OR {$field}_%s IS NOT NULL", Yii::app()->i18n->activeLanguage['code'], Yii::app()->i18n->fallbackLanguage['code']);

		}

		else

		{

			return sprintf("{$field}_%s IS NOT NULL", Yii::app()->i18n->activeLanguage['code']);

		}


	}


}

@Y!!: yeah, that works :) but I think your solution adds even more constraints since you have to touch the database schema when adding/removing languages. I’m not 100% sure but having lots of locale specific columns in one table doesn’t sound like a good strategy from a database design perspective in general, I recently moved away from that approach to do partisioning with product- and product_trans-table. I still think we can push this feature future :)

Yes, you’re right I guess. But from what I see it’s very hard to automate that in Yii with relations. Maybe I’m wrong, not much into relations yet. Will play around a little, maybe I just don’t see the possible easy soluton yet.

Am I right thinking the named scope mechanism could do the job?

There is a join condition available including translated content, and it’s possible to specify a defaultScope.

I think it’s possible to create a base model that use a language parameter in named scope call.

This line in __call() seems to be the one to change. Some parameter binding semantic has to be added.




  $this->getDbCriteria()->mergeWith($scopes[$name]);



The call would look something like




MyModel::model()->translated('language')->...



Edit:

Just recognized the guide section about parameterized named scopes: http://www.yiiframework.com/doc/guide/database.ar

No need to extend CActiveRecord then. Just define a method named by the scope.

/Tommy

I’ve written a LocalizedBehavior that works as following:

Suppose you have a table (and model) Post, with fields id, title, content. The title and content fields store values in the primary or default language of the application.

Then you have a table (and model) PostLang, with fields id, postId, lang, title, content, which stores title and content in additional languages.

By adding the LocalizedBehavior to the Post model, you can do:




$posts = Post::model()->localized('es')->findAll($criteria);



What this will do is overwrite the value of the title and content attributes with their localized version. In case these values are empty it will leave the original ones. If the requested language is the same as the primary language, it will have no effect. You can also call it without the language parameter:




$posts = Post::model()->localized()->findAll($criteria);



And it will use the language currently set in the application.

It works fine, but maybe the implementation could be improved. What I’m doing internally is creating a relation, applying the with() method, and using afterFind() to process the results. All comments are welcome!




<?php

class LocalizedBehavior extends CActiveRecordBehavior {

 

    /**

    * Name of the field that stores the language in the translations table

    */

    public $langField = 'lang';


    /**

    * Name of the model/table that stores the translations.

    * For 'Post', it defaults to 'PostLang'.

    */    

    public $langClassName;


    /**

    * Name of the foreign key column of the translations table.

    * For 'Post', it defaults to 'postId'.

    */     

    public $langForeignKey;

    

    public $skipFields;

    

    public $primaryLang;


    public $overwriteIfEmpty = false;

    

    

    public function localized($lang=null) {

     

      $obj = $this->Owner;

      

      if (!$lang) $lang = Yii::app()->language;

      

      if ($lang == $this->getPrimaryLang()) return $obj;

      

      $class = CActiveRecord::HAS_MANY;

      $obj->getMetaData()->relations['localized'] = new $class('localized', $this->getLangClassName(), $this->getLangForeignKey(), 

        array('index'=>$this->langField, 'condition'=>"??.".$this->langField."='".$lang."'"));

      return $obj->with('localized');

    }

 

    public function afterFind($event) {

      $obj = $this->Owner;

      if (isset($obj->localized) && sizeof($obj->localized)>0) {

        $row = current($obj->localized);

        $skipFields = $this->getSkipFields(); 

        foreach ($row->getAttributes() as $field=>$value) {    

          if (!in_array($field, $skipFields)) {

            if ($value || $this->overwriteIfEmpty) $obj->$field = $value;

          }

        }

      }   

    }

    

    private function getPrimaryLang() {

      if (!$this->primaryLang) 

        return Yii::app()->sourceLanguage;

      return $this->primaryLang; 

    }

    

    private function getLangForeignKey() {

      if (!$this->langForeignKey) 

        return strtolower($this->Owner->tableName()).'Id';

      return $this->langForeignKey;

    }

    

    private function getlangClassName() {

      if (!$this->langClassName) 

        return $this->Owner->tableName().'Lang';

      return $this->langClassName;

    }

    

    private function getSkipFields() {

      if (!$this->skipFields) 

        return array('id', $this->getLangForeignKey(), $this->langField);  

      return $this->skipFields;

    }

}



(Note: for some reason, camelcase in the class names LocalizedBehavior and CActiveRecordBehavior appears messed up)

I’ve been working on adding functionality for handling also the creation and updating of multilingual content, but haven’t found the way to do it from inside a behavior, so I’ve decided to make it a CActiveRecord subclass. You can find it here:

http://www.yiiframework.com/extension/multilingual-active-record/

Hi there,

I integrated your Extension yesterday and it’s just Great.

Thanks for that.

But I had a Problem with the CActiveDataProvider that is used with the grid view widget.

So I made a smal addition to your Extension. Maybe you can add this class to your next release.




class MultilingualActiveDataProvider extends CActiveDataProvider

{

    public $blnLocalize = true;

    public $blnAll      = false;


    public function __construct($modelClass, $config=array(), $blnLocalize=true, $blnAll=false)

    {

        $this->blnLocalize = $blnLocalize;

        $this->blnAll      = $blnAll;

        

        parent::__construct($modelClass, $config);

    }


    protected function fetchData()

    {

        $criteria=clone $this->getCriteria();

        if(($pagination=$this->getPagination())!==false)

        {

            $pagination->setItemCount($this->getTotalItemCount(true));

            $pagination->applyLimit($criteria);

        }

	if(($sort=$this->getSort())!==false)

	    $sort->applyOrder($criteria);


            if($this->blnLocalize) {

                if($this->blnAll) {

                    return CActiveRecord::model($this->modelClass)->multilingual()->findAll($criteria);

                } else {

                    return CActiveRecord::model($this->modelClass)->localized()->findAll($criteria);

                }

            } else {

                return CActiveRecord::model($this->modelClass)->findAll($criteria);

            }

	}

}



Now I can Use the DataProvider with his pagination with the Multilang extension




$dataProvider=new MultilingualActiveDataProvider('Post', array(

    'criteria'=>array(

        'condition'=>'status=1 AND tags LIKE :tags',

        'params'=>array(':tags'=>$_GET['tags']),

        'with'=>array('author'),

    ),

    'pagination'=>array(

        'pageSize'=>20,

    ),

), true, true);



hope you like it :)

Nice one! This can be very valuable for multilingual sites (like mine), but converting a non-multilingual site is a breeze too.

Its giving some issues with related models (like $model->related->localized()->field), but have to look in to that a bit further. Perhaps it can be defined in the relation.

If someone can create a behavior like this please let it know!

guillemc, your multilangual ActiveRecord is awesome, but really its no way to realize it as behavior? It would be much better! What is problem? Maby i will try to help

Hi there, I use similar idea of behavior for multi-language content.

In FlexicaCMS, the approach is to enable multi-language for developed features so there should be minimal effort to turn a website with article management features originally developed in mono-language to multi-language.

Assume that I have written all my code with Article::model()->findAll(…). What I try to avoid is to search and replace them with Article::model()->with(‘vi’)->findAll() or Article::model()->localized()->findAll().

Also, because in this context it’s in a CMS, or a sandbox, using behavior is better than a sub-class ActiveRecord. Save the inheritance for a more business specific thing.

It’s quite a long writing so allow me to post a link here.

http://flexicacms.co…ur-content.html

Is the code for your behavior available (opensource license)… Am looking for the same thing :)

Yes, it’s open source mech7. you can download it from http://www.flexicacms.com.

umm is this working in 1.1.3 ?

the langClassName method uses the table name instead of the object name… but even then when returning the proper classname with…


public function langClassName() {      

    return get_class($this).'Lang';

  }

then… it wants to include ModelLangLang… and if I remove the Lang string it seems to create a new instance of the model but it runs out of memory…

Also the foreign keys seems to be hardcoded depending on table name… cannot get this value from schema data?

Hi I’m having the same problems, please notify me if you’ll find a way around this!