El Mecanisme de Emmagatzematge Interna de Globalize

The Globalize for Rails for-1.2 release introduced a new storage mechanism for Model Translations.

If you’re new to Globalize, let me give you a bit of background to model translations (Click to view) in Globalize. Those of you reading this in a feed reader and are familiar with globalize feel free to just read ahead to the Before for-1.2 section.

Before for-1.2

Before the for-1.2 release, Globalize used an external table (globalize_translations) to store translations. To achieve this, it need to override the ActiveRecord::Base.find_every method to rewrite the query sent to the database and coalesce the model table’s columns with the globalize_translations table.

This rewriting of the sql query implicitly sets up a number of limitations, the most obvious of which is that it disallows overriding the :select option to the find method because in effect that’s exactly what globalize is already doing.

This leads to other limitations like disallowing the :include option as well. (Why? Same reason as before)

There are workarounds for most of these limitations (For example, you can replace :include with :include_translated) especially if you’re writing your application from scratch.

But if you’re trying to globalize an existing application you’ve got to go through quite a number of hoops.

Introducing the “Internal Storage Mechanism”

The new internal storage mechanism gets rid of these limitations. Because it stores the translations within the models own table, all the translations are loaded every time the instance is loaded. This means that there is no need to override the sql query, which consequently means you no longer have any limitation on ActiveRecord::Base.find. Only a little bit of ruby magic is required to get things to work.

So let’s see an example of it in action:

If you read the intro to globalize, you’d have seen we used a Product model and assumed the base locale was set to ‘en-US’.

The only slight change you need to do to your ActiveRecord class definitions is switch on the internal storage mechanism like so:


class Product < ActiveRecord::Base

  self.keep_translations_in_model = true
  translates :name

end

Note: This use of “self.keep_translations_in_model = true” in the class definition allows activation of this mechanism on a per class level since the normal external storage mechanism is active by default. However, you can also set the internal storage to be the default mechanism on an application-wide level by setting the following in your environment.rb:


Globalize::DbTranslate.keep_translations_in_model = true

As this method depends on storing the translations within the model’s own table we need to add in extra columns for all the attributes that are to be localised and one each for each locale to be supported in the application.

I’ve written a tool to automate this step but for now we’ll take a more manual approach and code up a migration. In our example application, we’ll have support for both english and spanish.


class AddLocalizedFieldsForProduct < ActiveRecord::Migration
  def self.up
    add_column :products, :name_es, :string
  end

  def self.down
    remove_column :products, :name_es
  end
end

Notice, that since we’ve only a got a single attribute marked as translatable in our Product class definition and we’re only supporting a single extra locale (es-ES), we only need to add one extra column to the products table.


'attribute' + language suffix

e.g

'name' + 'es' => 'name_es'

As you can see, the name of the new column is simply the original attribute name and a suffix which is simply the language part of the locale to be supported.

So let’s open up a console and see what we can do now that the internal storage mechanism is active.

$ script/console
Loading development environment
>>Locale.set('en-US')                           #We set the current locale to en-US (English)
>>product = Product.create(:name => 'Shoes')    #Create a new product
>>product.name                                  #Asking for the value of the 'name' attribute
=> "Socks"                                      #has correctly shown 'Shoes'

>>Locale.set('es-ES')                           #We change the current locale to es-ES (Spanish)

#Note: You no longer are required to reload the product instance on locale changes

>> product.name
=> nil                                          #By default, if there are no translations nil is returned
>> product.name = 'Zapatos'                     #In Spanish, 'Shoes' are called 'Zapatos'
>> product.save                                 #Save the product instance
>> product.name
=> "Zapatos"                                    #We now get the correctly translated value

>>Locale.set('en-US')                           #We change the current locale back to english
>> product.name
=> "Shoes"                                      #We correctly get back 'Shoes'

>>Locale.set('es-ES')                           #Once more we change the current locale
>> product.name
=> "Zapatos"                                    #We correctly get back 'Zapatos'

Exploring the “Internal Storage Mechanism”

So much for the simple example let’s see what else we can do:


#Let's specify that the base locales content should be returned if untranslated
class Product < ActiveRecord::Base
  self.keep_translations_in_model = true
  translates :name, :base_as_default => true

  belongs_to :sku
  belongs_to :price_list
  has_many :commerce_items
end

$ script/console
Loading development environment
>>Locale.set('en-US')
>> product = Product.find(:last)    #Simple query
>> product.name
=> "Shoes" 
>>Locale.set('es-ES')
>> product.name
=> "Zapatos" 

