crbelaus/trans

Idea: intermediary macro to remove boilerplate and get cleaner api

Closed this issue · 3 comments

rubas commented

There is a lot of boilerplate and duplicated code needed with the current version of Trans. For example, you are defining the translated fields three times (use statement, fields(), Translation.Fields).

We are solving most of those things with an intermediary macro. I understand this is probably not one size fits it all, but maybe there is some inspiration for a future version and other users.

We ended up with something like this.

defmodule Marko.Schema do
  use Marko, :typed_schema

  @required_fields [:key]
  @optional_fields [:this]

  typed_schema "categories" do
    field(:key, :string)
    field(:this, :string)

    translate_field(:foo)
    translate_field(:bar)
    embed_translations()

    timestamps(type: :naive_datetime_usec)
  end

  @spec changeset(schema :: t, params :: Marko.params()) :: Changeset.t()
  def changeset(schema, params \\ %{}) do
    schema
    |> cast(params, @required_fields ++ @optional_fields)
    |> validate_required(@required_fields)
    |> cast_translations()
  end

And the magic happens here

defmodule Marko.Translation do
  @moduledoc """
  Translation are based on https://github.com/crbelaus/trans

  This module handles a lot of the needed boilerplate and allows for a cleaner api.

  To enable translation:

  - call `translate_field(:foo)` in the schema
  - call `embed_translations()` in the schema
  - call `cast_translations()` in the changeset
  - create a database field `add(:translations, :map, null: false)`
  """

  import Ecto.Changeset, only: [cast: 3, cast_embed: 2, cast_embed: 3]

  @spec trans :: Macro.t()
  def trans() do
    quote do
      # We use the module attribute `:translated_fields` as storage for our macro

      Module.register_attribute(__MODULE__, :translated_fields, persist: true, accumulate: true)

      import Marko.Translation, only: [translate_field: 1, cast_translations: 1, embed_translations: 0]

      @before_compile Marko.Translation
    end
  end

  defmacro __before_compile__(env) do
    case Module.get_attribute(env.module, :translated_fields) do
      [] ->
        nil

      fields ->
        quote do
          use Trans, translates: unquote(fields)

          defmodule Translations.Fields do
            use Marko, :typed_schema

            @required_fields unquote(fields)

            @primary_key false
            typed_embedded_schema do
              for f <- unquote(fields) do
                field(f, :string)
              end
            end

            @spec changeset(schema :: t, params :: Marko.params()) :: Ecto.Changeset.t(t)
            def changeset(schema, params \\ %{}) do
              schema
              |> cast(params, @required_fields)
              |> validate_required(@required_fields)
            end
          end
        end
    end
  end

  defmacro translate_field(field) do
    quote do
      @translated_fields unquote(field)

      field(unquote(field), :string)
    end
  end

  defmacro embed_translations() do
    quote do
      Ecto.Schema.embeds_one :translations, Translations, on_replace: :update, primary_key: false do
        for locale <- MarkoCldr.known_locale_names() do
          Ecto.Schema.embeds_one(locale, __MODULE__.Fields, on_replace: :update)
        end
      end
    end
  end

  @spec cast_translations(Ecto.Changeset.t()) :: Ecto.Changeset.t()
  def cast_translations(changeset) do
    cast_embed(changeset, :translations,
      required: true,
      with: fn schema, params ->
        changeset = cast(schema, params, [])

        Enum.reduce(MarkoCldr.known_locale_names(), changeset, &cast_embed(&2, &1))
      end
    )
  end
end

Hi @rubas first of all thank for your interest in Trans!

This looks incredibly useful and a PR would be very welcome, this would be a great addition to Trans and I believe would be useful to others as well.

rubas commented

Thanks for the feedback.

There are some specific implementation details included. For example we use TypedEctoSchema and always expect translations for all defined locales (MarkoCldr.known_locale_names()).

This is not a one size fits all solution.

This is now supported by the new translations/2 macro provided by the just released Trans 3.0.0.