Skip to content

Migrating from Globalize

Olivier edited this page Apr 20, 2022 · 58 revisions

Mobility's behaviour and interface is largely the same as Globalize, so migrating is straightforward. Note that there are some minor differences, so having a comprehensive test suite for your existing application is important.

Basic setup

Change your Gemfile to remove globalize and add mobility

-gem 'globalize'
+gem 'mobility'

Run rails generate mobility:install --without_tables to create the initializer (without migration files to create shared translation tables for the default KeyValue backend, which you will not need).

Edit config/initializers/mobility.rb and change the default backend to table and turn on dirty tracking. ("Table" is the name of the backend where translations are stored in another table, equivalent to what Globalize does. See the wiki page on the Table backend for details.)

Although not required, it is also recommended to enable locale accessors for use with the dirty plugin, and set I18n.available_locales.

Here is what your configuration should look like:

Mobility 1.0

 Mobility.configure do
   plugins do
+    backend :table
-    backend :key_value

     # default plugins

+    dirty
+    locale_accessors # recommended
   end
 end

Mobility 0.8

 Mobility.configure do |config|
+  config.default_backend = :table
-  config.default_backend = :key_value
   config.accessor_method = :translates
   config.query_method    = :i18n
+  config.default_options[:dirty] = true
+  config.default_options[:locale_accessors] = true # recommended
end

Extend Mobility from every model which you plan to translate before calling translates, e.g.:

 class Post < ApplicationRecord
+  extend Mobility
   translates :title, :content
   # ...
 end

Alternatively, you can just extend Mobility from ApplicationRecord (but extending each model separately is preferred):

 class ApplicationRecord < ActiveRecord::Base
+  extend Mobility
   self.abstract_class = true
 end

Fallbacks

If you were using fallbacks, also turn them on in mobility:

Mobility 1.0

Mobility.configure do
  plugins do
     # ...
+    fallbacks { en: :ja, ja: :en } # or whatever fallbacks you had in Globalize
  end
end

Mobility 0.8

 Mobility.configure do |config|
   config.default_backend = :table
   config.accessor_method = :translates
   config.query_method    = :i18n
   config.default_options[:dirty] = true
+  config.default_options[:fallbacks] = { en: :ja, ja: :en }
end

Note that fallbacks (like all options) can be customized for each model, by passing the fallbacks option to translates. The default_options above just sets the default in case none is specified in the model.

Dirty Tracking

Mobility supports tracking changed (translated) attributes like Globalize. To enable this, enable the dirty option in your mobility initializer (as mentioned above).

Mobility 1.0

 Mobility.configure do
   plugins do
     # ...
+    dirty
   end
 end

Mobility 0.8

 Mobility.configure do |config|
   config.default_backend = :table
   config.accessor_method = :translates
   config.query_method    = :i18n
+  config.default_options[:dirty] = true
end

There is a subtle difference in how Mobility handles dirty attributes. In Globalize, a change to the title attribute in any locale will be tracked as a change to title. Mobility does things differently: changes to an attribute in a given locale will be tracked with a suffix containing the locale, so that you can see changes to multiple locales at once.

post.changed
["title_en", "title_ja"]

Again, like fallbacks, you can enable or disable dirty tracking on each model if you like by passing the dirty option to translates.

globalize-accessors

Mobility supports having locale specific accessors (e.g., title_en or title_ja) like the globalize-accessors gem does.

To use this, first remove globalize-accessors from your Gemfile

-gem 'globalize-accessors'

Then turn on the locale_accessors plugin in your mobility initializer:

Mobility 1.0

 Mobility.configure do
   plugins do
     # ...
+    locale_accessors
   end
 end

Mobility 0.8

 Mobility.configure do |config|
   config.default_backend = :table
   config.accessor_method = :translates
   config.query_method    = :i18n
   config.default_options[:dirty] = true
+  config.default_options[:locale_accessors] = true
end

This will define locale accessor methods for all locales in I18n.available_locales.

Also be sure to remove any globalize_accessors from your models. Locale accessors can be customized per model using the locale_accessors option to translates.

class Post < ApplicationRecord
  translates :title, locale_accessors: [:en, :fr, :de] # overrides default
end

Associations

The association from model to translations is the same as Globalize, a has_many relation (by default) named translations:

post = Post.first
post.translations #=> returns post translations

(You can actually change the name of the association to something else with the association_name option to translates.)

However, whereas the inverse association in Globalize is called globalized_model, it is called translated_model in Mobility:

translation = post.translations.first
translation.translated_model          #=> returns the post

This means, among other things, that if you are using fixtures, you'll need to change globalized_model everywhere to translated_model.

Migrations

Mobility does not have migration helper methods like Post.create_translation_table! to create new translation tables for translated models. Instead, there are rails generators for this.

If you have a model Post and want to add translations for attributes title (string) and content (text), you would do this with:

rails generate mobility:translations post title:string content:text

Querying

Mobility supports querying on translated attributes like Globalize. However, unlike Globalize, this is not enabled by default. To query on translated attributes, you must enable the query plugin and (optionally) set the name of the i18n query scope:

Mobility 1.0

 Mobility.configure do
   plugins do
     # ...
+    query # uses default i18n scope, or customize by passing the name of the scope as argument
   end
 end

