Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 75 additions & 12 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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!"
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,9 @@
*.bak
*.gem
.bundle
.DS_Store
.rspec_status
Gemfile.lock
coverage/
pkg/*
vendor/bundle
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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))

Expand Down
17 changes: 14 additions & 3 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
138 changes: 133 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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) <!-- [![Downloads](https://img.shields.io/gem/dt/embedded_localization)](https://rubygems.org/gems/embedded_localization) --> [![RubyGems](https://img.shields.io/badge/RubyGems-embedded__localization-brightgreen?logo=rubygems&logoColor=white)](https://rubygems.org/gems/embedded_localization) [![Ruby Toolbox](https://img.shields.io/badge/Ruby%20Toolbox-embedded__localization-brightgreen)](https://www.ruby-toolbox.com/projects/embedded_localization)

`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.

Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 `"<locale>.<attribute>"`, 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.
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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.
Expand All @@ -224,13 +350,15 @@ Each class which uses `embedded_localization` will have these additional methods
* Klass.translated_attributes
* Klass.translated?
* Klass.fallbacks?
* Klass.translation_storage

e.g.:

```ruby
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
Expand Down
Loading