-
Notifications
You must be signed in to change notification settings - Fork 82
Introduction to Mobility Backends
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
.
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.
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.
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.