Trans

TravisHex.pm

Trans provides a way to manage and query translations embedded into schemas and removes the necessity of maintaing extra tables only for translation storage.

Trans is inspired by the great hstore translate gem for Ruby.

Trans is published on hex.pm and the documentation is also available online.

Requirements

Support for MySQL JSON type (introduced in MySQL 5.7) will come also, but right now it is not yet implemented.

Why Trans?

The traditional approach to content internationalization consists on using an additional table for each translatable schema. This table works only as a storage for the original schema translations. For example, we may have a posts and a posts_translations tables.

This approach has a few disadvantages:

The approach used by Trans is based on modern RDBMSs support for unstructured datatypes. Instead of storing the translations in a different table, each translatable schema has an extra column that contains all of its translations. This approach drastically reduces the number of required JOINs when filtering or fetching records.

Trans is lightweight and modularized. The main functionality is provided by the Trans.Translator and the Trans.QueryBuilder modules, while the Trans module simplifies the calls to translator and query builder functions from a schema.

Making a schema translatable

Every translatable schema needs a field in which the translations are stored. This field is known as the translation container.

The first step consists on adding a new column to the schema's table:

defmodule MyApp.Repo.Migrations.AddTranslationsToArticles do
use Ecto.Migration
def change do
update table(:articles) do
add :translations, :map
end
end
end

The schema must be also updated, so the new column can be automatically mapped by Ecto.

defmodule Article do
use Ecto.Schema
schema "articles" do
field :title, :string # our previous fields...
field :body, :string # our previous fields...
field :translations, :map # this is our translation container
end
end

Storing translations

Translations are stored as a map of maps in the translation container of our schema. For example:

iex> changeset = Article.changeset(%Article{}, %{
...> title: "How to Write a Spelling Corrector",
...> body: "A wonderful article by Peter Norvig",
...> translations: %{
...> "es" => %{
...> title: "Cómo escribir un corrector ortográfico",
...> body: "Un artículo maravilloso de Peter Norvig"
...> },
...> "fr" => %{
...> title: "Comment écrire un correcteur orthographique",
...> body: "Un merveilleux article de Peter Norvig"
...> }
...> }
...> })
iex> article = Repo.insert!(changeset)

Filtering queries by translations

We may want to fetch articles that are translated into a certain language. To do this we may use the with_translations/3 function of the Trans.QueryBuilder module:

iex> Article
...> |> Trans.QueryBuilder.with_translations(:es)
...> |> Repo.all
[debug] SELECT a0."id", a0."title", a0."body", a0."translations"
FROM "articles" AS a0
WHERE ((a0."translations"->>$1) is not null) ["es"]
[debug] OK query=4.7ms queue=0.1ms

We can also get more specific and fetch only those articles for which their Spanish title contains the word "Trans".

iex> Article
...> |> Trans.QueryBuilder.with_translation(:es, :title, "Trans")
...> |> Repo.all
[debug] SELECT a0."id", a0."title", a0."body", a0."translations"
FROM "articles" AS a0
WHERE (a0."translations"->$1->>$2 = $3) ["es", "title", "Trans"]
[debug] OK query=2.6ms queue=0.1ms

By default Trans looks for an exact match when we add a condition. We may also perform a LIKE or ILIKE comparison and use wilcards like this:

iex> Article
...> |> Trans.QueryBuilder.with_translation(:es, :title, "%Trans%", type: :like)
...> |> Repo.all
[debug] SELECT a0."id", a0."title", a0."body", a0."translations"
FROM "articles" AS a0
WHERE (a0."translations"->$1->>$2 LIKE $3) ["es", "title", "%Trans%"]
[debug] OK query=2.1ms queue=0.1ms

Obtainig translations from a struct

In those examples we will be referring to this article:

iex> article = %Article{
...> title: "How to Write a Spelling Corrector",
...> body: "A wonderful article by Peter Norvig",
...> translations: %{
...> "es" => %{
...> title: "Cómo escribir un corrector ortográfico",
...> body: "Un artículo maravilloso de Peter Norvig"
...> },
...> "fr" => %{
...> title: "Comment écrire un correcteur orthographique",
...> body: "Un merveilleux article de Peter Norvig"
...> }
...> }
...> }

Once we have already loaded a struct, we may use the Trans.Translator.translate/4 function to easily access a translation for a certain field.

iex> Article.translate(article, :es, :body)
"Cómo escribir un corrector ortográfico"

The Trans.Translator.translate/4 function also provides a fallback mechanism that activates when the required translation does not exist:

iex> Article.translate(article, :de, :title)
"How to Write a Spelling Corrector" # Fallback to untranslated value

Using a different translation container

In the previous examples we have used translations as the name of the translation container and Trans looks automatically for translations into this field.

We can also give the translation container a different name:

defmodule Article do
use Ecto.Schema
schema "articles" do
field :title, :string
field :body, :string
field :article_translations, :map # this is our translation container
end
end

We can call the same functions as in previous examples, but we have to specify the name of the translation container to override the default:

iex> Article
...> |> Trans.QueryBuilder.with_translation(:es, :title, "Trans", container: :article_translations)
...> |> Repo.all
[debug] SELECT a0."id", a0."title", a0."body", a0."translations"
FROM "articles" AS a0
WHERE (a0."article_translations"->$1->>$2 = $3) ["es", "title", "Trans"]
[debug] OK query=2.6ms queue=0.1ms

Having to specify the name of the translation container everytime is error prone and can become tiresome. Instead we can use the Trans module in our schema and have this option specified automatically for us:

defmodule Article do
use Ecto.Schema
use Trans, defaults: [container: :article_translations],
translates: [:title, :body]
schema "articles" do
field :title, :string
field :body, :string
field :article_translations, :map
end
end

Now we can do:

iex> Article
...> |> Article.with_translation(:es, :title, "Trans")
...> |> Repo.all
[debug] SELECT a0."id", a0."title", a0."body", a0."translations"
FROM "articles" AS a0
WHERE (a0."article_translations"->$1->>$2 = $3) ["es", "title", "Trans"]
[debug] OK query=2.6ms queue=0.1ms