Hacking Globalize

Globalize is the first rubygem I ever hacked. It was a natural starting point: I was interested in building a tool to overcome language barriers on the Internet, and globalize was the most popular rails gem for translating model data. This was the gem for me.

But globalize is fairly counter-intuitive, especially for the relative beginner I was at the time. Just as I was taking my first baby steps with MVC and SQL, here I am trying to understand how globalize can take the value of an attribute on one table and magically save it to a column on a completely different table. Not only that, but depending on what locale I’m in, it will show me a different value for that same attribute.

For the unacquainted, let’s take a look at a bit of that magic. Assuming I’ve added the globalize gem to my Gemfile, I can tell globalize that my Post model has a translated attribute title like this:

class Post < ActiveRecord::Base
  translates :title
end

Assuming I do run the correct database migrations as explained in the documentation, I can now use this attribute:

post = Post.new(title: 'hacking globalize')
post.save
post.reload
post.title
#=> "hacking globalize"

So far so good, nothing too magical there. But here comes the interesting part. Assuming my default locale is English, I can change the value of title in another locale simply by changing the locale and assigning a new attribute value:

I18n.locale = :ja
post.title
#=> nil
post.title = 'globalizeをハッキングしようぜ'
#=> "globalizeをハッキングしようぜ"
post.save
post.reload
post.title
#=> "globalizeをハッキングしようぜ"

What in the world is happening? Is the value of title the one in English, or the one in Japanese?

Well, both actually:

I18n.with_locale(:en) { post.title }
#=> "hacking globalize"
I18n.with_locale(:ja) { post.title }
#=> "globalizeをハッキングしようぜ"

So the attribute has multiple values, one for each locale, and depending on what locale you’re in it will get/set a different value. That’s very handy if you have a website where you want to render content in many languages: just set the locale and you can use the same view across many languages.

If you dig into the code, as I eventually did, you find a module named InstanceMethods in Globalize::ActiveRecord that makes this little bit of black magic possible. It does so by overriding the default implementations of read_attribute and write_attribute, used throughout rails to get and set model attributes.

Here’s the overridden read_attribute:

def read_attribute(name, options = {})
  options = {:translated => true, :locale => nil}.merge(options)
  return super(name) unless options[:translated]

  if name == :locale
    self.try(:locale).presence || self.translation.locale
  elsif self.class.translated?(name)
    if (value = globalize.fetch(options[:locale] || Globalize.locale, name))
      value
    else
      super(name)
    end
  else
    super(name)
  end
end

Honestly, not the loveliest bit of code, but let’s focus on the key lines that make the magic happen:

if (value = globalize.fetch(options[:locale] || Globalize.locale, name))
  value
else
  super(name)
end

So post.title in the English locale returns globalize.fetch(:en, :title), or if this is nil then super. (Globalize.locale mostly mirrors I18n.locale – you can consider them the same.)

globalize is an instance of a class called Adapter, which caches attribute translations we have fetched and keeps track of new or changed values that need to be saved. When we call globalize.fetch(locale, name) for the first time, globalize fetches the value with this method:

def fetch_attribute(locale, name)
  translation = record.translation_for(locale, false)
  return translation && translation.send(name)
end

Getting a bit closer, but we’re not quite there yet. Let’s take a look at the translation_for method in the InstanceMethods module mentioned earlier:

def translation_for(locale, build_if_missing = true)
  unless translation_caches[locale]
    # Fetch translations from database as those in the translation collection may be incomplete
    _translation = translations.detect{|t| t.locale.to_s == locale.to_s}
    _translation ||= translations.with_locale(locale).first unless translations.loaded?
    _translation ||= translations.build(:locale => locale) if build_if_missing
    translation_caches[locale] = _translation if _translation
  end
  translation_caches[locale]
end

Here’s where the magic happens. translations is an association created when you call translates in your model class. It’s defined in the ActMacro module:

has_many :translations, :class_name  => translation_class.name,
                        :foreign_key => options[:foreign_key],
                        :dependent   => :destroy,
                        :extend      => HasManyExtensions,
                        :autosave    => false

translation_class is a subclass of the Translation class in Globalize::ActiveRecord, which inherits from ActiveRecord::Base. So this is an activerecord model, plain and simple. It’s this model (Post::Translation) that globalize uses to store and access translations of our translated model.

If you look at the database schema for a model with translations, you’ll see the table for the model’s translations class:

create_table "post_translations", force: true do |t|
  t.integer  "post_id", null: false
  t.string   "locale",  null: false
  t.datetime "created_at"
  t.datetime "updated_at"
  t.string   "title"
end

Each entry in the translations table stores a reference to the parent model (post_id), a locale specifying what language this translation is in (locale), the standard pair of timestamps, and one column for each translated attribute (in this case, one column for title).

So, returning to where we started, when you type post.title in the English locale, globalize fetches the row in the post_translations table whose locale column is “en” and post_id column is the id of the post, and returns the value of the title column.

In the end, the magic is not so magical really.


You might ask, at this point, why you wouldn’t just create columns for each locale directly on the parent model, and save yourself this long song and dance. There are gems that do this.

The catch though is that if you put the translations in the parent record, then you have to specify beforehand what locales you will be supporting, because you need to create columns for the attribute in each locale. If you add new locales, you’ll then have to migrate your database to support them.

Globalize is more flexible: each translated record in the translation table has a locale attached to it – nowhere do you “hard-code” the locales you want to support. That can come in handy when your project starts growing: just translate localization strings, update I18n.available_locales and you’re ready to go: globalize, your tables, your views and most everything else can stay exactly the way they were before, leaving you free to keep developing new features for your multilingual app.

Of course, things are never quite that easy! I’ll return to some of the globalize gotchas in the next couple of posts – stay tuned!

Posted on October 11th, 2013. Filed under: globalize, activerecord, rails, i18n.
blog comments powered by Disqus