From 1ea5253bd71676bf309cb0ba8dda85f672551c6b Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Mon, 24 Aug 2026 01:16:08 +0200 Subject: [PATCH 1/5] CI: fix the red test matrix; test ActiveRecord 6.1-8.1 against SQLite and PostgreSQL Every job of the Ruby workflow had been red since 2025-08 (tests passed, the codecov gem upload failed afterwards; JRuby cannot build the sqlite3 C extension; Ruby 2.5 has no setup-ruby build for the arm64 macos-latest runner; Rails 6.1 + concurrent-ruby >= 1.3.5 needs `require 'logger'` before `require 'active_record'`). - ruby.yml: Ruby 2.5-4.0, head, truffleruby(-head) on ubuntu + macos (2.5 excluded on macos only); JRuby removed; new rails_matrix job: ActiveRecord 6.1, 7.0, 7.1, 7.2, 8.0, 8.1 x SQLite / PostgreSQL (postgres service container), driven by RAILS_VERSION and DB; coverage upload via codecov/codecov-action@v7 (fail_ci_if_error: false); actions/checkout@v7 - spec_helper.rb: codecov gem block removed (SimpleCov unchanged); `require 'logger'` first; DB=postgresql selects PostgreSQL - Gemfile: https source; simplecov; pg only with DB=postgresql; activerecord pinned by RAILS_VERSION, with sqlite3 ~> 1.4 for Rails < 7.1 - gemspec: codecov removed, simplecov added, activerecord >= 6 kept as development dependency; spec.files excludes spec/ and the CI/git configuration; metadata bug_tracker_uri - Rakefile: same structure as smarter_csv (default task spec, coverage task kept) - .gitignore: coverage/, .rspec_status, vendor/bundle, .DS_Store --- .github/workflows/ruby.yml | 73 +++++++++++++++++---- .gitignore | 4 ++ Gemfile | 16 ++++- Rakefile | 18 ++--- embedded_localization.gemspec | 29 ++++---- lib/embedded_localization/storage/hstore.rb | 61 +++++++++++++++++ lib/embedded_localization/storage/json.rb | 25 +++++++ lib/extensions/hash.rb | 7 -- spec/spec_helper.rb | 14 ++-- 9 files changed, 192 insertions(+), 55 deletions(-) create mode 100644 lib/embedded_localization/storage/hstore.rb create mode 100644 lib/embedded_localization/storage/json.rb delete mode 100644 lib/extensions/hash.rb diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 367ccf4..c479944 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -11,6 +11,8 @@ on: pull_request: jobs: + # Every supported Ruby, each with the newest ActiveRecord that Ruby can run, against SQLite (yaml and json columns). + # JRuby is not in the matrix: the test suite needs the sqlite3 gem, which is a C extension JRuby cannot load. test_matrix: strategy: @@ -27,38 +29,85 @@ jobs: - 3.1 - 3.2 - 3.3 + - "3.4" + - "4.0" - head - truffleruby - truffleruby-head - - mingw - - jruby - - jruby-head exclude: - - { os: ubuntu, ruby: mingw } - - { os: macos, ruby: mingw } - - { os: windows, ruby: truffleruby } - - { os: windows, ruby: truffleruby-head } + - { os: macos, ruby: 2.5 } # ruby/setup-ruby has no CRuby < 2.6 build for the arm64 macos-latest runner runs-on: ${{ matrix.os }}-latest steps: - name: clone ${{ github.repository }} - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: setup Ruby ${{ matrix.ruby }} uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - env: - JAVA_OPTS: -Djdk.io.File.enableADS=true - name: Run tests run: bundle exec rake + + # Every supported ActiveRecord version, against SQLite (yaml and json columns) and PostgreSQL (yaml, json, jsonb and hstore columns). + # RAILS_VERSION and DB are read by the Gemfile and spec/spec_helper.rb; `include` picks a Ruby each Rails version supports. + rails_matrix: + + strategy: + fail-fast: false + matrix: + rails: ["6.1", "7.0", "7.1", "7.2", "8.0", "8.1"] + db: [sqlite3, postgresql] + include: + - { rails: "6.1", ruby: "3.0" } + - { rails: "7.0", ruby: "3.2" } + - { rails: "7.1", ruby: "3.4" } + - { rails: "7.2", ruby: "3.4" } + - { rails: "8.0", ruby: "3.4" } + - { rails: "8.1", ruby: "3.4" } + + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 env: - JAVA_OPTS: -Djdk.io.File.enableADS=true + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: embedded_localization_test + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + env: + RAILS_VERSION: ${{ matrix.rails }} + DB: ${{ matrix.db }} + PGHOST: localhost + PGUSER: postgres + PGPASSWORD: postgres + + steps: + - name: clone ${{ github.repository }} + uses: actions/checkout@v7 + - name: setup Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - name: Run tests + run: bundle exec rake + - name: Upload coverage to Codecov + if: matrix.db == 'postgresql' && matrix.rails == '8.1' # the PostgreSQL run covers the jsonb and hstore code paths too + uses: codecov/codecov-action@v7 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/.resultset.json + fail_ci_if_error: false finish: runs-on: ubuntu-latest - needs: [ test_matrix ] + needs: [ test_matrix, rails_matrix ] steps: - name: Wait for status checks run: echo "All Green!" diff --git a/.gitignore b/.gitignore index 90b5823..fe2be08 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,9 @@ *.bak *.gem .bundle +.DS_Store +.rspec_status Gemfile.lock +coverage/ pkg/* +vendor/bundle diff --git a/Gemfile b/Gemfile index 68458c7..744bab6 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,6 @@ -source "http://rubygems.org" +# frozen_string_literal: true + +source 'https://rubygems.org' # Specify your gem's dependencies in embedded_localization.gemspec gemspec @@ -7,7 +9,15 @@ gem 'rake' group :test do gem 'rspec' - gem 'activerecord' + gem 'simplecov' gem 'i18n' - gem 'sqlite3' + gem 'pg' if ENV['DB'] == 'postgresql' # DB=postgresql bundle exec rake (jsonb and hstore columns) + + if ENV['RAILS_VERSION'] # RAILS_VERSION=7.2 bundle exec rake + gem 'activerecord', "~> #{ENV['RAILS_VERSION']}.0" + 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 + else + gem 'activerecord' + gem 'sqlite3' + end end diff --git a/Rakefile b/Rakefile index dcb69ca..21be9a2 100644 --- a/Rakefile +++ b/Rakefile @@ -1,21 +1,11 @@ -require "bundler/gem_tasks" - -require 'rubygems' -require 'rake' +# frozen_string_literal: true +require 'bundler/gem_tasks' require 'rspec/core/rake_task' -desc "Run RSpec" -RSpec::Core::RakeTask.new do |t| - t.verbose = false -end - -desc "Run specs for all test cases" -task :spec_all do - system "rake spec" -end +RSpec::Core::RakeTask.new(:spec) -task :default => :spec +task default: :spec desc 'Run spec with coverage' task :coverage do diff --git a/embedded_localization.gemspec b/embedded_localization.gemspec index dcdcd8c..62d02c8 100644 --- a/embedded_localization.gemspec +++ b/embedded_localization.gemspec @@ -1,6 +1,7 @@ # -*- encoding: utf-8 -*- -$:.push File.expand_path("../lib", __FILE__) -require "embedded_localization/version" +# frozen_string_literal: true + +require File.expand_path("lib/embedded_localization/version", __dir__) Gem::Specification.new do |spec| spec.name = "embedded_localization" @@ -9,25 +10,27 @@ Gem::Specification.new do |spec| spec.email = ["tilo.sloboda@gmail.com"] spec.homepage = "https://github.com/tilo/embedded_localization" spec.summary = %q{Rails I18n: library for embedded ActiveRecord model/data translation} - 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!} + 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.} + spec.license = "MIT" - # spec.platform = Gem::Platform::RUBY spec.required_ruby_version = ">= 2.5.0" - spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage - spec.metadata["changelog_uri"] = "https://github.com/tilo/embedded_localization/blob/main/CHANGELOG.md" + spec.metadata["changelog_uri"] = "https://github.com/tilo/embedded_localization/blob/main/CHANGELOG.md" + spec.metadata["bug_tracker_uri"] = "https://github.com/tilo/embedded_localization/issues" - spec.files = `git ls-files`.split("\n") - spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") - spec.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } + # Files shipped in the gem: everything tracked by git except the specs and the CI / git configuration. + spec.files = Dir.chdir(__dir__) do + `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{\A(?:spec/|\.(?:git|github|rspec|circleci))}) + end + end spec.require_paths = ["lib"] - spec.licenses = ['MIT'] - # specify any dependencies here; for example: - spec.add_development_dependency "codecov" + spec.add_development_dependency "rspec" + spec.add_development_dependency "simplecov" spec.add_development_dependency "activerecord", ">= 6" spec.add_development_dependency "i18n" spec.add_development_dependency "sqlite3" - # spec.add_runtime_dependency "rest-client" end diff --git a/lib/embedded_localization/storage/hstore.rb b/lib/embedded_localization/storage/hstore.rb new file mode 100644 index 0000000..07c9039 --- /dev/null +++ b/lib/embedded_localization/storage/hstore.rb @@ -0,0 +1,61 @@ +module EmbeddedLocalization + module Storage + # ActiveRecord attribute type for a PostgreSQL hstore `i18n` column. + # + # hstore is a flat String => String map, so each translation is stored under the key ".", + # e.g. "de.name" => "Science-Fiction". In Ruby the gem works with the nested Hash {de: {name: "Science-Fiction"}}. + # Reading and writing the hstore text format is delegated to ActiveRecord's own hstore type. + class Hstore < ::ActiveRecord::Type::Value + include ::ActiveModel::Type::Helpers::Mutable + + SEPARATOR = '.'.freeze + + def type + :hstore + end + + def deserialize(value) + nest(hstore.deserialize(value)) + end + + def serialize(value) + hstore.serialize(flatten(value)) + end + + def changed_in_place?(raw_old_value, new_value) + deserialize(raw_old_value) != new_value + end + + private + + # ActiveRecord registers the hstore type when the PostgreSQL adapter is loaded, i.e. when the connection is + # established; it is looked up on first use, so a model can be defined before the connection exists. + def hstore + @hstore ||= ::ActiveRecord::Type.lookup(:hstore, adapter: :postgresql) + end + + # {de: {name: "x"}} => {"de.name" => "x"} + def flatten(translations) + return translations unless translations.is_a?(::Hash) + + translations.each_with_object({}) do |(locale, attributes), flat| + next unless attributes.is_a?(::Hash) + + attributes.each { |attr_name, value| flat["#{locale}#{SEPARATOR}#{attr_name}"] = value } + end + end + + # {"de.name" => "x"} => {de: {name: "x"}} ; keys without a separator are ignored + def nest(flat) + return flat unless flat.is_a?(::Hash) + + flat.each_with_object({}) do |(key, value), translations| + locale, attr_name = key.split(SEPARATOR, 2) + next unless attr_name + + (translations[locale.to_sym] ||= {})[attr_name.to_sym] = value + end + end + end + end +end diff --git a/lib/embedded_localization/storage/json.rb b/lib/embedded_localization/storage/json.rb new file mode 100644 index 0000000..b440778 --- /dev/null +++ b/lib/embedded_localization/storage/json.rb @@ -0,0 +1,25 @@ +module EmbeddedLocalization + module Storage + # ActiveRecord attribute type for a json or jsonb `i18n` column. + # + # The column holds {"en" => {"name" => "..."}, "de" => {...}}; in Ruby the gem works with Symbol keys on both + # levels, so this type converts the keys whenever a value comes from the database. Assignment goes through the + # same conversion: ActiveRecord::Type::Json includes ActiveModel::Type::Helpers::Mutable, whose cast(value) is + # deserialize(serialize(value)). + class Json < ::ActiveRecord::Type::Json + def deserialize(value) + symbolize_keys(super) + end + + private + + def symbolize_keys(translations) + return translations unless translations.is_a?(::Hash) + + translations.each_with_object({}) do |(locale, attributes), result| + result[locale.to_sym] = attributes.is_a?(::Hash) ? attributes.transform_keys(&:to_sym) : attributes + end + end + end + end +end diff --git a/lib/extensions/hash.rb b/lib/extensions/hash.rb deleted file mode 100644 index c70660f..0000000 --- a/lib/extensions/hash.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Hash - def self.zip(keys,values) # from Facets of Ruby library, CREDIT: Trans, Ara T. Howard - h = {} - keys.size.times{ |i| h[ keys[i] ] = values[i] } - h - end -end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d735d6a..5a08aba 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,4 @@ +require 'logger' # Rails 6.1 with concurrent-ruby >= 1.3.5: ActiveSupport::LoggerThreadSafeLevel needs ::Logger loaded first require 'active_record' require 'i18n' @@ -7,14 +8,15 @@ add_filter "/pkg/" end -if ENV['CI'] == 'true' || ENV['CODECOV_TOKEN'] - require 'codecov' - SimpleCov.formatter = SimpleCov::Formatter::Codecov -end - require 'embedded_localization' -ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:" +# DB=postgresql runs the suite against PostgreSQL, which is needed for the jsonb and hstore columns. +# The connection details come from the PG* environment variables (PGHOST, PGUSER, PGPASSWORD, PGDATABASE). +if ENV['DB'] == 'postgresql' + ActiveRecord::Base.establish_connection adapter: 'postgresql', database: ENV.fetch('PGDATABASE', 'embedded_localization_test') +else + ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: ':memory:' +end load File.dirname(__FILE__) + '/schema.rb' require File.dirname(__FILE__) + '/models.rb' From 241361603489609205acc4973604cb882c0fae31 Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Mon, 24 Aug 2026 01:16:08 +0200 Subject: [PATCH 2/5] Add storage: :json / :jsonb / :hstore for the i18n column; fix three bugs in the translation accessors translates :name, storage: :json # json or jsonb column (:jsonb accepted as alias) translates :name, storage: :hstore # PostgreSQL hstore column, keys "." The default (storage: :yaml, text column) is unchanged. - Storage::Json subclasses ActiveRecord::Type::Json and symbolizes both key levels on read - Storage::Hstore flattens / nests the translations and delegates the hstore text format to Rails' own hstore type - Klass.translation_storage reports the storage of a model; unknown storage values raise ArgumentError - the generated getters/setters now delegate to get_localized_attribute / set_localized_attribute Bugs fixed (found by the new tests): - fallbacks: reading an attribute without a translation in I18n.default_locale returned the Hash.new default value (a hash of nils) instead of nil on unsaved records - assigning a translation equal to the current value marked the record as changed on models without a DB column for that attribute (read_attribute of a non-existent column was compared) - set_localized_attribute compared against I18n.locale instead of its locale argument; when the current locale already held the same value, the translation for the other locale was not stored lib/extensions/hash.rb (Hash.zip monkey patch) removed: its only caller was the Hash.new default value above. Tests: storage_spec (one contract for yaml, json, jsonb, hstore), fallbacks_spec, storage_conversion_spec (YAML text column -> json/jsonb/hstore migration), regression tests for the three bugs. jsonb/hstore run with DB=postgresql. --- lib/embedded_localization.rb | 9 +- .../active_record/act_macro.rb | 96 ++++------- .../active_record/class_methods.rb | 5 + .../active_record/instance_methods.rb | 73 ++++---- lib/embedded_localization/version.rb | 2 +- spec/embedded_localization/fallbacks_spec.rb | 40 +++++ .../storage_conversion_spec.rb | 66 ++++++++ spec/embedded_localization/storage_spec.rb | 159 ++++++++++++++++++ spec/models.rb | 47 ++++++ spec/schema.rb | 51 ++++++ 10 files changed, 447 insertions(+), 101 deletions(-) create mode 100644 spec/embedded_localization/fallbacks_spec.rb create mode 100644 spec/embedded_localization/storage_conversion_spec.rb create mode 100644 spec/embedded_localization/storage_spec.rb diff --git a/lib/embedded_localization.rb b/lib/embedded_localization.rb index a07bf30..c2fe364 100644 --- a/lib/embedded_localization.rb +++ b/lib/embedded_localization.rb @@ -1,12 +1,16 @@ require "active_record" require "embedded_localization/version" -require 'extensions/hash' module EmbeddedLocalization autoload :ActiveRecord, 'embedded_localization/active_record' + + # ActiveRecord attribute types for the `i18n` column when it is not a YAML text column + module Storage + autoload :Json, 'embedded_localization/storage/json' + autoload :Hstore, 'embedded_localization/storage/hstore' + end end -# we're assuming for now only to be used with ActiveRecord 3, which is auto-required above ActiveRecord::Base.extend(EmbeddedLocalization::ActiveRecord::ActMacro) #- @@ -30,4 +34,3 @@ module EmbeddedLocalization # not as general: # I18n::Backend::Simple.send(:include, I18n::Backend::Fallbacks) - diff --git a/lib/embedded_localization/active_record/act_macro.rb b/lib/embedded_localization/active_record/act_macro.rb index 51518ed..32b662c 100644 --- a/lib/embedded_localization/active_record/act_macro.rb +++ b/lib/embedded_localization/active_record/act_macro.rb @@ -1,15 +1,24 @@ module EmbeddedLocalization module ActiveRecord module ActMacro + STORAGES = [:yaml, :json, :jsonb, :hstore].freeze + + # translates :name, :description # `i18n` is a text column, stored as YAML (default) + # translates :name, :description, storage: :json # `i18n` is a json or jsonb column + # translates :name, :description, storage: :hstore # `i18n` is a PostgreSQL hstore column + # translates :name, :description, fallbacks: true # a locale without translations reads the I18n.default_locale value + # + # for details about I18n fallbacks, check the source: + # i18n-0.9.0/lib/i18n/backend/fallbacks.rb + # i18n-0.9.0/lib/i18n/locale/fallbacks.rb def translates(*attr_names) return if translates? # cludge to make sure we don't set this up twice.. - # for details about I18n fallbacks, check the source: - # i18n-0.9.0/lib/i18n/backend/fallbacks.rb - # i18n-0.9.0/lib/i18n/locale/fallbacks.rb - - # options[:fallbacks] => true or false # not used at this time options = attr_names.extract_options! + storage = (options[:storage] || :yaml).to_sym + unless STORAGES.include?(storage) + raise ArgumentError, "unknown storage: #{storage.inspect} -- use one of #{STORAGES.inspect}" + end class_attribute :translated_attribute_names, :translation_options self.translated_attribute_names = attr_names.map(&:to_sym).sort.uniq @@ -18,73 +27,27 @@ def translates(*attr_names) include InstanceMethods extend ClassMethods - #- - # if ActiveRecord::Base is in the parent-chain of the class where we are included into: - # ::Rails::Railtie.subclasses.map(&:to_s).include?("ActiveRecord::Railtie") - #+ - - serialize :i18n, coder: YAML, type: Hash # we should also protect it from direct assignment by the user - - #- - # if Mongoid::Document is in the list of classes which extends the class we are included into: - # ::Rails::Railtie.subclasses.map(&:to_s).include?("Rails::Mongoid::Railtie") - - # field :i18n, type: Hash - # but on the other hand, Mongoid now supports "localized fields" -- so we don't need to re-implement this. - # Yay! Durran Jordan is awesome! :-) See: http://mongoid.org/docs/documents/localized.html - # - # NOTE: I like how Durran implemented the localization in Mongoid.. too bad I didn't see that before. - # I'm thinking of re-writing this gem to store the localization hash per attribute... hmm... hmm... thinking... - # there would be a couple of advantages to store the I18n-hash per attribute: - # - drop-in internationalization for existing String type attributes - # - works well with rails scaffolding and with protection of attributes (attr_protected / attr_accessible) - # - we can easily hide the internal hash by re-defining the attr-accessors for doing the I18n - # - we can better add the per-attribute versioning, which is planned - # - - #+ + # the `i18n` column holds all translations of the record as {locale => {attribute => value}} + # we should also protect it from direct assignment by the user + case storage + when :yaml + serialize :i18n, coder: YAML, type: Hash + when :json, :jsonb + attribute :i18n, EmbeddedLocalization::Storage::Json.new + when :hstore + attribute :i18n, EmbeddedLocalization::Storage::Hstore.new + end after_initialize :initialize_i18n_hashes # dynamically define the accessors for the translated attributes: - translated_attribute_names.each do |attr_name| - class_eval do - # define the getter method - # - define_method(attr_name) do |locale = I18n.locale| - if self.i18n.has_key?(locale) - if self.i18n[locale].keys.include?(attr_name) - self.i18n[ locale ][attr_name] - else - nil - end - else - # fallback to the I18n.default_locale if we do fallbacks: - if self.class.fallbacks? && self.i18n[ I18n.default_locale ] - return self.i18n[ I18n.default_locale ][attr_name] - else - return nil - end - end - end - - # define the setter method - # - define_method(attr_name.to_s+ '=') do |new_translation| - # first check if nothing changed - then we can just return, so that timestamps and other records don't get touched - if self.i18n.class == Hash && (self.i18n[I18n.locale]) && (self.i18n[I18n.locale][attr_name.to_sym] == new_translation) - return if (I18n.locale != I18n.default_locale) - 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 - end + define_method(attr_name) do |locale = I18n.locale| + get_localized_attribute(attr_name, locale) + end - self.i18n_will_change! # for ActiveModel Dirty tracking - if self.attributes.has_key?(attr_name.to_s) # if user has defined DB field with that name - write_attribute(attr_name , new_translation) if I18n.locale == I18n.default_locale - end - self.i18n ||= Hash.new - self.i18n[I18n.locale] ||= Hash.new - self.i18n[I18n.locale][attr_name.to_sym] = new_translation - end + define_method("#{attr_name}=") do |new_translation| + set_localized_attribute(attr_name, I18n.locale, new_translation) end end end @@ -95,4 +58,3 @@ def translates? end end end - diff --git a/lib/embedded_localization/active_record/class_methods.rb b/lib/embedded_localization/active_record/class_methods.rb index addb589..98c4ec8 100644 --- a/lib/embedded_localization/active_record/class_methods.rb +++ b/lib/embedded_localization/active_record/class_methods.rb @@ -16,6 +16,11 @@ def translated?(name) translated_attribute_names.include?(name.to_sym) end + # How the `i18n` column is stored: :yaml (text column, the default), :json, :jsonb or :hstore + def translation_storage + (translation_options[:storage] || :yaml).to_sym + end + # # determine if we are using fallbacks def fallbacks? i18n_fallbacks = I18n.backend.class.included_modules.map(&:to_s).include?('I18n::Backend::Fallbacks') # will be true if config.i18n.fallbacks => true in config diff --git a/lib/embedded_localization/active_record/instance_methods.rb b/lib/embedded_localization/active_record/instance_methods.rb index 9a7b13b..ee2220f 100644 --- a/lib/embedded_localization/active_record/instance_methods.rb +++ b/lib/embedded_localization/active_record/instance_methods.rb @@ -2,44 +2,41 @@ module EmbeddedLocalization module ActiveRecord module InstanceMethods - # - we only support fallbacks to I18n.default_locale for now + # Returns the translation of attr_name for the given locale; nil if there is none. # - will convert given locale to symbol, e.g. "en","En" to :en - + # - with `fallbacks: true`, a locale without any translations returns the I18n.default_locale value + # (we only support fallbacks to I18n.default_locale for now) def get_localized_attribute(attr_name, locale) - locale = locale.downcase.to_sym if locale.class == String # ensure that locale is always a symbol + attr_name = attr_name.to_sym + locale = normalize_locale(locale) if self.i18n.has_key?(locale) - if self.i18n[locale].keys.include?(attr_name) - self.i18n[locale][attr_name] - else - nil - end + self.i18n[locale][attr_name] # nil when this locale has no translation for attr_name + elsif self.class.fallbacks? && self.i18n[I18n.default_locale] + self.i18n[I18n.default_locale][attr_name] else - if self.class.fallbacks? && self.i18n[ I18n.default_locale ] - return self.i18n[ I18n.default_locale ][attr_name] - else - return nil - end + nil end end + # Sets the translation of attr_name for the given locale. # - will convert given locale to symbol, e.g. "en","En" to :en - + # - for I18n.default_locale, a DB column with the attribute's name (if the user defined one) is written too, + # so that the default locale values can be used in SQL queries + # - does nothing when the value is unchanged, so the record stays clean and its timestamps are not touched def set_localized_attribute(attr_name, locale, new_translation) - locale = locale.downcase.to_sym if locale.class == String # ensure that locale is always a symbol + attr_name = attr_name.to_sym + locale = normalize_locale(locale) - # first check if nothing changed - then we can just return, so that timestamps and other records don't get touched - if self.i18n.class == Hash && (self.i18n[I18n.locale]) && (self.i18n[I18n.locale][attr_name.to_sym] == new_translation) - return if (I18n.locale != I18n.default_locale) - 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 - end + return if translation_unchanged?(attr_name, locale, new_translation) self.i18n_will_change! # for ActiveModel Dirty tracking - if self.attributes.has_key?(attr_name.to_s) # if user has defined DB field with that name - write_attribute(attr_name , new_translation) if locale == I18n.default_locale + if native_column?(attr_name) && locale == I18n.default_locale + write_attribute(attr_name, new_translation) end + self.i18n ||= Hash.new self.i18n[locale] ||= Hash.new - self.i18n[locale][attr_name.to_sym] = new_translation + self.i18n[locale][attr_name] = new_translation end # Returns all locales used for translation of all documents of this class. @@ -63,7 +60,6 @@ def translated_attributes # def translated?(name) self.class.translated?(name) -# self.class.instance_variable_get(translated_attribute_names).include?(name.to_sym) end # Purpose: to see the translation coverage @@ -114,14 +110,31 @@ def translation_missing( attribute = nil ) end private - # initialized the serialized 'i18n' attribute with Hash of Hashes, - # containing all pre-defined translated attributes with nil value + + # initializes the `i18n` attribute with an empty Hash for I18n.locale and for I18n.default_locale def initialize_i18n_hashes self.i18n ||= Hash.new - self.i18n[ I18n.locale ] ||= Hash.new(Hash.zip(translated_attribute_names,[])) - if I18n.locale != I18n.default_locale - self.i18n[ I18n.default_locale ] ||= Hash.new(Hash.zip(translated_attribute_names,[])) - end + self.i18n[ I18n.locale ] ||= Hash.new + self.i18n[ I18n.default_locale ] ||= Hash.new + end + + def normalize_locale(locale) + locale.is_a?(String) ? locale.downcase.to_sym : locale # ensure that locale is always a symbol + end + + # did the user define a DB column with the name of the translated attribute? + def native_column?(attr_name) + has_attribute?(attr_name) + end + + # true when the record already holds exactly this translation -- and, for I18n.default_locale on a model + # with a native column for the attribute, the column holds it too + def translation_unchanged?(attr_name, locale, new_translation) + return false unless self.i18n.is_a?(Hash) && self.i18n[locale] + return false unless self.i18n[locale][attr_name] == new_translation + return true unless locale == I18n.default_locale && native_column?(attr_name) + + read_attribute(attr_name) == new_translation end end diff --git a/lib/embedded_localization/version.rb b/lib/embedded_localization/version.rb index a0f2ffd..495fc40 100644 --- a/lib/embedded_localization/version.rb +++ b/lib/embedded_localization/version.rb @@ -1,3 +1,3 @@ module EmbeddedLocalization - VERSION = '1.3.1' + VERSION = '1.4.0' end diff --git a/spec/embedded_localization/fallbacks_spec.rb b/spec/embedded_localization/fallbacks_spec.rb new file mode 100644 index 0000000..a25b202 --- /dev/null +++ b/spec/embedded_localization/fallbacks_spec.rb @@ -0,0 +1,40 @@ +# encoding: utf-8 +require 'spec_helper' + +# `translates ..., fallbacks: true` : reading a locale without translations returns the I18n.default_locale value + +describe 'fallbacks to I18n.default_locale' do + let(:genre) do + g = FallbackGenre.new + g.name = 'Science Fiction' + I18n.with_locale(:de) { g.description = 'ein grossartiges Genre' } + g + end + + it 'reports fallbacks? from the translates option' do + FallbackGenre.fallbacks?.should be_truthy + Genre.fallbacks?.should be_falsy + end + + it 'falls back via the getter for a locale without any translations' do + genre.name(:fr).should eq 'Science Fiction' + end + + it 'falls back via get_localized_attribute for a locale without any translations' do + genre.get_localized_attribute(:name, :fr).should eq 'Science Fiction' + genre.get_localized_attribute(:name, 'FR').should eq 'Science Fiction' + end + + it 'does not fall back when the locale has translations but not for that attribute' do + genre.name(:de).should be_nil + genre.get_localized_attribute(:name, :de).should be_nil + end + + it 'returns nil when the default locale has no translation for the attribute either' do + genre.description(:fr).should be_nil + end + + it 'still returns the translation of the requested locale when there is one' do + genre.description(:de).should eq 'ein grossartiges Genre' + end +end diff --git a/spec/embedded_localization/storage_conversion_spec.rb b/spec/embedded_localization/storage_conversion_spec.rb new file mode 100644 index 0000000..677a315 --- /dev/null +++ b/spec/embedded_localization/storage_conversion_spec.rb @@ -0,0 +1,66 @@ +# encoding: utf-8 +require 'spec_helper' + +# Converting an existing YAML `i18n` text column to a json / jsonb / hstore column with the migration shown in the +# README ("Data Migration"): rename the old column, add the new one, copy every record's translations through a model +# that reads the old column with `serialize` and writes the new one with `translates ... storage:`, drop the old column. + +describe 'converting the i18n column from YAML text to another storage' do + STORAGES.reject { |s| s[:storage] == :yaml }.each do |storage| + describe "to a #{storage[:name]}" do + let(:connection) { ActiveRecord::Base.connection } + + before :each do + I18n.locale = I18n.default_locale + connection.create_table(:converted_genres, force: true) { |t| t.text :i18n } + + stub_const('YamlGenre', Class.new(ActiveRecord::Base) do + self.table_name = 'converted_genres' + translates :name, :description + end) + scifi = YamlGenre.new(name: 'Science Fiction') + I18n.with_locale(:de) { scifi.name = 'Science-Fiction' } + scifi.set_localized_attribute(:description, :jp, 'すばらしいジャンル') + scifi.save! + YamlGenre.new(name: 'Horror').save! + end + + after :each do + connection.drop_table(:converted_genres) + end + + it 'keeps every translation and makes them queryable in SQL' do + # the migration: + connection.rename_column :converted_genres, :i18n, :i18n_yaml + connection.add_column :converted_genres, :i18n, storage[:storage] + + stub_const('MigrationGenre', Class.new(ActiveRecord::Base) do + self.table_name = 'converted_genres' + serialize :i18n_yaml, coder: YAML, type: Hash # the old column, read the way the gem wrote it + translates :name, :description, storage: storage[:storage] # the new column + end) + MigrationGenre.find_each do |genre| + genre.update_column(:i18n, genre.i18n_yaml) + end + + connection.remove_column :converted_genres, :i18n_yaml + + # the application afterwards: + stub_const('ConvertedGenre', Class.new(ActiveRecord::Base) do + self.table_name = 'converted_genres' + translates :name, :description, storage: storage[:storage] + end) + ConvertedGenre.count.should eq 2 + + scifi = ConvertedGenre.where(storage[:sql].call(:de, :name), 'Science-Fiction').first + scifi.should_not be_nil + scifi.name.should eq 'Science Fiction' + scifi.name(:de).should eq 'Science-Fiction' + scifi.description(:jp).should eq 'すばらしいジャンル' + scifi.translated_locales.sort.should eq [:de, :en, :jp] + + ConvertedGenre.where(storage[:sql].call(:en, :name), 'Horror').count.should eq 1 + end + end + end +end diff --git a/spec/embedded_localization/storage_spec.rb b/spec/embedded_localization/storage_spec.rb new file mode 100644 index 0000000..d697a39 --- /dev/null +++ b/spec/embedded_localization/storage_spec.rb @@ -0,0 +1,159 @@ +# encoding: utf-8 +require 'spec_helper' + +# One contract for every storage (yaml text column, json / jsonb column, hstore column): +# translations survive save + reload with Symbol keys, nil translations are kept, +# a freshly loaded record is not dirty, and assigning an unchanged value does not dirty it. + +describe 'translation storage' do + + it 'rejects an unknown storage option' do + lambda { + Class.new(ActiveRecord::Base) do + self.table_name = 'genres' + translates :name, storage: :xml + end + }.should raise_error(ArgumentError, /storage/) + end + + STORAGES.each do |storage| + describe storage[:name] do + let(:genre_class) { storage[:genre] } + let(:movie_class) { storage[:movie] } + + before :each do + I18n.locale = I18n.default_locale + genre_class.delete_all + movie_class.delete_all + end + + it 'reports its storage' do + genre_class.translation_storage.should eq storage[:storage] + movie_class.translation_storage.should eq storage[:storage] + end + + describe 'a model without a native column for the translated attribute' do + let(:genre) do + g = genre_class.new + g.name = 'Science Fiction' + g.description = 'an awesome genre' + I18n.with_locale(:de) { g.name = 'Science-Fiction' } + g.set_localized_attribute(:name, :ko, '공상 과학 소설') + g.set_localized_attribute(:description, 'JP', 'すばらしいジャンル') # String locale, any case + g.save! + genre_class.find(g.id) + end + + it 'reloads every translation' do + genre.name.should eq 'Science Fiction' + genre.description.should eq 'an awesome genre' + genre.name(:de).should eq 'Science-Fiction' + genre.name(:ko).should eq '공상 과학 소설' + genre.get_localized_attribute(:description, :jp).should eq 'すばらしいジャンル' + genre.get_localized_attribute(:description, 'jp').should eq 'すばらしいジャンル' + end + + it 'uses Symbol keys for locales and for attributes after reload' do + genre.i18n.keys.should all(be_a(Symbol)) + genre.i18n.values.each { |translations| translations.keys.should all(be_a(Symbol)) } + end + + it 'reports translated_locales, translation_coverage and translation_missing after reload' do + genre.translated_locales.sort.should eq [:de, :en, :jp, :ko] + genre.translation_coverage(:name).sort.should eq [:de, :en, :ko] + genre.translation_coverage(:description).sort.should eq [:en, :jp] + genre.translation_missing(:name).should eq [:jp] + genre.translation_missing(:description).sort.should eq [:de, :ko] + end + + it 'keeps a translation that was set to nil' do + genre.set_localized_attribute(:name, :ko, nil) + genre.save! + reloaded = genre_class.find(genre.id) + reloaded.name(:ko).should be_nil + reloaded.translation_coverage(:name).should include(:ko) + end + + it 'is not changed right after loading from the database' do + genre.changed?.should be_falsy + end + + it 'does not mark the record changed when a translation is set to its current value' do + genre.name = 'Science Fiction' + I18n.with_locale(:de) { genre.name = 'Science-Fiction' } + genre.set_localized_attribute(:name, :ko, '공상 과학 소설') + genre.changed?.should be_falsy + end + + it 'marks the record changed when a translation changes, and saves it' do + I18n.with_locale(:de) { genre.name = 'Sci-Fi' } + genre.i18n_changed?.should be_truthy + genre.save! + genre_class.find(genre.id).name(:de).should eq 'Sci-Fi' + end + + it 'stores a translation for another locale even when it equals the I18n.default_locale value' do + genre.set_localized_attribute(:name, :fr, 'Science Fiction') + genre.save! + genre_class.find(genre.id).name(:fr).should eq 'Science Fiction' + end + + it 'returns nil for a locale without translations, and for an attribute not translated in a locale' do + genre.name(:fr).should be_nil + genre.get_localized_attribute(:name, :fr).should be_nil + genre.get_localized_attribute(:name, :jp).should be_nil + genre.description(:ko).should be_nil + end + + it 'initializes both the current locale and I18n.default_locale for a record created in another locale' do + g = I18n.with_locale(:de) { genre_class.new } + g.translated_locales.sort.should eq [:de, :en] + end + end + + describe 'a model with a native column for the translated attribute' do + let(:movie) do + m = movie_class.new + m.title = 'Blade Runner' + I18n.with_locale(:de) { m.title = 'Der Blade Runner' } + m.save! + movie_class.find(m.id) + end + + it 'writes the I18n.default_locale value into the native column, other locales only into i18n' do + movie.read_attribute(:title).should eq 'Blade Runner' + movie.title(:de).should eq 'Der Blade Runner' + movie_class.where(title: 'Blade Runner').count.should eq 1 + movie_class.where(title: 'Der Blade Runner').count.should eq 0 + end + + it 'keeps the native column and the i18n hash in sync when the default locale value changes' do + movie.title = 'Blade Runner 2049' + movie.save! + reloaded = movie_class.find(movie.id) + reloaded.read_attribute(:title).should eq 'Blade Runner 2049' + reloaded.title(:en).should eq 'Blade Runner 2049' + reloaded.title(:de).should eq 'Der Blade Runner' + end + + it 'stores a translation for another locale even when it equals the native column value' do + movie.set_localized_attribute(:title, :fr, 'Blade Runner') + movie.save! + movie_class.find(movie.id).title(:fr).should eq 'Blade Runner' + end + end + + if storage[:sql] + it 'can be queried in SQL by a translated value' do + scifi = genre_class.new(name: 'Science Fiction') + I18n.with_locale(:de) { scifi.name = 'Science-Fiction' } + scifi.save! + genre_class.new(name: 'Horror').save! + + genre_class.where(storage[:sql].call(:de, :name), 'Science-Fiction').pluck(:id).should eq [scifi.id] + genre_class.where(storage[:sql].call(:en, :name), 'Horror').count.should eq 1 + end + end + end + end +end diff --git a/spec/models.rb b/spec/models.rb index 712f07a..57b3286 100644 --- a/spec/models.rb +++ b/spec/models.rb @@ -5,3 +5,50 @@ class Genre < ActiveRecord::Base class Movie < ActiveRecord::Base translates :title, :description end + +# same table as Genre, with fallbacks to I18n.default_locale enabled +class FallbackGenre < ActiveRecord::Base + self.table_name = 'genres' + translates :name, :description, fallbacks: true +end + +class JsonGenre < ActiveRecord::Base + translates :name, :description, storage: :json +end + +class JsonMovie < ActiveRecord::Base + translates :title, :description, storage: :json +end + +if ENV['DB'] == 'postgresql' + class JsonbGenre < ActiveRecord::Base + translates :name, :description, storage: :jsonb + end + + class JsonbMovie < ActiveRecord::Base + translates :title, :description, storage: :jsonb + end + + class HstoreGenre < ActiveRecord::Base + translates :name, :description, storage: :hstore + end + + class HstoreMovie < ActiveRecord::Base + translates :title, :description, storage: :hstore + end +end + +# Every storage the current database supports. +# :sql builds the WHERE fragment (with one `?` placeholder) that finds a record by one translated value; +# it is nil for the YAML text column, which cannot be queried by value. +STORAGES = [ + { name: 'yaml text column', storage: :yaml, genre: Genre, movie: Movie, sql: nil }, +] + +if ENV['DB'] == 'postgresql' + STORAGES << { name: 'json column', storage: :json, genre: JsonGenre, movie: JsonMovie, sql: ->(locale, attr) { "i18n -> '#{locale}' ->> '#{attr}' = ?" } } + STORAGES << { name: 'jsonb column', storage: :jsonb, genre: JsonbGenre, movie: JsonbMovie, sql: ->(locale, attr) { "i18n -> '#{locale}' ->> '#{attr}' = ?" } } + STORAGES << { name: 'hstore column', storage: :hstore, genre: HstoreGenre, movie: HstoreMovie, sql: ->(locale, attr) { "i18n -> '#{locale}.#{attr}' = ?" } } +else + STORAGES << { name: 'json column', storage: :json, genre: JsonGenre, movie: JsonMovie, sql: ->(locale, attr) { "json_extract(i18n, '$.#{locale}.#{attr}') = ?" } } +end diff --git a/spec/schema.rb b/spec/schema.rb index 13b5f09..4103480 100644 --- a/spec/schema.rb +++ b/spec/schema.rb @@ -15,4 +15,55 @@ t.string :other t.timestamps end + + # json column: SQLite, MySQL and PostgreSQL all have one + create_table :json_genres, :force => true do |t| + t.json :i18n + + t.string :other + t.timestamps + end + + create_table :json_movies, :force => true do |t| + t.string :title + t.json :i18n + + t.string :other + t.timestamps + end + + # jsonb and hstore columns: PostgreSQL only (DB=postgresql) + if ENV['DB'] == 'postgresql' + enable_extension 'hstore' + + create_table :jsonb_genres, :force => true do |t| + t.jsonb :i18n + + t.string :other + t.timestamps + end + + create_table :jsonb_movies, :force => true do |t| + t.string :title + t.jsonb :i18n + + t.string :other + t.timestamps + end + + create_table :hstore_genres, :force => true do |t| + t.hstore :i18n + + t.string :other + t.timestamps + end + + create_table :hstore_movies, :force => true do |t| + t.string :title + t.hstore :i18n + + t.string :other + t.timestamps + end + end end From 3201a101b88dd36a081f7468e1a336301597d86a Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Mon, 24 Aug 2026 01:16:08 +0200 Subject: [PATCH 3/5] Version 1.4.0: README and CHANGELOG README: badges as in smarter_json; Rails 6.1 / 7.x / 8.x and Ruby 2.5+ in Requirements; Example 3 (json / jsonb / hstore columns, with migrations and SQL queries); SQL queries against any locale; data migration from a YAML text column to json / jsonb / hstore; Klass.translation_storage. --- CHANGELOG.md | 8 ++++ README.md | 119 +++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e5b8b..9e3cd3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # EmbeddedLocalization CHANGE LOG +## 1.4.0 (2026-08-24) + * 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) + * 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 + * 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) + * 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 + * removed the `Hash.zip` monkey patch (`lib/extensions/hash.rb`); the gem no longer adds methods to core classes + * CI: ActiveRecord 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 are tested against SQLite and PostgreSQL; Ruby 2.5 through 4.0, `head` and TruffleRuby; coverage upload through `codecov/codecov-action` instead of the unmaintained `codecov` gem + ## 1.3.1 (2024-11-26) * [Issue 14](https://github.com/tilo/embedded_localization/pull/14) Fix active support proxy object deprecation (thanks to [Romain Morlevat](https://github.com/RomainMorlevat)) diff --git a/README.md b/README.md index adf522c..4df50bb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Embedded Localization -[![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) +![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) [![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) `embedded_localization` allows you to store your translations directly insight each record. -`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) +`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) `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. @@ -26,7 +26,8 @@ If your requirements are different, this approach might not work for you. In tha ## Requirements -* ActiveRecord >= 6 # including 7.x +* Ruby >= 2.5 # Ruby 2.5 through 4.0, `head` and TruffleRuby are tested in CI +* ActiveRecord >= 6 # ActiveRecord 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 are tested in CI, against SQLite and PostgreSQL * [I18n](http://guides.rubyonrails.org/i18n.html) ## Installation @@ -40,6 +41,8 @@ To install Embedded_Localization, use: 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. +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). + 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`. Model translations allow you to translate your models’ attribute values. The attribute type needs to be string or text. @@ -91,6 +94,67 @@ class CreateGenres < ActiveRecord::Migration end ``` +### Example 3 + +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: + +| `translates ... storage:` | `i18n` column type | Stored as | SQL example: find `name` translated to `:de` | +|---------------------------|------------------------------------------------------------|--------------------------------------------------------|-------------------------------------------------------------------------------------------| +| `:yaml` (default) | `text` | YAML: `{en: {name: "..."}, de: {name: "..."}}` | not possible | +| `: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') = ?` | +| `:hstore` | `hstore` (PostgreSQL) | flat keys: `"en.name" => "...", "de.name" => "..."` | `i18n -> 'de.name' = ?` | + +```ruby +class CreateGenres < ActiveRecord::Migration[7.1] + def change + create_table :genres do |t| + 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) + + t.timestamps + end + end +end + +class Genre < ActiveRecord::Base + translates :name, :description, storage: :json # :json for json and jsonb columns (:jsonb is accepted as well), :hstore for an hstore column +end + +Genre.where("i18n -> 'de' ->> 'name' = ?", "Science-Fiction") # PostgreSQL json / jsonb +Genre.where("json_extract(i18n, '$.de.name') = ?", "Science-Fiction") # SQLite json +``` + +For an `hstore` column, the migration needs `enable_extension 'hstore'`, and each translation is stored under the key `"."`, because `hstore` cannot nest: + +```ruby +class CreateGenres < ActiveRecord::Migration[7.1] + def change + enable_extension 'hstore' + + create_table :genres do |t| + t.hstore :i18n # stores ALL the translated attributes; persisted as "en.name" => "...", "de.name" => "...", ... + + t.timestamps + end + end +end + +class Genre < ActiveRecord::Base + translates :name, :description, storage: :hstore +end + +Genre.where("i18n -> 'de.name' = ?", "Science-Fiction") +``` + +The extra DB column for the default locale from Example 2 (`t.string :name`) works the same way with all three storages. + +Notes: + +* 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"}}`. +* 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. +* `json` columns on MySQL should work the same way as on SQLite / PostgreSQL (ActiveRecord uses the same `json` attribute type), but MySQL is not part of the CI test matrix. +* `Genre.translation_storage # => :json` reports the storage of a model. + + # Usage In your code you can modify the values of your translated attributes in two ways. @@ -164,7 +228,20 @@ g.set_localized_attribute( :name, :jp, "サイエンスフィクション" ) scifi = Genre.where(:name => "science fiction").first ``` -Limitation: You can not search for the translated strings other than for your default locale. +Limitation: with the YAML text column (the default), you can not search for the translated strings other than for your default locale. + +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: + +```ruby +class Genre < ActiveRecord::Base + translates :name, :description, storage: :json # or :hstore +end + +Genre.where("i18n -> 'jp' ->> 'name' = ?", "サイエンスフィクション") # PostgreSQL json / jsonb +Genre.where("json_extract(i18n, '$.jp.name') = ?", "サイエンスフィクション") # SQLite json +Genre.where("i18n ->> '$.jp.name' = ?", "サイエンスフィクション") # MySQL json (not covered by the CI tests) +Genre.where("i18n -> 'jp.name' = ?", "サイエンスフィクション") # PostgreSQL hstore (storage: :hstore) +``` ## Data Migration @@ -181,6 +258,38 @@ end Genre.record_timestamps = true ``` +### Converting the `i18n` column from YAML text to json / jsonb / hstore + +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`. + +```ruby +class ConvertGenresI18nToJsonb < ActiveRecord::Migration[7.1] + # a model for this migration only + class Genre < ActiveRecord::Base + serialize :i18n_yaml, coder: YAML, type: Hash # the old column + translates :name, :description, storage: :jsonb # the new column; :json or :hstore work the same way + end + + def up + rename_column :genres, :i18n, :i18n_yaml + add_column :genres, :i18n, :jsonb # or :json / :hstore (hstore also needs enable_extension 'hstore') + Genre.reset_column_information + + Genre.find_each do |genre| + genre.update_column(:i18n, genre.i18n_yaml) # update_column: no validations, no callbacks, timestamps untouched + end + + remove_column :genres, :i18n_yaml + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end +``` + +Afterwards, change the model to `translates :name, :description, storage: :jsonb` (or `:json` / `:hstore`). This migration is run by the test suite against SQLite (json) and PostgreSQL (json, jsonb, hstore), see `spec/embedded_localization/storage_conversion_spec.rb`. + ## I18n fallbacks for empty translations 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` @@ -224,6 +333,7 @@ Each class which uses `embedded_localization` will have these additional methods * Klass.translated_attributes * Klass.translated? * Klass.fallbacks? +* Klass.translation_storage e.g.: @@ -231,6 +341,7 @@ e.g.: Genre.translated_attributes # => [:name,:description] Genre.translated? # => true Genre.fallbacks? # => false +Genre.translation_storage # => :yaml (the `storage:` option given to `translates`: :yaml, :json, :jsonb or :hstore) ``` ### Instance Methods From e518aa1f68f149ff451d46399e76bd5a9f29423a Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Mon, 24 Aug 2026 01:21:52 +0200 Subject: [PATCH 4/5] Test against MySQL too (DB=mysql): json column on MySQL 8, in CI for every ActiveRecord version No change to the gem code was needed: storage: :json uses the gem's own attribute type, which reads and writes a JSON string, and MySQL's json column takes exactly that. - spec_helper.rb / Gemfile: DB=mysql selects the mysql2 adapter (MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE) - spec/models.rb: MySQL query for the json storage tests: JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.de.name')) = ? - ruby.yml: rails_matrix db: [sqlite3, postgresql, mysql] with a mysql:8 service container and libmysqlclient-dev for the mysql2 gem - README / CHANGELOG: MySQL query examples; MySQL is part of the test matrix --- .github/workflows/ruby.yml | 18 ++++++++++++++++-- CHANGELOG.md | 2 +- Gemfile | 3 ++- README.md | 29 +++++++++++++++-------------- spec/models.rb | 2 ++ spec/spec_helper.rb | 10 +++++++--- 6 files changed, 43 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index c479944..618a95e 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -50,7 +50,7 @@ jobs: - name: Run tests run: bundle exec rake - # Every supported ActiveRecord version, against SQLite (yaml and json columns) and PostgreSQL (yaml, json, jsonb and hstore columns). + # Every supported ActiveRecord version, against SQLite and MySQL (yaml and json columns) and PostgreSQL (yaml, json, jsonb and hstore columns). # RAILS_VERSION and DB are read by the Gemfile and spec/spec_helper.rb; `include` picks a Ruby each Rails version supports. rails_matrix: @@ -58,7 +58,7 @@ jobs: fail-fast: false matrix: rails: ["6.1", "7.0", "7.1", "7.2", "8.0", "8.1"] - db: [sqlite3, postgresql] + db: [sqlite3, postgresql, mysql] include: - { rails: "6.1", ruby: "3.0" } - { rails: "7.0", ruby: "3.2" } @@ -79,6 +79,14 @@ jobs: ports: - 5432:5432 options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: embedded_localization_test + ports: + - 3306:3306 + options: --health-cmd "mysqladmin ping -h 127.0.0.1 -uroot -proot" --health-interval 10s --health-timeout 5s --health-retries 10 env: RAILS_VERSION: ${{ matrix.rails }} @@ -86,10 +94,16 @@ jobs: PGHOST: localhost PGUSER: postgres PGPASSWORD: postgres + MYSQL_HOST: 127.0.0.1 + MYSQL_USER: root + MYSQL_PASSWORD: root steps: - name: clone ${{ github.repository }} uses: actions/checkout@v7 + - name: install the MySQL client library (needed to build the mysql2 gem) + if: matrix.db == 'mysql' + run: sudo apt-get update -qq && sudo apt-get install -y -qq libmysqlclient-dev - name: setup Ruby ${{ matrix.ruby }} uses: ruby/setup-ruby@v1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e3cd3c..3684772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ * 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) * 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 * removed the `Hash.zip` monkey patch (`lib/extensions/hash.rb`); the gem no longer adds methods to core classes - * CI: ActiveRecord 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 are tested against SQLite and PostgreSQL; Ruby 2.5 through 4.0, `head` and TruffleRuby; coverage upload through `codecov/codecov-action` instead of the unmaintained `codecov` gem + * 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 ## 1.3.1 (2024-11-26) * [Issue 14](https://github.com/tilo/embedded_localization/pull/14) Fix active support proxy object deprecation (thanks to [Romain Morlevat](https://github.com/RomainMorlevat)) diff --git a/Gemfile b/Gemfile index 744bab6..d52e850 100644 --- a/Gemfile +++ b/Gemfile @@ -11,7 +11,8 @@ group :test do gem 'rspec' gem 'simplecov' gem 'i18n' - gem 'pg' if ENV['DB'] == 'postgresql' # DB=postgresql bundle exec rake (jsonb and hstore columns) + gem 'pg' if ENV['DB'] == 'postgresql' # DB=postgresql bundle exec rake (jsonb and hstore columns) + gem 'mysql2' if ENV['DB'] == 'mysql' # DB=mysql bundle exec rake if ENV['RAILS_VERSION'] # RAILS_VERSION=7.2 bundle exec rake gem 'activerecord', "~> #{ENV['RAILS_VERSION']}.0" diff --git a/README.md b/README.md index 4df50bb..9d3bff1 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ If your requirements are different, this approach might not work for you. In tha ## Requirements * Ruby >= 2.5 # Ruby 2.5 through 4.0, `head` and TruffleRuby are tested in CI -* ActiveRecord >= 6 # ActiveRecord 6.1, 7.0, 7.1, 7.2, 8.0 and 8.1 are tested in CI, against SQLite and PostgreSQL +* 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 * [I18n](http://guides.rubyonrails.org/i18n.html) ## Installation @@ -98,11 +98,11 @@ end 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: -| `translates ... storage:` | `i18n` column type | Stored as | SQL example: find `name` translated to `:de` | -|---------------------------|------------------------------------------------------------|--------------------------------------------------------|-------------------------------------------------------------------------------------------| -| `:yaml` (default) | `text` | YAML: `{en: {name: "..."}, de: {name: "..."}}` | not possible | -| `: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') = ?` | -| `:hstore` | `hstore` (PostgreSQL) | flat keys: `"en.name" => "...", "de.name" => "..."` | `i18n -> 'de.name' = ?` | +| `translates ... storage:` | `i18n` column type | Stored as | SQL example: find `name` translated to `:de` | +|---------------------------|------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------| +| `:yaml` (default) | `text` | YAML: `{en: {name: "..."}, de: {name: "..."}}` | not possible | +| `: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')) = ?` | +| `:hstore` | `hstore` (PostgreSQL) | flat keys: `"en.name" => "...", "de.name" => "..."` | `i18n -> 'de.name' = ?` | ```ruby class CreateGenres < ActiveRecord::Migration[7.1] @@ -119,8 +119,9 @@ class Genre < ActiveRecord::Base translates :name, :description, storage: :json # :json for json and jsonb columns (:jsonb is accepted as well), :hstore for an hstore column end -Genre.where("i18n -> 'de' ->> 'name' = ?", "Science-Fiction") # PostgreSQL json / jsonb -Genre.where("json_extract(i18n, '$.de.name') = ?", "Science-Fiction") # SQLite json +Genre.where("i18n -> 'de' ->> 'name' = ?", "Science-Fiction") # PostgreSQL json / jsonb +Genre.where("json_extract(i18n, '$.de.name') = ?", "Science-Fiction") # SQLite json +Genre.where("JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.de.name')) = ?", "Science-Fiction") # MySQL json ``` For an `hstore` column, the migration needs `enable_extension 'hstore'`, and each translation is stored under the key `"."`, because `hstore` cannot nest: @@ -151,7 +152,7 @@ Notes: * 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"}}`. * 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. -* `json` columns on MySQL should work the same way as on SQLite / PostgreSQL (ActiveRecord uses the same `json` attribute type), but MySQL is not part of the CI test matrix. +* `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). * `Genre.translation_storage # => :json` reports the storage of a model. @@ -237,10 +238,10 @@ class Genre < ActiveRecord::Base translates :name, :description, storage: :json # or :hstore end -Genre.where("i18n -> 'jp' ->> 'name' = ?", "サイエンスフィクション") # PostgreSQL json / jsonb -Genre.where("json_extract(i18n, '$.jp.name') = ?", "サイエンスフィクション") # SQLite json -Genre.where("i18n ->> '$.jp.name' = ?", "サイエンスフィクション") # MySQL json (not covered by the CI tests) -Genre.where("i18n -> 'jp.name' = ?", "サイエンスフィクション") # PostgreSQL hstore (storage: :hstore) +Genre.where("i18n -> 'jp' ->> 'name' = ?", "サイエンスフィクション") # PostgreSQL json / jsonb +Genre.where("json_extract(i18n, '$.jp.name') = ?", "サイエンスフィクション") # SQLite json +Genre.where("JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.jp.name')) = ?", "サイエンスフィクション") # MySQL json +Genre.where("i18n -> 'jp.name' = ?", "サイエンスフィクション") # PostgreSQL hstore (storage: :hstore) ``` @@ -288,7 +289,7 @@ class ConvertGenresI18nToJsonb < ActiveRecord::Migration[7.1] end ``` -Afterwards, change the model to `translates :name, :description, storage: :jsonb` (or `:json` / `:hstore`). This migration is run by the test suite against SQLite (json) and PostgreSQL (json, jsonb, hstore), see `spec/embedded_localization/storage_conversion_spec.rb`. +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`. ## I18n fallbacks for empty translations diff --git a/spec/models.rb b/spec/models.rb index 57b3286..364341a 100644 --- a/spec/models.rb +++ b/spec/models.rb @@ -49,6 +49,8 @@ class HstoreMovie < ActiveRecord::Base STORAGES << { name: 'json column', storage: :json, genre: JsonGenre, movie: JsonMovie, sql: ->(locale, attr) { "i18n -> '#{locale}' ->> '#{attr}' = ?" } } STORAGES << { name: 'jsonb column', storage: :jsonb, genre: JsonbGenre, movie: JsonbMovie, sql: ->(locale, attr) { "i18n -> '#{locale}' ->> '#{attr}' = ?" } } STORAGES << { name: 'hstore column', storage: :hstore, genre: HstoreGenre, movie: HstoreMovie, sql: ->(locale, attr) { "i18n -> '#{locale}.#{attr}' = ?" } } +elsif ENV['DB'] == 'mysql' + STORAGES << { name: 'json column', storage: :json, genre: JsonGenre, movie: JsonMovie, sql: ->(locale, attr) { "JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.#{locale}.#{attr}')) = ?" } } else STORAGES << { name: 'json column', storage: :json, genre: JsonGenre, movie: JsonMovie, sql: ->(locale, attr) { "json_extract(i18n, '$.#{locale}.#{attr}') = ?" } } end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5a08aba..cfe0980 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -10,10 +10,14 @@ require 'embedded_localization' -# DB=postgresql runs the suite against PostgreSQL, which is needed for the jsonb and hstore columns. -# The connection details come from the PG* environment variables (PGHOST, PGUSER, PGPASSWORD, PGDATABASE). -if ENV['DB'] == 'postgresql' +# DB=postgresql runs the suite against PostgreSQL (needed for the jsonb and hstore columns); the connection details +# come from the PG* environment variables (PGHOST, PGUSER, PGPASSWORD, PGDATABASE). +# DB=mysql runs it against MySQL; connection details from MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE. +case ENV['DB'] +when 'postgresql' ActiveRecord::Base.establish_connection adapter: 'postgresql', database: ENV.fetch('PGDATABASE', 'embedded_localization_test') +when 'mysql' + ActiveRecord::Base.establish_connection adapter: 'mysql2', encoding: 'utf8mb4', host: ENV['MYSQL_HOST'], username: ENV.fetch('MYSQL_USER', 'root'), password: ENV['MYSQL_PASSWORD'], database: ENV.fetch('MYSQL_DATABASE', 'embedded_localization_test') else ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: ':memory:' end From 241cbd1ff775eb764c454377612b39ee44117545 Mon Sep 17 00:00:00 2001 From: Tilo Sloboda Date: Mon, 24 Aug 2026 01:32:16 +0200 Subject: [PATCH 5/5] Fallbacks follow I18n.fallbacks; a nil translation always falls back - Klass.fallback_locales(locale): the chain configured in I18n.fallbacks (e.g. config.i18n.fallbacks = { 'de-AT' => 'de' }), then I18n.default_locale, without the locale itself. I18n.fallbacks does not append the default locale on its own, so the gem does. - get_localized_attribute: a nil translation is looked up in the fallback locales, whatever the reason for the nil (locale absent, attribute absent, value nil). Before, only a locale without any hash fell back -- and initialize_i18n_hashes always creates an empty hash for the current I18n.locale, so a record loaded under I18n.locale = :de never fell back for :de. - fallbacks_spec: chain cases, current-locale case, nil-value case; storage_spec: type_for_attribute('i18n').type for every storage (covers Storage::Hstore#type), unknown hstore keys are ignored - README: fallback section describes the chain, with an example; CHANGELOG --- CHANGELOG.md | 2 + README.md | 18 ++- .../active_record/class_methods.rb | 9 ++ .../active_record/instance_methods.rb | 23 ++-- spec/embedded_localization/fallbacks_spec.rb | 106 +++++++++++++++--- spec/embedded_localization/storage_spec.rb | 17 +++ 6 files changed, 149 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3684772..9f5cda1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 1.4.0 (2026-08-24) * 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) + * 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 + * 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) * 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 * 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) * 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 diff --git a/README.md b/README.md index 9d3bff1..5649c9f 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ Afterwards, change the model to `translates :name, :description, storage: :jsonb ## I18n fallbacks for empty translations -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` +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. 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) @@ -323,6 +323,22 @@ I18n.locale = :de g.name # => 'science fiction' ``` +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`: + +```ruby +class Genre < ActiveRecord::Base + translates :name, :description, :fallbacks => true +end + +g = Genre.first +g.name(:en) # => 'science fiction' +g.name(:de) # => 'Science-Fiction' +g.name(:"de-AT") # => 'Science-Fiction' (no :"de-AT" translation, so :de is used) +g.name(:fr) # => 'science fiction' (no chain for :fr, so I18n.default_locale is used) + +Genre.fallback_locales(:"de-AT") # => [:de, :en] the locales tried after :"de-AT" +``` + ## Want some Candy? 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. diff --git a/lib/embedded_localization/active_record/class_methods.rb b/lib/embedded_localization/active_record/class_methods.rb index 98c4ec8..4f98726 100644 --- a/lib/embedded_localization/active_record/class_methods.rb +++ b/lib/embedded_localization/active_record/class_methods.rb @@ -27,6 +27,15 @@ def fallbacks? i18n_fallbacks || translation_options[:fallbacks] == true end + # The locales to look at, in order, when `locale` has no translation for an attribute: + # the chain configured in I18n.fallbacks (e.g. config.i18n.fallbacks = { 'de-AT' => 'de' } gives :de for :"de-AT"; + # I18n.fallbacks exists once i18n/backend/fallbacks is loaded, which Rails does for config.i18n.fallbacks), + # followed by I18n.default_locale. `locale` itself is not part of the result. + def fallback_locales(locale) + chain = I18n.respond_to?(:fallbacks) ? I18n.fallbacks[locale] : [] + (chain + [I18n.default_locale]).uniq - [locale] + end + #- # # fetch the fallbacks from the i18n backend # def fallbacks diff --git a/lib/embedded_localization/active_record/instance_methods.rb b/lib/embedded_localization/active_record/instance_methods.rb index ee2220f..5124eac 100644 --- a/lib/embedded_localization/active_record/instance_methods.rb +++ b/lib/embedded_localization/active_record/instance_methods.rb @@ -4,19 +4,20 @@ module InstanceMethods # Returns the translation of attr_name for the given locale; nil if there is none. # - will convert given locale to symbol, e.g. "en","En" to :en - # - with `fallbacks: true`, a locale without any translations returns the I18n.default_locale value - # (we only support fallbacks to I18n.default_locale for now) + # - with fallbacks (see ClassMethods#fallbacks?), a nil translation is looked up in the fallback locales instead: + # the chain configured in I18n.fallbacks, then I18n.default_locale (see ClassMethods#fallback_locales) def get_localized_attribute(attr_name, locale) attr_name = attr_name.to_sym locale = normalize_locale(locale) - if self.i18n.has_key?(locale) - self.i18n[locale][attr_name] # nil when this locale has no translation for attr_name - elsif self.class.fallbacks? && self.i18n[I18n.default_locale] - self.i18n[I18n.default_locale][attr_name] - else - nil + translation = translation_for(attr_name, locale) + return translation unless translation.nil? && self.class.fallbacks? + + self.class.fallback_locales(locale).each do |fallback_locale| + translation = translation_for(attr_name, fallback_locale) + return translation unless translation.nil? end + nil end # Sets the translation of attr_name for the given locale. @@ -122,6 +123,12 @@ def normalize_locale(locale) locale.is_a?(String) ? locale.downcase.to_sym : locale # ensure that locale is always a symbol end + # the stored translation of attr_name in locale; nil when the locale or the attribute is not there + def translation_for(attr_name, locale) + translations = self.i18n[locale] + translations[attr_name] if translations + end + # did the user define a DB column with the name of the translated attribute? def native_column?(attr_name) has_attribute?(attr_name) diff --git a/spec/embedded_localization/fallbacks_spec.rb b/spec/embedded_localization/fallbacks_spec.rb index a25b202..7cae75f 100644 --- a/spec/embedded_localization/fallbacks_spec.rb +++ b/spec/embedded_localization/fallbacks_spec.rb @@ -1,9 +1,11 @@ # encoding: utf-8 require 'spec_helper' +require 'i18n/backend/fallbacks' # defines I18n.fallbacks -# `translates ..., fallbacks: true` : reading a locale without translations returns the I18n.default_locale value +# `translates ..., fallbacks: true` : a nil translation is looked up in the fallback locales instead -- +# the chain configured in I18n.fallbacks, then I18n.default_locale. -describe 'fallbacks to I18n.default_locale' do +describe 'fallbacks' do let(:genre) do g = FallbackGenre.new g.name = 'Science Fiction' @@ -16,25 +18,95 @@ Genre.fallbacks?.should be_falsy end - it 'falls back via the getter for a locale without any translations' do - genre.name(:fr).should eq 'Science Fiction' - end + describe 'to I18n.default_locale' do + it 'falls back via the getter for a locale without any translations' do + genre.name(:fr).should eq 'Science Fiction' + end - it 'falls back via get_localized_attribute for a locale without any translations' do - genre.get_localized_attribute(:name, :fr).should eq 'Science Fiction' - genre.get_localized_attribute(:name, 'FR').should eq 'Science Fiction' - end + it 'falls back via get_localized_attribute for a locale without any translations' do + genre.get_localized_attribute(:name, :fr).should eq 'Science Fiction' + genre.get_localized_attribute(:name, 'FR').should eq 'Science Fiction' + end - it 'does not fall back when the locale has translations but not for that attribute' do - genre.name(:de).should be_nil - genre.get_localized_attribute(:name, :de).should be_nil - end + it 'falls back when the locale has translations, but none for that attribute' do + genre.name(:de).should eq 'Science Fiction' + genre.get_localized_attribute(:name, :de).should eq 'Science Fiction' + end + + it 'falls back for the current I18n.locale of a record loaded from the database' do + genre.save! + I18n.with_locale(:de) do + reloaded = FallbackGenre.find(genre.id) + reloaded.name.should eq 'Science Fiction' + reloaded.description.should eq 'ein grossartiges Genre' + end + end + + it 'falls back when an existing translation was set to nil' do + genre.set_localized_attribute(:name, :de, 'Science-Fiction') + genre.set_localized_attribute(:name, :de, nil) + genre.i18n[:de].should have_key(:name) # the nil value is kept ... + genre.name(:de).should eq 'Science Fiction' # ... and the getter falls back + end + + it 'returns nil when the default locale has no translation for the attribute either' do + genre.description(:fr).should be_nil + end + + it 'still returns the translation of the requested locale when there is one' do + genre.description(:de).should eq 'ein grossartiges Genre' + end - it 'returns nil when the default locale has no translation for the attribute either' do - genre.description(:fr).should be_nil + it 'does not fall back for a model without the fallbacks option' do + plain = Genre.new + plain.name = 'Science Fiction' + plain.name(:fr).should be_nil + plain.get_localized_attribute(:name, :fr).should be_nil + end end - it 'still returns the translation of the requested locale when there is one' do - genre.description(:de).should eq 'ein grossartiges Genre' + describe 'with a fallback chain configured in I18n.fallbacks' do + before :each do + @i18n_fallbacks = I18n.fallbacks + I18n.fallbacks = I18n::Locale::Fallbacks.new(:"de-AT" => :de, :fr => :de) + end + + after :each do + I18n.fallbacks = @i18n_fallbacks + end + + let(:genre) do + g = FallbackGenre.new + g.name = 'Science Fiction' + g.description = 'an awesome genre' + I18n.with_locale(:de) { g.name = 'Science-Fiction' } + g + end + + it 'lists the fallback locales: the configured chain, then I18n.default_locale, without the locale itself' do + FallbackGenre.fallback_locales(:"de-AT").should eq [:de, :en] + FallbackGenre.fallback_locales(:fr).should eq [:de, :en] + FallbackGenre.fallback_locales(:ko).should eq [:en] + FallbackGenre.fallback_locales(:en).should eq [] + end + + it 'uses the configured chain before I18n.default_locale' do + genre.name(:"de-AT").should eq 'Science-Fiction' + genre.name(:fr).should eq 'Science-Fiction' + genre.get_localized_attribute(:name, 'de-AT').should eq 'Science-Fiction' + end + + it 'continues to I18n.default_locale when no locale of the chain has the translation' do + genre.description(:"de-AT").should eq 'an awesome genre' + end + + it 'falls back to I18n.default_locale for a locale without a configured chain' do + genre.name(:ko).should eq 'Science Fiction' + end + + it 'prefers the translation of the requested locale over the chain' do + I18n.with_locale(:"de-AT") { genre.name = 'Science-Fiction (AT)' } + genre.name(:"de-AT").should eq 'Science-Fiction (AT)' + end end end diff --git a/spec/embedded_localization/storage_spec.rb b/spec/embedded_localization/storage_spec.rb index d697a39..d0dc30d 100644 --- a/spec/embedded_localization/storage_spec.rb +++ b/spec/embedded_localization/storage_spec.rb @@ -32,6 +32,23 @@ movie_class.translation_storage.should eq storage[:storage] end + it 'reports the attribute type of the i18n column' do + expected_type = { yaml: :text, json: :json, jsonb: :json, hstore: :hstore }[storage[:storage]] + genre_class.type_for_attribute('i18n').type.should eq expected_type + movie_class.type_for_attribute('i18n').type.should eq expected_type + end + + if storage[:storage] == :hstore + it 'ignores hstore keys without a "." structure' do + genre = genre_class.new(name: 'Science Fiction') + genre.save! + genre_class.connection.execute("UPDATE #{genre_class.table_name} SET i18n = i18n || hstore('junk', 'x') WHERE id = #{genre.id}") + reloaded = genre_class.find(genre.id) + reloaded.i18n.should eq({ en: { name: 'Science Fiction' } }) + reloaded.name.should eq 'Science Fiction' + end + end + describe 'a model without a native column for the translated attribute' do let(:genre) do g = genre_class.new