Skip to content
Devney Hamilton edited this page Jul 11, 2023 · 23 revisions

The KeyValue backend stores translations on a set of two shared tables, one for string-valued translations and one for text-valued translations. These tables are generated when you run the default install generator (rails generate mobility:install) and then migrate your database (rake db:migrate).

A detailed description of the backend is described as "Strategy #3" in this blog post.

Table Structure

The tables generated by the default migration look like this:

create_table "mobility_string_translations", force: :cascade do |t|
  t.string   "locale"
  t.string   "key"
  t.string   "value"
  t.integer  "translatable_id"
  t.string   "translatable_type"
  t.datetime "created_at",        null: false
  t.datetime "updated_at",        null: false
  t.index ["translatable_id", "translatable_type", "key"], name: "index_mobility_string_translations_on_translatable_attribute"
  t.index ["translatable_id", "translatable_type", "locale", "key"], name: "index_mobility_string_translations_on_keys", unique: true
  t.index ["translatable_type", "key", "value", "locale"], name: "index_mobility_string_translations_on_query_keys"
end

create_table "mobility_text_translations", force: :cascade do |t|
  t.string   "locale"
  t.string   "key"
  t.text     "value"
  t.integer  "translatable_id"
  t.string   "translatable_type"
  t.datetime "created_at",        null: false
  t.datetime "updated_at",        null: false
  t.index ["translatable_id", "translatable_type", "key"], name: "index_mobility_text_translations_on_translatable_attribute"
  t.index ["translatable_id", "translatable_type", "locale", "key"], name: "index_mobility_text_translations_on_keys", unique: true
end

Both tables have a translatable_type string column, because these will be used in a polymorphic relationship with translated models.

To understand how this works, suppose we have a model Post:

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

The backend will create associations on the class roughly resulting in:

class Post < ApplicationRecord
  has_many :mobility_string_translations, ->{ where key: [:title] },
    as:         :translatable,
    class_name: Mobility::ActiveRecord::StringTranslation,
    dependent:  :destroy,
    inverse_of: :translatable,
    autosave:   true

  has_many :mobility_text_translations,   ->{ where key: [:content] },
    as:         :translatable,
    class_name: Mobility::ActiveRecord::TextTranslation,
    dependent:  :destroy,
    inverse_of: :translatable,
    autosave:   true
end

So the backend adds associations for each type, scoped to only include translations whose keys are :title and :content, respectively.

The classes Mobility::ActiveRecord::StringTranslation and Mobility::ActiveRecord::TextTranslation both inherit from Mobility::ActiveRecord::Translation, which is an abstract class:

module Mobility
  module ActiveRecord
    class Translation < ::ActiveRecord::Base
      self.abstract_class = true

      belongs_to :translatable, polymorphic: true

      validates :key, presence: true, uniqueness: { scope: [:translatable_id, :translatable_type, :locale] }
      validates :translatable, presence: true
      validates :locale, presence: true
    end
  end
end

Since the class itself is abstract, it has no table; the table is set in the descendants to mobility_string_translations and mobility_text_translations, respectively. The different tables is actually the only difference between the string and text translation classes, and the only difference between the tables is that their value columns are of different types (string/text) (and that the string table has one additional index on value).

When you get a value with post.title, the backend does roughly the following to get the value:

locale = Mobility.locale
translation = mobility_string_translations.find { |t| t.key == "title" && t.locale == locale.to_s }
translation ||= translations.build(locale: locale, key: "title")
translation.value

So it finds the translation from all translations associated with the model which has a key "title" and a locale equal to Mobility.locale. If such a translation does not exist, it builds one. It then returns the value. Setting is much the same, with the value of the translation updated to a new value.

Querying

Querying on the KeyValue backend is quite complex since the associations above are polymorphic. Suppose we have a model, Post:

class Post < ApplicationRecord
  extend Mobility
  translates :title, backend: :key_value, type: :text
end

If we now add the Mobility scope (i18n) and query for posts with a title matching the string "foo", like this:

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

This is the resulting SQL:

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

As you can see, Mobility executes an INNER JOIN on a set of columns on mobility_text_translations (since this attribute is of type "text"), two of which match the Model name ('Post') and id "posts"."id", and the others matching the current locale ("en") and attribute name ("title"), and finally with the where condition matching the attribute value ("title_text_translations"."value" = 'foo').

