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.