Mobility

Gem Version Build Status Dependency Status Code Climate

Mobility is a gem for storing and retrieving localized data through attributes on a class. A variety of different storage strategies are supported through pluggable, customizable "backends" implemented via a common interface.

Out of the box, Mobility supports:

  • translations as localized columns on the model table (like Traco)
  • translations on a model-specific table (like Globalize)
  • translations as values on globally shared key-value tables (the default, see below)
  • translations as values of a hash serialized on a text column of the model table (like Multilang)
  • translations as values of a hash stored as an hstore column on a Postgres model table (like Trasto, Multilang-hstore, hstore_translate, etc.)
  • translations as values of a hash stored as a jsonb column on a Postgres model table (like json_translate)

Each backend is implemented for both ActiveRecord and Sequel ORM, including a common interface for querying the database on translated attributes using extended scopes/datasets. Mobility is however flexible enough to support any storage strategy, including ones not backed by a database.

All backends can optionally enable any of a set of common, ORM-independent features, including:

  • a cache to improve read/write performance (included by default)
  • translation fallbacks, in case a translation is missing in a given locale
  • (for classes that support it) dirty tracking of changed attributes (ActiveModel::Dirty in Rails)
  • locale-specific accessors for translated attributes, of the form <attribute>_<locale> (similar to globalize-accessors)

Installation

Add this line to your application's Gemfile:

gem 'mobility', '~> 0.1.3'

To translate attributes on a model, you must include (or extend) Mobility, then call translates passing in one or more attributes as well as a hash of options, including the backend to use. (See Defining Backend Attributes below.)

ActiveRecord (Rails)

Requirements:

  • ActiveRecord >= 5.0

(Support for some backends is also supported with ActiveRecord/Rails 4.2, see the active_record-4.2 branch.)

If using Mobility in a Rails project, you can run the generator to create an initializer and (optionally) a migration to create shared tables for the default key-value backend:

rails generate mobility:install

To skip the migration (if you do not plan to use the default KeyValue backend), use the --without_tables option:

rails generate mobility:install --without_tables

The generator will create an initializer file config/initializers/mobility.rb with the line:

Mobility.config.default_backend = :key_value

To set a different default backend, set default_backend to another value (see possibilities below). Other configuration options can be set using the configure method, see: Mobility::Configuration for details.

To get started quickly, run the generator with tables, and add the following to a class to add translated attributes:

class Post < ActiveRecord::Base
  translates :title,   type: :string
  translates :content, type: :content
end

You now have translated attributes title and content on the model:

I18n.locale = :en
post = Post.create(title: "foo")
post.title                         #=> "Mobility"
I18n.locale = :ja
post.title                         #=> nil
post.title = "モビリティ"
post.save
post.title                         #=> "モビリティ"
I18n.locale = :en
post.title                         #=> "Mobility"

Congratulations! Now have a look at the Usage section to see what else Mobility can do.

Sequel

Requirements:

  • Sequel >= 4.0

Essentially identical to ActiveRecord, with the exception that there is no equivalent to a Rails generator (so you will need to create the migration for any translation table(s) yourself, see the API docs for details).

Usage

Defining Backend Attributes

In order to use Mobility on a class, you will need to extend the Mobility module and call translates, passing in one or more attribute names as well as a hash of options.

The options hash is used to generate the backend, and has several reserved keys:

  • backend (Symbol or Class)
    The backend to use (defaults to the value of Mobility.default_backend). If its value is a symbol it will be converted to CamelCase and appended to the Mobility::Backend module name to get the backend class (so key_value will be converted to Mobility::Backend::KeyValue). See the list of backends.
  • cache (Boolean)
    Whether to use a cache.
  • fallbacks (Boolean or Hash)
    Enable fallbacks, and optionally configure them.
  • dirty (Boolean)
    Whether to enable dirty tracking.
  • accessor_locales (Boolean or Array)
    Enable locale accessors and optionally configure them.

