Skip to content

Commit

Permalink
Merge pull request #74 from kipcole9/main
Browse files Browse the repository at this point in the history
Implements :default_locale and translation fallback chains
  • Loading branch information
crbelaus committed Mar 19, 2022
2 parents 6150d30 + 5dbca7b commit 6de358c
Show file tree
Hide file tree
Showing 11 changed files with 860 additions and 46 deletions.
81 changes: 78 additions & 3 deletions lib/trans.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ defmodule Trans do
* `: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.
## Structured translations
Expand All @@ -25,7 +26,7 @@ defmodule Trans do
defmodule MyApp.Article do
use Ecto.Schema
use Trans, translates: [:title, :body]
use Trans, translates: [:title, :body], default_locale: :en
schema "articles" do
field :title, :string
Expand Down Expand Up @@ -65,7 +66,7 @@ defmodule Trans do
defmodule MyApp.Article do
use Ecto.Schema
use Trans, translates: [:title, :body]
use Trans, translates: [:title, :body], default_locale: :en
schema "articles" do
field :title, :string
Expand Down Expand Up @@ -98,6 +99,7 @@ defmodule Trans do
* `__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 """
Expand All @@ -110,11 +112,25 @@ defmodule Trans do
"""
@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__}

Expand All @@ -123,6 +139,58 @@ defmodule Trans do

@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

defmacro translations(field_name, translation_module, 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(translation_module) && unquote(build_field_schema) do
@before_compile {Trans, :__build_embedded_schema__}
end

@translation_module Module.concat(__MODULE__, unquote(translation_module))

embeds_one unquote(field_name), unquote(translation_module), unquote(options) do
for locale_name <- List.wrap(unquote(locales)) do
embeds_one locale_name, unquote(translation_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

Expand Down Expand Up @@ -208,7 +276,7 @@ defmodule Trans do
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"
"The field #{container} used as the translation container is not defined in #{inspect module} struct"
end
end

Expand All @@ -230,4 +298,11 @@ defmodule Trans do
{:ok, container} -> container
end
end

defp translation_default_locale(opts) do
case Keyword.fetch(opts, :default_locale) do
:error -> nil
{:ok, default_locale} -> default_locale
end
end
end
149 changes: 149 additions & 0 deletions lib/trans/gen_function_migration.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
if Code.ensure_loaded?(Ecto.Adapters.SQL) do
defmodule Mix.Tasks.Trans.Gen.TranslateFunction do
use Mix.Task

import Mix.Generator
import Mix.Ecto, except: [migrations_path: 1]
import Macro, only: [camelize: 1, underscore: 1]

@shortdoc "Generates an Ecto migration to create the translate_field database function"

@moduledoc """
Generates a migration to add a database function
`translate_field` that uses the `Trans` structured
transaltion schema to resolve a translation for a field.
"""

@doc false
@dialyzer {:no_return, run: 1}

def run(args) do
no_umbrella!("trans_gen_translate_function")
repos = parse_repo(args)
name = "trans_gen_translate_function"

Enum.each(repos, fn repo ->
ensure_repo(repo, args)
path = Path.relative_to(migrations_path(repo), Mix.Project.app_path())
file = Path.join(path, "#{timestamp()}_#{underscore(name)}.exs")
create_directory(path)

assigns = [mod: Module.concat([repo, Migrations, camelize(name)])]

content =
assigns
|> migration_template
|> format_string!

create_file(file, content)

if open?(file) and Mix.shell().yes?("Do you want to run this migration?") do
Mix.Task.run("ecto.migrate", [repo])
end
end)
end

defp timestamp do
{{y, m, d}, {hh, mm, ss}} = :calendar.universal_time()
"#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}"
end

defp pad(i) when i < 10, do: <<?0, ?0 + i>>
defp pad(i), do: to_string(i)

if Code.ensure_loaded?(Code) && function_exported?(Code, :format_string!, 1) do
@spec format_string!(String.t()) :: iodata()
@dialyzer {:no_return, format_string!: 1}
def format_string!(string) do
Code.format_string!(string)
end
else
@spec format_string!(String.t()) :: iodata()
def format_string!(string) do
string
end
end

if Code.ensure_loaded?(Ecto.Migrator) &&
function_exported?(Ecto.Migrator, :migrations_path, 1) do
def migrations_path(repo) do
Ecto.Migrator.migrations_path(repo)
end
end

if Code.ensure_loaded?(Mix.Ecto) && function_exported?(Mix.Ecto, :migrations_path, 1) do
def migrations_path(repo) do
Mix.Ecto.migrations_path(repo)
end
end

embed_template(:migration, ~S|
defmodule <%= inspect @mod %> do
use Ecto.Migration
def up do
execute """
CREATE OR REPLACE FUNCTION public.translate_field(record record, container varchar, field varchar, default_locale varchar, locales varchar[])
RETURNS varchar
STRICT
STABLE
LANGUAGE plpgsql
AS $$
DECLARE
locale varchar;
j json;
c json;
l varchar;
BEGIN
j := row_to_json(record);
c := j->container;
FOREACH locale IN ARRAY locales LOOP
IF locale = default_locale THEN
RETURN j->>field;
ELSEIF c->locale IS NOT NULL THEN
IF c->locale->>field IS NOT NULL THEN
RETURN c->locale->>field;
END IF;
END IF;
END LOOP;
RETURN j->>field;
END;
$$;
"""
execute("""
CREATE OR REPLACE FUNCTION public.translate_field(record record, container varchar, default_locale varchar, locales varchar[])
RETURNS jsonb
STRICT
STABLE
LANGUAGE plpgsql
AS $$
DECLARE
locale varchar;
j json;
c json;
BEGIN
j := row_to_json(record);
c := j->container;
FOREACH locale IN ARRAY locales LOOP
IF c->locale IS NOT NULL THEN
RETURN c->locale;
END IF;
END LOOP;
RETURN NULL;
END;
$$;
""")
end
def down do
execute "DROP FUNCTION IF EXISTS public.translate_field(container varchar, field varchar, default_locale varchar, locales varchar[])"
execute "DROP FUNCTION IF EXISTS public.translate_field(container varchar, default_locale varchar, locales varchar[])"
end
end
|)
end
end
Loading

0 comments on commit 6de358c

Please sign in to comment.