diff --git a/.formatter.exs b/.formatter.exs index a421916..05ef262 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,5 @@ [ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], import_deps: [:ecto], - locals_without_parens: [translations: 3, translations: 4] + locals_without_parens: [translations: 1, translations: 2] ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63d4da8..4368fb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,8 @@ jobs: fail-fast: false matrix: include: - - elixir: 1.7 - otp: 21 - - elixir: 1.12 - otp: 24 + - elixir: 1.15.0 + otp: 25.3 env: MIX_ENV: test @@ -29,7 +27,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup Elixir uses: erlef/setup-beam@v1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2a3d4..6e7a719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -# 2.3.0 - 2021-09-21 +# 3.0.0 - 2023-07-03 (requires Elixir 1.11 or newer) + +- Remove support for unstructured translations +- Add support for default locales and translation fallback chains +- Return `nil` for unitialised embed in struct +- Minor fixes, typos and dependency updates + +# 2.3.0 - 2021-09-21 (requires Elixir 1.7 or newer) - Update dependencies to avoid compilation warnings -- Require Elixir 1.7 or newer - Migrate from CircleCI to GitHub Actions - Allow translating entire structs - Add translate!/3 function to raise if a translation does not exist - Allow saving translations into embedded_schemas - Improve docs -# 2.2.0 - 2020-02-01 -- Require Elixir 1.6 or newer +# 2.2.0 - 2020-02-01 (requires Elixir 1.6 or newer) - Enable locale to be passed as a string - Update ExDoc dependency - Remove Faker dependency diff --git a/README.md b/README.md index ae8aec4..707ba56 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ that is used by the `Trans.Translator` and `Trans.QueryBuilder` modules, which implement the main functionality of this library. -## Quickstart +## Setup and Quickstart -Imagine that we have an `Article` schema that we want to translate: +Let's say that we have an `Article` schema that contains texts in English and we want to translate it to other languages. ```elixir defmodule MyApp.Article do @@ -67,7 +67,7 @@ defmodule MyApp.Article do end ``` -The first step would be to add a new JSON column to the table so we can store the translations in it. +The first step would be to add a new JSONB column to the table so we can store the translations in it. ```elixir defmodule MyApp.Repo.Migrations.AddTranslationsToArticles do @@ -81,37 +81,99 @@ defmodule MyApp.Repo.Migrations.AddTranslationsToArticles do end ``` -Once we have the new database column, we can update the Article schema to include the translations +Once we have the new database column, we can update the Article schema to include the translations. ```elixir 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 field :body, :string - embeds_one :translations, Translations, on_replace: :update, primary_key: false do - embeds_one :es, MyApp.Article.Translation, on_replace: :update - embeds_one :fr, MyApp.Article.Translation, on_replace: :update - end + + # This generates a MyApp.Article.Translations schema with a + # MyApp.Article.Translations.Fields for :es and :fr + translations [:es, :fr] + end +end +``` + +After doing this we can leverage the [Trans.Translator](https://hexdocs.pm/trans/Trans.Translator.html) and [Trans.QueryBuilder](https://hexdocs.pm/trans/Trans.QueryBuilder.html) modules to fetch and query translations from the database. + +The translation storage can be done using normal `Ecto.Changeset` functions just like it would be done for any other fields or associations. + +```elixir +defmodule MyApp.Article do + def changeset(article, attrs \\ %{}) do + article + |> cast(attrs, [:title, :body]) + |> validate_required([:title, :body]) + |> cast_embed(:translations, with: &cast_translations/2) + end + + defp cast_translations(translations, attrs \\ %{}) do + translations + |> cast(attrs, []) + |> cast_embed(:es) + |> cast_embed(:fr) end end -defmodule MyApp.Article.Translation do +# Then, anywhere in your code: +changeset = MyApp.Article.changeset(article, %{ + translations: %{ + es: %{title: "title ES", body: "body ES"}, + fr: %{title: "title FR", body: "body FR"} + } +}) +``` + +## Customizing the translation container + +By default Trans looks for a `translations` field that contains the translations. This is known as the "translation container". + +You can override the default translation container passing the `container` option to Trans. In the following example the translations will be stored in the `transcriptions` field. + +```elixir +defmodule MyApp.Article do use Ecto.Schema + use Trans, translates: [:title, :body], default_locale: :en, container: :transcriptions - @primary_key false - embedded_schema do + schema "articles" do field :title, :string - field :body, :string + field :body, :strings + translations [:es, :fr] end end ``` -After doing this we can leverage the [Trans.Translator](https://hexdocs.pm/trans/Trans.Translator.html) and [Trans.QueryBuilder](https://hexdocs.pm/trans/Trans.QueryBuilder.html) modules to fetch and query translations from the database. +## Customizing the translation schemas + +If you want to use your own translation module you can simply pass the `build_field_schema: false` option when using the `translations` macro. + +```elixir +defmodule MyApp.Article do + use Ecto.Schema + use Trans, translates: [:title, :body], default_locale: :en -The translation storage can be done using normal `Ecto.Changeset` functions just like any other fields. + defmodule Translations.Fields do + use Ecto.Schema + + embedded_schema do + field :title, :string + field :body, :string + end + end + + schema "articles" do + field :title, :string + field :body, :string + + translations [:es, :fr], build_field_schema: false + end +end +``` ## Is Trans dead? diff --git a/config/config.exs b/config/config.exs index 94f8a40..96704a7 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,6 +1,6 @@ # This file is responsible for configuring your application # and its dependencies with the aid of the Mix.Config module. -use Mix.Config +import Config # This configuration is loaded before any dependency and is restricted # to this project. If another project depends on this project, this diff --git a/config/test.ci.exs b/config/test.ci.exs index 03acf1d..bea39fa 100644 --- a/config/test.ci.exs +++ b/config/test.ci.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config config :trans, Trans.Repo, username: "postgres", diff --git a/config/test.sample.exs b/config/test.sample.exs index 7d5f422..57dd74a 100644 --- a/config/test.sample.exs +++ b/config/test.sample.exs @@ -1,4 +1,4 @@ -use Mix.Config +import Config config :trans, Trans.Repo, username: "postgres", diff --git a/lib/trans.ex b/lib/trans.ex index 88f2a69..32804eb 100644 --- a/lib/trans.ex +++ b/lib/trans.ex @@ -19,10 +19,9 @@ defmodule Trans do Defaults to`:translations`. * `:default_locale` (optional) - declares the locale of the base untranslated column. - ## Structured translations + ## Storing translations - Structured translations are the preferred and recommended way of using `Trans`. To use structured - translations **you must define the translations as embedded schemas**: + To store translations in a schema you must use the `translations` macro: defmodule MyApp.Article do use Ecto.Schema @@ -32,37 +31,39 @@ defmodule Trans 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 + translations [:es, :fr] end end - defmodule MyApp.Article.Translation do + This is equivalent to: + + defmodule MyApp.Article do use Ecto.Schema + use Trans, translates: [:title, :body], default_locale: :en - @primary_key false - embedded_schema do + 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 - 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 + defmodule MyApp.Article.Translations.Fields do + use Ecto.Schema - 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. + embedded_schema do + field :title, :string + field :body, :string + end + end - To use free-form translations you must define the translations as a map: + 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 @@ -71,16 +72,12 @@ defmodule Trans do schema "articles" do field :title, :string field :body, :string - field :translations, :map + + # Define MyApp.Article.Translations.Fields yourself + translations [:es, :fr], build_field_schema: false end end - Although they require less code, **free-form translations provide much less guarantees**: - - * There is no way to tell what content and which 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 @@ -150,20 +147,47 @@ defmodule Trans do [on_replace: :update, primary_key: false, build_field_schema: true] end - defmacro translations(field_name, translation_module, locales, options \\ []) do + @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(translation_module) && unquote(build_field_schema) do + if unquote(build_field_schema) do @before_compile {Trans, :__build_embedded_schema__} end - @translation_module Module.concat(__MODULE__, unquote(translation_module)) + @translation_module Module.concat(__MODULE__, Translations) - embeds_one unquote(field_name), unquote(translation_module), unquote(options) do + embeds_one @trans_container, Translations, unquote(options) do for locale_name <- List.wrap(unquote(locales)) do - embeds_one locale_name, unquote(translation_module).Fields, on_replace: :update + embeds_one locale_name, Module.concat([__MODULE__, Fields]), on_replace: :update end end end @@ -174,7 +198,7 @@ defmodule Trans do fields = Module.get_attribute(env.module, :trans_fields) quote do - defmodule Module.concat(unquote(translation_module), :Fields) do + defmodule Module.concat(unquote(translation_module), Fields) do use Ecto.Schema import Ecto.Changeset @@ -202,19 +226,14 @@ defmodule Trans do ## Examples - Assuming the Article schema defined in [Structured translations](#module-structued-translations). + 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 (we can also pass a struct instead of the module - name itself): + this function as follows: iex> Trans.translatable?(Article, :title) true - - May be also used with translatable structs: - - iex> article = %Article{} - iex> Trans.translatable?(article, :not_existing) + iex> Trans.translatable?(%Article{}, :not_existing) false Raises if the given module or struct does not use `Trans`: @@ -224,7 +243,7 @@ defmodule Trans do """ def translatable?(module_or_translatable, field) - @spec translatable?(module | translatable(), String.t() | atom) :: boolean + @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 @@ -276,9 +295,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 #{ - inspect(module) - } struct" + "The field #{container} used as the translation container is not defined in #{inspect(module)} struct" end end @@ -303,8 +320,12 @@ defmodule Trans do defp translation_default_locale(opts) do case Keyword.fetch(opts, :default_locale) do - :error -> nil - {:ok, default_locale} -> default_locale + {:ok, default_locale} -> + default_locale + + :error -> + raise ArgumentError, + message: "Trans requires a 'default_locale' option that contains the default locale" end end end diff --git a/lib/trans/query_builder.ex b/lib/trans/query_builder.ex index 5ff1ab9..47f099c 100644 --- a/lib/trans/query_builder.ex +++ b/lib/trans/query_builder.ex @@ -19,8 +19,7 @@ if Code.ensure_loaded?(Ecto.Adapters.SQL) do ## Examples - Assuming the Article schema defined in - [Structured translations](Trans.html#module-structured-translations): + Assuming the Article schema defined in [Trans](Trans.html#module-storing-translations): # Return all articles that have a Spanish translation from a in Article, where: translated(Article, a, :es) != "null" @@ -40,44 +39,26 @@ if Code.ensure_loaded?(Ecto.Adapters.SQL) do #=> FROM "articles" AS a0 #=> WHERE ((a0."translations"->"es"->>"body") ILIKE "%elixir%") - ## Structured translations vs free-form translations + ## Fallback chains - The `Trans.QueryBuilder` works with both - [Structured translations](Trans.html#module-structured-translations) - and with [Free-form translations](Transl.html#module-free-form-translations). + Just like when using `Trans.Translator.translate/2` you may also pass a list of locales. In + that case the query will automatically fall back through the list of provided locales until + it finds an existing translation. - In most situations, the queries can be performed in the same way for both cases. **When querying - for data translated into a certain locale we must know whether we are using structured or - free-form translations**. + # Query items translated into FR or ES (if FR translation does not exist) + from a in Article, where: not is_nil(translated(Article, a.body, [:fr, :es])) - When using structured translations, the translations are saved as an embedded schema. This means - that **the locale keys will be always present even if there is no translation for that locale.** - In the database we have a `"null"` JSON value. + If you plan to use fallback chains in the database you will need to set up the Trans DB + translation functions. - # If MyApp.Article uses structured translations - Repo.all(from a in MyApp.Article, where: translated(MyApp.Article, a, :es) != "null") - #=> SELECT a0."id", a0."title", a0."body", a0."translations" - #=> FROM "articles" AS a0 - #=> WHERE (a0."translations"->"es") != 'null' - - When using free-form translations, the translations are stored in a simple map. This means that - **the locale keys may be absent if there is no translation for that locale.** In the database we - have a `NULL` value. - - # If MyApp.Article uses free-form translations - Repo.all(from a in MyApp.Article, where: not is_nil(translated(MyApp.Article, a, :es))) - #=> SELECT a0."id", a0."title", a0."body", a0."translations" - #=> FROM "articles" AS a0 - #=> WHERE (NOT ((a0."translations"->"es") IS NULL)) + mix do trans.gen.translate_function, ecto.migrate ## More complex queries The `translated/3` macro can also be used with relations and joined schemas. For more complex examples take a look at the QueryBuilder tests (the file is located in `test/trans/query_builder_test.ex`). - """ - defmacro translated(module, translatable, locale) do static_locales? = static_locales?(locale) @@ -100,7 +81,6 @@ if Code.ensure_loaded?(Ecto.Adapters.SQL) do fragment with the column alias. """ - defmacro translated_as(module, translatable, locale) do field = field(translatable) translated = quote do: translated(unquote(module), unquote(translatable), unquote(locale)) diff --git a/lib/trans/translator.ex b/lib/trans/translator.ex index 4e5e629..c492834 100644 --- a/lib/trans/translator.ex +++ b/lib/trans/translator.ex @@ -21,11 +21,11 @@ defmodule Trans.Translator do title: "How to Write a Spelling Corrector", body: "A wonderful article by Peter Norvig", translations: %MyApp.Article.Translations{ - es: %MyApp.Article.Translation{ + es: %MyApp.Article.Translations.Fields{ title: "Cómo escribir un corrector ortográfico", body: "Un artículo maravilloso de Peter Norvig" }, - fr: %MyApp.Article.Translation{ + fr: %MyApp.Article.Translations.Fields{ title: "Comment écrire un correcteur orthographique", body: "Un merveilleux article de Peter Norvig" } @@ -144,7 +144,7 @@ defmodule Trans.Translator do @doc since: "2.3.0" @spec translate!(Trans.translatable(), atom, Trans.locale_list()) :: any def translate!(%{__struct__: module} = translatable, field, locale) - when (is_locale(locale) or is_list(locale)) and is_atom(field) do + when is_locale(locale) and is_atom(field) do default_locale = module.__trans__(:default_locale) unless Trans.translatable?(translatable, field) do @@ -152,12 +152,10 @@ defmodule Trans.Translator do end # Return the translation or fall back to the default value - case translate_field(translatable, locale, field, default_locale) do - :error -> - raise no_translation_error(field, locale) - - translation -> - translation + if translation = translate_field(translatable, locale, field, default_locale) do + translation + else + raise no_translation_error(field, locale) end end @@ -229,6 +227,7 @@ defmodule Trans.Translator do # fallback to default behaviour defp get_translations_for_locale(nil, _locale), do: nil + defp get_translations_for_locale(all_translations, locale) do Map.fetch(all_translations, to_string(locale)) end diff --git a/mix.exs b/mix.exs index 76e1412..be2ca19 100644 --- a/mix.exs +++ b/mix.exs @@ -1,13 +1,13 @@ defmodule Trans.Mixfile do use Mix.Project - @version "2.3.0" + @version "3.0.0" def project do [ app: :trans, version: @version, - elixir: "~> 1.7", + elixir: "~> 1.11", description: "Embedded translations for Elixir schemas", build_embedded: Mix.env() == :prod, start_permanent: Mix.env() == :prod, @@ -58,7 +58,7 @@ defmodule Trans.Mixfile do defp package do [ - licenses: ["Apache 2.0"], + licenses: ["Apache-2.0"], maintainers: ["Cristian Álvarez Belaustegui"], links: %{"GitHub" => "https://github.com/crbelaus/trans"} ] @@ -66,14 +66,12 @@ defmodule Trans.Mixfile do # Include Ecto and Postgrex applications in tests def app_list(:test), do: [:ecto, :postgrex] - def app_list(_), do: app_list() - def app_list, do: [] + def app_list(_), do: [] # Always compile files in "lib". In tests compile also files in # "test/support" - def elixirc_paths(:test), do: elixirc_paths() ++ ["test/support"] - def elixirc_paths(_), do: elixirc_paths() - def elixirc_paths, do: ["lib"] + def elixirc_paths(:test), do: ["lib", "test/support"] + def elixirc_paths(_), do: ["lib"] defp aliases do [ diff --git a/mix.lock b/mix.lock index af9f533..6de361e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,17 +1,17 @@ %{ "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, - "db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"}, - "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, + "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.15", "b29e8e729f4aa4a00436580dcc2c9c5c51890613457c193cc8525c388ccb2f06", [:mix], [], "hexpm", "044523d6438ea19c1b8ec877ec221b008661d3c27e3b848f4c879f500421ca5c"}, - "ecto": {:hex, :ecto, "3.7.0", "0b250b4aa5a9cdb80252802bd535c54c963e2d83f5bd179a57c093ed0779994b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a212cecd544a6f3d00921bc3e7545070eb50b9a1454525323027bf07eba1165"}, - "ecto_sql": {:hex, :ecto_sql, "3.7.0", "2fcaad4ab0c8d76a5afbef078162806adbe709c04160aca58400d5cbbe8eeac6", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a26135dfa1d99bf87a928c464cfa25bba6535a4fe761eefa56077a4febc60f70"}, - "ex_doc": {:hex, :ex_doc, "0.25.1", "4b736fa38dc76488a937e5ef2944f5474f3eff921de771b25371345a8dc810bc", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3200b0a69ddb2028365281fbef3753ea9e728683863d8cdaa96580925c891f67"}, - "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, - "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, - "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, - "postgrex": {:hex, :postgrex, "0.15.10", "2809dee1b1d76f7cbabe570b2a9285c2e7b41be60cf792f5f2804a54b838a067", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1560ca427542f6b213f8e281633ae1a3b31cdbcd84ebd7f50628765b8f6132be"}, - "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, + "ecto": {:hex, :ecto, "3.10.2", "6b887160281a61aa16843e47735b8a266caa437f80588c3ab80a8a960e6abe37", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6a895778f0d7648a4b34b486af59a1c8009041fbdf2b17f1ac215eb829c60235"}, + "ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, + "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, + "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, + "postgrex": {:hex, :postgrex, "0.17.1", "01c29fd1205940ee55f7addb8f1dc25618ca63a8817e56fac4f6846fc2cddcbe", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "14b057b488e73be2beee508fb1955d8db90d6485c6466428fe9ccf1d6692a555"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, } diff --git a/priv/repo/migrations/20160521170815_add_articles_table.exs b/priv/repo/migrations/20160521170815_add_articles_table.exs deleted file mode 100644 index 76a394b..0000000 --- a/priv/repo/migrations/20160521170815_add_articles_table.exs +++ /dev/null @@ -1,11 +0,0 @@ -defmodule Trans.Repo.Migrations.AddArticlesTable do - use Ecto.Migration - - def change do - create table(:articles) do - add :title, :string - add :body, :string - add :translations, :map - end - end -end diff --git a/priv/repo/migrations/20170402185205_add_comments_table.exs b/priv/repo/migrations/20170402185205_add_comments_table.exs deleted file mode 100644 index dc496a0..0000000 --- a/priv/repo/migrations/20170402185205_add_comments_table.exs +++ /dev/null @@ -1,11 +0,0 @@ -defmodule Trans.Repo.Migrations.AddCommentsTable do - use Ecto.Migration - - def change do - create table(:comments) do - add :comment, :string - add :article_id, references(:articles) - add :transcriptions, :map - end - end -end diff --git a/priv/repo/migrations/20230628153604_create_test_tables.exs b/priv/repo/migrations/20230628153604_create_test_tables.exs new file mode 100644 index 0000000..df4ad9b --- /dev/null +++ b/priv/repo/migrations/20230628153604_create_test_tables.exs @@ -0,0 +1,15 @@ +defmodule Trans.Repo.Migrations.CreateTestTables do + use Ecto.Migration + + def change do + create table(:default_translation_container) do + add :content, :string + add :translations, :map + end + + create table(:custom_translation_container) do + add :content, :string + add :transcriptions, :map + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex deleted file mode 100644 index 68f845e..0000000 --- a/test/support/factory.ex +++ /dev/null @@ -1,70 +0,0 @@ -alias Trans.{Article, Book} -alias Trans.Comment -alias Trans.Repo, as: Repo - -defmodule Trans.Factory do - @moduledoc false - - def build(factory, attributes) do - factory |> build() |> struct(attributes) - end - - def insert(factory, attributes \\ []) do - factory |> build(attributes) |> Repo.insert!() - end - - def build(:article) do - %Article{ - title: unique_string("Article title in English"), - body: unique_string("Article body in English"), - comments: [build(:comment), build(:comment)], - translations: %Article.Translations{ - es: %Article.Translations.Fields{ - title: unique_string("Article title in Spanish"), - body: unique_string("Article body in Spanish") - }, - fr: %Article.Translations.Fields{ - title: unique_string("Article title in French"), - body: unique_string("Article body in French") - } - } - } - end - - def build(:book) do - %Book{ - title: unique_string("Book title in English"), - body: unique_string("Book body in English"), - translations: %Book.Translations{ - es: %Book.Translations.Fields{ - title: unique_string("Book title in Spanish"), - body: unique_string("Book body in Spanish") - }, - fr: %Book.Translations.Fields{ - title: unique_string("Book title in French"), - body: unique_string("Book body in French") - } - } - } - end - - def build(:comment) do - %Comment{ - comment: unique_string("Comment in English"), - transcriptions: %{ - "es" => %{"comment" => unique_string("Comment in Spanish")}, - "fr" => %{"comment" => unique_string("Comment in French")} - } - } - end - - # Adds a random suffix to the given string to make it unique. - defp unique_string(string) do - Enum.join([string, System.unique_integer()], " - ") - end - - # Return locales at runtime, dynamically - def locales(locales) do - locales - end -end diff --git a/test/support/models/article.ex b/test/support/models/article.ex deleted file mode 100644 index 521dff5..0000000 --- a/test/support/models/article.ex +++ /dev/null @@ -1,62 +0,0 @@ -defmodule Trans.Article do - @moduledoc """ - Example schema using embedded structs for translations. - - Since the translation container field `translations` is only declared once, we define the - embedded schema inline. The translation fields are repeated for each translatable language, - so we extract this embedded schema to its own module. - - Embedded schemas are much more explicit about how everything works and provide higher flexibility - in validations and transformations by using their own changesets. Using embedded schemas also - makes it easier to build forms thanks to the `Phoenix.HTML.Form.inputs_for/2` helper. - - Due to this reasons, **embedded schemas are the preferred way of using Trans.** - """ - - use Ecto.Schema - use Trans, translates: [:title, :body] - - import Ecto.Changeset - - schema "articles" do - field :title, :string - field :body, :string - has_many :comments, Trans.Comment - - embeds_one :translations, Translations, on_replace: :update, primary_key: false do - embeds_one :es, __MODULE__.Fields, on_replace: :update - embeds_one :fr, __MODULE__.Fields, on_replace: :update - end - end - - def changeset(article, params \\ %{}) do - article - |> cast(params, [:title, :body]) - |> cast_embed(:translations, with: &translations_changeset/2) - |> validate_required([:title, :body]) - end - - defp translations_changeset(translations, params) do - translations - |> cast(params, []) - |> cast_embed(:es) - |> cast_embed(:fr) - end -end - -defmodule Trans.Article.Translations.Fields do - use Ecto.Schema - import Ecto.Changeset - - @primary_key false - embedded_schema do - field :title, :string - field :body, :string - end - - def changeset(fields, params) do - fields - |> cast(params, [:title, :body]) - |> validate_required([:title, :body]) - end -end diff --git a/test/support/models/book.ex b/test/support/models/book.ex deleted file mode 100644 index 358bb4d..0000000 --- a/test/support/models/book.ex +++ /dev/null @@ -1,30 +0,0 @@ -defmodule Trans.Book do - @moduledoc """ - The same as Article, but declares a default locale and - used only for testing default locale and locale fallbacks - """ - - use Ecto.Schema - use Trans, translates: [:title, :body], default_locale: :en - - schema "articles" do - field :title, :string - field :body, :string - - embeds_one :translations, Translations, on_replace: :update, primary_key: false do - embeds_one :es, __MODULE__.Fields, on_replace: :update - embeds_one :fr, __MODULE__.Fields, on_replace: :update - embeds_one :it, __MODULE__.Fields, on_replace: :update - end - end -end - -defmodule Trans.Book.Translations.Fields do - use Ecto.Schema - - @primary_key false - embedded_schema do - field :title, :string - field :body, :string - end -end diff --git a/test/support/models/comment.ex b/test/support/models/comment.ex deleted file mode 100644 index c6de407..0000000 --- a/test/support/models/comment.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Trans.Comment do - @moduledoc false - - use Ecto.Schema - use Trans, translates: [:comment], container: :transcriptions - - import Ecto.Changeset - - schema "comments" do - field(:comment, :string) - field(:transcriptions, :map) - belongs_to(:article, Trans.Article) - end - - def changeset(comment, params \\ %{}) do - comment - |> cast(params, [:comment, :transcriptions]) - |> validate_required([:comment]) - end -end diff --git a/test/support/models/magazine.ex b/test/support/models/magazine.ex deleted file mode 100644 index b3015d9..0000000 --- a/test/support/models/magazine.ex +++ /dev/null @@ -1,10 +0,0 @@ -defmodule Trans.Magazine do - use Ecto.Schema - use Trans, translates: [:title, :body], default_locale: :en - - schema "magazine" do - field :title, :string - field :body, :string - translations :translations, Translations, [:es, :it, :de] - end -end diff --git a/test/support/test_case.ex b/test/support/test_case.ex index a5dc11f..8830e17 100644 --- a/test/support/test_case.ex +++ b/test/support/test_case.ex @@ -5,7 +5,7 @@ defmodule Trans.TestCase do using do quote do - import Trans.{TestCase, Factory} + import Trans.TestCase import Ecto.Query alias Trans.Repo diff --git a/test/trans/query_builder_test.exs b/test/trans/query_builder_test.exs index a33a524..aff9bd5 100644 --- a/test/trans/query_builder_test.exs +++ b/test/trans/query_builder_test.exs @@ -3,419 +3,241 @@ defmodule Trans.QueryBuilderTest do import Trans.QueryBuilder - alias Trans.{Article, Book, Comment} + alias Trans.Repo - setup do - [ - translated_article: insert(:article), - untranslated_article: insert(:article, translations: %{}) - ] - end - - test "should find only one article translated to ES" do - # Articles use nested structs for translations, this means that the translation container - # always has the locale keys, but they are "null" if empty. - count = - Repo.one( - from( - a in Article, - where: not is_nil(translated(Article, a, :es)), - select: count(a.id) - ) - ) - - assert count == 1 - end + defmodule DefaultContainer do + use Ecto.Schema + use Trans, translates: [:content], default_locale: :en - test "should not find any article translated to DE" do - count = - Repo.one( - from( - a in Article, - where: not is_nil(translated(Article, a, :de)), - select: count(a.id) - ) - ) - - assert count == 0 + schema "default_translation_container" do + field :content, :string + translations [:es, :fr, :de] + end end - test "should find one article translated to ES falling back from DE" do - query = - from( - a in Article, - where: not is_nil(translated(Article, a, [:de, :es])), - select: count(a.id) - ) - - count = Repo.one(query) + describe "default container" do + setup do + translated_struct = + Repo.insert!(%DefaultContainer{ + content: "Content EN", + translations: %DefaultContainer.Translations{ + es: %DefaultContainer.Translations.Fields{ + content: "Content ES" + }, + fr: %DefaultContainer.Translations.Fields{ + content: "Content FR" + } + } + }) + + untranslated_struct = + Repo.insert!(%DefaultContainer{ + content: "Untranslated Content EN", + translations: %DefaultContainer.Translations{} + }) + + [translated_struct: translated_struct, untranslated_struct: untranslated_struct] + end - assert count == 1 - end + test "should find only one struct translated to ES" do + query = + from dc in DefaultContainer, + where: not is_nil(translated(DefaultContainer, dc, :es)) - test "should find no article translated to DE falling back from RU since neither exist" do - query = - from( - a in Article, - where: not is_nil(translated(Article, a, [:ru, :de])), - select: count(a.id) - ) + assert Repo.aggregate(query, :count) == 1 + end - count = Repo.one(query) + test "should not find any struct translated to DE" do + query = + from dc in DefaultContainer, + where: not is_nil(translated(DefaultContainer, dc, :de)) - assert count == 0 - end + refute Repo.exists?(query) + end - # This is an example where we use `NULLIF(value, 'null')` to - # standardise on using SQL NULL in all cases where there is no data. - test "that a valid locale that has no translations returns nil (not 'null')" do - query = - from( - a in Book, - where: is_nil(translated(Book, a, :it)), - select: count(a.id) - ) + test "should find one struct translated to ES falling back from DE" do + query = + from dc in DefaultContainer, + where: not is_nil(translated(DefaultContainer, dc, [:de, :es])) - count = Repo.one(query) + assert Repo.aggregate(query, :count) == 1 + end - assert count == 2 - end + test "should find no struct translated to DE falling back from RU since neither exist" do + query = + from dc in DefaultContainer, + where: not is_nil(translated(DefaultContainer, dc, [:ru, :de])) - test "that a valid locale that has no translations returns nil for locale chain" do - count = - Repo.one( - from( - a in Book, - where: is_nil(translated(Book, a, [:de, :it])), - select: count(translated(Book, a, [:de, :it])) - ) - ) + refute Repo.exists?(query) + end - # Both rows return NULL and coun(column) doesn't unclude - # rows where is_null(column) - assert count == 0 - end + # This is an example where we use `NULLIF(value, 'null')` to + # standardise on using SQL NULL in all cases where there is no data. + test "that a valid locale that has no translations returns nil (not 'null')" do + query = + from dc in DefaultContainer, + where: is_nil(translated(DefaultContainer, dc, :it)) - test "that a valid locale that has no translations returns nil for dynamic locales" do - count = - Repo.one( - from( - a in Book, - where: is_nil(translated(Book, a, Trans.Factory.locales(:it))), - select: count(a.id) - ) - ) + assert Repo.aggregate(query, :count) == 2 + end - assert count == 2 - end + test "that a valid locale that has no translations returns nil for locale chain" do + query = + from dc in DefaultContainer, + where: not is_nil(translated(DefaultContainer, dc, [:de])) - test "should find all books falling back from DE since EN is default" do - count = - Repo.one( - from( - a in Book, - where: not is_nil(translated(Book, a.title, [:de, :en])), - select: count(a.id) - ) - ) - - assert count == 2 - end + refute Repo.exists?(query) + end - test "should find all books with dynamic fallback chain" do - count = - Repo.one( - from( - a in Book, - where: not is_nil(translated(Book, a0.title, Trans.Factory.locales([:it, :es]))), - select: count(a.id) - ) - ) + test "should find all structs falling back from DE since EN is default" do + query = + from dc in DefaultContainer, + where: not is_nil(translated(DefaultContainer, dc.content, [:de, :en])) - assert count == 2 - end + assert Repo.aggregate(query, :count) == 2 + end - test "should select all books with dynamic fallback chain" do - result = - Repo.all( - from( - a in Book, - select: translated_as(Book, a.title, Trans.Factory.locales([:it, :es])), - where: not is_nil(translated(Book, a.title, Trans.Factory.locales([:it, :es]))) - ) - ) + test "should find all structs with dynamic fallback chain" do + query = + from dc in DefaultContainer, + where: not is_nil(translated(DefaultContainer, dc.content, [:es, :fr])) - assert length(result) == 2 - end + assert Repo.aggregate(query, :count) == 2 + end - test "should find all books falling back from DE since EN is default (using is_nil)" do - count = - Repo.one( - from( - a in Book, - where: not is_nil(translated(Book, a.title, [:de, :en])), - select: count(a.id) + test "should select all structs with dynamic fallback chain" do + result = + Repo.all( + from dc in DefaultContainer, + select: translated_as(DefaultContainer, dc.content, [:es, :fr]), + where: not is_nil(translated(DefaultContainer, dc.content, [:es, :fr])) ) - ) - assert count == 2 - end + assert length(result) == 2 + end - test "select the translated (or base) column falling back from unknown DE to default EN", - %{translated_article: translated_article, untranslated_article: untranslated_article} do - result = - Repo.all( - from( - a in Book, - select: translated_as(Book, a.title, [:de, :en]), - where: not is_nil(translated(Book, a.title, [:de, :en])) + test "select the translated (or base) column falling back from unknown DE to default EN", + %{translated_struct: translated_struct, untranslated_struct: untranslated_struct} do + result = + Repo.all( + from dc in DefaultContainer, + select: translated_as(DefaultContainer, dc.content, [:de, :en]), + where: not is_nil(translated(DefaultContainer, dc.content, [:de, :en])) ) - ) - assert length(result) == 2 - assert [translated_article.title, untranslated_article.title] - end + assert result == [translated_struct.content, untranslated_struct.content] + end - test "select translations for a valid locale with no data should return the default", - %{translated_article: translated_article, untranslated_article: untranslated_article} do - result = - Repo.all( - from( - a in Book, - select: translated_as(Book, a.title, :it) + test "select translations for a valid locale with no data should return the default", + %{translated_struct: translated_struct, untranslated_struct: untranslated_struct} do + result = + Repo.all( + from dc in DefaultContainer, + select: translated_as(DefaultContainer, dc.content, :it) ) - ) - assert result == [translated_article.title, untranslated_article.title] - end + assert result == [translated_struct.content, untranslated_struct.content] + end - test "select translations for a valid locale with no data should fallback to the default" do - results = - Repo.all( - from( - a in Book, - select: translated_as(Book, a.title, [:it, :en]) + test "select translations for a valid locale with no data should fallback to the default" do + results = + Repo.all( + from adc in DefaultContainer, + select: translated_as(DefaultContainer, adc.content, [:de, :en]) ) - ) - for result <- results do - assert result =~ "Article title in English" + for result <- results do + assert result =~ "Content EN" + end end - end - test "should use a custom translation container automatically", - %{translated_article: article} do - with comment <- hd(article.comments) do + test "should find a struct by its FR title", %{translated_struct: struct} do matches = Repo.all( - from( - c in Comment, - where: translated(Comment, c.comment, :fr) == ^comment.transcriptions["fr"]["comment"] - ) + from dc in DefaultContainer, + where: + translated(DefaultContainer, dc.content, :fr) == ^struct.translations.fr.content, + select: dc.id ) - assert Enum.count(matches) == 1 - assert hd(matches).id == comment.id + assert matches == [struct.id] end - end - - test "should find an article by its FR title", - %{translated_article: article} do - matches = - Repo.all( - from( - a in Article, - where: translated(Article, a.title, :fr) == ^article.translations.fr.title - ) - ) - - assert Enum.count(matches) == 1 - assert hd(matches).id == article.id - end - test "should not find an article by a non existent translation" do - count = - Repo.one( - from( - a in Article, - select: count(a.id), - where: translated(Article, a.title, :es) == "FAKE TITLE" - ) - ) - - assert count == 0 - end - - # In the current released version this returns a count of 1 because - # it doesn't return the default value (the base column) when the - # article doesn't have a translated body. This would seem inconsistent - # with the documentation. - # - # This implementation returns the base column in all cases which - # I think is the original authors intent. - - test "should find an article by partial and case sensitive translation", - %{translated_article: article} do - first_words = - article.translations.es.body - |> String.split() - |> Enum.take(3) - |> Enum.join(" ") - |> Kernel.<>("%") - - matches = - Repo.all( - from( - a in Article, - where: ilike(translated(Article, a.body, :es), ^first_words) - ) - ) + test "should not find a struct by a non existent translation" do + query = + from dc in DefaultContainer, + where: translated(DefaultContainer, dc.content, :es) == "FAKE TITLE" - assert Enum.count(matches) == 2 - assert hd(matches).id == article.id - end - - test "should not find an article by incorrect case using case sensitive translation", - %{translated_article: article} do - first_words = - article.translations.fr.body - |> String.split() - |> Enum.take(3) - |> Enum.join(" ") - |> String.upcase() - |> Kernel.<>("%") - - count = - Repo.one( - from( - a in Article, - select: count(a.id), - where: like(translated(Article, a.body, :fr), ^first_words) - ) - ) - - assert count == 0 - end - - # In the current released version this returns a count of 1 because - # it doesn't return the default value (the base column) when the - # article doesn't have a translated body. This would seem inconsistent - # with the documentation. - # - # This implementation returns the base column in all cases which - # I think is the original authors intent. - - test "should find an article by incorrect case using case insensitive translation", - %{translated_article: article} do - first_words = - article.translations.fr.body - |> String.split() - |> Enum.take(3) - |> Enum.join(" ") - |> String.upcase() - |> Kernel.<>("%") - - query = - from( - a in Article, - where: ilike(translated(Article, a.body, :fr), ^first_words) - ) - - # IO.inspect Ecto.Adapters.SQL.to_sql(:all, Repo, query) - - matches = Repo.all(query) - - assert Enum.count(matches) == 2 - assert hd(matches).id == article.id - end - - test "should find an article looking for one of its comments translations", - %{translated_article: article} do - with comment <- hd(article.comments).transcriptions["es"]["comment"] do - matches = - Repo.all( - from( - a in Article, - join: c in Comment, - on: a.id == c.article_id, - where: translated(Comment, c.comment, :es) == ^comment - ) - ) - - assert Enum.count(matches) == 1 - assert hd(matches).id == article.id + refute Repo.exists?(query) end - end - test "should find an article looking for a translation and one of its comments translations", - %{translated_article: article} do - with title <- article.translations.fr.title, - comment <- hd(article.comments).transcriptions["fr"]["comment"] do + test "should find an struct by partial and case sensitive translation", + %{translated_struct: struct} do matches = Repo.all( - from( - a in Article, - join: c in Comment, - on: a.id == c.article_id, - where: translated(Article, a.title, :fr) == ^title, - where: translated(Comment, c.comment, :fr) == ^comment - ) + from dc in DefaultContainer, + where: ilike(translated(DefaultContainer, dc.content, :es), "%ES%"), + select: dc.id ) - assert Enum.count(matches) == 1 - assert hd(matches).id == article.id + assert matches == [struct.id] end - end - test "should raise when adding conditions to an untranslatable field" do - # Since the QueryBuilder errors are emitted during compilation, we do a - # little trick to delay the compilation of the query until the test - # is running, so we can catch the raised error. - invalid_module = - quote do - defmodule TestWrongQuery do - require Ecto.Query - import Ecto.Query, only: [from: 2] - - def invalid_query do - from( - a in Article, - where: not is_nil(translated(Article, a.translations, :es)) - ) + test "should raise when adding conditions to an untranslatable field" do + # Since the QueryBuilder errors are emitted during compilation, we do a + # little trick to delay the compilation of the query until the test + # is running, so we can catch the raised error. + invalid_module = + quote do + defmodule TestWrongQuery do + require Ecto.Query + import Ecto.Query, only: [from: 2] + + def invalid_query do + from dc in DefaultContainer, + where: not is_nil(translated(DefaultContainer, dc.translations, :es)) + end end end - end - assert_raise ArgumentError, - "'Trans.Article' module must declare 'translations' as translatable", - fn -> Code.eval_quoted(invalid_module) end + expected_error = + "'Trans.QueryBuilderTest.DefaultContainer' module must declare 'translations' as translatable" + + assert_raise ArgumentError, expected_error, fn -> Code.eval_quoted(invalid_module) end + end end - test "should allow passing the locale from a variable" do - locale = :es + defmodule CustomContainer do + use Ecto.Schema + use Trans, translates: [:content], default_locale: :en, container: :transcriptions - articles = - Repo.all( - from( - a in Article, - order_by: translated(Article, a.title, locale) - ) - ) - - assert Enum.any?(articles) + schema "custom_translation_container" do + field :content, :string + translations [:es, :fr, :de] + end end - test "should allow passing the locale from a function" do - locale = fn -> :es end + describe "custom container" do + setup do + struct = + Repo.insert!(%CustomContainer{ + content: "Content EN", + transcriptions: %CustomContainer.Translations{ + es: %CustomContainer.Translations.Fields{ + content: "Content ES" + } + } + }) - articles = - Repo.all( - from( - a in Article, - order_by: translated(Article, a.title, locale.()) - ) - ) + [struct: struct] + end - assert Enum.any?(articles) + test "uses the custom container automatically", %{struct: struct} do + query = + from cc in CustomContainer, + where: translated(CustomContainer, cc.content, :es) == ^struct.transcriptions.es.content + + assert Repo.exists?(query) + end end end diff --git a/test/trans/translator_test.exs b/test/trans/translator_test.exs index 3e5cb90..c79af82 100644 --- a/test/trans/translator_test.exs +++ b/test/trans/translator_test.exs @@ -1,146 +1,77 @@ defmodule Trans.TranslatorTest do use Trans.TestCase - import Trans.Translator + alias Trans.Translator - describe "with embedded schema translations" do - setup do - # Only has translations for :es and :fr - [article: build(:article)] - end - - test "translate/3 returns the translation of a field", - %{article: article} do - assert translate(article, :body, :fr) == article.translations.fr.body - end - - test "translate/3 falls back to the default value if the field is not translated", - %{article: article} do - assert translate(article, :body, :de) == article.body - end - - test "translate!/3 fails if the field is not translated", - %{article: article} do - expected_error = "translation doesn't exist for field ':body' in locale :de" - - assert_raise RuntimeError, expected_error, fn -> - translate!(article, :body, :de) - end - end - - test "translate/2 translates the whole struct", - %{article: article} do - fr_article = translate(article, :fr) - - assert fr_article.title == article.translations.fr.title - assert fr_article.body == article.translations.fr.body + defmodule ExampleSchema do + use Ecto.Schema + use Trans, translates: [:content], default_locale: :en - for {fr_comment, index} <- Enum.with_index(fr_article.comments) do - original_comment = Enum.at(fr_article.comments, index) - assert fr_comment.comment == original_comment.transcriptions["fr"]["comment"] - end - end - - test "translate/2 falls back to the default locale if the translation does not exist", - %{article: article} do - de_article = translate(article, :de) - - assert de_article.title == article.title - assert de_article.body == article.body - - for {de_comment, index} <- Enum.with_index(de_article.comments) do - original_comment = Enum.at(de_article.comments, index) - assert de_comment.comment == original_comment.comment - end + embedded_schema do + field :content, :string + translations [:es, :fr] end end - describe "with free map translations" do - setup do - # Only has translations for :es and :fr - [comment: build(:comment)] - end + setup do + struct = %ExampleSchema{ + content: "Content EN", + translations: %ExampleSchema.Translations{ + es: %ExampleSchema.Translations.Fields{ + content: "Content ES" + } + } + } + + [struct: struct] + end - test "translate/3 returns the translation of a field", - %{comment: comment} do - assert translate(comment, :comment, :es) == comment.transcriptions["es"]["comment"] - end + describe inspect(&Translator.translate/2) do + test "translates the whole struct to the desired locale", %{struct: struct} do + translated = Translator.translate(struct, :es) - test "translate/3 falls back to the default value if the field is not translated", - %{comment: comment} do - assert translate(comment, :comment, :non_existing_locale) == comment.comment + assert translated.content == struct.translations.es.content end - test "translate!/3 fails if the field is not translated", - %{comment: comment} do - expected_error = "translation doesn't exist for field ':comment' in locale :de" + test "falls back to the next locale with a custom fallback chain", %{struct: struct} do + translated = Translator.translate(struct, [:fr, :es]) - assert_raise RuntimeError, expected_error, fn -> - translate!(comment, :comment, :de) - end + assert translated.content == struct.translations.es.content end - test "translate/2 translates the whole struct", - %{comment: comment} do - fr_comment = translate(comment, :fr) + test "falls back to the default locale with an unresolved fallback chain", %{struct: struct} do + translated = Translator.translate(struct, [:fr]) - assert fr_comment.comment == comment.transcriptions["fr"]["comment"] + assert translated.content == struct.content end end - describe "with default locale" do - setup do - # Only has translations for :es and :fr - [book: build(:book)] - end - - test "has a default locale of :en" do - assert Trans.Book.__trans__(:default_locale) == :en - end - - test "translate/2 translates the whole book struct", - %{book: book} do - fr_book = translate(book, :fr) - - assert fr_book.title == book.translations.fr.title - assert fr_book.body == book.translations.fr.body + describe inspect(&Translator.translate/3) do + test "translate the field to the desired locale", %{struct: struct} do + assert Translator.translate(struct, :content, :es) == struct.translations.es.content end - test "translate/2 translates the whole book struct to the default locale", - %{book: book} do - en_book = translate(book, :de) - - assert en_book.title == book.title - assert en_book.body == book.body - - en_book = translate(book, [:de]) - - assert en_book.title == book.title - assert en_book.body == book.body + test "falls back to the default locale if translation does not exist", %{struct: struct} do + assert Translator.translate(struct, :content, :fr) == struct.content end - test "translate/2 translates the whole book struct via a fallback chain", - %{book: book} do - fr_book = translate(book, [:de, :fr, :en]) - - assert fr_book.title == book.translations.fr.title - assert fr_book.body == book.translations.fr.body + test "falls back to the next locale in a custom fallback chain", %{struct: struct} do + assert Translator.translate(struct, :content, [:fr, :es]) == + struct.translations.es.content end - test "translate/2 translates the book to the default locale in a fallback chain", - %{book: book} do - en_book = translate(book, [:de, :en, :fr]) - - assert en_book.title == book.title - assert en_book.body == book.body + test "falls back to the default locale in an unresolved fallback chain", %{struct: struct} do + assert Translator.translate(struct, :content, [:fr]) == struct.content end + end - test "translate/2 translates the book to the default locale in an unresolved fallback chain", - %{book: book} do - en_book = translate(book, [:de, :it]) + describe inspect(&Translator.translate!/3) do + test "raises if the translation does not exist", %{struct: struct} do + expected_error = ~s[translation doesn't exist for field ':content' in locale :fr] - assert en_book.title == book.title - assert en_book.body == book.body + assert_raise RuntimeError, expected_error, fn -> + Translator.translate!(struct, :content, :fr) + end end end end diff --git a/test/trans_test.exs b/test/trans_test.exs index 6264cb6..06ec2ee 100644 --- a/test/trans_test.exs +++ b/test/trans_test.exs @@ -1,58 +1,107 @@ defmodule TransTest do + require Trans use Trans.TestCase - alias Trans.{Article, Comment} + defmodule DefaultContainer do + use Ecto.Schema + use Trans, translates: [:content], default_locale: :en - doctest Trans + embedded_schema do + field :content, :string + field :metadata, :map + translations [:es, :fr] + end + end + + test "with default container" do + assert Trans.translatable?(DefaultContainer, :content) + refute Trans.translatable?(DefaultContainer, :metadata) + + assert DefaultContainer.__trans__(:default_locale) == :en + assert DefaultContainer.__trans__(:container) == :translations + + assert { + :parameterized, + Ecto.Embedded, + %Ecto.Embedded{ + cardinality: :one, + field: :translations, + on_cast: nil, + on_replace: :update, + ordered: true, + owner: DefaultContainer, + related: DefaultContainer.Translations, + unique: true + } + } = DefaultContainer.__schema__(:type, :translations) - test "checks whether a field is translatable or not given a module" do - assert Trans.translatable?(Article, :title) == true - assert Trans.translatable?(Article, "title") == true - assert Trans.translatable?(Article, :fake_field) == false + assert [:es, :fr] = DefaultContainer.Translations.__schema__(:fields) + assert [:content] = DefaultContainer.Translations.Fields.__schema__(:fields) end - test "checks whether a field is translatable or not given a struct" do - with article <- build(:article) do - assert Trans.translatable?(article, :title) == true - assert Trans.translatable?(article, "title") == true - assert Trans.translatable?(article, :fake_field) == false + defmodule CustomContainer do + use Ecto.Schema + use Trans, translates: [:content], default_locale: :en, container: :transcriptions + + embedded_schema do + field :content, :string + field :metadata, :map + translations [:es, :fr] end end - test "returns the default translation container when unspecified" do - assert Article.__trans__(:container) == :translations - end + test "with custom container" do + assert Trans.translatable?(CustomContainer, :content) + refute Trans.translatable?(CustomContainer, :metadata) - test "the default locale" do - defmodule Book do - use Trans, translates: [:title, :body], default_locale: :en - defstruct title: "", body: "", translations: %{} - end + assert CustomContainer.__trans__(:default_locale) == :en + assert CustomContainer.__trans__(:container) == :transcriptions - assert Book.__trans__(:default_locale) == :en - assert Article.__trans__(:default_locale) == nil - end + assert { + :parameterized, + Ecto.Embedded, + %Ecto.Embedded{ + cardinality: :one, + field: :transcriptions, + on_cast: nil, + on_replace: :update, + ordered: true, + owner: CustomContainer, + related: CustomContainer.Translations, + unique: true + } + } = CustomContainer.__schema__(:type, :transcriptions) - test "returns the custom translation container name if specified" do - assert Comment.__trans__(:container) == :transcriptions + assert [:es, :fr] = CustomContainer.Translations.__schema__(:fields) + assert [:content] = CustomContainer.Translations.Fields.__schema__(:fields) end - test "compilation fails when translation container is not a valid field" do - invalid_module = - quote do - defmodule TestArticle do - use Trans, translates: [:title, :body], container: :invalid_container - defstruct title: "", body: "", translations: %{} - end + defmodule CustomSchema do + use Ecto.Schema + use Trans, translates: [:content], default_locale: :en + + defmodule Translations.Fields do + use Ecto.Schema + + @primary_key false + embedded_schema do + field :content, :string end + end - assert_raise ArgumentError, - "The field invalid_container used as the translation container is not defined in TestArticle struct", - fn -> Code.eval_quoted(invalid_module) end + embedded_schema do + field :content, :string + field :metadata, :map + translations [:es, :fr], build_field_schema: false + end end - test "translations/3 macro" do - assert :translations in Trans.Magazine.__schema__(:fields) + test "with custom schema" do + assert Trans.translatable?(CustomSchema, :content) + refute Trans.translatable?(CustomSchema, :metadata) + + assert CustomSchema.__trans__(:default_locale) == :en + assert CustomSchema.__trans__(:container) == :translations assert { :parameterized, @@ -63,13 +112,13 @@ defmodule TransTest do on_cast: nil, on_replace: :update, ordered: true, - owner: Trans.Magazine, - related: Trans.Magazine.Translations, + owner: CustomSchema, + related: CustomSchema.Translations, unique: true } - } = Trans.Magazine.__schema__(:type, :translations) + } = CustomSchema.__schema__(:type, :translations) - assert [:es, :it, :de] = Trans.Magazine.Translations.__schema__(:fields) - assert [:title, :body] = Trans.Magazine.Translations.Fields.__schema__(:fields) + assert [:es, :fr] = CustomSchema.Translations.__schema__(:fields) + assert [:content] = CustomSchema.Translations.Fields.__schema__(:fields) end end