Current section

Files

Jump to
trans lib trans.ex
Raw

lib/trans.ex

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`.
## Structured translations
Structured translations are the preferred and recommended way of using `Trans`. To use structured
translations **you must define the translations as embedded schemas**:
defmodule MyApp.Article do
use Ecto.Schema
use 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.Translation
embeds_one :fr, MyApp.Article.Translation
end
end
end
defmodule MyApp.Article.Translation do
use Ecto.Schema
@primary_key false
embedded_schema do
field :title, :string
field :body, :string
end
end
Although they required more code than free-form translations, **structured translations provide
some nice benefits** that make them the preferred way of using `Trans`:
* High flexibility when making validations and transformation using the embedded schema's own
changeset.
* Easy to integrate with HTML forms leveraging the capabilities of `inputs_for`
* Easy navegability using the dot notation.
## Free-form translations
Free-form translations were the main way of using `Trans` until the 2.3.0 version. They are still
supported for compatibility with older versions but not recommended for new projects.
To use free-form translations you must define the translations as a map:
defmodule MyApp.Article do
use Ecto.Schema
use Trans, translates: [:title, :body]
schema "articles" do
field :title, :string
field :body, :string
field :translations, :map
end
end
Although they require less code, **free-form translations provide much less guarantees**:
* There is no way to tell what content and wich form will be stored in the translations field.
* Hard to integrate with HTML forms since the Phoenix helpers are not available.
* Difficult navigation requiring the braces notation from the `Access` protocol.
## 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.
"""
@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()
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)))
@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
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 #{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
end