embedded_localization 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c558dc3ee3d3357b8bf674ee00423dda8dd10c855deb800b1b4e47946fa1318f
4
- data.tar.gz: 252b5481dbc39a41e800551411a1426db9f4fea1759c2c6a7b05e9e6285a1a24
3
+ metadata.gz: 6957e8085bbd37d3849c1b6f53b58f5d43188277db18acb757da7072d72ca6bc
4
+ data.tar.gz: 2f1ad574f456f63b92ab8f569e30da8a6d929ca70b941c4dfa1e5685694e0ce9
5
5
  SHA512:
6
- metadata.gz: a8a28976e403460d65d6fd9c5af50b224ffa46a9d7b466c7bfec4e4766aa762c2c65e5bb54fdb2b0dd7b7c2a4f6d91759889b067e370af9161a869e7ad7b1804
7
- data.tar.gz: f7353af27a41afcc4db023bcff8b229f90e062bb8388c84bf4991de528f3021c5e1d00116ea91d8728ff1ffc59f22c648326379d1501e5749f2c3c999992a3df
6
+ metadata.gz: 940207de0ca5710093871a3abada7fc47ff1eef2993f0038f3bc624a94c1b7af9971fc817eaefadd0db73a1e88ac28f650e55237c1f2f04a72a49516883b6db6
7
+ data.tar.gz: dc86f831f57c6ec45e8192a8f1ae1dda75c2ae7f0107ff36b4a48ab68840c657e39031b115431d3967825ab2866b39183414e00a4c6825e0265038f55a4f26d7
data/CHANGELOG.md CHANGED
@@ -1,7 +1,20 @@
1
1
  # EmbeddedLocalization CHANGE LOG
2
2
 