(For Mobility 0.8 and earlier, the query plugin is enabled by default.)

Post.i18n.where(title: "foo")
Post.i18n.find_by(title: "foo")
Post.i18n.find_by_title("foo")

Although it is not recommended, you can make this query scope enabled by default using a default_scope, like this, in your model class:

default_scope { i18n }

Then you can query on translated attributes like with Globalize, without any extra scope:

Post.where(title: "foo")
Post.find_by(title: "foo")
Post.find_by_title("foo")

To enable the i18n by default on all models, put the default scope above in ApplicationRecord.

Joining Translations

There are some subtle differences in how Mobility creates queries on translated attributes. When JOINing translations, Globalize uses an INNER JOIN for performance reasons (see this issue). However, if you query for a nil translation (Post.find_by_title(nil)), you will only get a result if the model has a blank translation, not if it has no translation. This can be problematic since you cannot be sure you will have translations in every locale (and no translation should be treated as a blank translation). See the note on blank translations below.

Rather than always use an INNER join, Mobility instead uses either INNER or OUTER depending on what you are querying for. So if you query for only nil translated values, it will use an OUTER JOIN to ensure that records without translations in a given local will be correctly matched.

Another difference is that when joining translations, Mobility aliases the join with a suffix including the locale you are joining on. So your join on a query like this:

Post.i18n.where(title: "foo")

will look like this:

SELECT "posts".* FROM "posts"
  INNER JOIN "post_translations" "post_translations_en"
  ON "post_translations_en"."post_id" = "posts"."id" AND "post_translations_en"."locale" = 'en'
  WHERE "post_translations_en"."title" = 'foo'

The alias post_translations_en here is used to allow for more complex queries on multiple locales at once, like this:

Post.i18n.where(title: "foo", locale: :ja).where(title: "bar", locale: :en)

This is querying for posts whose title is "foo" in Japanese and "bar" in English. This becomes:

SELECT "posts".* FROM "posts"
  INNER JOIN "post_translations" "post_translations_ja"
  ON "post_translations_ja"."post_id" = "posts"."id" AND "post_translations_ja"."locale" = 'ja'
  INNER JOIN "post_translations" "post_translations_en" 
  ON "post_translations_en"."post_id" = "posts"."id" AND "post_translations_en"."locale" = 'en'
  WHERE "post_translations_ja"."title" = 'foo' AND "post_translations_en"."title" = 'bar'

This type of query is not possible in Globalize since the translation table is joined only once (not once per locale), so you cannot distinguish between joins on different locales.

Querying with Order, Fallbacks

As of 0.8.0, like Globalize, Mobility supports ordering query results on translated attributes (order(:foo) where foo is translated). (This works for any backend.) Mobility does not, however, currently support querying with fallbacks like Globalize does, although this is in the roadmap.

Querying with Block Format (Arel)

For more complex querying, you can also pass a block to i18n and use arel nodes in the block:

Post.i18n do
  title.matches("foo").and(content.matches("bar"))
end

which generates:

SELECT "posts".* FROM "posts"
  ...
WHERE "Post_title_en_string_translations"."value" ILIKE 'foo'
  AND "Post_content_en_text_translations"."value" ILIKE 'bar'

See the readme section on querying for more details.

Uniqueness Validation

Mobility has a uniqueness validator like Globalize. Unlike Globalize, uniqueness validation in Mobility does not use monkey-patching. This means that in order for it to work, you need to call validates after you extend Mobility, like this:

class Post < ApplicationRecord
  extend Mobility
  translates :title

  validates :title, uniqueness: true
end

Interpolation

Mobility does not support interpolation like Globalize does, but you can get the same effect of code like this (in Globalize):

greeter.greeting(name: 'Chris')

with the slightly more verbose:

I18n.interpolate(greeter.greeting, name: 'Chris')

See #162 for more discussion on this.

Serialization

Serialization of translated attributes is not currently supported, but may be in the future. See #144.

Translation Locale is a String, not a Symbol

If you have any code which currently compares the value of a translation locale, like this (from RefineryCMS):

def translated_to_default_locale?
  persisted? && translations.any? { |t| t.locale == Refinery::I18n.default_frontend_locale}
end

... you will need to change it to convert the translation locale to a symbol, like this:

def translated_to_default_locale?
  persisted? && translations.any? { |t| t.locale.to_sym == Refinery::I18n.default_frontend_locale}
end

This is because unlike Globalize, Mobility does not override the locale method on the translation class to convert the value to a symbol.

Blank Translations

A quirk in Globalize is that if you save a model, even with no translated attributes set, you will save a translation in the current locale, which can be a problem (see globalize/globalize#328). Mobility does not do this. If you save a translated model and no translated attributes are present, no translation will be created in any locale.

This actually also applies to updating. If a model has a translated attribute, and you set that attribute to a blank value (blank string or nil) and save, and no other translated attributes are present in that locale, the translation record will be destroyed. Mobility strives to keep your database in a state where models only have translation records if at least one attribute in the given locale is present.

This is a good thing, and Mobility can do it this way because it correctly queries on nil values using an OUTER join (see note on Querying above), whereas with Globalize there must be a blank translation present in the current locale otherwise a query will not match.

Gem Integrations

More info

See the Table Backend section of the wiki.