Migrating from Globalize - shioyama/mobility GitHub Wiki
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
- FriendlyId: friendly_id-mobility
- Ransack: mobility-ransack
More info
See the Table Backend section of the wiki.