3
+ ## 1.4.0 (2026-08-24)
4
+ * new `storage:` option for `translates`: store the translations in a `json` / `jsonb` column (`storage: :json`) or in a PostgreSQL `hstore` column (`storage: :hstore`) instead of the YAML `text` column; the translated values are then queryable in SQL (see README, Example 3)
5
+ * fallbacks now follow the chain configured in `I18n.fallbacks` (e.g. `config.i18n.fallbacks = { 'de-AT' => 'de' }`: `:"de-AT"` → `:de` → `I18n.default_locale`); `I18n.default_locale` is always the last fallback, as before
6
+ * bug fix: fallbacks did not work for the current `I18n.locale` of a record loaded from the database, nor for a locale that had translations for other attributes; a `nil` translation now always falls back (as the README documented)
7
+ * bug fix: with `fallbacks: true`, reading an attribute that has no translation in `I18n.default_locale` returned a Hash of nils on unsaved records instead of nil
8
+ * bug fix: assigning a translation equal to the current value marked the record as changed on models without a DB column for that attribute (the fix for issue #4 only covered models with such a column)
9
+ * bug fix: `set_localized_attribute(attr, locale, value)` compared against `I18n.locale` instead of `locale`; when the current locale already held the same value, the translation for `locale` was not stored
10
+ * removed the `Hash.zip` monkey patch (`lib/extensions/hash.rb`); the gem no longer adds methods to core classes
11
+ * CI: ActiveRecord 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 are tested against SQLite, PostgreSQL and MySQL; Ruby 2.5 through 4.0, `head` and TruffleRuby; coverage upload through `codecov/codecov-action` instead of the unmaintained `codecov` gem
12
+
13
+ ## 1.3.1 (2024-11-26)
14
+ * [Issue 14](https://github.com/tilo/embedded_localization/pull/14) Fix active support proxy object deprecation (thanks to [Romain Morlevat](https://github.com/RomainMorlevat))
15
+
3
16
  ## 1.3.0 (2024-11-13)
4
- - fixed ([issue 10](https://github.com/tilo/embedded_localization/issues)) to support Rails >= 7.1 (thanks to [Romain Morlevat](https://github.com/RomainMorlevat))
17
+ * fixed ([issue 10](https://github.com/tilo/embedded_localization/issues)) to support Rails >= 7.1 (thanks to [Romain Morlevat](https://github.com/RomainMorlevat))
5
18
 
6
19
  ## 1.2.2 (2022-04-25)
7
20
  * improved docs
data/Gemfile CHANGED
@@ -1,4 +1,6 @@
1
- source "http://rubygems.org"
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
2
4
 
3
5
  # Specify your gem's dependencies in embedded_localization.gemspec
4
6
  gemspec
@@ -7,7 +9,16 @@ gem 'rake'
7
9
 
8
10
  group :test do
9
11
  gem 'rspec'
10
- gem 'activerecord'
12
+ gem 'simplecov'
11
13
  gem 'i18n'
12
- gem 'sqlite3'
14
+ gem 'pg' if ENV['DB'] == 'postgresql' # DB=postgresql bundle exec rake (jsonb and hstore columns)
15
+ gem 'mysql2' if ENV['DB'] == 'mysql' # DB=mysql bundle exec rake
16
+
17
+ if ENV['RAILS_VERSION'] # RAILS_VERSION=7.2 bundle exec rake
18
+ gem 'activerecord', "~> #{ENV['RAILS_VERSION']}.0"
19
+ gem 'sqlite3', Gem::Version.new(ENV['RAILS_VERSION']) < Gem::Version.new('7.1') ? '~> 1.4' : '>= 1.4' # the sqlite adapter of Rails < 7.1 requires sqlite3 ~> 1.4
20
+ else
21
+ gem 'activerecord'
22
+ gem 'sqlite3'
23
+ end
13
24
  end
data/README.md CHANGED
@@ -1,10 +1,12 @@
1
1
  # Embedded Localization
2
2
 
3
- [![Build Status](https://secure.travis-ci.org/tilo/embedded_localization.png?branch=master)](http://travis-ci.org/tilo/embedded_localization) [![Gem Version](https://badge.fury.io/rb/embedded_localization.svg)](http://badge.fury.io/rb/embedded_localization)
3
+ ![Gem Version](https://img.shields.io/gem/v/embedded_localization) [![codecov](https://codecov.io/gh/tilo/embedded_localization/graph/badge.svg?token=MX3ULB0S1Y)](https://codecov.io/gh/tilo/embedded_localization) <!-- [![Downloads](https://img.shields.io/gem/dt/embedded_localization)](https://rubygems.org/gems/embedded_localization) --> [![RubyGems](https://img.shields.io/badge/RubyGems-embedded__localization-brightgreen?logo=rubygems&logoColor=white)](https://rubygems.org/gems/embedded_localization) [![Ruby Toolbox](https://img.shields.io/badge/Ruby%20Toolbox-embedded__localization-brightgreen)](https://www.ruby-toolbox.com/projects/embedded_localization)
4
4
 
5
- `embedded_localization` is compatible with Rails 6.x, 7.x, and adds model translations to ActiveRecord. `embedded_localization` is compatible with and builds on the new [I18n API in Ruby on Rails](http://guides.rubyonrails.org/i18n.html)
5
+ `embedded_localization` allows you to store your translations directly insight each record.
6
6
 
7
- `embedded_localization` is very lightweight, and allows you to transparently store translations of attributes right inside each record no extra database tables needed to store the localization data! Make sure that your database default encoding is UTF-8 or UFT-16.
7
+ `embedded_localization` is compatible with Rails 6.1, 7.x and 8.x, and adds model translations to ActiveRecord, and is compatible with and builds on the [I18n API in Ruby on Rails](http://guides.rubyonrails.org/i18n.html)
8
+
9
+ `embedded_localization` is very lightweight, and allows you to transparently store multiple translations of attributes right inside each record — no extra database tables needed to store the localization data! Make sure that your database default encoding is UTF-8 or UFT-16.
8
10
 
9
11
  Model translations with `embedded_localization` use default ActiveRecord features and do not limit any ActiveRecord functionality.
10
12
 
@@ -13,7 +15,7 @@ On top of that, you also get tools for checking into which locales an attribute
13
15
 
14
16
  ## Motivation
15
17
 
16
- One real-life scenario is that you have a SaaS system which needs custom text for each company, which also needs to be translated in to several languages. Another scenario is that you have dynamic content that needs to be translated.
18
+ One real-life scenario is that you have a SaaS system which needs custom text for each company, which also needs to be translated in to several languages. Product translations are another use case, as well as movies, books, TV-shows, etc. Another scenario is that you have dynamic content that needs to be translated.
17
19
 
18
20
  A recent project needed some localization support for ActiveRecord model data, but I did not want to clutter the schema with one additional table for each translated model, as the globalize gem requires. A second requirement was to allow SQL queries of the fields using the default locale.
19
21
 
@@ -24,7 +26,8 @@ If your requirements are different, this approach might not work for you. In tha
24
26
 
25
27
  ## Requirements
26
28
 
27
- * ActiveRecord >= 6
29
+ * Ruby >= 2.5 # Ruby 2.5 through 4.0, `head` and TruffleRuby are tested in CI
30
+ * ActiveRecord >= 6 # ActiveRecord 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 are tested in CI, against SQLite, PostgreSQL and MySQL
28
31
  * [I18n](http://guides.rubyonrails.org/i18n.html)
29
32
 
30
33
  ## Installation
@@ -38,6 +41,8 @@ To install Embedded_Localization, use:
38
41
 
39
42
  Adding localization to a table is very simple. Just add a text field named `i18n` to the table, and you are ready to go! This allows you to add translated fields via the helper method `translates` in the model.
40
43
 
44
+ Instead of a text field, the `i18n` column can also be a `json` / `jsonb` column, or a PostgreSQL `hstore` column, which makes the translated values queryable in SQL — see [Example 3](#example-3).
45
+
41
46
  Optionally, you can also keep a DB field with the same name as the translated field, which will store the values for the `I18n.default_locale`.
42
47
 
43
48
  Model translations allow you to translate your models’ attribute values. The attribute type needs to be string or text.
@@ -89,6 +94,68 @@ class CreateGenres < ActiveRecord::Migration
89
94
  end
90
95
  ```
91
96
 
97
+ ### Example 3
98
+
99
+ Instead of the YAML text column, the translations can be stored in a `json` / `jsonb` column, or in a PostgreSQL `hstore` column. Tell `translates` which one you use with the `storage:` option:
100
+
101
+ | `translates ... storage:` | `i18n` column type | Stored as | SQL example: find `name` translated to `:de` |
102
+ |---------------------------|------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
103
+ | `:yaml` (default) | `text` | YAML: `{en: {name: "..."}, de: {name: "..."}}` | not possible |
104
+ | `:json` or `:jsonb` | `json` (SQLite, MySQL, PostgreSQL) or `jsonb` (PostgreSQL) | JSON: `{"en": {"name": "..."}, "de": {"name": "..."}}` | PostgreSQL: `i18n -> 'de' ->> 'name' = ?` ; SQLite: `json_extract(i18n, '$.de.name') = ?` ; MySQL: `JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.de.name')) = ?` |
105
+ | `:hstore` | `hstore` (PostgreSQL) | flat keys: `"en.name" => "...", "de.name" => "..."` | `i18n -> 'de.name' = ?` |
106
+
107
+ ```ruby
108
+ class CreateGenres < ActiveRecord::Migration[7.1]
109
+ def change
110
+ create_table :genres do |t|
111
+ t.jsonb :i18n # stores ALL the translated attributes; persisted as JSON (`t.json :i18n` on SQLite / MySQL, or on PostgreSQL if you prefer json over jsonb)
112
+
113
+ t.timestamps
114
+ end
115
+ end
116
+ end
117
+
118
+ class Genre < ActiveRecord::Base
119
+ translates :name, :description, storage: :json # :json for json and jsonb columns (:jsonb is accepted as well), :hstore for an hstore column
120
+ end
121
+
122
+ Genre.where("i18n -> 'de' ->> 'name' = ?", "Science-Fiction") # PostgreSQL json / jsonb
123
+ Genre.where("json_extract(i18n, '$.de.name') = ?", "Science-Fiction") # SQLite json
124
+ Genre.where("JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.de.name')) = ?", "Science-Fiction") # MySQL json
125
+ ```
126
+
127
+ For an `hstore` column, the migration needs `enable_extension 'hstore'`, and each translation is stored under the key `"<locale>.<attribute>"`, because `hstore` cannot nest:
128
+
129
+ ```ruby
130
+ class CreateGenres < ActiveRecord::Migration[7.1]
131
+ def change
132
+ enable_extension 'hstore'
133
+
134
+ create_table :genres do |t|
135
+ t.hstore :i18n # stores ALL the translated attributes; persisted as "en.name" => "...", "de.name" => "...", ...
136
+
137
+ t.timestamps
138
+ end
139
+ end
140
+ end
141
+
142
+ class Genre < ActiveRecord::Base
143
+ translates :name, :description, storage: :hstore
144
+ end
145
+
146
+ Genre.where("i18n -> 'de.name' = ?", "Science-Fiction")
147
+ ```
148
+
149
+ The extra DB column for the default locale from Example 2 (`t.string :name`) works the same way with all three storages.
150
+
151
+ Notes:
152
+
153
+ * In Ruby, the `i18n` Hash always has Symbol keys for locales and attributes, whatever the storage: `genre.i18n # => {en: {name: "Science Fiction"}, de: {name: "Science-Fiction"}}`.
154
+ * Existing tables keep working unchanged: the default is still the YAML `text` column. Changing the column type of an existing table means converting the stored YAML to JSON or hstore in a data migration; the gem does not do that for you.
155
+ * `json` columns work on SQLite, MySQL and PostgreSQL; `jsonb` and `hstore` columns are PostgreSQL only. The test suite runs against all three databases (`DB=postgresql` / `DB=mysql`, default SQLite).
156
+ * `Genre.translation_storage # => :json` reports the storage of a model.
157
+
158
+
92
159
  # Usage
93
160
 
94
161
  In your code you can modify the values of your translated attributes in two ways.
@@ -162,7 +229,20 @@ g.set_localized_attribute( :name, :jp, "サイエンスフィクション" )
162
229
  scifi = Genre.where(:name => "science fiction").first
163
230
  ```
164
231
 
165
- Limitation: You can not search for the translated strings other than for your default locale.
232
+ Limitation: with the YAML text column (the default), you can not search for the translated strings other than for your default locale.
233
+
234
+ With a `json` / `jsonb` / `hstore` column (see Example 3) every locale can be searched, and the extra DB column for the default locale is still possible on top of that:
235
+
236
+ ```ruby
237
+ class Genre < ActiveRecord::Base
238
+ translates :name, :description, storage: :json # or :hstore
239
+ end
240
+
241
+ Genre.where("i18n -> 'jp' ->> 'name' = ?", "サイエンスフィクション") # PostgreSQL json / jsonb
242
+ Genre.where("json_extract(i18n, '$.jp.name') = ?", "サイエンスフィクション") # SQLite json
243
+ Genre.where("JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.jp.name')) = ?", "サイエンスフィクション") # MySQL json
244
+ Genre.where("i18n -> 'jp.name' = ?", "サイエンスフィクション") # PostgreSQL hstore (storage: :hstore)
245
+ ```
166
246
 
167
247
 
168
248
  ## Data Migration
@@ -179,9 +259,41 @@ end
179
259
  Genre.record_timestamps = true
180
260
  ```
181
261
 
262
+ ### Converting the `i18n` column from YAML text to json / jsonb / hstore
263
+
264
+ The gem does not convert stored data. This migration does it for a table that was created as in Example 1 or 2: it renames the old column, adds the new one, copies every record's translations, and drops the old column. The model inside the migration reads the old column with `serialize` (the way the gem wrote it) and writes the new column through `translates ... storage:`, so the conversion is the same for `:json`, `:jsonb` and `:hstore`.
265
+
266
+ ```ruby
267
+ class ConvertGenresI18nToJsonb < ActiveRecord::Migration[7.1]
268
+ # a model for this migration only
269
+ class Genre < ActiveRecord::Base
270
+ serialize :i18n_yaml, coder: YAML, type: Hash # the old column
271
+ translates :name, :description, storage: :jsonb # the new column; :json or :hstore work the same way
272
+ end
273
+
274
+ def up
275
+ rename_column :genres, :i18n, :i18n_yaml
276
+ add_column :genres, :i18n, :jsonb # or :json / :hstore (hstore also needs enable_extension 'hstore')
277
+ Genre.reset_column_information
278
+
279
+ Genre.find_each do |genre|
280
+ genre.update_column(:i18n, genre.i18n_yaml) # update_column: no validations, no callbacks, timestamps untouched
281
+ end
282
+
283
+ remove_column :genres, :i18n_yaml
284
+ end
285
+
286
+ def down
287
+ raise ActiveRecord::IrreversibleMigration
288
+ end
289
+ end
290
+ ```
291
+
292
+ Afterwards, change the model to `translates :name, :description, storage: :jsonb` (or `:json` / `:hstore`). This migration is run by the test suite against SQLite (json), MySQL (json) and PostgreSQL (json, jsonb, hstore), see `spec/embedded_localization/storage_conversion_spec.rb`.
293
+
182
294
  ## I18n fallbacks for empty translations
183
295
 
184
- It is possible to enable fallbacks for empty translations. It will depend on the configuration setting you have set for I18n translations in your Rails config, or you can enable fallback when you define the translation fields. Currently we only support fallback to `I18n.default_locale`
296
+ It is possible to enable fallbacks for empty translations. It will depend on the configuration setting you have set for I18n translations in your Rails config, or you can enable fallback when you define the translation fields. The fallback locales are the chain configured in `I18n.fallbacks` (e.g. `config.i18n.fallbacks = { 'de-AT' => 'de' }` makes `:"de-AT"` fall back to `:de`), followed by `I18n.default_locale`, which is always the last fallback; without a configured chain, the fallback is `I18n.default_locale` alone.
185
297
 
186
298
  You can enable them by adding the next line to `config/application.rb` (or only `config/environments/production.rb` if you only want them in production)
187
299
 
@@ -211,6 +323,22 @@ I18n.locale = :de
211
323
  g.name # => 'science fiction'
212
324
  ```
213
325
 
326
+ With a fallback chain (Rails: `config.i18n.fallbacks = { 'de-AT' => 'de' }`), the locales are tried in this order: the requested locale, its chain, then `I18n.default_locale`:
327
+
328
+ ```ruby
329
+ class Genre < ActiveRecord::Base
330
+ translates :name, :description, :fallbacks => true
331
+ end
332
+
333
+ g = Genre.first
334
+ g.name(:en) # => 'science fiction'
335
+ g.name(:de) # => 'Science-Fiction'
336
+ g.name(:"de-AT") # => 'Science-Fiction' (no :"de-AT" translation, so :de is used)
337
+ g.name(:fr) # => 'science fiction' (no chain for :fr, so I18n.default_locale is used)
338
+
339
+ Genre.fallback_locales(:"de-AT") # => [:de, :en] the locales tried after :"de-AT"
340
+ ```
341
+
214
342
  ## Want some Candy?
215
343
 
216
344
  It's nice to have the values of attributes be set or read with the current locale, but `embedded_localization` offers you a couple of additional features, which can come in handy.
@@ -222,6 +350,7 @@ Each class which uses `embedded_localization` will have these additional methods
222
350
  * Klass.translated_attributes
223
351
  * Klass.translated?
224
352
  * Klass.fallbacks?
353
+ * Klass.translation_storage
225
354
 
226
355
  e.g.:
227
356
 
@@ -229,6 +358,7 @@ e.g.:
229
358
  Genre.translated_attributes # => [:name,:description]
230
359
  Genre.translated? # => true
231
360
  Genre.fallbacks? # => false
361
+ Genre.translation_storage # => :yaml (the `storage:` option given to `translates`: :yaml, :json, :jsonb or :hstore)
232
362
  ```
233
363
 
234
364
  ### Instance Methods
data/Rakefile CHANGED
@@ -1,18 +1,15 @@
1
- require "bundler/gem_tasks"
2
-
3
- require 'rubygems'
4
- require 'rake'
1
+ # frozen_string_literal: true
5
2
 
3
+ require 'bundler/gem_tasks'
6
4
  require 'rspec/core/rake_task'
7
5
 
8
- desc "Run RSpec"
9
- RSpec::Core::RakeTask.new do |t|
10
- t.verbose = false
11
- end
6
+ RSpec::Core::RakeTask.new(:spec)
12
7
 
13
- desc "Run specs for all test cases"
14
- task :spec_all do
15
- system "rake spec"
16
- end
8
+ task default: :spec
17
9
 
18
- task :default => :spec
10
+ desc 'Run spec with coverage'
11
+ task :coverage do
12
+ ENV['COVERAGE'] = 'true'
13
+ Rake::Task['spec'].execute
14
+ `open coverage/index.html`
15
+ end
@@ -1,6 +1,7 @@
1
1
  # -*- encoding: utf-8 -*-
2
- $:.push File.expand_path("../lib", __FILE__)
3
- require "embedded_localization/version"
2
+ # frozen_string_literal: true
3
+
4
+ require File.expand_path("lib/embedded_localization/version", __dir__)
4
5
 
5
6
  Gem::Specification.new do |spec|
6
7
  spec.name = "embedded_localization"
@@ -9,24 +10,27 @@ Gem::Specification.new do |spec|
9
10
  spec.email = ["tilo.sloboda@gmail.com"]
10
11
  spec.homepage = "https://github.com/tilo/embedded_localization"
11
12
  spec.summary = %q{Rails I18n: library for embedded ActiveRecord model/data translation}
12
- spec.description = %q{Rails I18n: Embedded_Localization for ActiveRecord is very lightweight, and allows you to transparently store translations of attributes right inside each record -- no extra database tables needed to store the localization data!}
13
+ spec.description = %q{Rails I18n: a very lightweight tool to allow you to transparently store multiple translations of attributes directly inside each DB record -- no extra database tables needed to store the localization data! All translations of a record live in one column: a YAML text column, a json/jsonb column, or a PostgreSQL hstore column.}
14
+ spec.license = "MIT"
13
15
 
14
- # spec.platform = Gem::Platform::RUBY
15
16
  spec.required_ruby_version = ">= 2.5.0"
16
17
 
17
- spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["homepage_uri"] = spec.homepage
18
19
  spec.metadata["source_code_uri"] = spec.homepage
19
- spec.metadata["changelog_uri"] = "https://github.com/tilo/embedded_localization/blob/main/CHANGELOG.md"
20
+ spec.metadata["changelog_uri"] = "https://github.com/tilo/embedded_localization/blob/main/CHANGELOG.md"
21
+ spec.metadata["bug_tracker_uri"] = "https://github.com/tilo/embedded_localization/issues"
20
22
 
21
- spec.files = `git ls-files`.split("\n")
22
- spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
23
- spec.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
23
+ # Files shipped in the gem: everything tracked by git except the specs and the CI / git configuration.
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ f.match(%r{\A(?:spec/|\.(?:git|github|rspec|circleci))})
27
+ end
28
+ end
24
29
  spec.require_paths = ["lib"]
25
- spec.licenses = ['MIT']
26
- # specify any dependencies here; for example:
30
+
27
31
  spec.add_development_dependency "rspec"
32
+ spec.add_development_dependency "simplecov"
28
33
  spec.add_development_dependency "activerecord", ">= 6"
29
34
  spec.add_development_dependency "i18n"
30
35
  spec.add_development_dependency "sqlite3"
31
- # spec.add_runtime_dependency "rest-client"
32
36
  end
@@ -1,15 +1,24 @@
1
1
  module EmbeddedLocalization
2
2
  module ActiveRecord
3
3
  module ActMacro
4
+ STORAGES = [:yaml, :json, :jsonb, :hstore].freeze
5
+
6
+ # translates :name, :description # `i18n` is a text column, stored as YAML (default)
7
+ # translates :name, :description, storage: :json # `i18n` is a json or jsonb column
8
+ # translates :name, :description, storage: :hstore # `i18n` is a PostgreSQL hstore column
9
+ # translates :name, :description, fallbacks: true # a locale without translations reads the I18n.default_locale value
10
+ #
11
+ # for details about I18n fallbacks, check the source:
12
+ # i18n-0.9.0/lib/i18n/backend/fallbacks.rb
13
+ # i18n-0.9.0/lib/i18n/locale/fallbacks.rb
4
14
  def translates(*attr_names)
5
15
  return if translates? # cludge to make sure we don't set this up twice..
6
16
 
7
- # for details about I18n fallbacks, check the source:
8
- # i18n-0.9.0/lib/i18n/backend/fallbacks.rb
9
- # i18n-0.9.0/lib/i18n/locale/fallbacks.rb
10
-
11
- # options[:fallbacks] => true or false # not used at this time
12
17
  options = attr_names.extract_options!
18
+ storage = (options[:storage] || :yaml).to_sym
19
+ unless STORAGES.include?(storage)
20
+ raise ArgumentError, "unknown storage: #{storage.inspect} -- use one of #{STORAGES.inspect}"
21
+ end
13
22
 
14
23
  class_attribute :translated_attribute_names, :translation_options
15
24
  self.translated_attribute_names = attr_names.map(&:to_sym).sort.uniq
@@ -18,73 +27,27 @@ module EmbeddedLocalization
18
27
  include InstanceMethods
19
28
  extend ClassMethods
20
29
 
21
- #-
22
- # if ActiveRecord::Base is in the parent-chain of the class where we are included into:
23
- # ::Rails::Railtie.subclasses.map(&:to_s).include?("ActiveRecord::Railtie")
24
- #+
25
-
26
- serialize :i18n, coder: YAML, type: Hash # we should also protect it from direct assignment by the user
27
-
28
- #-
29
- # if Mongoid::Document is in the list of classes which extends the class we are included into:
30
- # ::Rails::Railtie.subclasses.map(&:to_s).include?("Rails::Mongoid::Railtie")
31
-
32
- # field :i18n, type: Hash
33
- # but on the other hand, Mongoid now supports "localized fields" -- so we don't need to re-implement this.
34
- # Yay! Durran Jordan is awesome! :-) See: http://mongoid.org/docs/documents/localized.html
35
- #
36
- # NOTE: I like how Durran implemented the localization in Mongoid.. too bad I didn't see that before.
37
- # I'm thinking of re-writing this gem to store the localization hash per attribute... hmm... hmm... thinking...
38
- # there would be a couple of advantages to store the I18n-hash per attribute:
39
- # - drop-in internationalization for existing String type attributes
40
- # - works well with rails scaffolding and with protection of attributes (attr_protected / attr_accessible)
41
- # - we can easily hide the internal hash by re-defining the attr-accessors for doing the I18n
42
- # - we can better add the per-attribute versioning, which is planned
43
- # -
44
- #+
30
+ # the `i18n` column holds all translations of the record as {locale => {attribute => value}}
31
+ # we should also protect it from direct assignment by the user
32
+ case storage
33
+ when :yaml
34
+ serialize :i18n, coder: YAML, type: Hash
35
+ when :json, :jsonb
36
+ attribute :i18n, EmbeddedLocalization::Storage::Json.new
37
+ when :hstore
38
+ attribute :i18n, EmbeddedLocalization::Storage::Hstore.new
39
+ end
45
40
 
46
41
  after_initialize :initialize_i18n_hashes
47
42
 
48
43
  # dynamically define the accessors for the translated attributes:
49
-
50
44
  translated_attribute_names.each do |attr_name|
51
- class_eval do
52
- # define the getter method
53
- #
54
- define_method(attr_name) do |locale = I18n.locale|
55
- if self.i18n.has_key?(locale)
56
- if self.i18n[locale].keys.include?(attr_name)
57
- self.i18n[ locale ][attr_name]
58
- else
59
- nil
60
- end
61
- else
62
- # fallback to the I18n.default_locale if we do fallbacks:
63
- if self.class.fallbacks? && self.i18n[ I18n.default_locale ]
64
- return self.i18n[ I18n.default_locale ][attr_name]
65
- else
66
- return nil
67
- end
68
- end
69
- end
70
-
71
- # define the setter method
72
- #
73
- define_method(attr_name.to_s+ '=') do |new_translation|
74
- # first check if nothing changed - then we can just return, so that timestamps and other records don't get touched
75
- if self.i18n.class == Hash && (self.i18n[I18n.locale]) && (self.i18n[I18n.locale][attr_name.to_sym] == new_translation)
76
- return if (I18n.locale != I18n.default_locale)
77
- return if (I18n.locale == I18n.default_locale) && (read_attribute(attr_name) == new_translation) # both i18n and attr_name need to be equal to new_translation
78
- end
45
+ define_method(attr_name) do |locale = I18n.locale|
46
+ get_localized_attribute(attr_name, locale)
47
+ end
79
48
 
80
- self.i18n_will_change! # for ActiveModel Dirty tracking
81
- if self.attributes.has_key?(attr_name.to_s) # if user has defined DB field with that name
82
- write_attribute(attr_name , new_translation) if I18n.locale == I18n.default_locale
83
- end
84
- self.i18n ||= Hash.new
85
- self.i18n[I18n.locale] ||= Hash.new
86
- self.i18n[I18n.locale][attr_name.to_sym] = new_translation
87
- end
49
+ define_method("#{attr_name}=") do |new_translation|
50
+ set_localized_attribute(attr_name, I18n.locale, new_translation)
88
51
  end
89
52
  end
90
53
  end
@@ -95,4 +58,3 @@ module EmbeddedLocalization
95
58
  end
96
59
  end
97
60
  end
98
-
@@ -16,12 +16,26 @@ module EmbeddedLocalization
16
16
  translated_attribute_names.include?(name.to_sym)
17
17
  end
18
18
 
19
+ # How the `i18n` column is stored: :yaml (text column, the default), :json, :jsonb or :hstore
20
+ def translation_storage
21
+ (translation_options[:storage] || :yaml).to_sym
22
+ end
23
+
19
24
  # # determine if we are using fallbacks
20
25
  def fallbacks?
21
26
  i18n_fallbacks = I18n.backend.class.included_modules.map(&:to_s).include?('I18n::Backend::Fallbacks') # will be true if config.i18n.fallbacks => true in config
22
27
  i18n_fallbacks || translation_options[:fallbacks] == true
23
28
  end
24
29
 
30
+ # The locales to look at, in order, when `locale` has no translation for an attribute:
31
+ # the chain configured in I18n.fallbacks (e.g. config.i18n.fallbacks = { 'de-AT' => 'de' } gives :de for :"de-AT";
32
+ # I18n.fallbacks exists once i18n/backend/fallbacks is loaded, which Rails does for config.i18n.fallbacks),
33
+ # followed by I18n.default_locale. `locale` itself is not part of the result.
34
+ def fallback_locales(locale)
35
+ chain = I18n.respond_to?(:fallbacks) ? I18n.fallbacks[locale] : []
36
+ (chain + [I18n.default_locale]).uniq - [locale]
37
+ end
38
+
25
39
  #-
26
40
  # # fetch the fallbacks from the i18n backend
27
41
  # def fallbacks
@@ -2,44 +2,42 @@ module EmbeddedLocalization
2
2
  module ActiveRecord
3
3
  module InstanceMethods
4
4
 
5
- # - we only support fallbacks to I18n.default_locale for now
5
+ # Returns the translation of attr_name for the given locale; nil if there is none.
6
6
  # - will convert given locale to symbol, e.g. "en","En" to :en
7
-
7
+ # - with fallbacks (see ClassMethods#fallbacks?), a nil translation is looked up in the fallback locales instead:
8
+ # the chain configured in I18n.fallbacks, then I18n.default_locale (see ClassMethods#fallback_locales)
8
9
  def get_localized_attribute(attr_name, locale)
9
- locale = locale.downcase.to_sym if locale.class == String # ensure that locale is always a symbol
10
+ attr_name = attr_name.to_sym
11
+ locale = normalize_locale(locale)
10
12
 
11
- if self.i18n.has_key?(locale)
12
- if self.i18n[locale].keys.include?(attr_name)
13
- self.i18n[locale][attr_name]
14
- else
15
- nil
16
- end
17
- else
18
- if self.class.fallbacks? && self.i18n[ I18n.default_locale ]
19
- return self.i18n[ I18n.default_locale ][attr_name]
20
- else
21
- return nil
22
- end
13
+ translation = translation_for(attr_name, locale)
14
+ return translation unless translation.nil? && self.class.fallbacks?
15
+
16
+ self.class.fallback_locales(locale).each do |fallback_locale|
17
+ translation = translation_for(attr_name, fallback_locale)
18
+ return translation unless translation.nil?
23
19
  end
20
+ nil
24
21
  end
25
22
 
23
+ # Sets the translation of attr_name for the given locale.
26
24
  # - will convert given locale to symbol, e.g. "en","En" to :en
27
-
25
+ # - for I18n.default_locale, a DB column with the attribute's name (if the user defined one) is written too,
26
+ # so that the default locale values can be used in SQL queries
27
+ # - does nothing when the value is unchanged, so the record stays clean and its timestamps are not touched
28
28
  def set_localized_attribute(attr_name, locale, new_translation)
29
- locale = locale.downcase.to_sym if locale.class == String # ensure that locale is always a symbol
29
+ attr_name = attr_name.to_sym
30
+ locale = normalize_locale(locale)
30
31
 
31
- # first check if nothing changed - then we can just return, so that timestamps and other records don't get touched
32
- if self.i18n.class == Hash && (self.i18n[I18n.locale]) && (self.i18n[I18n.locale][attr_name.to_sym] == new_translation)
33
- return if (I18n.locale != I18n.default_locale)
34
- return if (I18n.locale == I18n.default_locale) && (read_attribute(attr_name) == new_translation) # both i18n and attr_name need to be equal to new_translation
35
- end
32
+ return if translation_unchanged?(attr_name, locale, new_translation)
36
33
 
37
34
  self.i18n_will_change! # for ActiveModel Dirty tracking
38
- if self.attributes.has_key?(attr_name.to_s) # if user has defined DB field with that name
39
- write_attribute(attr_name , new_translation) if locale == I18n.default_locale
35
+ if native_column?(attr_name) && locale == I18n.default_locale
36
+ write_attribute(attr_name, new_translation)
40
37
  end
38
+ self.i18n ||= Hash.new
41
39
  self.i18n[locale] ||= Hash.new
42
- self.i18n[locale][attr_name.to_sym] = new_translation
40
+ self.i18n[locale][attr_name] = new_translation
43
41
  end
44
42
 
45
43
  # Returns all locales used for translation of all documents of this class.
@@ -63,7 +61,6 @@ module EmbeddedLocalization
63
61
  #
64
62
  def translated?(name)
65
63
  self.class.translated?(name)
66
- # self.class.instance_variable_get(translated_attribute_names).include?(name.to_sym)
67
64
  end
68
65
 
69
66
  # Purpose: to see the translation coverage
@@ -114,14 +111,37 @@ module EmbeddedLocalization
114
111
  end
115
112
 
116
113
  private
117
- # initialized the serialized 'i18n' attribute with Hash of Hashes,
118
- # containing all pre-defined translated attributes with nil value
114
+
115
+ # initializes the `i18n` attribute with an empty Hash for I18n.locale and for I18n.default_locale
119
116
  def initialize_i18n_hashes
120
117
  self.i18n ||= Hash.new
121
- self.i18n[ I18n.locale ] ||= Hash.new(Hash.zip(translated_attribute_names,[]))
122
- if I18n.locale != I18n.default_locale
123
- self.i18n[ I18n.default_locale ] ||= Hash.new(Hash.zip(translated_attribute_names,[]))
124
- end
118
+ self.i18n[ I18n.locale ] ||= Hash.new
119
+ self.i18n[ I18n.default_locale ] ||= Hash.new
120
+ end
121
+
122
+ def normalize_locale(locale)
123
+ locale.is_a?(String) ? locale.downcase.to_sym : locale # ensure that locale is always a symbol
124
+ end
125
+
126
+ # the stored translation of attr_name in locale; nil when the locale or the attribute is not there
127
+ def translation_for(attr_name, locale)
128
+ translations = self.i18n[locale]
129
+ translations[attr_name] if translations
130
+ end
131
+
132
+ # did the user define a DB column with the name of the translated attribute?
133
+ def native_column?(attr_name)
134
+ has_attribute?(attr_name)
135
+ end
136
+
137
+ # true when the record already holds exactly this translation -- and, for I18n.default_locale on a model
138
+ # with a native column for the attribute, the column holds it too
139
+ def translation_unchanged?(attr_name, locale, new_translation)
140
+ return false unless self.i18n.is_a?(Hash) && self.i18n[locale]
141
+ return false unless self.i18n[locale][attr_name] == new_translation
142
+ return true unless locale == I18n.default_locale && native_column?(attr_name)
143
+
144
+ read_attribute(attr_name) == new_translation
125
145
  end
126
146
 
127
147
  end