Packages
EctoTranslate is a library that helps with translating Ecto data. EctoTranslate can help you with returning translated values of your Ecto data attributes. For this it uses a singe table called 'translations' which will contain polymorphic entries for all of your Ecto data stucts.
Current section
Files
Jump to
Current section
Files
lib/ecto_translate.ex
defmodule EctoTranslate do
@moduledoc """
EctoTranslate is a library that helps with translating Ecto data. EctoTranslate can help you with returning translated values of your Ecto data attributes. For this it uses a singe table called "translations" which will contain polymorphic entries for all of your Ecto data stucts.
## examples
Given an ecto module like :
defmodule MyApp.Post do
...
use EctoTranslate, [:title, :body]
...
schema "posts" do
field :title, :string
field :body, :string
end
...
end
You can set translations using :
locale: :nl, title: "Een nederlandse titel", description: "Een nederlandse beschrijving"]
Then you can ask for a translated fields explicitly using :
iex> MyApp.Post.translated_title(post, :nl)
"Een nederlandse titel"
Or you can update the model by replacing the fields with their translations using :
iex> translated_post = MyApp.Post.translate!(post, :nl)
iex> translated_post.title
"Een nederlandse titel"
iex> translated_post.description
"Een nederlandse beschrijving"
You can also pass in a collection to translate in batch preventing n+1 queries
iex> posts = MyApp.Post |> MyApp.Repo.all
iex> translated_posts = MyApp.Post.translate!(posts, :nl)
If a translation is not found, it will fall back to the original database value.
If you ommit the locale in the function calls, the current gettext locale will be used.
iex> Gettext.set_locale(MyApp.Gettext, :nl)
iex> translated_post = MyApp.Post.translate!(post)
iex> translated_post.title
"""
use Ecto.Schema
import Ecto
import Ecto.Changeset
import Ecto.Query
@doc """
EctoTranslate is meant to be `use`d by a module.
`use` needs a list of attributes that you would like to make available for translation.
defmodule MyApp.Post do
use EctoTranslatable, [:title, :body]
end
When `use` is called, it will add the following functions to your module
- translatable_fields/0
- translate!/1
- translate!/2
- translated_attr/1 i.e. translated_title/1
- translated_attr/2 i.e. translated_title/2
For each of the functions the second parameter is an optional locale. if ommitted, it will use the current Gettext locale.
"""
@spec __using__(fields :: List.t[Atom.t]) :: nil
defmacro __using__(fields) do
repo = Application.get_env(:ecto_translate, :repo)
translatable_fields_ast = quote do
@docs"""
A simple helper funtion to return a list of translatable fields on this model
"""
@spec translatable_fields :: List.t[Atom.t]
def translatable_fields, do: unquote(fields)
end
translated_field_ast = Enum.map(fields, fn field ->
quote do
@docs"""
Returns a translated value for the requested field in the optionally given locale.
If locale was ommitted, it will use the current Gettext locale.
This will cause a query to be run to get the translation.
"""
def unquote(:"translated_#{(field)}")(%{__meta__: %{source: {_,translatable_type}}, id: translatable_id} = model, locale \\ nil) do
locale = locale || EctoTranslate.current_locale
record = EctoTranslate
|> where(translatable_type: ^translatable_type, translatable_id: ^translatable_id, field: unquote(Atom.to_string(field)))
|> unquote(repo).one
case record do
nil -> Map.get(model, unquote(field))
record -> Map.get(record, :content)
end
end
end
end)
translate_ast = quote do
@docs"""
Updates the model(s) that has/have been passed by replacing the content of the translatable fields with the optional locale given.
If locale was ommited, it will use the current Gettext locale.
This will cause a query to be run to get the translations.
"""
@spec translate!(model :: Ecto.Schema.t | List.t[Ecto.Schema.t], locale :: Atom.t) :: Ecto.Schema.t | List.t[Ecto.Schema.t]
def translate!(model, locale \\ nil)
def translate!(%{__meta__: %{source: {_,translatable_type}}, id: translatable_id} = model, locale) do
locale = Atom.to_string((locale || String.to_atom(EctoTranslate.current_locale)))
translations = EctoTranslate
|> where(translatable_type: ^translatable_type, translatable_id: ^translatable_id, locale: ^locale)
|> unquote(repo).all
|> Enum.map(fn record -> {String.to_atom(Map.get(record, :field)) , Map.get(record, :content)} end)
|> Enum.into(%{})
Map.merge(model, translations)
end
def translate!(models, locale) when is_list(models) do
locale = Atom.to_string((locale || String.to_atom(EctoTranslate.current_locale)))
ids = Enum.map(models, fn model -> model.id end)
%{__meta__: %{source: {_,translatable_type}}} = Enum.at(models, 0)
translations = EctoTranslate
|> where(translatable_type: ^translatable_type, locale: ^locale)
|> where([t], t.translatable_id in ^ids)
|> unquote(repo).all
#FIXME this might not be the most optimized way to do this, but for now, it works :)
Enum.map(models, fn model ->
attributes = Enum.filter(translations, fn (translation) -> translation.translatable_id == model.id end)
|> Enum.map(fn record -> {String.to_atom(Map.get(record, :field)) , Map.get(record, :content)} end)
|> Enum.into(%{})
Map.merge(model, attributes)
end)
end
end
{translatable_fields_ast, {translated_field_ast, translate_ast}}
end
schema "translations" do
field :translatable_id, :integer
field :translatable_type, :string
field :locale, :string
field :field, :string
field :content, :string
timestamps
end
@repo Application.get_env(:ecto_translate, :repo)
@required_fields ~w(translatable_id translatable_type locale field content)a
@doc """
Builds a changeset based on the `struct` and `params` and validates the required fields and given locale
"""
@spec changeset(struct :: Ecto.Schema.t, params :: Map.t) :: Ecto.Changeset.t
def changeset(struct, params \\ %{}) do
struct
|> cast(params, @required_fields)
|> validate_required(@required_fields)
|> validate_locale
|> unique_constraint(:translatable_id, name: :translations_translatable_id_translatable_type_locale_field_ind)
end
@doc """
Creates the translations for the given fields in the database or will update those when they already exist.
## Example
iex> %EctoTranslate.ExampleModel{title: "A title in english", description: "A description in english"}
...> |> EctoTranslate.Repo.insert!
...> |> EctoTranslate.set(locale: :de, title: "Eine deutche titel", description: "Ein deutsche umschreibung")
[
%EctoTranslate{__meta__: #Ecto.Schema.Metadata<:loaded, "translations">, content: "Eine deutche titel", field: "title", id: 241, inserted_at: #Ecto.DateTime<2016-07-01 21:09:11>, locale: "de", translatable_id: 221, translatable_type: "test_model", updated_at: #Ecto.DateTime<2016-07-01 21:09:11>},
%EctoTranslate{__meta__: #Ecto.Schema.Metadata<:loaded, "translations">, content: "Ein deutsche umschreibung", field: "description", id: 242, inserted_at: #Ecto.DateTime<2016-07-01 21:09:11>, locale: "de", translatable_id: 221, translatable_type: "test_model", updated_at: #Ecto.DateTime<2016-07-01 21:09:11>}
]
"""
@spec set(model :: Ecto.Schema.t, options :: List.t[{Atom.t, Any.t}]) :: :ok | {:error, List.t}
def set(%{__meta__: %{source: {_,translatable_type}}, id: translatable_id} = model, [{:locale, locale} | options]) do
params = %{
translatable_type: translatable_type,
translatable_id: translatable_id,
locale: Atom.to_string(locale)
}
changesets = create_changesets(model, params, options)
case validate_changesets(changesets) do
{:ok, changesets} -> changesets |> upsert_translations
error -> error
end
end
@doc """
An helper method to get the current Gettext locale
"""
@spec current_locale :: String.t
def current_locale, do: Gettext.get_locale(Application.get_env(:ecto_translate, :gettext))
@doc """
An helper method to get the known Gettext locales
"""
@spec known_locales :: List.t[String.t]
def known_locales, do: Gettext.known_locales(Application.get_env(:ecto_translate, :gettext))
defp validate_changesets(changesets) do
case Enum.filter(changesets, fn changeset -> !changeset.valid? end) do
invalid when invalid == [] -> {:ok, changesets}
_ -> {:error, Enum.map(changesets, fn changeset -> {changeset.changes.field, changeset.errors} end)}
end
end
defp create_changesets(model, params, options) do
options
|> Enum.filter(fn {k, _v} -> Enum.member?(model.__struct__.translatable_fields, k) end)
|> Enum.map(fn {field, content} ->
params = Map.merge(params, %{field: Atom.to_string(field), content: content})
EctoTranslate.changeset(%EctoTranslate{}, params)
end)
end
defp validate_locale(%{changes: %{locale: locale}} = changeset) do
case Enum.member?(EctoTranslate.known_locales, locale) do
true -> changeset
false -> add_error(changeset, :locale, "The locale '#{locale}' is not supported, supported are: #{Enum.join(EctoTranslate.known_locales, ", ")}, if you think this is incorrect, make sure your Gettext.known_locales/1 knows about the locale you want to add...")
end
end
defp validate_locale(changeset), do: changeset
defp upsert_translations([]), do: :ok
defp upsert_translations([changeset| changesets]) do
case @repo.insert(changeset) do
{:ok, cs} -> cs
{:error, cs} -> cs |> update_translation
end
upsert_translations(changesets)
end
defp update_translation(%{changes: changes}) do
record = EctoTranslate
|> where(translatable_type: ^changes.translatable_type, translatable_id: ^changes.translatable_id, locale: ^changes.locale, field: ^changes.field)
|> @repo.one!
record = Ecto.Changeset.change(record, changes)
@repo.update!(record)
end
end