embedded_localization 1.3.1 → 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: 162d9b08b64767f51c21f4b82a05973b3f8b351c783e0d8dc11b9d79e67900fd
4
- data.tar.gz: d397c1f3bcdc9968ea13b375c2434c7c9eab5fd298c89ae91f607e050a93a14f
3
+ metadata.gz: 6957e8085bbd37d3849c1b6f53b58f5d43188277db18acb757da7072d72ca6bc
4
+ data.tar.gz: 2f1ad574f456f63b92ab8f569e30da8a6d929ca70b941c4dfa1e5685694e0ce9
5
5
  SHA512:
6
- metadata.gz: 7dafa255cbae9e668e862e6cb7dbf684554d205c14b14dd34e13dfd8e5516e556545558cecbd313f1b87957d4ddd81b9c5a8e7ef80bb0f9545ac2c836969f9d6
7
- data.tar.gz: 8284a738edaf160761752d7eeaaa6ff73a24cc898b82c46dbca830c6ce39a66f524f7ad1d287cc88d3749ae01869cc61ae091c0e5ca3c022ee6935465369d79f
6
+ metadata.gz: 940207de0ca5710093871a3abada7fc47ff1eef2993f0038f3bc624a94c1b7af9971fc817eaefadd0db73a1e88ac28f650e55237c1f2f04a72a49516883b6db6
7
+ data.tar.gz: dc86f831f57c6ec45e8192a8f1ae1dda75c2ae7f0107ff36b4a48ab68840c657e39031b115431d3967825ab2866b39183414e00a4c6825e0265038f55a4f26d7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 1.3.1 (2024-11-26)
4
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))
5
15
 
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,10 @@
1
1
  # Embedded Localization
2
2
 
3
- [![codecov](https://codecov.io/gh/tilo/embedded_localization/graph/badge.svg?token=MX3ULB0S1Y)](https://codecov.io/gh/tilo/embedded_localization) ![Gem Version](https://img.shields.io/gem/v/embedded_localization) [View on RubyGems](https://rubygems.org/gems/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
5
  `embedded_localization` allows you to store your translations directly insight each record.
6
6
 
7
- `embedded_localization` is compatible with Rails 6.x, 7.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)
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
8
 
9
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.
10
10
 
@@ -26,7 +26,8 @@ If your requirements are different, this approach might not work for you. In tha
26
26
 
27
27
  ## Requirements
28
28
 
29
- * ActiveRecord >= 6 # including 7.x
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
30
31
  * [I18n](http://guides.rubyonrails.org/i18n.html)
31
32
 
32
33
  ## Installation
@@ -40,6 +41,8 @@ To install Embedded_Localization, use:
40
41
 
41
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.
42
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
+
43
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`.
44
47
 
45
48
  Model translations allow you to translate your models’ attribute values. The attribute type needs to be string or text.
@@ -91,6 +94,68 @@ class CreateGenres < ActiveRecord::Migration
91
94
  end
92
95
  ```
93
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
+
94
159
  # Usage
95
160
 
96
161
  In your code you can modify the values of your translated attributes in two ways.
@@ -164,7 +229,20 @@ g.set_localized_attribute( :name, :jp, "サイエンスフィクション" )
164
229
  scifi = Genre.where(:name => "science fiction").first
165
230
  ```
166
231
 
167
- 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
+ ```
168
246
 
169
247
 
170
248
  ## Data Migration
@@ -181,9 +259,41 @@ end
181
259
  Genre.record_timestamps = true
182
260
  ```
183
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
+
184
294
  ## I18n fallbacks for empty translations
185
295
 
186
- 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.
187
297
 
188
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)
189
299
 
@@ -213,6 +323,22 @@ I18n.locale = :de
213
323
  g.name # => 'science fiction'
214
324
  ```
215
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
+
216
342
  ## Want some Candy?
217
343
 
218
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.
@@ -224,6 +350,7 @@ Each class which uses `embedded_localization` will have these additional methods
224
350
  * Klass.translated_attributes
225
351
  * Klass.translated?
226
352
  * Klass.fallbacks?
353
+ * Klass.translation_storage
227
354
 
228
355
  e.g.:
229
356
 
@@ -231,6 +358,7 @@ e.g.:
231
358
  Genre.translated_attributes # => [:name,:description]
232
359
  Genre.translated? # => true
233
360
  Genre.fallbacks? # => false
361
+ Genre.translation_storage # => :yaml (the `storage:` option given to `translates`: :yaml, :json, :jsonb or :hstore)
234
362
  ```
235
363
 
236
364
  ### Instance Methods
data/Rakefile CHANGED
@@ -1,21 +1,11 @@
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
12
-
13
- desc "Run specs for all test cases"
14
- task :spec_all do
15
- system "rake spec"
16
- end
6
+ RSpec::Core::RakeTask.new(:spec)
17
7
 
18
- task :default => :spec
8
+ task default: :spec
19
9
 
20
10
  desc 'Run spec with coverage'
21
11
  task :coverage do
@@ -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,25 +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: 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!}
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:
27
- spec.add_development_dependency "codecov"
30
+
28
31
  spec.add_development_dependency "rspec"
32
+ spec.add_development_dependency "simplecov"
29
33
  spec.add_development_dependency "activerecord", ">= 6"
30
34
  spec.add_development_dependency "i18n"
31
35
  spec.add_development_dependency "sqlite3"
32
- # spec.add_runtime_dependency "rest-client"
33
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