defmodule Localize.Translate do
@moduledoc """
Manage translations embedded into translation structs.
Although it can be used with any struct **`Localize.Translate` shines when paired with an
`Ecto.Schema`**. It allows you to keep the translations into a field of the schema and avoids
requiring extra tables for translation storage and complex _joins_ when retrieving translations
from the database.
The runtime API lives on `Localize.Translate` itself:
* `translate/1`, `translate/2` return a translated copy of an entire struct, or a single
field when the second argument is a field name.
* `translate/3` and `translate!/3` return a single translated field with an explicit
locale or locale fallback chain.
* `translatable?/2` reports whether a field is declared as translatable.
For `Ecto.Query` support, `Localize.Translate.QueryBuilder` provides the `translated/3`
and `translated_as/3` macros (requires `Ecto.SQL`).
### Locale handling
`Localize.Translate` builds on [`:localize`](https://hex.pm/packages/localize) for
CLDR-aware locale handling. Three properties follow:
* **All locale inputs validate.** Atoms (`:en`), strings (`"en"`), and
`%Localize.LanguageTag{}` structs are accepted anywhere a locale is expected — in
`:locales`, `translate/N`, and `QueryBuilder.translated/3`. Each value is run
through `Localize.validate_locale/1` and unwrapped to its `:cldr_locale_id` atom.
Invalid locale names raise.
* **Fallbacks walk the CLDR parent chain.** A locale expands into a fallback chain
by walking parents (e.g. `:"en-AU"` → `:"en-001"` → `:en`, stopping before the
`:und` root). For `QueryBuilder`, the chain is filtered to the locales declared in
`:locales` so unsupported parents don't generate dead branches.
* **The current locale is `Localize.get_locale/0`.** `translate/1` and `translate/2`
(with a field name) default the locale from `Localize.get_locale/0` and walk its
parents.
Mixed `:locales` lists like `[:en, %Localize.LanguageTag{cldr_locale_id: :fr}]` are
supported. After validation each entry is reduced to an atom, deduplicated, and
sorted.
When used, `Localize.Translate` accepts the following options:
* `:translates` (required) - list of the fields that will be translated.
* `:locales` (optional) - the list of locales for which translations will be stored. Required
if you use the `translations/1` macro to auto-generate the embedded schema. Locales are
atoms (for example `:en`, `:"en-AU"`).
* `:default_locale` (optional) - declares the locale of the base untranslated columns. The
fields in the main schema are considered to be in this locale, and no separate translation
embed is generated for it.
* `:container` (optional) - the name of the field that holds the translations. Defaults to
`:translations`.
### Structured translations
Structured translations are the preferred and recommended way of using `Localize.Translate`. To
use structured translations, use the `translations/1` macro when defining a schema. The
`translations/1` macro will define an embedded schema with the name being the parameter passed
to the `translations/1` macro. Within the `:translations` field that is generated, an
`embeds_one/2` call is added for each non-default locale in `:locales`. Here's an example
schema configuration:
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
translations :translations
end
end
When expanded by the Elixir compiler, the example above will look like the following code. It
is provided here only as an example to show what the `translations/1` macro compiles to.
defmodule MyApp.Article do
use Ecto.Schema
schema "articles" do
field :title, :string
field :body, :string
embeds_one :translations, Translations, on_replace: :update, primary_key: false do
embeds_one :es, MyApp.Article.Translations.Fields
embeds_one :fr, MyApp.Article.Translations.Fields
end
end
end
defmodule MyApp.Article.Translations.Fields do
use Ecto.Schema
@primary_key false
embedded_schema do
field :title, :string
field :body, :string
end
end
### Reflection
Any module that uses `Localize.Translate` will have an autogenerated `__trans__` function that
can be used for runtime introspection of the translation metadata.
* `__trans__(:fields)` - Returns the list of translatable fields.
* `__trans__(:container)` - Returns the name of the translation container.
* `__trans__(:locales)` - Returns the configured locales (or `nil` if not given).
* `__trans__(:default_locale)` - Returns the default locale. The fields in the main schema are
considered to be in this locale.
"""
@typedoc """
A translatable struct that uses `Localize.Translate`.
"""
@type translatable() :: struct()
@typedoc """
A struct field as an atom.
"""
@type field :: atom()
@typedoc """
A locale identifier as an atom (for example `:en`, `:"en-AU"`) or a string.
"""
@type locale :: atom() | String.t()
@typedoc """
When translating or querying either a single locale or a list of locales can be provided.
A list of locales acts as a fallback chain: each locale is tried in order until a translation
is found.
"""
@type locale_list :: locale() | [locale(), ...]
@doc false
defmacro using(opts) do
quote do
import Localize.Translate,
only: [trans_fields: 1, trans_container: 1, trans_locales: 1, trans_default_locale: 1]
Module.put_attribute(__MODULE__, :trans_fields, trans_fields(unquote(opts)))
Module.put_attribute(__MODULE__, :trans_container, trans_container(unquote(opts)))
Module.put_attribute(__MODULE__, :trans_locales, trans_locales(unquote(opts)))
Module.put_attribute(__MODULE__, :trans_default_locale, trans_default_locale(unquote(opts)))
@after_compile {Localize.Translate, :__validate_translatable_fields__}
@after_compile {Localize.Translate, :__validate_translation_container__}
@spec __trans__(:fields) :: list(atom)
def __trans__(:fields), do: @trans_fields
@spec __trans__(:container) :: atom
def __trans__(:container), do: @trans_container
@spec __trans__(:locales) :: [atom(), ...] | nil
def __trans__(:locales), do: @trans_locales
@spec __trans__(:default_locale) :: atom() | nil
def __trans__(:default_locale), do: @trans_default_locale
end
end
defmacro __using__(opts) do
quote do
require Localize.Translate
Localize.Translate.using(unquote(opts))
import Localize.Translate, only: :macros
end
end
@doc false
def default_trans_options do
[build_field_schema: true]
end
@doc """
Defines the embedded translation container field on an `Ecto.Schema`.
Within an `Ecto.Schema` block, expands into an `embeds_one/3` declaration for the named
container field plus auto-generated `Translations` and `Translations.Fields` embedded
schemas — one `embeds_one` per non-default locale, each holding the translatable string
fields declared in `:translates`.
### Arguments
* `field_name` is the name of the container field on the parent schema, given as an atom
(commonly `:translations`).
* `translation_module` (optional) is the alias name of the generated translation schema.
Defaults to `Translations`. Pass an alias to use a different module name under the parent.
* `locales_or_options` (optional) is either an explicit list of locale atoms, or a keyword
list of options. When omitted, the locales configured via the `:locales` option on
`use Localize.Translate` are used.
### Options
* `:build_field_schema` - when `true` (the default), generates the inner
`Translations.Fields` module. Set to `false` if you want to define that module yourself.
### Examples
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
translations :translations
end
end
"""
defmacro translations(field_name, translation_module \\ nil, locales_or_options \\ []) do
module = __CALLER__.module
translation_module = trans_module(translation_module)
if Keyword.keyword?(locales_or_options) do
quote do
translations(
unquote(field_name),
unquote(translation_module),
Module.get_attribute(unquote(module), :trans_locales),
unquote(locales_or_options)
)
end
else
quote do
translations(
unquote(field_name),
unquote(translation_module),
unquote(locales_or_options),
[]
)
end
end
end
@doc """
Defines the embedded translation container field with explicit locales.
Four-arity form of `translations/3`. Use this when you want to override the configured
`:locales` option for a single schema, or to pass an options keyword list alongside an
explicit locale list.
### Arguments
* `field_name` is the name of the container field on the parent schema, as an atom.
* `translation_module` is the alias name of the generated translation schema.
* `locales` is the explicit list of locale atoms for which translation embeds are
generated. The schema's `:default_locale` is excluded.
* `options` is a keyword list of options, the same as accepted by `translations/3`.
"""
defmacro translations(field_name, translation_module, locales, options) do
caller = __CALLER__.module
options = Keyword.merge(Localize.Translate.default_trans_options(), options)
{build_field_schema, _options} = Keyword.pop(options, :build_field_schema)
quote do
if unquote(translation_module) && unquote(build_field_schema) do
@before_compile {Localize.Translate, :__build_embedded_schema__}
end
@translation_module Module.concat(unquote(caller), unquote(translation_module))
@locales unquote(locales)
embeds_one(unquote(field_name), @translation_module, on_replace: :update)
end
end
@doc false
defmacro __build_embedded_schema__(env) do
translation_module = Module.get_attribute(env.module, :translation_module)
fields = Module.get_attribute(env.module, :trans_fields)
locales = Module.get_attribute(env.module, :locales)
default_locale = Module.get_attribute(env.module, :trans_default_locale)
quote do
defmodule unquote(translation_module) do
@moduledoc false
use Ecto.Schema
@primary_key false
embedded_schema do
for locale_name <- List.wrap(unquote(locales)),
locale_name != unquote(default_locale) do
embeds_one(locale_name, Module.concat(__MODULE__, Fields), on_replace: :update)
end
end
end
defmodule Module.concat(unquote(translation_module), Fields) do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
for a_field <- unquote(fields) do
field(a_field, :string)
end
end
def changeset(fields, params) do
fields
|> cast(params, unquote(fields))
|> validate_required(unquote(fields))
end
end
end
end
@doc """
Checks whether the given field is translatable or not.
Returns true if the given field is translatable. Raises if the given module or struct does not
use `Localize.Translate`.
### Arguments
* `module_or_translatable` is either a module that uses `Localize.Translate` or a struct of
that module.
* `field` is the field name as an atom or string.
### Returns
* `true` if the given field is translatable.
* `false` if the given field is not translatable.
### Examples
Assuming the Article schema defined in [Structured translations](#module-structured-translations).
iex> Localize.Translate.translatable?(Article, :title)
true
iex> article = %Article{}
iex> Localize.Translate.translatable?(article, :not_existing)
false
iex> Localize.Translate.translatable?(Date, :day)
** (RuntimeError) Elixir.Date must use `Localize.Translate` in order to be translated
"""
def translatable?(module_or_translatable, field)
@spec translatable?(module | translatable(), String.t() | atom) :: boolean
def translatable?(%{__struct__: module}, field), do: translatable?(module, field)
def translatable?(module, field) when is_atom(module) and is_binary(field) do
translatable?(module, String.to_atom(field))
end
def translatable?(module, field) when is_atom(module) and is_atom(field) do
if Keyword.has_key?(module.__info__(:functions), :__trans__) do
Enum.member?(module.__trans__(:fields), field)
else
raise "#{module} must use `Localize.Translate` in order to be translated"
end
end
@doc """
Translates a whole struct into the current locale.
Equivalent to `translate(translatable, locale)` with `locale` resolved from
`Localize.get_locale/0` when the optional `:localize` dependency is loaded, falling back
to the schema's `:default_locale` otherwise.
### Arguments
* `translatable` is a struct that uses `Localize.Translate`.
### Returns
* The translated struct.
"""
@spec translate(translatable()) :: translatable()
def translate(translatable), do: translate(translatable, default_locale_for(translatable))
@doc """
Translates a whole struct (or a single field) into the given locale.
Returns the struct with every translatable field — and every translatable association or
embed — replaced by the value for the requested locale, falling back to the default
value when the locale has no entry. If the second argument is the name of a translatable
field, returns that single translated field using the current locale (see `translate/3`
for an explicit locale).
### Arguments
* `translatable` is a struct that uses `Localize.Translate`.
* `locale_or_field` is one of:
* an atom or string locale (e.g. `:es`, `"fr"`),
* a list of locales acting as a fallback chain,
* a `%Localize.LanguageTag{}` — when the optional `:localize` dependency is loaded,
expanded into a fallback chain by walking the CLDR parent chain, or
* an atom field name — translates a single field using the current locale (see
`translate/3`).
### Returns
* The translated struct, or the translated value of a single field.
### Examples
# Translate the entire article into Spanish
Localize.Translate.translate(article, :es)
# Fallback chain — Deutsch missing, Spanish wins
Localize.Translate.translate(article, [:de, :es])
"""
@spec translate(translatable(), locale_list() | field() | struct()) ::
translatable() | any()
def translate(translatable, locale_or_field) do
case classify_locale_or_field(translatable, locale_or_field) do
{:field, field} -> translate(translatable, field, default_locale_for(translatable))
{:locale, chain} -> do_translate(translatable, chain)
end
end
defp do_translate(%{__struct__: module} = translatable, locale) do
if Keyword.has_key?(module.__info__(:functions), :__trans__) do
default_locale = module.__trans__(:default_locale)
translatable
|> translate_fields(locale, default_locale)
|> translate_assocs(locale)
else
translatable
end
end
@doc """
Translates a single field into the given locale.
Looks up the field's translation in the given locale (or first match in a fallback
chain) and returns it. Returns the base value if no translation is available.
### Arguments
* `translatable` is a struct that uses `Localize.Translate`.
* `field` is the name of the translatable field as an atom.
* `locale` is either a single locale or a list of locales acting as a fallback chain.
### Returns
* The translated value, or the value from the base column when no translation exists
for any locale in the chain.
### Examples
# Spanish title
Localize.Translate.translate(article, :title, :es)
# Unknown locale falls back to the base column
Localize.Translate.translate(article, :title, :de)
# Fallback chain
Localize.Translate.translate(article, :title, [:de, :es])
Raises if the field isn't declared as translatable:
Localize.Translate.translate(article, :fake_attr, :es)
** (RuntimeError) 'Article' module must declare 'fake_attr' as translatable
"""
@spec translate(translatable(), field(), locale_list() | struct()) :: any()
def translate(%{__struct__: module} = translatable, field, locale) when is_atom(field) do
chain = expand_locale(locale)
default_locale = module.__trans__(:default_locale)
unless translatable?(translatable, field) do
raise not_translatable_error(module, field)
end
case translate_field(translatable, chain, field, default_locale) do
:error -> Map.fetch!(translatable, field)
nil -> Map.fetch!(translatable, field)
translation -> translation
end
end
@doc """
Translates a single field into the given locale, raising if no translation is available.
Strict variant of `translate/3`. Where `translate/3` falls back to the base value when a
translation is missing, `translate!/3` raises so callers can distinguish "no
translation" from "translation equals the default".
### Arguments
* `translatable` is a struct that uses `Localize.Translate`.
* `field` is the name of the translatable field as an atom.
* `locale` is either a single locale or a list of locales acting as a fallback chain.
### Returns
* The translated value.
* Raises a `RuntimeError` if no translation exists for any locale in the chain.
### Examples
Localize.Translate.translate!(article, :title, :de)
** (RuntimeError) translation doesn't exist for field ':title' in locale :de
"""
@spec translate!(translatable(), field(), locale_list() | struct()) :: any()
def translate!(%{__struct__: module} = translatable, field, locale) when is_atom(field) do
chain = expand_locale(locale)
default_locale = module.__trans__(:default_locale)
unless translatable?(translatable, field) do
raise not_translatable_error(module, field)
end
case translate_field(translatable, chain, field, default_locale) do
:error -> raise no_translation_error(field, locale)
translation -> translation
end
end
defp classify_locale_or_field(%{__struct__: module}, value) do
cond do
is_atom(value) and not is_nil(value) and not is_boolean(value) and
Keyword.has_key?(module.__info__(:functions), :__trans__) and
value in module.__trans__(:fields) ->
{:field, value}
true ->
{:locale, expand_locale(value)}
end
end
defp expand_locale(locale), do: Localize.Translate.Locale.expand(locale)
# With `:localize` always present, `current/0` reliably returns a `LanguageTag`.
# `translate/N` then expands it through the CLDR parent chain, so a missing
# translation degrades through parents to the schema's `:default_locale` value
# in the base columns.
defp default_locale_for(_struct), do: Localize.Translate.Locale.current()
defp translate_field(%{__struct__: _module} = struct, locales, field, default_locale)
when is_list(locales) do
Enum.reduce_while(locales, :error, fn locale, translated_field ->
case translate_field(struct, locale, field, default_locale) do
:error -> {:cont, translated_field}
nil -> {:cont, translated_field}
translation -> {:halt, translation}
end
end)
end
defp translate_field(%{__struct__: _module} = struct, default_locale, field, default_locale) do
Map.fetch!(struct, field)
end
defp translate_field(%{__struct__: module} = struct, locale, field, _default_locale) do
with {:ok, all_translations} <- Map.fetch(struct, module.__trans__(:container)),
{:ok, translations_for_locale} <- get_translations_for_locale(all_translations, locale),
{:ok, translated_field} <- get_translated_field(translations_for_locale, field) do
translated_field || Map.fetch!(struct, field)
end
end
defp translate_fields(%{__struct__: module} = struct, locale, default_locale) do
fields = module.__trans__(:fields)
Enum.reduce(fields, struct, fn field, struct ->
case translate_field(struct, locale, field, default_locale) do
:error -> struct
nil -> struct
translation -> Map.put(struct, field, translation)
end
end)
end
defp translate_assocs(%{__struct__: module} = struct, locale) do
associations = module.__schema__(:associations)
embeds = module.__schema__(:embeds)
Enum.reduce(associations ++ embeds, struct, fn assoc_name, struct ->
Map.update(struct, assoc_name, nil, fn
%Ecto.Association.NotLoaded{} = item ->
item
items when is_list(items) ->
Enum.map(items, &translate(&1, locale))
%{} = item ->
translate(item, locale)
item ->
item
end)
end)
end
defp get_translations_for_locale(%{__struct__: _} = all_translations, locale)
when is_binary(locale) do
get_translations_for_locale(all_translations, String.to_existing_atom(locale))
end
defp get_translations_for_locale(%{__struct__: _} = all_translations, locale)
when is_atom(locale) do
Map.fetch(all_translations, locale)
end
defp get_translations_for_locale(all_translations, locale) do
Map.fetch(all_translations, to_string(locale))
end
defp get_translated_field(nil, _field), do: nil
defp get_translated_field(%{__struct__: _} = translations_for_locale, field)
when is_binary(field) do
get_translated_field(translations_for_locale, String.to_existing_atom(field))
end
defp get_translated_field(%{__struct__: _} = translations_for_locale, field)
when is_atom(field) do
Map.fetch(translations_for_locale, field)
end
defp get_translated_field(translations_for_locale, field) do
Map.fetch(translations_for_locale, to_string(field))
end
defp no_translation_error(field, locales) when is_list(locales) do
"translation doesn't exist for field '#{inspect(field)}' in locales #{inspect(locales)}"
end
defp no_translation_error(field, locale) do
"translation doesn't exist for field '#{inspect(field)}' in locale #{inspect(locale)}"
end
defp not_translatable_error(module, field) do
"'#{inspect(module)}' module must declare '#{inspect(field)}' as translatable"
end
@doc false
def __validate_translatable_fields__(%{module: module}, _bytecode) do
struct_fields = Map.keys(module.__struct__())
translatable_fields = module.__trans__(:fields)
invalid_fields = translatable_fields -- struct_fields
case invalid_fields do
[] ->
nil
[_] ->
raise ArgumentError,
message:
"#{module} declares '#{invalid_fields}' as translatable but it is not defined in the module's struct"
_ ->
raise ArgumentError,
message:
"#{module} declares '#{invalid_fields}' as translatable but they are not defined in the module's struct"
end
end
@doc false
def __validate_translation_container__(%{module: module}, _bytecode) do
container = module.__trans__(:container)
unless Enum.member?(Map.keys(module.__struct__()), container) do
raise ArgumentError,
message:
"The field #{container} used as the translation container is not defined in #{inspect(module)} struct"
end
end
@doc false
def trans_fields(options) do
case Keyword.fetch(options, :translates) do
{:ok, fields} when is_list(fields) ->
fields
_ ->
raise ArgumentError,
message:
"Localize.Translate requires a 'translates' option that contains the list of translatable field names"
end
end
@doc false
def trans_container(options) do
case Keyword.fetch(options, :container) do
:error -> :translations
{:ok, container} -> container
end
end
@doc false
def trans_locales(options) do
case Keyword.fetch(options, :locales) do
:error ->
nil
{:ok, locales} ->
locales
|> Enum.map(&Localize.Translate.Locale.normalise!/1)
|> Enum.uniq()
|> Enum.sort()
end
end
@doc false
def trans_default_locale(options) do
case Keyword.fetch(options, :default_locale) do
:error -> nil
{:ok, default_locale} -> default_locale
end
end
@doc false
def trans_module(nil) do
{:__aliases__, [], [:Translations]}
end
def trans_module(translation_module) do
translation_module
end
end