In addition to these, each backend may have specific configuration options. For example, the default key-value backend, which stores attributes and their translations as key/value pairs on shared tables, has a type option which specifies which type of column (string or text) to use for storing translations.

Here is an example defining three attributes on an ActiveRecord model:

class Post < ActiveRecord::Base
  include Mobility
  translates :title, :author, backend: :key_value, type: :string, cache: false
  translates :content,        backend: :key_value, type: :text, fallbacks: true
end

title, author and content will use the KeyValue backend, which stores translations on two shared translation tables (one for string-valued translations and one for text-valued translations). The cache (enabled by default) is disabled for title and author (but not content). Fallbacks are enabled for content (but not title or author).

Finally, title and author store their translations as string columns (type: :string) whereas content stores its values as text columns (type: :text). The type key is a backend-specific option used by the KeyValue backend.

Note that Mobility will detect the model class and use this to determine which ORM-specific backend to use. In the example above, it will use Mobility::Backend::ActiveRecord::KeyValue; if the class were a Sequel::Model, it would have used Mobility::Backend::Sequel::KeyValue. In general options to configure the backend are ORM-independent.

Setting the Locale

Similar to Globalize, Mobility has its own locale which defaults to the value of I18n.locale but can also be set independently with a setter:

I18n.locale = :en
Mobility.locale              #=> :en
Mobility.locale = :fr
Mobility.locale              #=> :fr
I18n.locale                  #=> :en

To set the Mobility locale in a block, use Mobility.with_locale:

Mobility.locale = :en
Mobility.with_locale(:ja) do
  Mobility.locale            #=> :ja
end
Mobility.locale              #=> :en

Getting and Setting Translations

Mobility defines getter, setter, and presence methods for translated attributes on the model class. Regardless of which backend you use to store translations, the basic interface for accessing them is the same.

Assuming we have a model Post as above, we can first set the locale, then create a post with a translated attribute:

Mobility.locale = :en
post = Post.create(title: "Mobility")
post.title
#=> "Mobility"
post.title?
#=> true

Attributes can similarly be written just like a normal attribute:

post.title = "Mobility (noun): quality of being changeable, adaptable or versatile"
post.title
#=> "Mobility (noun): quality of being changeable, adaptable or versatile"

If you change locale, you will read/write the attribute in that locale:

Mobility.locale = :ja
post.title
#=> nil
post.title?
#=> false
post.title = "Mobility(名詞):動きやすさ、可動性"
post.title
#=> "Mobility(名詞):動きやすさ、可動性"
post.title?
#=> true

Internally, Mobility maps the title accessor method to a backend, which then handles reading and writing of data. You can access the backend instance for a given attribute with <attribute>_backend, in this case post.title_backend, and read and write locale values directly to/from the backend (although this should not generally be necessary):

post.title_backend.read(:ja)
#=> "Mobility(名詞):動きやすさ、可動性"
post.title_backend.read(:en)
#=> "Mobility (noun): quality of being changeable, adaptable or versatile"

You can also access different locales by passing the locale into the getter method in the options hash:

post.title(locale: :ja)
#=> "Mobility(名詞):動きやすさ、可動性"
post.title(locale: :en)
#=> "Mobility (noun): quality of being changeable, adaptable or versatile"

The translated value can be written using the backend's write method:

post.title_backend.write(:en, "new title")
post.save
post.title
#=> "new title"
post.title_backend.write(:en, "Mobility (noun): quality of being changeable, adaptable or versatile")
post.save
post.title
#=> "Mobility (noun): quality of being changeable, adaptable or versatile"

Backends vary in how they implement reading and writing of translated attributes. The default Mobility::Backend::KeyValue backend stores these translations on two shared tables, mobility_string_translations and mobility_text_translations, depending on the type of the attribute (corresponding to the type of column used).

For more details on backend-specific options, see the documentation for each backend (below).

Choosing a Backend

