Installation

The package can be installed by adding localize_translate to your list of dependencies in mix.exs:

def deps do
  [
    {:localize_translate, "~> 0.1"}
  ]
end

Documentation can be found at https://hexdocs.pm/localize_translate.

Attribution

localize_translate derives from trans by @crbelaus and its CLDR-integrated fork ex_cldr_trans. It is the continuation of that work within the localize ecosystem, built on :localize for CLDR-aware locale validation and parent-chain fallback.

Introduction

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

Optional Requirements

Having Ecto SQL and Postgrex in your application will allow you to use the Localize.Translate.QueryBuilder component to generate database queries based on translated data. The runtime Localize.Translate.translate/2,3 functions work without those dependencies.

Why Localize Translate?

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 Localize.Translate 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.

Localize.Translate is lightweight and modularized. The Localize.Translate module provides the use macro for declaring translatable schemas, the runtime translate/2,3 functions, and field reflection. Localize.Translate.QueryBuilder provides the Ecto.Query macros for filtering and selecting translated values in SQL.

Quickstart

Imagine that we have an Article schema that we want to translate:

defmodule MyApp.Article do
  use Ecto.Schema

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

Add a JSON column

The first step would be to add a new JSON column to the table so we can store the translations in it.

defmodule MyApp.Repo.Migrations.AddTranslationsToArticles do
  use Ecto.Migration

  def change do
    alter table(:articles) do
      add :translations, :map
    end
  end
end

Generate database function migration

localize_translate defines a Postgres database function to support in-db field translation. A migration task is provided to generate the migration required to define this function.

% MIX_ENV=test mix localize.translate.gen.translate_function
* creating priv/repo/migrations
* creating priv/repo/migrations/20220307212312_localize_translate_gen_translate_function.exs

Run migrations

Migrate the database to add the translations column and define the database function.

% mix ecto.migrate

Add translations to schema

Once we have the new database column, update the Article schema to declare translatable fields and the configured locales:

defmodule MyApp.Article do
  use Ecto.Schema
  use Localize.Translate,
    translates: [:title, :body],
    locales: [:en, :es, :fr],
    default_locale: :en

  schema "articles" do
    field :title, :string
    field :body, :string
    # use the 'translations' macro to set up a map-field with a set of nested
    # structs to handle translation values for each configured locale and each
    # translatable field
    translations :translations
  end
end

The :default_locale field stores its values in the main schema columns; only the other locales get embedded translation fields.

Casting translations

localize_translate will generate a simple default changeset for the translations field. It looks like this:

def changeset(fields, params) do
  fields
  |> cast(params, list_of_translatable_fields)
  |> validate_required(list_of_translatable_fields)
end

That may not be flexible enough for all requirements. A custom changeset can be defined for the translations field:

  def changeset(article, params \\ %{}) do
    article
    |> cast(params, [:title, :body])
    |> cast_embed(:translations, with: &translations_changeset/2)
    |> validate_required([:title, :body])
  end

  defp translations_changeset(translations, params) do
    translations
    |> cast(params, [])
    |> cast_embed(:es)
    |> cast_embed(:fr)
  end
end

Query Building

After the schema is configured, use Localize.Translate.translate/2,3 to fetch translations and Localize.Translate.QueryBuilder to query them:

# Translate a single field, with fallback chain
Localize.Translate.translate(article, :title, [:de, :es])

# Translate the whole struct (and its embeds/associations) into Spanish
Localize.Translate.translate(article, :es)

# Filter on a translation in a query
from a in Article,
  where: translated(Article, a.title, :fr) == "Elixir"

Locales are validated via Localize.validate_locale/1, so atoms (:en), strings ("en"), and Localize.LanguageTag structs are all accepted. Fallback chains follow CLDR parent locales automatically — for example, Localize.Translate.translate(article, :title, Localize.LanguageTag.new!("en-AU")) walks :"en-AU":"en-001":en.