Skip to content

Introduction to Mobility Backends

Chris Salzberg edited this page Oct 17, 2018 · 15 revisions

In Mobility, a "backend" is a class that encapsulates all the logic dealing with how to store and retrieve translations. Concretely speaking, this is simply a class with a few standard methods that inherits from Mobility::Backend.

Using Mobility Backends

You initialize a backend by passing it a model, an attribute, and a set of options, and the backend can then be used to read and write translations of that attribute on the model. The first time you read or write to a translated attribute on a model, Mobility instantiates a backend this way and then uses it to get and set translations (here is where that actually happens).

Let's make that a bit more concrete. If you setup a model using the default KeyValue backend, like this:

class Post < ApplicationRecord
  extend Mobility
  translates :title,   type: :string
  translates :content, type: :text
end

... you can get the backend for each attribute like this:

post.title_backend
#=> #<#<Class:0x005626d81aa388>:0x005626d7a8ea90 @model=#<Post id: 1, ... >, @attribute="title", @association_name=:mobility_string_translations, @fallbacks=nil>
post.content_backend
#=> #<#<Class:0x005626d80a7648>:0x005626d7a5c950 @model=#<Post id: 1, ... >, @attribute="content", @association_name=:mobility_text_translations, @fallbacks=nil>

You can see that each attribute has an instance of the backend associated with it, with its own attribute name and some other configuration options. To read the value of a translated attribute in a locale, you just call read on the backend, passing in the locale:

post.title_backend.read(:en)
#=> "Introduction to Mobility Backends"
post.content_backend.read(:en)
#=> "In Mobility, a "backend" is a class that encapsulates all the logic..."

You can also write values to the backend, by calling write and passing in a locale and a new value:

post.title_backend.write(:en, "foo")
post.title_backend.read(:en)
#=> "foo"

The magic of handling translations in Mobility – whether the translations are stored in different tables, or on special columns, or anywhere else – basically boils down to these two read and write methods on the backend.

Designing Mobility Backends

Why is this special? Well, take a look at how other translation gems deal with managing translations, and you'll see that they add many methods to the model class, in many different ways, with special mechanisms for implementing things like cacheing, locale fallbacks, dirty tracking, etc.

With Mobility, everything is encapsulated in one class and modules modifying the class. So to understand how a backend works, you literally only need to look at one class. This makes it very easy to handle many different translation strategies, since they all follow the same format.

So what is that format? It's described in the API documentation for the Mobility::Backend module, which we'll reproduce here:

class MyBackend
  include Mobility::Backend

  def read(locale, **options)
    # ...
  end

  def write(locale, value, **options)
    # ...
  end

  def self.configure(options)
    # ...
  end

  def each_locale
    # iterate through each locale, yielding the locale
  end

  setup do |attributes, options|
    # Do something with attributes and options in context of model class.
  end
end

The read and write methods were mentioned earlier. configure is a method which can optionally be used to normalize configuration options for the backend when it is initialized. each_locale is a method which yields each available locale, used to generate Enumerable methods on the backend.

What is really important though is the setup method, which takes a set of attributes and options, which correspond to the attributes and options passed into translates on the model. The block is executed in the context of the model class, so everything that happens in this setup block will happen on the model; this makes it an ideal place to add any methods or do any other setup stuff that needs to be done for translations to work.

The ActiveRecord KeyValue backend, for example, has this code in its setup block:

setup do |attributes, options|
  association_name   = options[:association_name]
  translations_class = options[:class_name]

  # ...

  has_many association_name, ->{ where key: attributes },
    as: :translatable,
    class_name: translations_class.name,
    dependent:  :destroy,
    inverse_of: :translatable,
    autosave:   true

  # ...

end

This creates a polymorphic association for translations on the model class, using the association name that is passed in from the options. The options[:class_name] is created in the configure method, based on the value of type passed in to translates (either string or text).

With this setup in place, the read and write methods are very simple:

def read(locale, options = {})
  translation_for(locale, options).value
end

... where translation_for is a private method that fetches a translation from associated translations defined above in the setup block:

def translation_for(locale, _)
  translation = translations.find { |t| t.key == attribute && t.locale == locale.to_s }
  translation ||= translations.build(locale: locale, key: attribute)
  translation
end

def translations
  model.send(association_name)
end

(translation_for accepts options for use in the Cache plugin. Without the cache enabled, it simply discards them.)

The backend also defines an each_locale method, which iterates through each translation and yields it if its key matches the backend attribute:

def each_locale
  translations.each { |t| yield(t.locale.to_sym) if t.key == attribute }
end

each_locale is important since it allows us to build enumerable methods like find and select on the backend.

This all may seem a bit tricky, but it is very tightly encapsulated so that it can be seen all in one class. Other backends have their own ways of handling translation, but each one follows this same pattern, so although the code may be difficult to parse at first glance, once you see the pattern it becomes easier to understand Mobility as a whole, and what it is trying to do.

Querying

In addition to the setup block and read and write methods, backends for ActiveRecord and Sequel models can also support querying on translated attributes. To do this in an ActiveRecord backend, you simply define a class method on the backend called build_node which takes an attribute name and locale, and returns an Arel node.

Here is the build_node method on the KeyValue backend, for example:

def build_node(attr, locale)
  aliased_table = class_name.arel_table.alias(table_alias(attr, locale))
  Arel::Attribute.new(aliased_table, :value, locale, self, attribute_name: attr.to_sym)
end

Here, class_name.arel_table resolves to either mobility_string_translations or mobility_text_translations, depending on the attribute was defined with type: :string or type: :text. It is then aliased to a name which includes the model class, attribute name and locale, like Post_title_en_string_translations. An arel node is created for the value column on this aliased table.

In addition to build_node, if the backend needs to apply any additional scope to the relation, this can be done by defining a class method apply_scope. Two backends, the KeyValue and Table backends, use this hook in order to join translation tables, which cannot be done from within the arel node returned by build_node.

The actual code for apply_scope in these backends is somewhat complex since it uses the visitor pattern to carefully determine which type of join to use (INNER or OUTER), but the basic idea is simple: the method takes a relation, an Arel predicate, a locale and an optional invert option and returns a modified relation (in the cases mentioned, a relation with tables joined).

For Sequel, mostly the same is true except the names of these methods are build_op and prepare_dataset, respectively.