defmodule Trans do
@moduledoc """
Manage translations embedded into structs.
Although it can be used with any struct **`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.
`Trans` is split into two main components:
* `Trans.Translator` - provides easy access to struct translations.
* `Trans.QueryBuilder` - provides helpers for querying translations using `Ecto.Query`
(requires `Ecto.SQL`).
When used, `Trans` accepts the following options:
* `:translates` (required) - list of the fields that will be translated.
* `:container` (optional) - name of the field that contains the embedded translations.
Defaults to`:translations`.
* `:default_locale` (optional) - declares the locale of the base untranslated column.
## Storing translations
To store translations in a schema you must use the `translations` macro:
defmodule MyApp.Article do
use Ecto.Schema
use Trans, translates: [:title, :body], default_locale: :en
schema "articles" do
field :title, :string
field :body, :string
translations [:es, :fr]
end
end
This is equivalent to:
defmodule MyApp.Article do
use Ecto.Schema
use Trans, translates: [:title, :body], default_locale: :en
schema "articles" do
field :title, :string
field :body, :string
embeds_many :translations, Translations, primary_key: :false do
embeds_one :es, Fields
embeds_one :fr, Fields
end
end
end
defmodule MyApp.Article.Translations.Fields do
use Ecto.Schema
embedded_schema do
field :title, :string
field :body, :string
end
end
If you want to customize the translation fields (for example how they are casted) you may define
them yourself manually. In such cases you may tell Trans not to generate the fields automatically
for you:
defmodule MyApp.Article do
use Ecto.Schema
use Trans, translates: [:title, :body], default_locale: :en
schema "articles" do
field :title, :string
field :body, :string
# Define MyApp.Article.Translations.Fields yourself
translations [:es, :fr], build_field_schema: false
end
end
## The translation container
As we have seen in the previous examples, `Trans` automatically stores and looks for translations
in a field called `:translations`. This is known as the **translations container.**
In certain cases you may want to use a different field for storing the translations, this can
be specified when using `Trans` in your module.
# Use the field `:locales` as translation container instead of the default `:translations`
use Trans, translates: [...], container: :locales
## 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.
"""
@typedoc """
A translatable struct that uses `Trans`
"""
@type translatable() :: struct()
@typedoc """
A locale that may be a string or an atom
"""
@type locale() :: String.t() | atom()
@typedoc """
When translating or querying either a single
locale or a list of locales can be provided
"""
@type locale_list :: locale | [locale, ...]
defmacro __using__(opts) do
quote do
Module.put_attribute(__MODULE__, :trans_fields, unquote(translatable_fields(opts)))
Module.put_attribute(__MODULE__, :trans_container, unquote(translation_container(opts)))
Module.put_attribute(
__MODULE__,
:trans_default_locale,
unquote(translation_default_locale(opts))
)
import Trans, only: :macros
@after_compile {Trans, :__validate_translatable_fields__}
@after_compile {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__(:default_locale) :: atom
def __trans__(:default_locale), do: @trans_default_locale
end
end
@doc false
def default_trans_options do
[on_replace: :update, primary_key: false, build_field_schema: true]
end
@doc """
Create the translation container and fields.
This macro creates a field named like the module's translation container to store the
translations. By default `YourModule.Translations` and `YourModule.Translations.Fields`
schemas will be created.
This macro creates an embedded field named after your "translation container" of type
`YourModule.Translations`. This field in turn has an embedded field for each locale
of type `YourModule.Translations.Fields`.
Calling:
translations [:en, :es]
Is equivalent to:
embeds_one :translations, Translations do
embeds_one :en, Fields
embeds_one :es, Fields
end
## Options
- **build_field_schema (boolean / default: false)** wether to automatically generate the module for
locales or not. Set this to false if you want to customize how the field translations
are stored and keep in mind that you must create a `YourModule.Translations.Fields` schema.
"""
defmacro translations(locales, options \\ []) do
options = Keyword.merge(Trans.default_trans_options(), options)
{build_field_schema, options} = Keyword.pop(options, :build_field_schema)
quote do
if unquote(build_field_schema) do
@before_compile {Trans, :__build_embedded_schema__}
end
@translation_module Module.concat(__MODULE__, Translations)
embeds_one @trans_container, Translations, unquote(options) do
for locale_name <- List.wrap(unquote(locales)) do
embeds_one locale_name, Module.concat([__MODULE__, Fields]), on_replace: :update
end
end
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)
quote do
defmodule Module.concat(unquote(translation_module), Fields) do
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 before.
If we want to know whether a certain field is translatable or not we can use
this function as follows:
iex> Trans.translatable?(Article, :title)
true
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(), locale()) :: 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
defp translatable_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
defp translation_container(opts) do
case Keyword.fetch(opts, :container) do
:error -> :translations
{:ok, container} -> container
end
end
defp translation_default_locale(opts) do
case Keyword.fetch(opts, :default_locale) do
{:ok, default_locale} ->
default_locale
:error ->
raise ArgumentError,
message: "Trans requires a 'default_locale' option that contains the default locale"
end
end
end