Mobility supports six different (database) backends:

  • :column (Mobility::Backend::Column)
    Store translations as columns on a table with locale as a postfix, of the form title_en, title_fr, etc. for an attribute title.
  • :table (Mobility::Backend::Table)
    Store translations on a model-specific table, e.g. for a model Post with table posts, store translations on a table post_translations, and join the translation table when fetching translated values.
  • :key_value (Mobility::Backend::KeyValue)
    Store translations on a shared table of locale/attribute translation pairs, associated through a polymorphic relation with multiple models.
  • :serialized (Mobility::Backend::Serialized)
    Store translations as serialized YAML or JSON on a text column.
  • :hstore (Mobility::Backend::Hstore)
    Store translations as values of a hash stored as a PostgreSQL hstore column.
  • :jsonb (Mobility::Backend::Jsonb)
    Store translations as values of a hash stored as a PostgreSQL jsonb column.

Each backend has strengths and weaknesses. If you're unsure of which backend to use, a rule of thumb would be:

  • If you're using PostgreSQL as your database, use :jsonb.
  • If you have a fixed, small set of locales that are not likely to increase, and have a small number of models to translate, consider :column.
  • If you have a small set of models to be translated but translation to potentially many different languages, consider :table.
  • For all other cases (many locales, many translated models), or if you're just not sure, the recommended solution is :key_value for maximum flexibility and minimum database migrations.

Locale Accessors

It can sometimes be more convenient to access translations through dedicated locale-specific methods (for example to update multiple locales at once in a form). For this purpose, Mobility has a locale_accessors option that can be used to define such methods on a given class:

class Post < ActiveRecord::Base
  include Mobility
  translates :title, locale_accessors: [:en, :ja]
end

(Note: The backend defaults to key_value, and type defaults to text, but options described here are independent of backend so we will omit both for what follows.)

Since we have enabled locale accessors for English and Japanese, we can access translations for these locales with:

post.title_en
#=> "Mobility (noun): quality of being changeable, adaptable or versatile"
post.title_ja
#=> "Mobility(名詞):動きやすさ、可動性"
post.title_en = "foo"
post.title
#=> "foo"

Alternatively, just using locale_accessors: true will enable all locales in I18n.available_locales.

For more details, see: Mobility::Attributes (specifically, the private method define_locale_accessors).

Cache

The Mobility cache caches localized values that have been fetched once so they can be quickly retrieved again, and also speeds up writes for some backends. The cache is enabled by default and should generally only be disabled when debugging; this can be done by passing cache: false to any backend.

In general, you should not need to actually see the cache, but for debugging purposes you can access it by calling the private cache method on the backend:

post.title_backend.send :cache
#=> #<Mobility::Backend::KeyValue::TranslationsCache:0x0056139b391b38 @cache={}>

For more details, see: Mobility::Backend::Cache.

Fallbacks

Mobility offers basic support for translation fallbacks (similar to gems such as Globalize and Traco). To enable fallbacks, pass a hash with fallbacks for each locale as an option to the backend:

class Post < ActiveRecord::Base
  include Mobility
  translates :title, locale_accessors: [:en, :ja, :fr], fallbacks: { en: :ja, fr: :ja }
end

By setting fallbacks for English and French to Japanese, values will fall through to the Japanese value if none is present for either of these locales:

Mobility.locale = :en
post = Post.first
post.title = nil
post.save
post.title_en
#=> "Mobility(名詞):動きやすさ、可動性"
post.title_ja
#=> "Mobility(名詞):動きやすさ、可動性"
post.title_fr
#=> "Mobility(名詞):動きやすさ、可動性"

You can optionally disable fallbacks to get the real value for a given locale (for example, to check if a value in a particular locale is set or not) by passing fallbacks: false to the getter method:

post.title(fallbacks: false)
#=> nil
post.title_fr(fallbacks: false)
#=> nil

(Mobility assigns the fallbacks hash to an instance of I18n::Locale::Fallbacks.new.)

For more details, see: Mobility::Backend::Fallbacks.

