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`.
  * `: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