diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 367ccf4..618a95e 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,99 @@ 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 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: + + strategy: + fail-fast: false + matrix: + rails: ["6.1", "7.0", "7.1", "7.2", "8.0", "8.1"] + db: [sqlite3, postgresql, mysql] + 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 + 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 }} + DB: ${{ matrix.db }} + 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: + 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/CHANGELOG.md b/CHANGELOG.md index 15e5b8b..9f5cda1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 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) + * 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 + * 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, 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 68458c7..d52e850 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,16 @@ 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) + 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" + 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/README.md b/README.md index adf522c..5649c9f 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, PostgreSQL and MySQL * [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,68 @@ 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') = ?` ; 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] + 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 +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: + +```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 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. + + # Usage 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, "サイエンスフィクション" ) 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("JSON_UNQUOTE(JSON_EXTRACT(i18n, '$.jp.name')) = ?", "サイエンスフィクション") # MySQL json +Genre.where("i18n -> 'jp.name' = ?", "サイエンスフィクション") # PostgreSQL hstore (storage: :hstore) +``` ## Data Migration @@ -181,9 +259,41 @@ 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), MySQL (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` +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) @@ -213,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. @@ -224,6 +350,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 +358,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 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.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..4f98726 100644 --- a/lib/embedded_localization/active_record/class_methods.rb +++ b/lib/embedded_localization/active_record/class_methods.rb @@ -16,12 +16,26 @@ 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 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 9a7b13b..5124eac 100644 --- a/lib/embedded_localization/active_record/instance_methods.rb +++ b/lib/embedded_localization/active_record/instance_methods.rb @@ -2,44 +2,42 @@ 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 (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) - 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 - else - if self.class.fallbacks? && self.i18n[ I18n.default_locale ] - return self.i18n[ I18n.default_locale ][attr_name] - else - return nil - end + 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. # - 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 +61,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 +111,37 @@ 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 + + # 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) + 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/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/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/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/embedded_localization/fallbacks_spec.rb b/spec/embedded_localization/fallbacks_spec.rb new file mode 100644 index 0000000..7cae75f --- /dev/null +++ b/spec/embedded_localization/fallbacks_spec.rb @@ -0,0 +1,112 @@ +# encoding: utf-8 +require 'spec_helper' +require 'i18n/backend/fallbacks' # defines I18n.fallbacks + +# `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' 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 + + 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 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 '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 + + 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_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..d0dc30d --- /dev/null +++ b/spec/embedded_localization/storage_spec.rb @@ -0,0 +1,176 @@ +# 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 + + 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 + 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..364341a 100644 --- a/spec/models.rb +++ b/spec/models.rb @@ -5,3 +5,52 @@ 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}' = ?" } } +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/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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index d735d6a..cfe0980 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,19 @@ 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 (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 load File.dirname(__FILE__) + '/schema.rb' require File.dirname(__FILE__) + '/models.rb'