Dirty Tracking

Dirty tracking (tracking of changed attributes) can be enabled for models which support it. Currently this includes models including ActiveModel::Dirty or Sequel models with the dirty plugin enabled.

Enabling dirty tracking is as simple as sending the dirty: true option to any backend. The way dirty tracking works is somewhat dependent on the model class (ActiveModel or Sequel); we will describe the ActiveModel implementation here.

First, enable dirty tracking (note that this is a persisted AR model, although dirty tracking is not specific to AR and works for non-persisted models as well):

class Post < ActiveRecord::Base
  include Mobility
  translates :title, locale_accessors: [:en, :ja], dirty: true
end

Now set the attribute in both locales:

post.title
#=> "Mobility (noun): quality of being changeable, adaptable or versatile"
post.title = "a new title"
post.title_ja
#=> "Mobility(名詞):動きやすさ、可動性"
post.title = "新しいタイトル"

Now you can use dirty methods as you would any other (untranslated) attribute:

post.title_was
#=> "Mobility (noun): quality of being changeable, adaptable or versatile"
Mobility.locale = :ja
post.title_was
#=> "Mobility(名詞):動きやすさ、可動性"
post.changed
["title_en", "title_ja"]
post.save

You can also access previous_changes:

post.previous_changes
#=>
{
  "title_en" =>
    [
      "Mobility (noun): quality of being changeable, adaptable or versatile",
      "a new title"
    ],
  "title_ja" =>
    [
      "Mobility(名詞):動きやすさ、可動性",
      "新しいタイトル"
    ]
}

You will notice that Mobility uses locale accessors to indicate which locale has changed; dirty tracking is implemented this way to ensure that it is clear what has changed in which locale, avoiding any possible ambiguity.

For more details, see: Mobility::Backend::Dirty.

Querying

Database-backed Mobility backends also optionally support querying through where and other query methods (not and find_by for ActiveRecord models, except for Sequel models, etc). To query on these attributes, use the i18n class method, which will return a model relation extended with Mobility-specific query method overrides.

So assuming a model:

class Post < ActiveRecord::Base
  include Mobility
  translates :title, backend: :key_value, type: :string
  translates :content, backend: :key_value, type: :text
end

we can query for posts with title "foo" and content "bar" just as we would query on untranslated attributes, and Mobility will convert the queries to whatever the backend requires to actually return the correct results:

Post.i18n.find_by(title: "foo", content: "bar")

results in the SQL:

SELECT "posts".* FROM "posts"
  INNER JOIN "mobility_string_translations" "title_mobility_string_translations"
  ON "title_mobility_string_translations"."key" = 'title'
  AND "title_mobility_string_translations"."locale" = 'en'
  AND "title_mobility_string_translations"."translatable_type" = 'Post'
  AND "title_mobility_string_translations"."translatable_id" = "posts"."id"
  INNER JOIN "mobility_text_translations" "content_mobility_text_translations"
  ON "content_mobility_text_translations"."key" = 'content'
  AND "content_mobility_text_translations"."locale" = 'en'
  AND "content_mobility_text_translations"."translatable_type" = 'Post'
  AND "content_mobility_text_translations"."translatable_id" = "posts"."id"
  WHERE "content_mobility_text_translations"."value" = 'bar' AND
  "title_mobility_string_translations"."value" = 'foo'

As can be seen in the query above, behind the scenes Mobility joins two tables, one with string translations and one with text translations, and aliases the joins for each attribute so as to match the particular values passed in to the query. Details of how this is done can be found in Mobility::Backend::ActiveRecord::QueryMethods.

Note that this feature is available for all backends except the serialized backend, since serialized database values are not query-able (an ArgumentError error will be raised if you try to query on attributes of this backend).

For more details, see subclasses of Mobility::Backend::ActiveRecord::QueryMethods or Mobility::Backend::Sequel::QueryMethods.

Philosophy

