Skip to content
Embedded translations for Elixir
Elixir
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
.circleci Require Elixir 1.6 or higher Feb 1, 2020
config Use Ecto 3.0 Dec 8, 2018
lib Enable locale to be passed as string instead of atom (#63) Dec 11, 2019
priv/test_repo/migrations Add Comment schema for tests Apr 2, 2017
test Remove Faker dependency Feb 1, 2020
.formatter.exs Use elixir formatter Oct 13, 2018
.gitignore Add test configuration sample Feb 26, 2017
CHANGELOG.md Release version 2.2.0 Feb 1, 2020
CONTRIBUTING.md Add contribution guidelines Feb 19, 2017
LICENSE Add Apache2 License Oct 13, 2018
README.md Enable locale to be passed as string instead of atom (#63) Dec 11, 2019
ebert.yml Add Ebert configuration file Apr 23, 2017
mix.exs Release version 2.2.0 Feb 1, 2020
mix.lock

README.md

Trans

CircleCI Hex.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. It is inspired by the great hstore translate gem for Ruby.

Trans is published on hex.pm and the documentation is also available online. Source code is available in this same repository under the Apache2 License.

On April 17th, 2017, Trans was featured in HackerNoon

Optional Requirements

Having Ecto SQL and Postgrex in your application will allow you to use the Trans.QueryBuilder component to generate database queries based on translated data. You can still use the Trans.Translator component without those dependencies though.

  • Ecto SQL 3.0 or higher
  • PostgreSQL 9.4 or higher (since Trans leverages the JSONB datatype)

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

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:

  • It complicates the database schema because it creates extra tables that are coupled to the "main" ones.
  • It makes migrations and schemas more complicated, since we always have to keep the two tables in sync.
  • It requires constant JOINs in order to filter or fetch records along with their translations.

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 Trans module provides metadata that is used by the Trans.Translator and Trans.QueryBuilder modules, which implement the main functionality of this library.

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
    alter 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

Then we must use Trans from our schema module to indicate which fields will be translated.

defmodule Article do
  use Ecto.Schema
  use Trans, translates: [:title, :body]

  schema "articles" do
    field :title, :string
    field :body, :string
    field :translations, :map
  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 use the Trans.QueryBuilder.translated/3 macro, which generates the required SQL fragment for us.

iex> Repo.all(from a in Article,
...>   where: not is_nil(translated(Article, a, :es)))
# SELECT a0."id", a0."title", a0."body", a0."translations"
# FROM "articles" AS a0
# WHERE (NOT ((a0."translations"->"es") IS NULL))

We can also get more specific and fetch only those articles for which their Spanish title matches "Elixir".

iex> Repo.all(from a in Article,
...>   where: translated(Article, a.title, :es) == "Elixir")
# SELECT a0."id", a0."title", a0."body", a0."translations"
# FROM "articles" AS a0
# WHERE ((a0."translations"->"fr"->>"title") = "Elixir")

The SQL fragment generated by the Trans.QueryBuilder.translated/3 macro is compatible with the rest of functions and macros provided by Ecto.Query and Ecto.Query.Api.

iex> Repo.all(from a in Article,
...> where: ilike(translated(Article, a.body, :es), "%elixir%"))
# SELECT a0."id", a0."title", a0."body", a0."translations"
# FROM "articles" AS a0
# WHERE ((a0."translations"->"es"->>"body") ILIKE "%elixir%")

More complex queries such as adding conditions to joined schemas can be easily generated in the same way. Take a look at the documentation and tests for more examples.

Obtaining 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/3 function to easily access a translation of a certain field. Locale can be passed as an atom or a string

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

or

iex> Trans.Translator.translate(article, :title, Gettext.get_locale())
"Cómo escribir un corrector ortográfico"

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

iex> Trans.Translator.translate(article, :title, :de)
"How to Write a Spelling Corrector"

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, for example article_translations:

defmodule Article do
  use Ecto.Schema
  use Trans, translates: [:title, :body], container: :article_translations

  schema "articles" do
    field :title, :string
    field :body, :string
    field :article_translations, :map
  end
end

We can call the same functions as in previous examples and both Trans.Translator and Trans.QueryBuilder will automatically look for translations in the correct field.

iex> Repo.all(from a in Article,
...>   where: not is_nil(translated(Article, a, :es)))
# SELECT a0."id", a0."title", a0."body", a0."article_translations"
# FROM "articles" AS a0
# WHERE (NOT ((a0."article_translations"->"es") IS NULL))
You can’t perform that action at this time.