>>product2 = Product.create(:name => 'Shirts')
>>product.name
=> "Shirts" 
>>Locale.set('es-ES')
>> product.name
=> "Shirts"    #_base_as_default_ = true means untranslated attributes will return the base locale value
>> product.name = 'Camisas'
>> product.save
>> product.name
=> "Camisas" 

#Now for a slightly more complex query
>>Locale.set('en-US')
>> products = Product.find(:all, :select => 'select distinct(name)', :include => :items)
=> [<#Product :name => 'Socks'>, <#Product :name => 'Shoes' >]
>> products.first.name
=> "Socks" 
>>Locale.set('es-ES')
>> products.first.name
=> "Calcetines" 

#Now for some conditions

>>Locale.set('en-US')
>> product = Product.find(:first, :conditions => {:name => 'Socks'})
=> <#Product :name => 'Socks'>
>> product.name
=> "Socks" 
>>Locale.set('es-ES')
>> product = Product.find(:first, :conditions => {:name => 'Calcetines})
=> nil    #Eh? What happened here?

Let’s look at the query that got executed:

select * from products where products.name = 'Calcetines';

Remember that for the Spanish locale, the translated data has been stored in the name_es column so obviously, the find method wasn’t being clever enough to recognize that it should have generated:


select * from products where products.name_es = 'Calcetines';

It would have been too complex to add functionality to make this seamless and would have ended up defeating the whole purpose of this method as it would most likely need to override the sql query generation mechanism and we’d be back to the same problem as the external storage mechanism has.

However, there’s a simple solution to this problem.


>>Locale.set('es-ES')
>> product = Product.find(:first, :conditions => ["#{Product.localized_facet(:name)} = ?",'Calcetines'])
=> <#Product :name => 'Calcetines'>
>> product.name
=> "Calcetines" 
>>Locale.set('en-US')
>> product.name
=> "Socks" 

For simple condition statements like that we can go one better and use dynamic finders:


>>Locale.set('es-ES')
>> product = Product.find_by_name('Calcetines')
=> <#Product :name => 'Calcetines'>
>> product.name
=> "Calcetines" 
>>Locale.set('en-US')
>> product = Product.find_by_name('Socks')
>> product.name
=> "Socks" 

Activating the internal storage mechanism, overrides the AR method_missing to ensure that dynamic finders correctly use the right column name for the current locale.

What other things can we do:


>>Locale.set('en-US')
>> product = Product.find_by_name('Socks')
>> product.name
=> "Socks" 
>>Locale.set('en-US')
>> product.name
=> "Calcetines" 
>> product._name  #Using the '_' prefix to the attribute means you want the base locale content
=> "Socks" 

>> product.name_before_type_cast
=> "Calcetines" 
>> product._name_before_type_cast
=> "Socks" 


Trouble spots?

Well, this all seems hunky dory, super fantastic, right? Right :) However, there are a few things that you should keep in mind.

Maintenance of the database schema

In our simple example, we only had to add one extra column to our products table as we were only supporting one other language. But let’s move onto something more realistic. Imagine you’re developing a “typical” web application with something like 10 models and which supports 6 different languages. And there an average of at least 5 translatable attributes per model. That works out to 300 extra fields in your tables. You can imagine the scope for errors here. Now imagine you’ve got a much larger application, schema evolution…extrapolate? :(

This is where the external mechanism’s advantage kicks in. No matter how many additional languages you want to support, or how your schema evolves, you never need to modify the schema to support these. With the internal mechanism, you have to be alert and ensure you add the right columns with the right suffix as your schema evolves.

Luckily two things go a long way to reduce the pain of this:

  • ActiveRecord Migrations
  • the for-1.2 rake tasks that automatically generate a migration to add the required columns. (The subject of my next article)
Database row limitations

The fact that you’re stuffing a table full of duplicated columns means that for some databases you may begin to reach certain limits, especially if those columns are blobs. e.g. MySQL InnoDB tables have a limit on row size that is easily reached by globalizing a table with about 4 varchar/text columns when you have to support 4 different languages. In this case, you need to switch to MyISAM tables which means you lose transactions (or you could switch your db to postgres or something like that).

So it’s important you keep these points in mind when deciding on which storage mechanism to use.

Conclusion

Phew…That’s was quite a long article. Anyway I may have a very heavy writing style and may tend to overwrite but if anything I hope this has been a fairly enlightening walk through globalize’s new storage mechanism.

As I mentioned in this article, I’ve also written a couple of tools which should make migrating your existing globalized application to this system a breeze. Look out for the next article in this series where I’ll be writing a short tutorial on how to use these tools. It’ll be a lot shorter, I promise :)


Torna a articles