├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── config ├── config.exs ├── test.ci.exs └── test.sample.exs ├── lib ├── trans.ex └── trans │ ├── gen_function_migration.ex │ ├── query_builder.ex │ └── translator.ex ├── mix.exs ├── mix.lock ├── priv └── repo │ └── migrations │ ├── 20220306005524_trans_gen_translate_function.exs │ └── 20230628153604_create_test_tables.exs └── test ├── support ├── repo.ex └── test_case.ex ├── test_helper.exs ├── trans ├── query_builder_test.exs └── translator_test.exs └── trans_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 3 | import_deps: [:ecto], 4 | locals_without_parens: [translations: 1, translations: 2] 5 | ] 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | include: 11 | - elixir: 1.15.0 12 | otp: 25.3 13 | 14 | env: 15 | MIX_ENV: test 16 | 17 | services: 18 | postgres: 19 | image: postgres:13 20 | ports: 21 | - 5432:5432 22 | env: 23 | POSTGRES_PASSWORD: postgres 24 | POSTGRES_USER: postgres 25 | POSTGRES_DB: trans_test 26 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 27 | 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v3 31 | - name: Setup Elixir 32 | uses: erlef/setup-beam@v1 33 | with: 34 | otp-version: ${{ matrix.otp }} 35 | elixir-version: ${{ matrix.elixir }} 36 | - name: Prepare test environment 37 | run: | 38 | cp config/test.ci.exs config/test.exs 39 | - name: Fetch and compile dependencies 40 | run: | 41 | mix deps.get 42 | mix deps.compile 43 | - name: Compile application 44 | run: | 45 | mix compile --warnings-as-erros 46 | - name: Check format 47 | run: | 48 | mix format --check-formatted 49 | - name: Run tests 50 | run: | 51 | mix test 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | erl_crash.dump 5 | *.ez 6 | /doc 7 | 8 | # Ignore test configuration. 9 | config/test.exs 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | # 3.0.1 - 2024-07-07 (requires Elixir 1.11 or newer) 8 | 9 | - Fall back to default locale when translation is missing 10 | 11 | # 3.0.0 - 2023-07-03 (requires Elixir 1.11 or newer) 12 | 13 | - Remove support for unstructured translations 14 | - Add support for default locales and translation fallback chains 15 | - Return `nil` for unitialised embed in struct 16 | - Minor fixes, typos and dependency updates 17 | 18 | # 2.3.0 - 2021-09-21 (requires Elixir 1.7 or newer) 19 | - Update dependencies to avoid compilation warnings 20 | - Migrate from CircleCI to GitHub Actions 21 | - Allow translating entire structs 22 | - Add translate!/3 function to raise if a translation does not exist 23 | - Allow saving translations into embedded_schemas 24 | - Improve docs 25 | 26 | # 2.2.0 - 2020-02-01 (requires Elixir 1.6 or newer) 27 | - Enable locale to be passed as a string 28 | - Update ExDoc dependency 29 | - Remove Faker dependency 30 | 31 | # 2.1.0 - 2018-12-08 32 | - Update `Ecto` dependency to 3.0 version 33 | 34 | # 2.0.4 - 2018-10-14 35 | - Remove `Module.eval_quoted` calls. 36 | - Migrate to CircleCI 37 | - Add Apache 2.0 License 38 | - Use Elixir formatter 39 | 40 | # 2.0.3 - 2018-08-11 41 | - Update canonical URLs to GitHub repository 42 | 43 | ## 2.0.2 - 2017-09-29 44 | - Support Elixir 1.5 and Erlant/OTP 20. 45 | - Fix bug when passing the locale in a variable to `Trans.QueryBuilder.translated/3`. 46 | 47 | ## 2.0.1 - 2017-07-09 48 | - Relax `Poison` dependency version restriction. 49 | - Integrate Ebert for code style checks and static analysis. 50 | - Fix typos and mistakes in README. 51 | 52 | ## 2.0.0 - 2017-04-11 53 | - Rewrite the `Trans` module to use underscore functions to store configuration. 54 | - Rewrite the `QueryBuilder` module to unify previous functions into a single macro with compile time checks. Translations can now be used directly when building queries and are compatible with functions and macros provided by `Ecto.Query` and `Ecto.Query.Api`. 55 | - Update the `Translator` module to use the new underscore functions. 56 | - Update documentation and improve the tests. 57 | 58 | ## 1.1.0 - 2017-02-28 59 | - Make `Ecto` an optional dependency. If `Ecto` is not available the `QueryBuilder` module will not be compiled. 60 | 61 | ## 1.0.2 - 2017-02-19 62 | - Remove `earmark` as a direct dependency since it is already required by `ex_doc`. 63 | - Remove warnings when compiling with Elixir 1.4. 64 | - Adds contribution guidelines detailed in `CONTRIBUTING.md`. 65 | 66 | ## 1.0.1 - 2016-10-22 67 | - New testing environments for Travis CI. 68 | - The project has now a changelog. 69 | - Improved documentation. 70 | - Improved README. 71 | 72 | ## 1.0.0 - 2016-07-30 73 | - Support for Ecto 2.0. 74 | 75 | ## 0.1.0 - 2016-06-04 76 | - Initial release with basic functionality and documentation. 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute to _Trans_ 2 | 3 | ## Did you find a bug? 4 | 5 | If you want to report a bug in _Trans_ take a look among the open [issues](https://github.com/belaustegui/trans/issues) to see if it has been already reported. 6 | 7 | If the bug has been already reported, post a comment and add as much information as you can. Any relevant information can be helpful for fixing the bug. 8 | 9 | If the bug has not been reported yet, [open a new issue](https://github.com/belaustegui/trans/issues/new). Be sure to include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring. 10 | 11 | ## Did you write a patch that fixes a bug? 12 | 13 | First of all, thank you very much for contributing :heart:. 14 | 15 | If you have written a patch that fixes a bug in _Trans_, open a new pull request with the patch. It is important that the pull request description clearly describes the problem and solution. You can also link to the relevant issue if there is one. 16 | 17 | The same process applies for contributions related to _Trans_ documentation. 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2018 Cristian Álvarez Belaustegui 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Trans 2 | 3 | [![Tests](https://github.com/crbelaus/trans/actions/workflows/ci.yml/badge.svg)](https://github.com/crbelaus/trans/actions/workflows/ci.yml/badge.svg) 4 | [![Hex.pm](https://img.shields.io/hexpm/dt/trans.svg?maxAge=2592000&style=flat-square)](https://hex.pm/packages/trans) 5 | 6 | `Trans` provides a way to manage and query translations embedded into schemas 7 | and removes the necessity of maintaining extra tables only for translation storage. 8 | It is inspired by the great [hstore translate](https://rubygems.org/gems/hstore_translate) 9 | gem for Ruby. 10 | 11 | `Trans` is published on [hex.pm](https://hex.pm/packages/trans) and the documentation 12 | is also [available online](https://hexdocs.pm/trans/). Source code is available in this same 13 | repository under the Apache2 License. 14 | 15 | On April 17th, 2017, `Trans` was [featured in HackerNoon](https://hackernoon.com/introducing-trans2-407610887068) 16 | 17 | 18 | ## Optional Requirements 19 | 20 | Having Ecto SQL and Postgrex in your application will allow you to use the `Trans.QueryBuilder` 21 | component to generate database queries based on translated data. You can still 22 | use the `Trans.Translator` component without those dependencies though. 23 | 24 | - [Ecto SQL](https://hex.pm/packages/ecto_sql) 3.0 or higher 25 | - [PostgreSQL](https://hex.pm/packages/postgrex) 9.4 or higher (since `Trans` leverages the JSONB datatype) 26 | 27 | 28 | ## Why Trans? 29 | 30 | The traditional approach to content internationalization consists on using an 31 | additional table for each translatable schema. This table works only as a storage 32 | for the original schema translations. For example, we may have a `posts` and 33 | a `posts_translations` tables. 34 | 35 | This approach has a few disadvantages: 36 | 37 | - It complicates the database schema because it creates extra tables that are 38 | coupled to the "main" ones. 39 | - It makes migrations and schemas more complicated, since we always have to keep 40 | the two tables in sync. 41 | - It requires constant JOINs in order to filter or fetch records along with their 42 | translations. 43 | 44 | The approach used by `Trans` is based on modern RDBMSs support for unstructured 45 | datatypes. Instead of storing the translations in a different table, each 46 | translatable schema has an extra column that contains all of its translations. 47 | This approach drastically reduces the number of required JOINs when filtering or 48 | fetching records. 49 | 50 | `Trans` is lightweight and modularized. The `Trans` module provides metadata 51 | that is used by the `Trans.Translator` and `Trans.QueryBuilder` modules, which 52 | implement the main functionality of this library. 53 | 54 | 55 | ## Setup and Quickstart 56 | 57 | Let's say that we have an `Article` schema that contains texts in English and we want to translate it to other languages. 58 | 59 | ```elixir 60 | defmodule MyApp.Article do 61 | use Ecto.Schema 62 | 63 | schema "articles" do 64 | field :title, :string 65 | field :body, :string 66 | end 67 | end 68 | ``` 69 | 70 | The first step would be to add a new JSONB column to the table so we can store the translations in it. 71 | 72 | ```elixir 73 | defmodule MyApp.Repo.Migrations.AddTranslationsToArticles do 74 | use Ecto.Migration 75 | 76 | def change do 77 | alter table(:articles) do 78 | add :translations, :map 79 | end 80 | end 81 | end 82 | ``` 83 | 84 | Once we have the new database column, we can update the Article schema to include the translations. 85 | 86 | ```elixir 87 | defmodule MyApp.Article do 88 | use Ecto.Schema 89 | use Trans, translates: [:title, :body], default_locale: :en 90 | 91 | schema "articles" do 92 | field :title, :string 93 | field :body, :string 94 | 95 | # This generates a MyApp.Article.Translations schema with a 96 | # MyApp.Article.Translations.Fields for :es and :fr 97 | translations [:es, :fr] 98 | end 99 | end 100 | ``` 101 | 102 | 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. 103 | 104 | The translation storage can be done using normal `Ecto.Changeset` functions just like it would be done for any other fields or associations. 105 | 106 | ```elixir 107 | defmodule MyApp.Article do 108 | def changeset(article, attrs \\ %{}) do 109 | article 110 | |> cast(attrs, [:title, :body]) 111 | |> validate_required([:title, :body]) 112 | |> cast_embed(:translations, with: &cast_translations/2) 113 | end 114 | 115 | defp cast_translations(translations, attrs \\ %{}) do 116 | translations 117 | |> cast(attrs, []) 118 | |> cast_embed(:es) 119 | |> cast_embed(:fr) 120 | end 121 | end 122 | 123 | # Then, anywhere in your code: 124 | changeset = MyApp.Article.changeset(article, %{ 125 | translations: %{ 126 | es: %{title: "title ES", body: "body ES"}, 127 | fr: %{title: "title FR", body: "body FR"} 128 | } 129 | }) 130 | ``` 131 | 132 | ## Customizing the translation container 133 | 134 | By default Trans looks for a `translations` field that contains the translations. This is known as the "translation container". 135 | 136 | 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. 137 | 138 | ```elixir 139 | defmodule MyApp.Article do 140 | use Ecto.Schema 141 | use Trans, translates: [:title, :body], default_locale: :en, container: :transcriptions 142 | 143 | schema "articles" do 144 | field :title, :string 145 | field :body, :strings 146 | translations [:es, :fr] 147 | end 148 | end 149 | ``` 150 | 151 | ## Customizing the translation schemas 152 | 153 | If you want to use your own translation module you can simply pass the `build_field_schema: false` option when using the `translations` macro. 154 | 155 | ```elixir 156 | defmodule MyApp.Article do 157 | use Ecto.Schema 158 | use Trans, translates: [:title, :body], default_locale: :en 159 | 160 | defmodule Translations.Fields do 161 | use Ecto.Schema 162 | 163 | embedded_schema do 164 | field :title, :string 165 | field :body, :string 166 | end 167 | end 168 | 169 | schema "articles" do 170 | field :title, :string 171 | field :body, :string 172 | 173 | translations [:es, :fr], build_field_schema: false 174 | end 175 | end 176 | ``` 177 | 178 | 179 | ## Is Trans dead? 180 | 181 | Trans has a slow release cadence, but that does not mean that it is dead. Trans can be considered as "done" in the sense that it does one thing and does it well. 182 | 183 | New releases will happen when there are bugs or new changes. **If the last release is from a long time ago you should take this as a sign of stability and maturity, not as a sign of abandonment.** 184 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :trans, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:trans, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | if File.exists?("config/#{Mix.env()}.exs") do 32 | import_config("#{Mix.env()}.exs") 33 | end 34 | 35 | config :trans, ecto_repos: [Trans.Repo] 36 | -------------------------------------------------------------------------------- /config/test.ci.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :trans, Trans.Repo, 4 | username: "postgres", 5 | password: "postgres", 6 | database: "trans_test", 7 | pool: Ecto.Adapters.SQL.Sandbox, 8 | log: false 9 | -------------------------------------------------------------------------------- /config/test.sample.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :trans, Trans.Repo, 4 | username: "postgres", 5 | password: "postgres", 6 | database: "trans_test", 7 | hostname: "localhost", 8 | port: 5432, 9 | pool: Ecto.Adapters.SQL.Sandbox, 10 | log: false 11 | -------------------------------------------------------------------------------- /lib/trans.ex: -------------------------------------------------------------------------------- 1 | defmodule Trans do 2 | @moduledoc """ 3 | Manage translations embedded into structs. 4 | 5 | Although it can be used with any struct **`Trans` shines when paired with an `Ecto.Schema`**. It 6 | allows you to keep the translations into a field of the schema and avoids requiring extra tables 7 | for translation storage and complex _joins_ when retrieving translations from the database. 8 | 9 | `Trans` is split into two main components: 10 | 11 | * `Trans.Translator` - provides easy access to struct translations. 12 | * `Trans.QueryBuilder` - provides helpers for querying translations using `Ecto.Query` 13 | (requires `Ecto.SQL`). 14 | 15 | When used, `Trans` accepts the following options: 16 | 17 | * `:translates` (required) - list of the fields that will be translated. 18 | * `:container` (optional) - name of the field that contains the embedded translations. 19 | Defaults to`:translations`. 20 | * `:default_locale` (optional) - declares the locale of the base untranslated column. 21 | 22 | ## Storing translations 23 | 24 | To store translations in a schema you must use the `translations` macro: 25 | 26 | defmodule MyApp.Article do 27 | use Ecto.Schema 28 | use Trans, translates: [:title, :body], default_locale: :en 29 | 30 | schema "articles" do 31 | field :title, :string 32 | field :body, :string 33 | 34 | translations [:es, :fr] 35 | end 36 | end 37 | 38 | This is equivalent to: 39 | 40 | defmodule MyApp.Article do 41 | use Ecto.Schema 42 | use Trans, translates: [:title, :body], default_locale: :en 43 | 44 | schema "articles" do 45 | field :title, :string 46 | field :body, :string 47 | 48 | embeds_many :translations, Translations, primary_key: :false do 49 | embeds_one :es, Fields 50 | embeds_one :fr, Fields 51 | end 52 | end 53 | end 54 | 55 | defmodule MyApp.Article.Translations.Fields do 56 | use Ecto.Schema 57 | 58 | embedded_schema do 59 | field :title, :string 60 | field :body, :string 61 | end 62 | end 63 | 64 | If you want to customize the translation fields (for example how they are casted) you may define 65 | them yourself manually. In such cases you may tell Trans not to generate the fields automatically 66 | for you: 67 | 68 | defmodule MyApp.Article do 69 | use Ecto.Schema 70 | use Trans, translates: [:title, :body], default_locale: :en 71 | 72 | schema "articles" do 73 | field :title, :string 74 | field :body, :string 75 | 76 | # Define MyApp.Article.Translations.Fields yourself 77 | translations [:es, :fr], build_field_schema: false 78 | end 79 | end 80 | 81 | ## The translation container 82 | 83 | As we have seen in the previous examples, `Trans` automatically stores and looks for translations 84 | in a field called `:translations`. This is known as the **translations container.** 85 | 86 | In certain cases you may want to use a different field for storing the translations, this can 87 | be specified when using `Trans` in your module. 88 | 89 | # Use the field `:locales` as translation container instead of the default `:translations` 90 | use Trans, translates: [...], container: :locales 91 | 92 | ## Reflection 93 | 94 | Any module that uses `Trans` will have an autogenerated `__trans__` function that can be used for 95 | runtime introspection of the translation metadata. 96 | 97 | * `__trans__(:fields)` - Returns the list of translatable fields. 98 | * `__trans__(:container)` - Returns the name of the translation container. 99 | * `__trans__(:default_locale)` - Returns the name of default locale. 100 | """ 101 | 102 | @typedoc """ 103 | A translatable struct that uses `Trans` 104 | """ 105 | @type translatable() :: struct() 106 | 107 | @typedoc """ 108 | A locale that may be a string or an atom 109 | """ 110 | @type locale() :: String.t() | atom() 111 | 112 | @typedoc """ 113 | When translating or querying either a single 114 | locale or a list of locales can be provided 115 | """ 116 | @type locale_list :: locale | [locale, ...] 117 | 118 | defmacro __using__(opts) do 119 | quote do 120 | Module.put_attribute(__MODULE__, :trans_fields, unquote(translatable_fields(opts))) 121 | Module.put_attribute(__MODULE__, :trans_container, unquote(translation_container(opts))) 122 | 123 | Module.put_attribute( 124 | __MODULE__, 125 | :trans_default_locale, 126 | unquote(translation_default_locale(opts)) 127 | ) 128 | 129 | import Trans, only: :macros 130 | 131 | @after_compile {Trans, :__validate_translatable_fields__} 132 | @after_compile {Trans, :__validate_translation_container__} 133 | 134 | @spec __trans__(:fields) :: list(atom) 135 | def __trans__(:fields), do: @trans_fields 136 | 137 | @spec __trans__(:container) :: atom 138 | def __trans__(:container), do: @trans_container 139 | 140 | @spec __trans__(:default_locale) :: atom 141 | def __trans__(:default_locale), do: @trans_default_locale 142 | end 143 | end 144 | 145 | @doc false 146 | def default_trans_options do 147 | [on_replace: :update, primary_key: false, build_field_schema: true] 148 | end 149 | 150 | @doc """ 151 | Create the translation container and fields. 152 | 153 | This macro creates a field named like the module's translation container to store the 154 | translations. By default `YourModule.Translations` and `YourModule.Translations.Fields` 155 | schemas will be created. 156 | 157 | This macro creates an embedded field named after your "translation container" of type 158 | `YourModule.Translations`. This field in turn has an embedded field for each locale 159 | of type `YourModule.Translations.Fields`. 160 | 161 | Calling: 162 | 163 | translations [:en, :es] 164 | 165 | Is equivalent to: 166 | 167 | embeds_one :translations, Translations do 168 | embeds_one :en, Fields 169 | embeds_one :es, Fields 170 | end 171 | 172 | ## Options 173 | - **build_field_schema (boolean / default: false)** wether to automatically generate the module for 174 | locales or not. Set this to false if you want to customize how the field translations 175 | are stored and keep in mind that you must create a `YourModule.Translations.Fields` schema. 176 | """ 177 | defmacro translations(locales, options \\ []) do 178 | options = Keyword.merge(Trans.default_trans_options(), options) 179 | {build_field_schema, options} = Keyword.pop(options, :build_field_schema) 180 | 181 | quote do 182 | if unquote(build_field_schema) do 183 | @before_compile {Trans, :__build_embedded_schema__} 184 | end 185 | 186 | @translation_module Module.concat(__MODULE__, Translations) 187 | 188 | embeds_one @trans_container, Translations, unquote(options) do 189 | for locale_name <- List.wrap(unquote(locales)) do 190 | embeds_one locale_name, Module.concat([__MODULE__, Fields]), on_replace: :update 191 | end 192 | end 193 | end 194 | end 195 | 196 | defmacro __build_embedded_schema__(env) do 197 | translation_module = Module.get_attribute(env.module, :translation_module) 198 | fields = Module.get_attribute(env.module, :trans_fields) 199 | 200 | quote do 201 | defmodule Module.concat(unquote(translation_module), Fields) do 202 | use Ecto.Schema 203 | import Ecto.Changeset 204 | 205 | @primary_key false 206 | embedded_schema do 207 | for a_field <- unquote(fields) do 208 | field a_field, :string 209 | end 210 | end 211 | 212 | def changeset(fields, params) do 213 | fields 214 | |> cast(params, unquote(fields)) 215 | |> validate_required(unquote(fields)) 216 | end 217 | end 218 | end 219 | end 220 | 221 | @doc """ 222 | Checks whether the given field is translatable or not. 223 | 224 | Returns true if the given field is translatable. Raises if the given module or struct does not use 225 | `Trans`. 226 | 227 | ## Examples 228 | 229 | Assuming the Article schema defined before. 230 | 231 | If we want to know whether a certain field is translatable or not we can use 232 | this function as follows: 233 | 234 | iex> Trans.translatable?(Article, :title) 235 | true 236 | iex> Trans.translatable?(%Article{}, :not_existing) 237 | false 238 | 239 | Raises if the given module or struct does not use `Trans`: 240 | 241 | iex> Trans.translatable?(Date, :day) 242 | ** (RuntimeError) Elixir.Date must use `Trans` in order to be translated 243 | """ 244 | def translatable?(module_or_translatable, field) 245 | 246 | @spec translatable?(module | translatable(), locale()) :: boolean 247 | def translatable?(%{__struct__: module}, field), do: translatable?(module, field) 248 | 249 | def translatable?(module, field) when is_atom(module) and is_binary(field) do 250 | translatable?(module, String.to_atom(field)) 251 | end 252 | 253 | def translatable?(module, field) when is_atom(module) and is_atom(field) do 254 | if Keyword.has_key?(module.__info__(:functions), :__trans__) do 255 | Enum.member?(module.__trans__(:fields), field) 256 | else 257 | raise "#{module} must use `Trans` in order to be translated" 258 | end 259 | end 260 | 261 | @doc false 262 | def __validate_translatable_fields__(%{module: module}, _bytecode) do 263 | struct_fields = 264 | module.__struct__() 265 | |> Map.keys() 266 | |> MapSet.new() 267 | 268 | translatable_fields = 269 | :fields 270 | |> module.__trans__ 271 | |> MapSet.new() 272 | 273 | invalid_fields = MapSet.difference(translatable_fields, struct_fields) 274 | 275 | case MapSet.size(invalid_fields) do 276 | 0 -> 277 | nil 278 | 279 | 1 -> 280 | raise ArgumentError, 281 | message: 282 | "#{module} declares '#{MapSet.to_list(invalid_fields)}' as translatable but it is not defined in the module's struct" 283 | 284 | _ -> 285 | raise ArgumentError, 286 | message: 287 | "#{module} declares '#{MapSet.to_list(invalid_fields)}' as translatable but it they not defined in the module's struct" 288 | end 289 | end 290 | 291 | @doc false 292 | def __validate_translation_container__(%{module: module}, _bytecode) do 293 | container = module.__trans__(:container) 294 | 295 | unless Enum.member?(Map.keys(module.__struct__()), container) do 296 | raise ArgumentError, 297 | message: 298 | "The field #{container} used as the translation container is not defined in #{inspect(module)} struct" 299 | end 300 | end 301 | 302 | defp translatable_fields(opts) do 303 | case Keyword.fetch(opts, :translates) do 304 | {:ok, fields} when is_list(fields) -> 305 | fields 306 | 307 | _ -> 308 | raise ArgumentError, 309 | message: 310 | "Trans requires a 'translates' option that contains the list of translatable fields names" 311 | end 312 | end 313 | 314 | defp translation_container(opts) do 315 | case Keyword.fetch(opts, :container) do 316 | :error -> :translations 317 | {:ok, container} -> container 318 | end 319 | end 320 | 321 | defp translation_default_locale(opts) do 322 | case Keyword.fetch(opts, :default_locale) do 323 | {:ok, default_locale} -> 324 | default_locale 325 | 326 | :error -> 327 | raise ArgumentError, 328 | message: "Trans requires a 'default_locale' option that contains the default locale" 329 | end 330 | end 331 | end 332 | -------------------------------------------------------------------------------- /lib/trans/gen_function_migration.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Adapters.SQL) do 2 | defmodule Mix.Tasks.Trans.Gen.TranslateFunction do 3 | use Mix.Task 4 | 5 | import Mix.Generator 6 | import Mix.Ecto, except: [migrations_path: 1] 7 | import Macro, only: [camelize: 1, underscore: 1] 8 | 9 | @shortdoc "Generates an Ecto migration to create the translate_field database function" 10 | 11 | @moduledoc """ 12 | Generates a migration to add a database function 13 | `translate_field` that uses the `Trans` structured 14 | translation schema to resolve a translation for a field. 15 | 16 | """ 17 | 18 | @doc false 19 | @dialyzer {:no_return, run: 1} 20 | 21 | def run(args) do 22 | no_umbrella!("trans_gen_translate_function") 23 | repos = parse_repo(args) 24 | name = "trans_gen_translate_function" 25 | 26 | Enum.each(repos, fn repo -> 27 | ensure_repo(repo, args) 28 | path = Path.relative_to(migrations_path(repo), Mix.Project.app_path()) 29 | file = Path.join(path, "#{timestamp()}_#{underscore(name)}.exs") 30 | create_directory(path) 31 | 32 | assigns = [mod: Module.concat([repo, Migrations, camelize(name)])] 33 | 34 | content = 35 | assigns 36 | |> migration_template 37 | |> format_string! 38 | 39 | create_file(file, content) 40 | 41 | if open?(file) and Mix.shell().yes?("Do you want to run this migration?") do 42 | Mix.Task.run("ecto.migrate", [repo]) 43 | end 44 | end) 45 | end 46 | 47 | defp timestamp do 48 | {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() 49 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 50 | end 51 | 52 | defp pad(i) when i < 10, do: <> 53 | defp pad(i), do: to_string(i) 54 | 55 | if Code.ensure_loaded?(Code) && function_exported?(Code, :format_string!, 1) do 56 | @spec format_string!(String.t()) :: iodata() 57 | @dialyzer {:no_return, format_string!: 1} 58 | def format_string!(string) do 59 | Code.format_string!(string) 60 | end 61 | else 62 | @spec format_string!(String.t()) :: iodata() 63 | def format_string!(string) do 64 | string 65 | end 66 | end 67 | 68 | if Code.ensure_loaded?(Ecto.Migrator) && 69 | function_exported?(Ecto.Migrator, :migrations_path, 1) do 70 | def migrations_path(repo) do 71 | Ecto.Migrator.migrations_path(repo) 72 | end 73 | end 74 | 75 | if Code.ensure_loaded?(Mix.Ecto) && function_exported?(Mix.Ecto, :migrations_path, 1) do 76 | def migrations_path(repo) do 77 | Mix.Ecto.migrations_path(repo) 78 | end 79 | end 80 | 81 | embed_template(:migration, ~S| 82 | defmodule <%= inspect @mod %> do 83 | use Ecto.Migration 84 | 85 | def up do 86 | execute """ 87 | CREATE OR REPLACE FUNCTION public.translate_field(record record, container varchar, field varchar, default_locale varchar, locales varchar[]) 88 | RETURNS varchar 89 | STRICT 90 | STABLE 91 | LANGUAGE plpgsql 92 | AS $$ 93 | DECLARE 94 | locale varchar; 95 | j json; 96 | c json; 97 | l varchar; 98 | BEGIN 99 | j := row_to_json(record); 100 | c := j->container; 101 | 102 | FOREACH locale IN ARRAY locales LOOP 103 | IF locale = default_locale THEN 104 | RETURN j->>field; 105 | ELSEIF c->locale IS NOT NULL THEN 106 | IF c->locale->>field IS NOT NULL THEN 107 | RETURN c->locale->>field; 108 | END IF; 109 | END IF; 110 | END LOOP; 111 | RETURN j->>field; 112 | END; 113 | $$; 114 | """ 115 | 116 | execute(""" 117 | CREATE OR REPLACE FUNCTION public.translate_field(record record, container varchar, default_locale varchar, locales varchar[]) 118 | RETURNS jsonb 119 | STRICT 120 | STABLE 121 | LANGUAGE plpgsql 122 | AS $$ 123 | DECLARE 124 | locale varchar; 125 | j json; 126 | c json; 127 | BEGIN 128 | j := row_to_json(record); 129 | c := j->container; 130 | 131 | FOREACH locale IN ARRAY locales LOOP 132 | IF c->locale IS NOT NULL THEN 133 | RETURN c->locale; 134 | END IF; 135 | END LOOP; 136 | RETURN NULL; 137 | END; 138 | $$; 139 | """) 140 | end 141 | 142 | def down do 143 | execute "DROP FUNCTION IF EXISTS public.translate_field(container varchar, field varchar, default_locale varchar, locales varchar[])" 144 | execute "DROP FUNCTION IF EXISTS public.translate_field(container varchar, default_locale varchar, locales varchar[])" 145 | end 146 | end 147 | |) 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /lib/trans/query_builder.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Ecto.Adapters.SQL) do 2 | defmodule Trans.QueryBuilder do 3 | @moduledoc """ 4 | Provides helpers for filtering translations in `Ecto.Queries`. 5 | 6 | This module requires `Ecto.SQL` to be available during the compilation. 7 | """ 8 | 9 | @doc """ 10 | Generates a SQL fragment for accessing a translated field in an `Ecto.Query`. 11 | 12 | The generated SQL fragment can be coupled with the rest of the functions and operators provided 13 | by `Ecto.Query` and `Ecto.Query.API`. 14 | 15 | ## Safety 16 | 17 | This macro will emit errors when used with untranslatable schema modules or fields. Errors are 18 | emitted during the compilation phase thus avoiding runtime errors after the queries are built. 19 | 20 | ## Examples 21 | 22 | Assuming the Article schema defined in [Trans](Trans.html#module-storing-translations): 23 | 24 | # Return all articles that have a Spanish translation 25 | from a in Article, where: translated(Article, a, :es) != "null" 26 | #=> SELECT a0."id", a0."title", a0."body", a0."translations" 27 | #=> FROM "articles" AS a0 28 | #=> WHERE a0."translations"->"es" != 'null' 29 | 30 | # Query items with a certain translated value 31 | from a in Article, where: translated(Article, a.title, :fr) == "Elixir" 32 | #=> SELECT a0."id", a0."title", a0."body", a0."translations" 33 | #=> FROM "articles" AS a0 34 | #=> WHERE ((a0."translations"->"fr"->>"title") = "Elixir") 35 | 36 | # Query items using a case insensitive comparison 37 | from a in Article, where: ilike(translated(Article, a.body, :es), "%elixir%") 38 | #=> SELECT a0."id", a0."title", a0."body", a0."translations" 39 | #=> FROM "articles" AS a0 40 | #=> WHERE ((a0."translations"->"es"->>"body") ILIKE "%elixir%") 41 | 42 | ## Fallback chains 43 | 44 | Just like when using `Trans.Translator.translate/2` you may also pass a list of locales. In 45 | that case the query will automatically fall back through the list of provided locales until 46 | it finds an existing translation. 47 | 48 | # Query items translated into FR or ES (if FR translation does not exist) 49 | from a in Article, where: not is_nil(translated(Article, a.body, [:fr, :es])) 50 | 51 | If you plan to use fallback chains in the database you will need to set up the Trans DB 52 | translation functions. 53 | 54 | mix do trans.gen.translate_function, ecto.migrate 55 | 56 | ## More complex queries 57 | 58 | The `translated/3` macro can also be used with relations and joined schemas. 59 | For more complex examples take a look at the QueryBuilder tests (the file 60 | is located in `test/trans/query_builder_test.ex`). 61 | """ 62 | defmacro translated(module, translatable, locale) do 63 | static_locales? = static_locales?(locale) 64 | 65 | with field <- field(translatable) do 66 | module = Macro.expand(module, __CALLER__) 67 | validate_field(module, field) 68 | generate_query(schema(translatable), module, field, locale, static_locales?) 69 | end 70 | end 71 | 72 | @doc """ 73 | Generates a SQL fragment for accessing a translated field in an `Ecto.Query` 74 | `select` clause and returning it aliased to the original field name. 75 | 76 | Therefore, this macro returned a translated field with the name of the 77 | table's base column name which means Ecto can load it into a struct 78 | without further processing or conversion. 79 | 80 | In practise it call the macro `translated/3` and wraps the result in a 81 | fragment with the column alias. 82 | 83 | """ 84 | defmacro translated_as(module, translatable, locale) do 85 | field = field(translatable) 86 | translated = quote do: translated(unquote(module), unquote(translatable), unquote(locale)) 87 | translated_as(translated, field) 88 | end 89 | 90 | defp translated_as(translated, nil) do 91 | translated 92 | end 93 | 94 | defp translated_as(translated, field) do 95 | {:fragment, [], ["? AS #{inspect(to_string(field))}", translated]} 96 | end 97 | 98 | defp generate_query(schema, module, field, locales, true = static_locales?) 99 | when is_list(locales) do 100 | for locale <- locales do 101 | generate_query(schema, module, field, locale, static_locales?) 102 | end 103 | |> coalesce(locales) 104 | end 105 | 106 | defp generate_query(schema, module, nil, locale, true = _static_locales?) do 107 | quote do 108 | fragment( 109 | "NULLIF((?->?),'null')", 110 | field(unquote(schema), unquote(module.__trans__(:container))), 111 | unquote(to_string(locale)) 112 | ) 113 | end 114 | end 115 | 116 | defp generate_query(schema, module, field, locale, true = _static_locales?) do 117 | if locale == module.__trans__(:default_locale) do 118 | quote do 119 | field(unquote(schema), unquote(field)) 120 | end 121 | else 122 | quote do 123 | fragment( 124 | "COALESCE(?->?->>?, ?)", 125 | field(unquote(schema), unquote(module.__trans__(:container))), 126 | ^to_string(unquote(locale)), 127 | ^to_string(unquote(field)), 128 | field(unquote(schema), unquote(field)) 129 | ) 130 | end 131 | end 132 | end 133 | 134 | # Called at runtime - we use a database function 135 | defp generate_query(schema, module, field, locales, false = _static_locales?) do 136 | default_locale = to_string(module.__trans__(:default_locale) || :en) 137 | translate_field(module, schema, field, default_locale, locales) 138 | end 139 | 140 | defp translate_field(module, schema, nil, default_locale, locales) do 141 | table_alias = table_alias(schema) 142 | 143 | funcall = "translate_field(#{table_alias}, ?::varchar, ?::varchar, ?::varchar[])" 144 | 145 | quote do 146 | fragment( 147 | unquote(funcall), 148 | ^to_string(unquote(module.__trans__(:container))), 149 | ^to_string(unquote(default_locale)), 150 | ^Trans.QueryBuilder.list_to_sql_array(unquote(locales)) 151 | ) 152 | end 153 | end 154 | 155 | defp translate_field(module, schema, field, default_locale, locales) do 156 | table_alias = table_alias(schema) 157 | 158 | funcall = 159 | "translate_field(#{table_alias}, ?::varchar, ?::varchar, ?::varchar, ?::varchar[])" 160 | 161 | quote do 162 | fragment( 163 | unquote(funcall), 164 | ^to_string(unquote(module.__trans__(:container))), 165 | ^to_string(unquote(field)), 166 | ^to_string(unquote(default_locale)), 167 | ^Trans.QueryBuilder.list_to_sql_array(unquote(locales)) 168 | ) 169 | end 170 | end 171 | 172 | @doc false 173 | def list_to_sql_array(locales) do 174 | locales 175 | |> List.wrap() 176 | |> Enum.map(&to_string/1) 177 | end 178 | 179 | defp coalesce(ast, enum) do 180 | fun = "COALESCE(" <> fragment_placeholders(enum) <> ")" 181 | 182 | quote do 183 | fragment(unquote(fun), unquote_splicing(ast)) 184 | end 185 | end 186 | 187 | defp fragment_placeholders(enum) do 188 | enum 189 | |> Enum.map(fn _x -> "?" end) 190 | |> Enum.join(",") 191 | end 192 | 193 | # Heuristic to guess the Ecto table alias name based upon 194 | # the binding. If the binding ends in a digit then we assume 195 | # this is actually the table alias. If it is not, append a `0` 196 | # and treat it as the table alias. This because its not 197 | # possible to know the table alias at compile time. 198 | @digits Enum.map(0..9, &to_string/1) 199 | 200 | defp table_alias({schema, _, _}) do 201 | schema = to_string(schema) 202 | if String.ends_with?(schema, @digits), do: schema, else: schema <> "0" 203 | end 204 | 205 | defp schema({{:., _, [schema, _field]}, _metadata, _args}), do: schema 206 | defp schema(schema), do: schema 207 | 208 | defp field({{:., _, [_schema, field]}, _metadata, _args}), do: field 209 | defp field(_), do: nil 210 | 211 | defp validate_field(module, field) do 212 | cond do 213 | is_nil(field) -> 214 | nil 215 | 216 | not Trans.translatable?(module, field) -> 217 | raise ArgumentError, 218 | message: "'#{inspect(module)}' module must declare '#{field}' as translatable" 219 | 220 | true -> 221 | nil 222 | end 223 | end 224 | 225 | defp static_locales?(locale) when is_atom(locale), do: true 226 | defp static_locales?(locale) when is_binary(locale), do: true 227 | 228 | defp static_locales?(locales) when is_list(locales), 229 | do: Enum.all?(locales, &(is_atom(&1) || is_binary(&1))) 230 | 231 | defp static_locales?(_locales), do: false 232 | end 233 | end 234 | -------------------------------------------------------------------------------- /lib/trans/translator.ex: -------------------------------------------------------------------------------- 1 | defmodule Trans.Translator do 2 | @moduledoc """ 3 | Provides easy access to struct translations. 4 | 5 | Although translations are stored in regular fields of an struct and can be accessed directly, **it 6 | is recommended to access translations using the functions provided by this module** instead. This 7 | functions present additional behaviours such as: 8 | 9 | * Checking that the given struct uses `Trans` 10 | * Automatically inferring the [translation container](Trans.html#module-translation-container) 11 | when needed. 12 | * Falling back along a locale fallback chain (list of locales in which to look for 13 | a translation). If not found, then return the default value or raise and exception if a 14 | translation does not exist. 15 | * Translating entire structs. 16 | 17 | All examples in this module assume the following article, based on the schema defined in 18 | [Structured translations](Trans.html#module-structured-translations) 19 | 20 | article = %MyApp.Article{ 21 | title: "How to Write a Spelling Corrector", 22 | body: "A wonderful article by Peter Norvig", 23 | translations: %MyApp.Article.Translations{ 24 | es: %MyApp.Article.Translations.Fields{ 25 | title: "Cómo escribir un corrector ortográfico", 26 | body: "Un artículo maravilloso de Peter Norvig" 27 | }, 28 | fr: %MyApp.Article.Translations.Fields{ 29 | title: "Comment écrire un correcteur orthographique", 30 | body: "Un merveilleux article de Peter Norvig" 31 | } 32 | } 33 | } 34 | """ 35 | 36 | defguardp is_locale(locale) when is_binary(locale) or is_atom(locale) 37 | 38 | @doc """ 39 | Translate a whole struct into the given locale. 40 | 41 | Translates the whole struct with all translatable values and translatable associations into the 42 | given locale. Similar to `translate/3` but returns the whole struct. 43 | 44 | ## Examples 45 | 46 | Assuming the example article in this module, we can translate the entire struct into Spanish: 47 | 48 | # Translate the entire article into Spanish 49 | article_es = Trans.Translator.translate(article, :es) 50 | 51 | article_es.title #=> "Cómo escribir un corrector ortográfico" 52 | article_es.body #=> "Un artículo maravilloso de Peter Norvig" 53 | 54 | Just like `translate/3`, falls back to the default locale if the translation does not exist: 55 | 56 | # The Deutsch translation does not exist so the default values are returned 57 | article_de = Trans.Translator.translate(article, :de) 58 | 59 | article_de.title #=> "How to Write a Spelling Corrector" 60 | article_de.body #=> "A wonderful article by Peter Norvig" 61 | 62 | Rather than just one locale, a list of locales (a locale fallback chain) can also be 63 | used. In this case, translation tries each locale in the fallback chain in sequence 64 | until a translation is found. If none is found, the default value is returned. 65 | 66 | # The Deutsch translation does not exist but the Spanish one does 67 | article_de = Trans.Translator.translate(article, [:de, :es]) 68 | 69 | article_de.title #=> "Cómo escribir un corrector ortográfico" 70 | article_de.body #=> "Un artículo maravilloso de Peter Norvig" 71 | """ 72 | @doc since: "2.3.0" 73 | @spec translate(Trans.translatable(), Trans.locale_list()) :: Trans.translatable() 74 | 75 | def translate(%{__struct__: module} = translatable, locale) 76 | when is_locale(locale) or is_list(locale) do 77 | if Keyword.has_key?(module.__info__(:functions), :__trans__) do 78 | default_locale = module.__trans__(:default_locale) 79 | 80 | translatable 81 | |> translate_fields(locale, default_locale) 82 | |> translate_assocs(locale) 83 | else 84 | translatable 85 | end 86 | end 87 | 88 | @doc """ 89 | Translate a single field into the given locale. 90 | 91 | Translates the field into the given locale or falls back to the default value if there is no 92 | translation available. 93 | 94 | ## Examples 95 | 96 | Assuming the example article in this module: 97 | 98 | # We can get the Spanish title: 99 | Trans.Translator.translate(article, :title, :es) 100 | "Cómo escribir un corrector ortográfico" 101 | 102 | # If the requested locale is not available, the default value will be returned: 103 | Trans.Translator.translate(article, :title, :de) 104 | "How to Write a Spelling Corrector" 105 | 106 | # A fallback chain can also be used: 107 | Trans.Translator.translate(article, :title, [:de, :es]) 108 | "Cómo escribir un corrector ortográfico" 109 | 110 | # If we request a translation for an invalid field, we will receive an error: 111 | Trans.Translator.translate(article, :fake_attr, :es) 112 | ** (RuntimeError) 'Article' module must declare 'fake_attr' as translatable 113 | """ 114 | @spec translate(Trans.translatable(), atom, Trans.locale_list()) :: any 115 | def translate(%{__struct__: module} = translatable, field, locale) 116 | when (is_locale(locale) or is_list(locale)) and is_atom(field) do 117 | default_locale = module.__trans__(:default_locale) 118 | 119 | unless Trans.translatable?(translatable, field) do 120 | raise not_translatable_error(module, field) 121 | end 122 | 123 | # Return the translation or fall back to the default value 124 | case translate_field(translatable, locale, field, default_locale) do 125 | :error -> Map.fetch!(translatable, field) 126 | nil -> Map.fetch!(translatable, field) 127 | translation -> translation 128 | end 129 | end 130 | 131 | @doc """ 132 | Translate a single field into the given locale or raise if there is no translation. 133 | 134 | Just like `translate/3` gets a translated field into the given locale. Raises if there is no 135 | translation available. 136 | 137 | ## Examples 138 | 139 | Assuming the example article in this module: 140 | 141 | Trans.Translator.translate!(article, :title, :de) 142 | ** (RuntimeError) translation doesn't exist for field ':title' in locale 'de' 143 | """ 144 | @doc since: "2.3.0" 145 | @spec translate!(Trans.translatable(), atom, Trans.locale_list()) :: any 146 | def translate!(%{__struct__: module} = translatable, field, locale) 147 | when is_locale(locale) and is_atom(field) do 148 | default_locale = module.__trans__(:default_locale) 149 | 150 | unless Trans.translatable?(translatable, field) do 151 | raise not_translatable_error(module, field) 152 | end 153 | 154 | # Return the translation or fall back to the default value 155 | if translation = translate_field(translatable, locale, field, default_locale) do 156 | translation 157 | else 158 | raise no_translation_error(field, locale) 159 | end 160 | end 161 | 162 | defp translate_field(%{__struct__: _module} = struct, locales, field, default_locale) 163 | when is_list(locales) do 164 | Enum.reduce_while(locales, :error, fn locale, translated_field -> 165 | case translate_field(struct, locale, field, default_locale) do 166 | :error -> {:cont, translated_field} 167 | nil -> {:cont, translated_field} 168 | translation -> {:halt, translation} 169 | end 170 | end) 171 | end 172 | 173 | defp translate_field(%{__struct__: _module} = struct, default_locale, field, default_locale) do 174 | Map.fetch!(struct, field) 175 | end 176 | 177 | defp translate_field(%{__struct__: module} = struct, locale, field, _default_locale) do 178 | with {:ok, all_translations} <- Map.fetch(struct, module.__trans__(:container)), 179 | {:ok, translations_for_locale} <- get_translations_for_locale(all_translations, locale), 180 | {:ok, translated_field} <- get_translated_field(translations_for_locale, field) do 181 | translated_field 182 | end 183 | end 184 | 185 | defp translate_fields(%{__struct__: module} = struct, locale, default_locale) 186 | when is_list(locale) do 187 | fields = module.__trans__(:fields) 188 | 189 | Enum.reduce(fields, struct, fn field, struct -> 190 | case translate_field(struct, locale, field, default_locale) do 191 | :error -> struct 192 | translation -> Map.put(struct, field, translation) 193 | end 194 | end) 195 | end 196 | 197 | defp translate_fields(%{__struct__: _module} = struct, locale, default_locale) do 198 | translate_fields(struct, [locale], default_locale) 199 | end 200 | 201 | defp translate_assocs(%{__struct__: module} = struct, locale) do 202 | associations = module.__schema__(:associations) 203 | embeds = module.__schema__(:embeds) 204 | 205 | Enum.reduce(associations ++ embeds, struct, fn assoc_name, struct -> 206 | Map.update(struct, assoc_name, nil, fn 207 | %Ecto.Association.NotLoaded{} = item -> 208 | item 209 | 210 | items when is_list(items) -> 211 | Enum.map(items, &translate(&1, locale)) 212 | 213 | %{} = item -> 214 | translate(item, locale) 215 | 216 | item -> 217 | item 218 | end) 219 | end) 220 | end 221 | 222 | # check if struct (means it's using ecto embeds); if so, make sure 'locale' is also atom 223 | defp get_translations_for_locale(%{__struct__: _} = all_translations, locale) 224 | when is_binary(locale) do 225 | get_translations_for_locale(all_translations, String.to_existing_atom(locale)) 226 | end 227 | 228 | defp get_translations_for_locale(%{__struct__: _} = all_translations, locale) 229 | when is_atom(locale) do 230 | Map.fetch(all_translations, locale) 231 | end 232 | 233 | # fallback to default behaviour 234 | defp get_translations_for_locale(nil, _locale), do: nil 235 | 236 | defp get_translations_for_locale(all_translations, locale) do 237 | Map.fetch(all_translations, to_string(locale)) 238 | end 239 | 240 | # there are no translations for this locale embed 241 | defp get_translated_field(nil, _field), do: nil 242 | 243 | # check if struct (means it's using ecto embeds); if so, make sure 'field' is also atom 244 | defp get_translated_field(%{__struct__: _} = translations_for_locale, field) 245 | when is_binary(field) do 246 | get_translated_field(translations_for_locale, String.to_existing_atom(field)) 247 | end 248 | 249 | defp get_translated_field(%{__struct__: _} = translations_for_locale, field) 250 | when is_atom(field) do 251 | Map.fetch(translations_for_locale, field) 252 | end 253 | 254 | # fallback to default behaviour 255 | defp get_translated_field(translations_for_locale, field) do 256 | Map.fetch(translations_for_locale, to_string(field)) 257 | end 258 | 259 | defp no_translation_error(field, locales) when is_list(locales) do 260 | "translation doesn't exist for field '#{inspect(field)}' in locales #{inspect(locales)}" 261 | end 262 | 263 | defp no_translation_error(field, locale) do 264 | "translation doesn't exist for field '#{inspect(field)}' in locale #{inspect(locale)}" 265 | end 266 | 267 | defp not_translatable_error(module, field) do 268 | "'#{inspect(module)}' module must declare '#{inspect(field)}' as translatable" 269 | end 270 | end 271 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Trans.Mixfile do 2 | use Mix.Project 3 | 4 | @version "3.0.1" 5 | 6 | def project do 7 | [ 8 | app: :trans, 9 | version: @version, 10 | elixir: "~> 1.11", 11 | description: "Embedded translations for Elixir schemas", 12 | build_embedded: Mix.env() == :prod, 13 | start_permanent: Mix.env() == :prod, 14 | aliases: aliases(), 15 | elixirc_paths: elixirc_paths(Mix.env()), 16 | app_list: app_list(Mix.env()), 17 | package: package(), 18 | deps: deps(), 19 | 20 | # Docs 21 | name: "Trans", 22 | source_url: "https://github.com/crbelaus/trans", 23 | homepage_url: "https://hex.pm/packages/trans", 24 | docs: [ 25 | source_ref: "v#{@version}", 26 | main: "Trans" 27 | ] 28 | ] 29 | end 30 | 31 | # Configuration for the OTP application 32 | # 33 | # Type "mix help compile.app" for more information 34 | def application do 35 | [extra_applications: [:logger]] 36 | end 37 | 38 | # Dependencies can be Hex packages: 39 | # 40 | # {:mydep, "~> 0.3.0"} 41 | # 42 | # Or git/path repositories: 43 | # 44 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 45 | # 46 | # Type "mix help deps" for more examples and options 47 | defp deps do 48 | [ 49 | {:jason, "~> 1.1"}, 50 | {:ecto, "~> 3.0"}, 51 | # Optional dependencies 52 | {:ecto_sql, "~> 3.0", optional: true}, 53 | {:postgrex, "~> 0.14", optional: true}, 54 | # Doc dependencies 55 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 56 | ] 57 | end 58 | 59 | defp package do 60 | [ 61 | licenses: ["Apache-2.0"], 62 | maintainers: ["Cristian Álvarez Belaustegui"], 63 | links: %{"GitHub" => "https://github.com/crbelaus/trans"} 64 | ] 65 | end 66 | 67 | # Include Ecto and Postgrex applications in tests 68 | def app_list(:test), do: [:ecto, :postgrex] 69 | def app_list(_), do: [] 70 | 71 | # Always compile files in "lib". In tests compile also files in 72 | # "test/support" 73 | def elixirc_paths(:test), do: ["lib", "test/support"] 74 | def elixirc_paths(_), do: ["lib"] 75 | 76 | defp aliases do 77 | [ 78 | test: [ 79 | "ecto.create --quiet", 80 | "ecto.migrate --quiet", 81 | "test" 82 | ] 83 | ] 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 3 | "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, 4 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 5 | "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, 7 | "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"}, 8 | "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"}, 9 | "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"}, 10 | "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, 11 | "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"}, 12 | "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"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 15 | "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"}, 16 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 17 | } 18 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220306005524_trans_gen_translate_function.exs: -------------------------------------------------------------------------------- 1 | defmodule Trans.Repo.Migrations.TransGenTranslateFunction do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute(""" 6 | CREATE OR REPLACE FUNCTION public.translate_field(record record, container varchar, field varchar, default_locale varchar, locales varchar[]) 7 | RETURNS varchar 8 | STRICT 9 | STABLE 10 | LANGUAGE plpgsql 11 | AS $$ 12 | DECLARE 13 | locale varchar; 14 | j json; 15 | c json; 16 | l varchar; 17 | BEGIN 18 | j := row_to_json(record); 19 | c := j->container; 20 | 21 | FOREACH locale IN ARRAY locales LOOP 22 | IF locale = default_locale THEN 23 | RETURN j->>field; 24 | ELSEIF c->locale IS NOT NULL THEN 25 | IF c->locale->>field IS NOT NULL THEN 26 | RETURN c->locale->>field; 27 | END IF; 28 | END IF; 29 | END LOOP; 30 | RETURN j->>field; 31 | END; 32 | $$; 33 | """) 34 | 35 | execute(""" 36 | CREATE OR REPLACE FUNCTION public.translate_field(record record, container varchar, default_locale varchar, locales varchar[]) 37 | RETURNS jsonb 38 | STRICT 39 | STABLE 40 | LANGUAGE plpgsql 41 | AS $$ 42 | DECLARE 43 | locale varchar; 44 | j json; 45 | c json; 46 | BEGIN 47 | j := row_to_json(record); 48 | c := j->container; 49 | 50 | FOREACH locale IN ARRAY locales LOOP 51 | IF c->locale IS NOT NULL THEN 52 | RETURN c->locale; 53 | END IF; 54 | END LOOP; 55 | RETURN NULL; 56 | END; 57 | $$; 58 | """) 59 | end 60 | 61 | def down do 62 | execute( 63 | "DROP FUNCTION IF EXISTS public.translate_field(container varchar, field varchar, default_locale varchar, locales varchar[])" 64 | ) 65 | 66 | execute( 67 | "DROP FUNCTION IF EXISTS public.translate_field(container varchar, default_locale varchar, locales varchar[])" 68 | ) 69 | end 70 | end -------------------------------------------------------------------------------- /priv/repo/migrations/20230628153604_create_test_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule Trans.Repo.Migrations.CreateTestTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:default_translation_container) do 6 | add :content, :string 7 | add :translations, :map 8 | end 9 | 10 | create table(:custom_translation_container) do 11 | add :content, :string 12 | add :transcriptions, :map 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Trans.Repo do 2 | @moduledoc false 3 | 4 | use Ecto.Repo, 5 | otp_app: :trans, 6 | adapter: Ecto.Adapters.Postgres 7 | end 8 | -------------------------------------------------------------------------------- /test/support/test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Trans.TestCase do 2 | @moduledoc false 3 | 4 | use ExUnit.CaseTemplate 5 | 6 | using do 7 | quote do 8 | import Trans.TestCase 9 | import Ecto.Query 10 | 11 | alias Trans.Repo 12 | end 13 | end 14 | 15 | setup tags do 16 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Trans.Repo, shared: not tags[:async]) 17 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 18 | :ok 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Trans.Repo.start_link() 2 | 3 | ExUnit.start() 4 | Ecto.Adapters.SQL.Sandbox.mode(Trans.Repo, :manual) 5 | -------------------------------------------------------------------------------- /test/trans/query_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Trans.QueryBuilderTest do 2 | use Trans.TestCase 3 | 4 | import Trans.QueryBuilder 5 | 6 | alias Trans.Repo 7 | 8 | defmodule DefaultContainer do 9 | use Ecto.Schema 10 | use Trans, translates: [:content], default_locale: :en 11 | 12 | schema "default_translation_container" do 13 | field :content, :string 14 | translations [:es, :fr, :de] 15 | end 16 | end 17 | 18 | describe "default container" do 19 | setup do 20 | translated_struct = 21 | Repo.insert!(%DefaultContainer{ 22 | content: "Content EN", 23 | translations: %DefaultContainer.Translations{ 24 | es: %DefaultContainer.Translations.Fields{ 25 | content: "Content ES" 26 | }, 27 | fr: %DefaultContainer.Translations.Fields{ 28 | content: "Content FR" 29 | } 30 | } 31 | }) 32 | 33 | untranslated_struct = 34 | Repo.insert!(%DefaultContainer{ 35 | content: "Untranslated Content EN", 36 | translations: %DefaultContainer.Translations{} 37 | }) 38 | 39 | [translated_struct: translated_struct, untranslated_struct: untranslated_struct] 40 | end 41 | 42 | test "should find only one struct translated to ES" do 43 | query = 44 | from dc in DefaultContainer, 45 | where: not is_nil(translated(DefaultContainer, dc, :es)) 46 | 47 | assert Repo.aggregate(query, :count) == 1 48 | end 49 | 50 | test "should not find any struct translated to DE" do 51 | query = 52 | from dc in DefaultContainer, 53 | where: not is_nil(translated(DefaultContainer, dc, :de)) 54 | 55 | refute Repo.exists?(query) 56 | end 57 | 58 | test "should find one struct translated to ES falling back from DE" do 59 | query = 60 | from dc in DefaultContainer, 61 | where: not is_nil(translated(DefaultContainer, dc, [:de, :es])) 62 | 63 | assert Repo.aggregate(query, :count) == 1 64 | end 65 | 66 | test "should find no struct translated to DE falling back from RU since neither exist" do 67 | query = 68 | from dc in DefaultContainer, 69 | where: not is_nil(translated(DefaultContainer, dc, [:ru, :de])) 70 | 71 | refute Repo.exists?(query) 72 | end 73 | 74 | # This is an example where we use `NULLIF(value, 'null')` to 75 | # standardise on using SQL NULL in all cases where there is no data. 76 | test "that a valid locale that has no translations returns nil (not 'null')" do 77 | query = 78 | from dc in DefaultContainer, 79 | where: is_nil(translated(DefaultContainer, dc, :it)) 80 | 81 | assert Repo.aggregate(query, :count) == 2 82 | end 83 | 84 | test "that a valid locale that has no translations returns nil for locale chain" do 85 | query = 86 | from dc in DefaultContainer, 87 | where: not is_nil(translated(DefaultContainer, dc, [:de])) 88 | 89 | refute Repo.exists?(query) 90 | end 91 | 92 | test "should find all structs falling back from DE since EN is default" do 93 | query = 94 | from dc in DefaultContainer, 95 | where: not is_nil(translated(DefaultContainer, dc.content, [:de, :en])) 96 | 97 | assert Repo.aggregate(query, :count) == 2 98 | end 99 | 100 | test "should find all structs with dynamic fallback chain" do 101 | query = 102 | from dc in DefaultContainer, 103 | where: not is_nil(translated(DefaultContainer, dc.content, [:es, :fr])) 104 | 105 | assert Repo.aggregate(query, :count) == 2 106 | end 107 | 108 | test "should select all structs with dynamic fallback chain" do 109 | result = 110 | Repo.all( 111 | from dc in DefaultContainer, 112 | select: translated_as(DefaultContainer, dc.content, [:es, :fr]), 113 | where: not is_nil(translated(DefaultContainer, dc.content, [:es, :fr])) 114 | ) 115 | 116 | assert length(result) == 2 117 | end 118 | 119 | test "select the translated (or base) column falling back from unknown DE to default EN", 120 | %{translated_struct: translated_struct, untranslated_struct: untranslated_struct} do 121 | result = 122 | Repo.all( 123 | from dc in DefaultContainer, 124 | select: translated_as(DefaultContainer, dc.content, [:de, :en]), 125 | where: not is_nil(translated(DefaultContainer, dc.content, [:de, :en])) 126 | ) 127 | 128 | assert result == [translated_struct.content, untranslated_struct.content] 129 | end 130 | 131 | test "select translations for a valid locale with no data should return the default", 132 | %{translated_struct: translated_struct, untranslated_struct: untranslated_struct} do 133 | result = 134 | Repo.all( 135 | from dc in DefaultContainer, 136 | select: translated_as(DefaultContainer, dc.content, :it) 137 | ) 138 | 139 | assert result == [translated_struct.content, untranslated_struct.content] 140 | end 141 | 142 | test "select translations for a valid locale with no data should fallback to the default" do 143 | results = 144 | Repo.all( 145 | from adc in DefaultContainer, 146 | select: translated_as(DefaultContainer, adc.content, [:de, :en]) 147 | ) 148 | 149 | for result <- results do 150 | assert result =~ "Content EN" 151 | end 152 | end 153 | 154 | test "should find a struct by its FR title", %{translated_struct: struct} do 155 | matches = 156 | Repo.all( 157 | from dc in DefaultContainer, 158 | where: 159 | translated(DefaultContainer, dc.content, :fr) == ^struct.translations.fr.content, 160 | select: dc.id 161 | ) 162 | 163 | assert matches == [struct.id] 164 | end 165 | 166 | test "should not find a struct by a non existent translation" do 167 | query = 168 | from dc in DefaultContainer, 169 | where: translated(DefaultContainer, dc.content, :es) == "FAKE TITLE" 170 | 171 | refute Repo.exists?(query) 172 | end 173 | 174 | test "should find an struct by partial and case sensitive translation", 175 | %{translated_struct: struct} do 176 | matches = 177 | Repo.all( 178 | from dc in DefaultContainer, 179 | where: ilike(translated(DefaultContainer, dc.content, :es), "%ES%"), 180 | select: dc.id 181 | ) 182 | 183 | assert matches == [struct.id] 184 | end 185 | 186 | test "should raise when adding conditions to an untranslatable field" do 187 | # Since the QueryBuilder errors are emitted during compilation, we do a 188 | # little trick to delay the compilation of the query until the test 189 | # is running, so we can catch the raised error. 190 | invalid_module = 191 | quote do 192 | defmodule TestWrongQuery do 193 | require Ecto.Query 194 | import Ecto.Query, only: [from: 2] 195 | 196 | def invalid_query do 197 | from dc in DefaultContainer, 198 | where: not is_nil(translated(DefaultContainer, dc.translations, :es)) 199 | end 200 | end 201 | end 202 | 203 | expected_error = 204 | "'Trans.QueryBuilderTest.DefaultContainer' module must declare 'translations' as translatable" 205 | 206 | assert_raise ArgumentError, expected_error, fn -> Code.eval_quoted(invalid_module) end 207 | end 208 | end 209 | 210 | defmodule CustomContainer do 211 | use Ecto.Schema 212 | use Trans, translates: [:content], default_locale: :en, container: :transcriptions 213 | 214 | schema "custom_translation_container" do 215 | field :content, :string 216 | translations [:es, :fr, :de] 217 | end 218 | end 219 | 220 | describe "custom container" do 221 | setup do 222 | struct = 223 | Repo.insert!(%CustomContainer{ 224 | content: "Content EN", 225 | transcriptions: %CustomContainer.Translations{ 226 | es: %CustomContainer.Translations.Fields{ 227 | content: "Content ES" 228 | } 229 | } 230 | }) 231 | 232 | [struct: struct] 233 | end 234 | 235 | test "uses the custom container automatically", %{struct: struct} do 236 | query = 237 | from cc in CustomContainer, 238 | where: translated(CustomContainer, cc.content, :es) == ^struct.transcriptions.es.content 239 | 240 | assert Repo.exists?(query) 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /test/trans/translator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Trans.TranslatorTest do 2 | use Trans.TestCase 3 | 4 | alias Trans.Translator 5 | 6 | defmodule ExampleSchema do 7 | use Ecto.Schema 8 | use Trans, translates: [:content], default_locale: :en 9 | 10 | embedded_schema do 11 | field :content, :string 12 | translations [:es, :fr] 13 | end 14 | end 15 | 16 | setup do 17 | struct = %ExampleSchema{ 18 | content: "Content EN", 19 | translations: %ExampleSchema.Translations{ 20 | es: %ExampleSchema.Translations.Fields{ 21 | content: "Content ES" 22 | } 23 | } 24 | } 25 | 26 | [struct: struct] 27 | end 28 | 29 | describe inspect(&Translator.translate/2) do 30 | test "translates the whole struct to the desired locale", %{struct: struct} do 31 | translated = Translator.translate(struct, :es) 32 | 33 | assert translated.content == struct.translations.es.content 34 | end 35 | 36 | test "falls back to the next locale with a custom fallback chain", %{struct: struct} do 37 | translated = Translator.translate(struct, [:fr, :es]) 38 | 39 | assert translated.content == struct.translations.es.content 40 | end 41 | 42 | test "falls back to the default locale with an unresolved fallback chain", %{struct: struct} do 43 | translated = Translator.translate(struct, [:fr]) 44 | 45 | assert translated.content == struct.content 46 | end 47 | 48 | test "falls back to the default locale if translation does not exist", %{struct: struct} do 49 | translated = Translator.translate(struct, :fr) 50 | 51 | assert translated.content == struct.content 52 | end 53 | end 54 | 55 | describe inspect(&Translator.translate/3) do 56 | test "translate the field to the desired locale", %{struct: struct} do 57 | assert Translator.translate(struct, :content, :es) == struct.translations.es.content 58 | end 59 | 60 | test "falls back to the default locale if translation does not exist", %{struct: struct} do 61 | assert Translator.translate(struct, :content, :fr) == struct.content 62 | end 63 | 64 | test "falls back to the next locale in a custom fallback chain", %{struct: struct} do 65 | assert Translator.translate(struct, :content, [:fr, :es]) == 66 | struct.translations.es.content 67 | end 68 | 69 | test "falls back to the default locale in an unresolved fallback chain", %{struct: struct} do 70 | assert Translator.translate(struct, :content, [:fr]) == struct.content 71 | end 72 | end 73 | 74 | describe inspect(&Translator.translate!/3) do 75 | test "raises if the translation does not exist", %{struct: struct} do 76 | expected_error = ~s[translation doesn't exist for field ':content' in locale :fr] 77 | 78 | assert_raise RuntimeError, expected_error, fn -> 79 | Translator.translate!(struct, :content, :fr) 80 | end 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/trans_test.exs: -------------------------------------------------------------------------------- 1 | defmodule TransTest do 2 | require Trans 3 | use Trans.TestCase 4 | 5 | defmodule DefaultContainer do 6 | use Ecto.Schema 7 | use Trans, translates: [:content], default_locale: :en 8 | 9 | embedded_schema do 10 | field :content, :string 11 | field :metadata, :map 12 | translations [:es, :fr] 13 | end 14 | end 15 | 16 | test "with default container" do 17 | assert Trans.translatable?(DefaultContainer, :content) 18 | refute Trans.translatable?(DefaultContainer, :metadata) 19 | 20 | assert DefaultContainer.__trans__(:default_locale) == :en 21 | assert DefaultContainer.__trans__(:container) == :translations 22 | 23 | assert { 24 | :parameterized, 25 | Ecto.Embedded, 26 | %Ecto.Embedded{ 27 | cardinality: :one, 28 | field: :translations, 29 | on_cast: nil, 30 | on_replace: :update, 31 | ordered: true, 32 | owner: DefaultContainer, 33 | related: DefaultContainer.Translations, 34 | unique: true 35 | } 36 | } = DefaultContainer.__schema__(:type, :translations) 37 | 38 | assert [:es, :fr] = DefaultContainer.Translations.__schema__(:fields) 39 | assert [:content] = DefaultContainer.Translations.Fields.__schema__(:fields) 40 | end 41 | 42 | defmodule CustomContainer do 43 | use Ecto.Schema 44 | use Trans, translates: [:content], default_locale: :en, container: :transcriptions 45 | 46 | embedded_schema do 47 | field :content, :string 48 | field :metadata, :map 49 | translations [:es, :fr] 50 | end 51 | end 52 | 53 | test "with custom container" do 54 | assert Trans.translatable?(CustomContainer, :content) 55 | refute Trans.translatable?(CustomContainer, :metadata) 56 | 57 | assert CustomContainer.__trans__(:default_locale) == :en 58 | assert CustomContainer.__trans__(:container) == :transcriptions 59 | 60 | assert { 61 | :parameterized, 62 | Ecto.Embedded, 63 | %Ecto.Embedded{ 64 | cardinality: :one, 65 | field: :transcriptions, 66 | on_cast: nil, 67 | on_replace: :update, 68 | ordered: true, 69 | owner: CustomContainer, 70 | related: CustomContainer.Translations, 71 | unique: true 72 | } 73 | } = CustomContainer.__schema__(:type, :transcriptions) 74 | 75 | assert [:es, :fr] = CustomContainer.Translations.__schema__(:fields) 76 | assert [:content] = CustomContainer.Translations.Fields.__schema__(:fields) 77 | end 78 | 79 | defmodule CustomSchema do 80 | use Ecto.Schema 81 | use Trans, translates: [:content], default_locale: :en 82 | 83 | defmodule Translations.Fields do 84 | use Ecto.Schema 85 | 86 | @primary_key false 87 | embedded_schema do 88 | field :content, :string 89 | end 90 | end 91 | 92 | embedded_schema do 93 | field :content, :string 94 | field :metadata, :map 95 | translations [:es, :fr], build_field_schema: false 96 | end 97 | end 98 | 99 | test "with custom schema" do 100 | assert Trans.translatable?(CustomSchema, :content) 101 | refute Trans.translatable?(CustomSchema, :metadata) 102 | 103 | assert CustomSchema.__trans__(:default_locale) == :en 104 | assert CustomSchema.__trans__(:container) == :translations 105 | 106 | assert { 107 | :parameterized, 108 | Ecto.Embedded, 109 | %Ecto.Embedded{ 110 | cardinality: :one, 111 | field: :translations, 112 | on_cast: nil, 113 | on_replace: :update, 114 | ordered: true, 115 | owner: CustomSchema, 116 | related: CustomSchema.Translations, 117 | unique: true 118 | } 119 | } = CustomSchema.__schema__(:type, :translations) 120 | 121 | assert [:es, :fr] = CustomSchema.Translations.__schema__(:fields) 122 | assert [:content] = CustomSchema.Translations.Fields.__schema__(:fields) 123 | end 124 | end 125 | --------------------------------------------------------------------------------