defmodule Cldr.Trans do
@moduledoc """
Manage translations embedded into translation structs.
Although it can be used with any struct **`Cldr.Trans` 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.
`Cldr.Trans` is split into two main components:
* `Cldr.Trans.Translator` - provides easy access to struct translations.
* `Cldr.Trans.QueryBuilder` - provides helpers for querying translations using `Ecto.Query`
(requires `Ecto.SQL`).
When used, `Cldr.Trans` accepts the following options:
* `:translates` (required) - list of the fields that will be translated.
* `:default_locale` (optional) - declares the locale of the base untranslated column.
Defaults to the `default_locale` configured for the Cldr backend.
### Structured translations
Structured translations are the preferred and recommended way of using `Trans`. 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
locale configured in the `MyApp.Cldr` backend module. Here's an example schema configuraiton:
defmodule MyApp.Article do
use Ecto.Schema
use MyApp.Cldr.Trans, translates: [:title, :body]
schema "articles" do
field :title, :string
field :body, :string
translations :translations
end
end
When expanded by the Elxir compiler, the example above will look like the following code
(assuming the `MyApp.Cldr` is configured with three locales: `:en`, `:es` and `:fr`). It is
provided here only as an example to show what the `translations/1` macro compiles to.
defmodule MyApp.Article do
use Ecto.Schema
use MyApp.Cldr.Trans, translates: [:title, :body]
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 `Trans` 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__(:default_locale)` - Returns the name of default locale. The locale of the fields
in the main schema are considered to be in the language of the default locale.
"""
alias Cldr.Locale
@typedoc """
A translatable struct that uses `Trans`.
"""
@type translatable() :: struct()
@typedoc """
A struct field as an atom.
"""
@type field :: atom()
@typedoc """
When translating or querying either a single
locale or a list of locales can be provided.
"""
@type locale_list :: Locale.locale_reference() | [Locale.locale_name(), ...]
@doc false
def cldr_backend_provider(config) do
module = __MODULE__
backend = config.backend
quote location: :keep, bind_quoted: [module: module, backend: backend] do
defmodule Trans do
@moduledoc false
defmacro __using__(opts) do
module = unquote(module)
backend = unquote(backend)
locales = backend.known_locale_names()
default_locale = backend.default_locale().cldr_locale_name
opts =
opts
|> Keyword.put(:locales, locales)
|> Keyword.put(:default_locale, default_locale)
quote do
require Cldr.Trans
Cldr.Trans.using(unquote(opts))
import Cldr.Trans, only: :macros
end
end
end
end
end
@doc false
defmacro using(opts) do
quote do
import Cldr.Trans,
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 {Cldr.Trans, :__validate_translatable_fields__}
@after_compile {Cldr.Trans, :__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) :: [Cldr.Locale.locale_name(), ...]
def __trans__(:locales), do: @trans_locales
@spec __trans__(:default_locale) :: atom
def __trans__(:default_locale), do: @trans_default_locale
end
end
defmacro __using__(opts) do
quote do
require Cldr.Trans
Cldr.Trans.using(unquote(opts))
import Cldr.Trans, only: :macros
end
end
@doc false
def default_trans_options do
[build_field_schema: true]
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
defmacro translations(field_name, translation_module, locales, options) do
caller = __CALLER__.module
options = Keyword.merge(Cldr.Trans.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 {Cldr.Trans, :__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
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
`Trans`.
## Examples
Assuming the Article schema defined in [Structured translations](#module-structued-translations).
If we want to know whether a certain field is translatable or not we can use
this function as follows (we can also pass a struct instead of the module
name itself):
iex> Trans.translatable?(Article, :title)
true
May be also used with translatable structs:
iex> article = %Article{}
iex> Trans.translatable?(article, :not_existing)
false
Raises if the given module or struct does not use `Trans`:
iex> Trans.translatable?(Date, :day)
** (RuntimeError) Elixir.Date must use `Trans` 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 `Trans` in order to be translated"
end
end
@doc false
def __validate_translatable_fields__(%{module: module}, _bytecode) do
struct_fields =
module.__struct__()
|> Map.keys()
|> MapSet.new()
translatable_fields =
:fields
|> module.__trans__
|> MapSet.new()
invalid_fields = MapSet.difference(translatable_fields, struct_fields)
case MapSet.size(invalid_fields) do
0 ->
nil
1 ->
raise ArgumentError,
message:
"#{module} declares '#{MapSet.to_list(invalid_fields)}' as translatable but it is not defined in the module's struct"
_ ->
raise ArgumentError,
message:
"#{module} declares '#{MapSet.to_list(invalid_fields)}' as translatable but it they 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(opts) do
case Keyword.fetch(opts, :translates) do
{:ok, fields} when is_list(fields) ->
fields
_ ->
raise ArgumentError,
message:
"Trans requires a 'translates' option that contains the list of translatable fields names"
end
end
@doc false
def trans_container(opts) do
case Keyword.fetch(opts, :container) do
:error -> :translations
{:ok, container} -> container
end
end
@doc false
def trans_locales(opts) do
case Keyword.fetch(opts, :locales) do
:error -> nil
{:ok, locales} -> Enum.sort(locales)
end
end
@doc false
def trans_default_locale(opts) do
case Keyword.fetch(opts, :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