As its name implies, Mobility was created with a very specific design goal: to separate the problem of translating model attributes from the constraints of any particular translation solution, so that application designers are free to mix, match and customize strategies to suit their needs.

To this end, Mobility backends strictly enforce the rule that no backend should modify a parent class in any way which would interfere with other backends operating on the same class. This is done using a heavy dose of metaprogramming, details of which can be found in the API documentation and in the actual code.

In practice, this means that you can use different backends for different attributes on the same class without any conflict, e.g. (assuming we are using Postgres as our database):

class Post < ActiveRecord::Base
  include Mobility
  translates :title,       backend: :key_value, type: :string
  translates :content,     backend: :column, cache: false
  translates :author_name, backend: :jsonb
end

Attributes can be set and fetched and Mobility will transparently handle reading and writing through the respective backend: a shared mobility_string_translations table for title, the content_en and content_ja columns on the posts table for content, and JSON keys and values on the jsonb author_name column for author_name.

Similarly, we can query for a particular post using the i18n scope without worrying about how attributes are actually stored. So this query:

Post.i18n.where(title: "foo",
                content: "bar",
                author_name: "baz")

will result in the following SQL:

SELECT "posts".* FROM "posts"
  INNER JOIN "mobility_string_translations" "title_mobility_string_translations"
  ON "title_mobility_string_translations"."key" = 'title'
  AND "title_mobility_string_translations"."locale" = 'en'
  AND "title_mobility_string_translations"."translatable_type" = 'Post'
  AND "title_mobility_string_translations"."translatable_id" = "posts"."id"
  WHERE (posts.author_name @> ('{"en":"baz"}')::jsonb)
  AND "posts"."content_en" = 'bar'
  AND "title_mobility_string_translations"."value" = 'foo'

The query combines conditions specific to each backend, together fetching the record which satisfies all of them.

Beyond the goal of making it easy to combine backends in a single class (which admittedly is a rather specialized use-case), the flexibility Mobility enforces makes it possible to build more complex translation-based applications without worrying about the details of the translation storage strategy used. It also saves effort in integrating translation storage with various other gems, since only one integration is required rather than one for each translation gem.

Development

Custom Backends

Although Mobility is primarily oriented toward storing ActiveRecord model translations, it can potentially be used to handle storing translations in other formats, for example in the cloud through an API, or in files. In particular, the features mentioned above (locale accessors, caching, fallbacks, dirty tracking to some degree) are not specific to database storage.

To use a custom backend, simply pass the name of a class which includes Mobility::Backend to translates:

class MyBackend
  include Mobility::Backend
  # ...
end

class MyClass
  include Mobility
  translates :foo, backend: MyBackend
end

For details on how to define a backend class, see the Mobility::Backend module and other classes defined in the API documentation.

Testing Backends

All included backends are tested against a suite of shared specs which ensure they conform to the same expected behaviour. These examples can be found in:

  • spec/support/shared_examples/accessor_examples.rb (minimal specs testing translation setting/getting)
  • spec/support/shared_examples/querying_examples.rb (specs for querying)
  • spec/support/shared_examples/serialization_examples.rb (specialized specs for backends which store translations as a Hash: serialized, hstore and jsonb backends)

A minimal test can simply define a model class and use helpers defined in spec/support/helpers.rb to run these examples, by extending either Helpers::ActiveRecord or Helpers::Sequel:

describe MyBackend do
  extend Helpers::ActiveRecord

  before do
    stub_const 'MyPost', Class.new(ActiveRecord::Base)
    MyPost.include Mobility
    MyPost.translates :title, :content, backend: MyBackend
  end

  include_accessor_examples 'MyPost'
  include_querying_examples 'MyPost'
  # ...
end

Shared examples expect the model class to have translated attributes title and content, and an untranslated boolean column published. These defaults can be changed, see the shared examples for details.

Backends are also each tested against specialized specs targeted at their particular implementations.

More Information

License

The gem is available as open source under the terms of the MIT License.