The key trick here is the alias of "mobility_text_translations" to "Post_title_en_text_translations". This is required because if we query on multiple translated (key-value) attributes, we need to manage these joins and query conditions separately. So if you query on a few different translated attributes, you may end up with a some complex-looking SQL in your logs.

The presence of en and the class name Post in the alias is important for distinguishing between different translated models joining translations in different locales at once. So, for example, you can query for posts whose title is foo in Japanese but bar in English like this:

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

and this will generate the SQL:

SELECT "posts".* FROM "posts"
  INNER JOIN "mobility_text_translations" "Post_title_ja_text_translations"
  ON "Post_title_ja_text_translations"."key" = 'title'
  AND "Post_title_ja_text_translations"."locale" = 'ja'
  AND "Post_title_ja_text_translations"."translatable_type" = 'Post'
  AND "Post_title_ja_text_translations"."translatable_id" = "posts"."id"
  INNER JOIN "mobility_text_translations" "Post_title_en_text_translations"
  ON "Post_title_en_text_translations"."key" = 'title'
  AND "Post_title_en_text_translations"."locale" = 'en'
  AND "Post_title_en_text_translations"."translatable_type" = 'Post'
  AND "Post_title_en_text_translations"."translatable_id" = "posts"."id"
  WHERE "Post_title_ja_text_translations"."value" = 'foo'
  AND "Post_title_en_text_translations"."value" = 'bar'

Since we have two joins here, one for each locale, the result will correctly distinguish between records matching each locale separately. (The Table backend uses a similar trick when joining its translation table(s).)

Eager Loading and Avoiding N+1 Queries

In general, the SQL above should not be an issue. However, one thing to keep in mind is that by default, the results returned by Post.i18n.where(title: "foo") (or any query on Post actually) will not eager load the translations. This has nothing really to do with the query structure, but instead results from the polymorphic associations described above.

If you want to eagerly load results (to avoid "N+1" queries when e.g. listing a set of query results), you can overcome this problem by explicitly eager loading the associations you will need. For example, if you are querying for text-valued key-value attributes, you can tack eager_load(:text_translations) onto your query, like this:

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

This will result in a query which selects all the columns you need from the polymorphic text_translations association, like this:

SELECT "posts"."id" AS t0_r0,
  "posts"."created_at" AS t0_r1,
  "posts"."updated_at" AS t0_r2,
  "text_translations_posts"."id" AS t1_r0,
  "text_translations_posts"."locale" AS t1_r1,
  "text_translations_posts"."key" AS t1_r2,
  "text_translations_posts"."value" AS t1_r3,
  "text_translations_posts"."translatable_id" AS t1_r4,
  "text_translations_posts"."translatable_type" AS t1_r5,
  "text_translations_posts"."created_at" AS t1_r6,
  "text_translations_posts"."updated_at" AS t1_r7
FROM "posts"
LEFT OUTER JOIN "mobility_text_translations" "text_translations_posts"
  ON "text_translations_posts"."translatable_id" = "posts"."id"
  AND "text_translations_posts"."key" = 'title'
  AND "text_translations_posts"."translatable_type" = $1
INNER JOIN "mobility_text_translations" "Post_title_en_text_translations"
  ON "Post_title_en_text_translations"."key" = 'title'
  AND "Post_title_en_text_translations"."locale" = 'en'
  AND "Post_title_en_text_translations"."translatable_type" = 'Post'
  AND "Post_title_en_text_translations"."translatable_id" = "posts"."id"
WHERE "Post_title_en_text_translations"."value" = $2  [["translatable_type", "Post"], ["value", "foo"]]

You can see here that we now have two joins, one to match only posts whose title is "foo", the other one to join (with an OUTER JOIN) all columns on mobility_text_translations. Once you have eager-loaded results this way, you should not see any extra queries when later fetching post.title (or whatever the translated attribute name is).

If instead you were using a string-valued attribute, this would be the query:

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

Or, if you have both string and text translations:

Post.i18n.where(title: "foo").eager_load(:string_translations, :text_translations)

(Note that if you try to eager-load one of these associations but you do not actually have any translated attributes of the corresponding type, you will get an error from ActiveRecord.)

Action Text

An extension of the KeyValue backend can be used to translate Rails Action Text rich text.

UUIDs

If you are using UUIDs that are not integers (e.g. in Postgres they are strings) you likely need to run a migration to change the translatable_id columns to be the correct UUID type. For example:

def change
    change_column :mobility_string_translations, :translatable_id, :string
    change_column :mobility_text_translations, :translatable_id, :string
  end