internationalize 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +232 -0
- data/lib/generators/internationalize/translation/templates/add_translations_migration.rb.erb +9 -0
- data/lib/generators/internationalize/translation_generator.rb +36 -0
- data/lib/internationalize/adapters/base.rb +63 -0
- data/lib/internationalize/adapters/mysql.rb +37 -0
- data/lib/internationalize/adapters/postgresql.rb +37 -0
- data/lib/internationalize/adapters/sqlite.rb +48 -0
- data/lib/internationalize/adapters.rb +34 -0
- data/lib/internationalize/model.rb +390 -0
- data/lib/internationalize/version.rb +5 -0
- data/lib/internationalize.rb +34 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0ba5df71bafb02b68108aed602ac75ad4ced036109e49dbd40dc90354a7c351a
|
|
4
|
+
data.tar.gz: d39b4232e8b76511d1a409d6848fa98e8f60f8f9523b62985c467cb2767ec7e3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8ff4adb5021405380a123cc1cc06b8dd3818851e32570ae90e7bdf776f566bbc9ff6504ab26960dae909109550444875aaca42d3cf988ae4393b18c05628e292
|
|
7
|
+
data.tar.gz: 8d2643b882281a39f0446d274b25321be03909c2ba41510a75a4e498918fc0ee37a59abae42e373e246a4d1b1f1ebeefdb5ec9e12e29d8a81a13c3d05f9478a6
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2024-11-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release
|
|
13
|
+
- `Internationalize::Model` mixin for ActiveRecord models
|
|
14
|
+
- `international` declaration for translatable attributes
|
|
15
|
+
- Locale-specific accessors (`title_en`, `title_de`, etc.)
|
|
16
|
+
- Query methods:
|
|
17
|
+
- `international(attr: value)` - exact match queries
|
|
18
|
+
- `international(attr: value, match: :partial)` - case-insensitive LIKE search
|
|
19
|
+
- `international_not()` - exclusion queries
|
|
20
|
+
- `international_order()` - order by translated attribute
|
|
21
|
+
- `translated()` - find records with translations
|
|
22
|
+
- `untranslated()` - find records missing translations
|
|
23
|
+
- Creation helpers:
|
|
24
|
+
- `international_create!` / `international_create` / `international_new`
|
|
25
|
+
- Support both hash format `title: { en: "Hello" }` and direct string `title: "Hello"` (uses current locale)
|
|
26
|
+
- Instance helpers:
|
|
27
|
+
- `set_translation(attr, locale, value)`
|
|
28
|
+
- `translation_for(attr, locale)`
|
|
29
|
+
- `translated?(attr, locale)`
|
|
30
|
+
- `translated_locales(attr)`
|
|
31
|
+
- Fallback to default locale when translation missing (configurable)
|
|
32
|
+
- SQLite adapter using `json_extract()`
|
|
33
|
+
- PostgreSQL adapter using `->>` operator
|
|
34
|
+
- MySQL 8+ adapter using `->>` operator (supports mysql2 and trilogy gems)
|
|
35
|
+
- Rails generator: `rails g internationalize:translation Model attr1 attr2`
|
|
36
|
+
- Warning when JSON columns are missing `default: {}`
|
|
37
|
+
- Security: locale parameter sanitization to prevent SQL injection
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Sampo Kuokkanen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Internationalize
|
|
2
|
+
|
|
3
|
+
Lightweight, performant internationalization for Rails with JSON column storage.
|
|
4
|
+
|
|
5
|
+
## Why Internationalize?
|
|
6
|
+
|
|
7
|
+
Unlike Globalize or Mobility which use separate translation tables requiring JOINs, Internationalize stores translations inline using JSON columns. This means:
|
|
8
|
+
|
|
9
|
+
- **No JOINs** - translations live in the same table
|
|
10
|
+
- **No N+1 queries** - data is always loaded with the record
|
|
11
|
+
- **Direct method dispatch** - no `method_missing` overhead
|
|
12
|
+
- **Multi-database support** - works with SQLite, PostgreSQL, and MySQL
|
|
13
|
+
- **Visible in schema.rb** - translated fields appear directly in your model's schema
|
|
14
|
+
|
|
15
|
+
## Supported Databases
|
|
16
|
+
|
|
17
|
+
| Database | JSON Column | Query Syntax |
|
|
18
|
+
|----------|-------------|--------------|
|
|
19
|
+
| SQLite 3.38+ | `json` | `json_extract()` |
|
|
20
|
+
| PostgreSQL 9.4+ | `json` / `jsonb` | `->>` operator |
|
|
21
|
+
| MySQL 8.0+ | `json` | `->>` operator |
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
Add to your Gemfile:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
gem "internationalize"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### 1. Generate a migration
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
rails generate internationalize:translation Article title description
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
This creates a migration adding `title_translations` and `description_translations` JSON columns with `default: {}`.
|
|
40
|
+
|
|
41
|
+
**Important:** JSON columns must have `default: {}` set. The generator handles this automatically, but if writing migrations manually:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
add_column :articles, :title_translations, :json, default: {}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 2. Include the model mixin
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class Article < ApplicationRecord
|
|
51
|
+
include Internationalize::Model
|
|
52
|
+
international :title, :description
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 3. Use translations
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# Set via current locale (I18n.locale)
|
|
60
|
+
article.title = "Hello World"
|
|
61
|
+
|
|
62
|
+
# Set for specific locale
|
|
63
|
+
article.title_en = "Hello World"
|
|
64
|
+
article.title_de = "Hallo Welt"
|
|
65
|
+
|
|
66
|
+
# Read via current locale
|
|
67
|
+
article.title # => "Hello World" (when I18n.locale == :en)
|
|
68
|
+
|
|
69
|
+
# Read specific locale
|
|
70
|
+
article.title_de # => "Hallo Welt"
|
|
71
|
+
|
|
72
|
+
# Access raw translations
|
|
73
|
+
article.title_translations # => {"en" => "Hello World", "de" => "Hallo Welt"}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Creating Records
|
|
77
|
+
|
|
78
|
+
Use the helper methods for a cleaner syntax when creating records with translations:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# Create with multiple locales using a hash
|
|
82
|
+
Article.international_create!(
|
|
83
|
+
title: { en: "Hello World", de: "Hallo Welt" },
|
|
84
|
+
description: { en: "A greeting" },
|
|
85
|
+
status: "published" # non-translated attributes work normally
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Or use direct assignment for current locale (I18n.locale)
|
|
89
|
+
I18n.locale = :de
|
|
90
|
+
Article.international_create!(title: "Achtung!") # Sets title_de
|
|
91
|
+
|
|
92
|
+
# Mix both styles
|
|
93
|
+
Article.international_create!(
|
|
94
|
+
title: "Hello", # Current locale only
|
|
95
|
+
description: { en: "English", de: "German" } # Multiple locales
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Build without saving
|
|
99
|
+
article = Article.international_new(title: "Hello")
|
|
100
|
+
article.save!
|
|
101
|
+
|
|
102
|
+
# Non-bang version returns unsaved record on validation failure
|
|
103
|
+
article = Article.international_create(title: { en: "Hello" })
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Querying
|
|
107
|
+
|
|
108
|
+
All query methods default to the current `I18n.locale` and return ActiveRecord relations that can be chained with standard AR methods.
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# Exact match on translation (uses current locale by default)
|
|
112
|
+
Article.international(title: "Hello World")
|
|
113
|
+
Article.international(title: "Hallo Welt", locale: :de)
|
|
114
|
+
|
|
115
|
+
# Partial match / search (case-insensitive LIKE)
|
|
116
|
+
Article.international(title: "hello", match: :partial)
|
|
117
|
+
Article.international(title: "Hello", match: :partial, case_sensitive: true)
|
|
118
|
+
Article.international(title: "hallo", match: :partial, locale: :de)
|
|
119
|
+
|
|
120
|
+
# Exclude matches
|
|
121
|
+
Article.international_not(title: "Draft")
|
|
122
|
+
Article.international_not(title: "Entwurf", locale: :de)
|
|
123
|
+
|
|
124
|
+
# Order by translation
|
|
125
|
+
Article.international_order(:title)
|
|
126
|
+
Article.international_order(:title, :desc)
|
|
127
|
+
Article.international_order(:title, :asc, locale: :de)
|
|
128
|
+
|
|
129
|
+
# Find translated/untranslated records
|
|
130
|
+
Article.translated(:title)
|
|
131
|
+
Article.translated(:title, locale: :de)
|
|
132
|
+
Article.untranslated(:title, locale: :de)
|
|
133
|
+
|
|
134
|
+
# Chain with ActiveRecord methods
|
|
135
|
+
Article.international(title: "Hello World")
|
|
136
|
+
.where(published: true)
|
|
137
|
+
.includes(:author)
|
|
138
|
+
.limit(10)
|
|
139
|
+
|
|
140
|
+
# Combine queries
|
|
141
|
+
Article.international(title: "hello", match: :partial)
|
|
142
|
+
.where(status: "published")
|
|
143
|
+
.merge(Article.international_order(:title, :desc))
|
|
144
|
+
|
|
145
|
+
# Query across multiple locales
|
|
146
|
+
Article.international(title: "Hello World", locale: :en)
|
|
147
|
+
.merge(Article.international(title: "Hallo Welt", locale: :de))
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Helper Methods
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# Check if translation exists
|
|
154
|
+
article.translated?(:title, :de) # => true/false
|
|
155
|
+
|
|
156
|
+
# Get all translated locales for an attribute
|
|
157
|
+
article.translated_locales(:title) # => [:en, :de]
|
|
158
|
+
|
|
159
|
+
# Set/get translation for specific locale
|
|
160
|
+
article.set_translation(:title, :de, "Hallo Welt")
|
|
161
|
+
article.translation_for(:title, :de) # => "Hallo Welt"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Fallbacks
|
|
165
|
+
|
|
166
|
+
By default, Internationalize falls back to the default locale when a translation is missing:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
article.title_en = "Hello"
|
|
170
|
+
article.title_de # => nil
|
|
171
|
+
|
|
172
|
+
I18n.locale = :de
|
|
173
|
+
article.title # => "Hello" (falls back to :en)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Disable fallbacks:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
international :title, fallback: false
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Configuration
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# config/initializers/internationalize.rb
|
|
186
|
+
Internationalize.configure do |config|
|
|
187
|
+
config.fallback_locale = :en
|
|
188
|
+
config.available_locales = [:en, :de, :fr]
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Performance Comparison
|
|
193
|
+
|
|
194
|
+
Benchmark with 1000 records, 2 translated attributes (title + body), 3 locales:
|
|
195
|
+
|
|
196
|
+
| Metric | Internationalize | Mobility (Table) | Improvement |
|
|
197
|
+
|--------|------------------|------------------|-------------|
|
|
198
|
+
| Storage | 172 KB | 332 KB | **48% smaller** |
|
|
199
|
+
| Create | 0.27s | 2.1s | **7.8x faster** |
|
|
200
|
+
| Read all | 0.005s | 0.37s | **74x faster** |
|
|
201
|
+
| Query (match) | 0.001s | 0.01s | **10x faster** |
|
|
202
|
+
|
|
203
|
+
## Trade-offs
|
|
204
|
+
|
|
205
|
+
### Pros
|
|
206
|
+
- **Faster reads** - No JOINs needed, translations are inline
|
|
207
|
+
- **Less storage** - No separate translation tables with foreign keys and indices
|
|
208
|
+
- **Simpler schema** - Everything in one table
|
|
209
|
+
|
|
210
|
+
### Cons
|
|
211
|
+
- **Schema changes required** - Each translated attribute needs a JSON column added to the table
|
|
212
|
+
- **Migration complexity** - Adding translations to existing tables requires data migration
|
|
213
|
+
- **JSON column support** - Requires SQLite 3.38+, PostgreSQL 9.4+, or MySQL 8.0+
|
|
214
|
+
|
|
215
|
+
### When to use Internationalize
|
|
216
|
+
- New projects where you can design the schema upfront
|
|
217
|
+
- Applications with heavy read workloads
|
|
218
|
+
- When you need maximum query performance
|
|
219
|
+
|
|
220
|
+
### When to consider Mobility
|
|
221
|
+
|
|
222
|
+
For table-based translation storage, consider [Mobility](https://github.com/shioyama/mobility) instead:
|
|
223
|
+
|
|
224
|
+
- Existing projects with large tables where adding columns is expensive
|
|
225
|
+
- When you need to add translations without modifying existing table schemas
|
|
226
|
+
- Applications that rarely query by translated content
|
|
227
|
+
|
|
228
|
+
Mobility's table backend stores translations in separate tables with JOINs, which trades query performance for schema flexibility.
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
MIT License. See LICENSE.txt.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
4
|
+
def change
|
|
5
|
+
<% attributes.each do |attr| -%>
|
|
6
|
+
add_column :<%= table_name %>, :<%= attr %>_translations, :json, default: {}
|
|
7
|
+
<% end -%>
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Internationalize
|
|
7
|
+
module Generators
|
|
8
|
+
class TranslationGenerator < Rails::Generators::Base
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("translation/templates", __dir__)
|
|
12
|
+
|
|
13
|
+
argument :model_name, type: :string, desc: "The model to add translations to"
|
|
14
|
+
argument :attributes, type: :array, desc: "Attributes to make translatable"
|
|
15
|
+
|
|
16
|
+
desc "Generates a migration to add translation columns to a model"
|
|
17
|
+
|
|
18
|
+
def create_migration_file
|
|
19
|
+
migration_template(
|
|
20
|
+
"add_translations_migration.rb.erb",
|
|
21
|
+
"db/migrate/add_#{attributes.join("_and_")}_translations_to_#{table_name}.rb",
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def table_name
|
|
28
|
+
model_name.tableize.tr("/", "_")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def migration_class_name
|
|
32
|
+
"Add#{attributes.map(&:camelize).join("And")}TranslationsTo#{model_name.gsub("::", "").pluralize}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Internationalize
|
|
4
|
+
module Adapters
|
|
5
|
+
# Base adapter class defining the interface for database-specific SQL generation
|
|
6
|
+
class Base
|
|
7
|
+
# Sanitize a locale for safe SQL interpolation
|
|
8
|
+
# Only allows alphanumeric characters, underscores, and hyphens
|
|
9
|
+
#
|
|
10
|
+
# @param locale [String, Symbol] the locale key
|
|
11
|
+
# @return [String] sanitized locale safe for SQL interpolation
|
|
12
|
+
def sanitize_locale(locale)
|
|
13
|
+
locale.to_s.gsub(/[^a-zA-Z0-9_\-]/, "")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Extract a value from a JSON column
|
|
17
|
+
#
|
|
18
|
+
# @param column [String] the JSON column name
|
|
19
|
+
# @param locale [String, Symbol] the locale key
|
|
20
|
+
# @return [String] SQL fragment for extracting the value
|
|
21
|
+
def json_extract(column, locale)
|
|
22
|
+
raise NotImplementedError, "#{self.class} must implement #json_extract"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Generate a case-insensitive LIKE condition
|
|
26
|
+
#
|
|
27
|
+
# @param column [String] the JSON column name
|
|
28
|
+
# @param locale [String, Symbol] the locale key
|
|
29
|
+
# @return [Array<String, Symbol>] SQL fragment and pattern type
|
|
30
|
+
def like_insensitive(column, locale)
|
|
31
|
+
raise NotImplementedError, "#{self.class} must implement #like_insensitive"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Generate a case-sensitive LIKE/GLOB condition
|
|
35
|
+
#
|
|
36
|
+
# @param column [String] the JSON column name
|
|
37
|
+
# @param locale [String, Symbol] the locale key
|
|
38
|
+
# @return [Array<String, Symbol>] SQL fragment and pattern type
|
|
39
|
+
def like_sensitive(column, locale)
|
|
40
|
+
raise NotImplementedError, "#{self.class} must implement #like_sensitive"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Wrap a LIKE pattern with wildcards
|
|
44
|
+
# Uses ActiveRecord's built-in sanitize_sql_like for escaping
|
|
45
|
+
#
|
|
46
|
+
# @param term [String] the search term
|
|
47
|
+
# @return [String] pattern with wildcards
|
|
48
|
+
def like_pattern(term)
|
|
49
|
+
"%#{ActiveRecord::Base.sanitize_sql_like(term.to_s)}%"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Wrap a term with pattern wildcards for case-sensitive search
|
|
53
|
+
# Default implementation uses LIKE pattern (PostgreSQL/MySQL)
|
|
54
|
+
# SQLite overrides this to use GLOB pattern
|
|
55
|
+
#
|
|
56
|
+
# @param term [String] the search term
|
|
57
|
+
# @return [String] pattern with wildcards
|
|
58
|
+
def glob_pattern(term)
|
|
59
|
+
like_pattern(term)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Internationalize
|
|
6
|
+
module Adapters
|
|
7
|
+
# MySQL 8+ specific SQL generation for JSON queries
|
|
8
|
+
#
|
|
9
|
+
# Uses ->> operator for text extraction from JSON columns
|
|
10
|
+
# LIKE for case-insensitive matching (default with utf8mb4 collation)
|
|
11
|
+
# LIKE BINARY for case-sensitive matching
|
|
12
|
+
class MySQL < Base
|
|
13
|
+
# Extract a value from a JSON column using ->> operator
|
|
14
|
+
#
|
|
15
|
+
# @param column [String] the JSON column name
|
|
16
|
+
# @param locale [String, Symbol] the locale key
|
|
17
|
+
# @return [String] SQL fragment
|
|
18
|
+
def json_extract(column, locale)
|
|
19
|
+
"#{column}->>'$.#{sanitize_locale(locale)}'"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Case-insensitive search using LIKE (default behavior with utf8mb4)
|
|
23
|
+
#
|
|
24
|
+
# @return [Array<String, Symbol>] SQL condition and pattern type
|
|
25
|
+
def like_insensitive(column, locale)
|
|
26
|
+
["#{json_extract(column, locale)} LIKE ?", :like]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Case-sensitive search using LIKE BINARY
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<String, Symbol>] SQL condition and pattern type
|
|
32
|
+
def like_sensitive(column, locale)
|
|
33
|
+
["#{json_extract(column, locale)} LIKE BINARY ?", :like]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Internationalize
|
|
6
|
+
module Adapters
|
|
7
|
+
# PostgreSQL-specific SQL generation for JSON/JSONB queries
|
|
8
|
+
#
|
|
9
|
+
# Uses ->> operator for text extraction from JSON/JSONB columns
|
|
10
|
+
# ILIKE for case-insensitive matching
|
|
11
|
+
# LIKE for case-sensitive matching
|
|
12
|
+
class PostgreSQL < Base
|
|
13
|
+
# Extract a value from a JSON/JSONB column using ->> operator
|
|
14
|
+
#
|
|
15
|
+
# @param column [String] the JSON column name
|
|
16
|
+
# @param locale [String, Symbol] the locale key
|
|
17
|
+
# @return [String] SQL fragment
|
|
18
|
+
def json_extract(column, locale)
|
|
19
|
+
"#{column}->>'#{sanitize_locale(locale)}'"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Case-insensitive search using ILIKE
|
|
23
|
+
#
|
|
24
|
+
# @return [Array<String, Symbol>] SQL condition and pattern type
|
|
25
|
+
def like_insensitive(column, locale)
|
|
26
|
+
["#{json_extract(column, locale)} ILIKE ?", :like]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Case-sensitive search using LIKE
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<String, Symbol>] SQL condition and pattern type
|
|
32
|
+
def like_sensitive(column, locale)
|
|
33
|
+
["#{json_extract(column, locale)} LIKE ?", :like]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Internationalize
|
|
6
|
+
module Adapters
|
|
7
|
+
# SQLite-specific SQL generation for JSON queries
|
|
8
|
+
#
|
|
9
|
+
# Uses json_extract() function (SQLite 3.38+)
|
|
10
|
+
# LIKE for case-insensitive matching (default for ASCII)
|
|
11
|
+
# GLOB for case-sensitive matching
|
|
12
|
+
class SQLite < Base
|
|
13
|
+
# Extract a value from a JSON column using json_extract
|
|
14
|
+
#
|
|
15
|
+
# @param column [String] the JSON column name
|
|
16
|
+
# @param locale [String, Symbol] the locale key
|
|
17
|
+
# @return [String] SQL fragment
|
|
18
|
+
def json_extract(column, locale)
|
|
19
|
+
"json_extract(#{column}, '$.#{sanitize_locale(locale)}')"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Case-insensitive search using LIKE (default for ASCII in SQLite)
|
|
23
|
+
#
|
|
24
|
+
# @return [Array<String, Symbol>] SQL condition and pattern type
|
|
25
|
+
def like_insensitive(column, locale)
|
|
26
|
+
["#{json_extract(column, locale)} LIKE ? ESCAPE '\\'", :like]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Case-sensitive search using GLOB
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<String, Symbol>] SQL condition and pattern type
|
|
32
|
+
def like_sensitive(column, locale)
|
|
33
|
+
["#{json_extract(column, locale)} GLOB ?", :glob]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# GLOB pattern with wildcards
|
|
37
|
+
# GLOB uses * and ? instead of % and _, and is case-sensitive
|
|
38
|
+
#
|
|
39
|
+
# @param term [String] the search term
|
|
40
|
+
# @return [String] GLOB pattern with wildcards
|
|
41
|
+
def glob_pattern(term)
|
|
42
|
+
# Escape GLOB special characters: * ? [ ]
|
|
43
|
+
escaped = term.to_s.gsub(/[*?\[\]]/) { |m| "[#{m}]" }
|
|
44
|
+
"*#{escaped}*"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapters/base"
|
|
4
|
+
require_relative "adapters/sqlite"
|
|
5
|
+
require_relative "adapters/postgresql"
|
|
6
|
+
require_relative "adapters/mysql"
|
|
7
|
+
|
|
8
|
+
module Internationalize
|
|
9
|
+
module Adapters
|
|
10
|
+
class << self
|
|
11
|
+
# Returns the appropriate SQL generator for the current database
|
|
12
|
+
# Detection is based on ActiveRecord's connection adapter name
|
|
13
|
+
#
|
|
14
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
15
|
+
# @return [Internationalize::Adapters::Base] the SQL generator
|
|
16
|
+
def resolve(connection = ActiveRecord::Base.connection)
|
|
17
|
+
case connection.adapter_name.downcase
|
|
18
|
+
when /sqlite/
|
|
19
|
+
SQLite.new
|
|
20
|
+
when /postgres/
|
|
21
|
+
PostgreSQL.new
|
|
22
|
+
when /mysql|trilogy/
|
|
23
|
+
MySQL.new
|
|
24
|
+
else
|
|
25
|
+
raise UnsupportedAdapter, "Database adapter '#{connection.adapter_name}' is not supported. " \
|
|
26
|
+
"Supported adapters: sqlite, postgresql, mysql"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Raised when an unsupported database adapter is detected
|
|
32
|
+
class UnsupportedAdapter < StandardError; end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Internationalize
|
|
4
|
+
# Mixin for ActiveRecord models to enable internationalization
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# class Article < ApplicationRecord
|
|
8
|
+
# include Internationalize::Model
|
|
9
|
+
# international :title, :description
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# # Querying
|
|
13
|
+
# Article.international(title: "Hello")
|
|
14
|
+
# Article.international(title: "hello", match: :partial)
|
|
15
|
+
# Article.international_order(:title, :desc)
|
|
16
|
+
#
|
|
17
|
+
module Model
|
|
18
|
+
extend ActiveSupport::Concern
|
|
19
|
+
|
|
20
|
+
VALID_DIRECTIONS = ["ASC", "DESC"].freeze
|
|
21
|
+
|
|
22
|
+
included do
|
|
23
|
+
class_attribute :international_attributes, default: []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class_methods do
|
|
27
|
+
# Declares attributes as internationalized OR queries by translated attributes
|
|
28
|
+
#
|
|
29
|
+
# When called with Symbol arguments, declares attributes as internationalized:
|
|
30
|
+
# international :title, :description
|
|
31
|
+
# international :title, fallback: false
|
|
32
|
+
#
|
|
33
|
+
# When called with keyword arguments, queries translated attributes:
|
|
34
|
+
# Article.international(title: "Hello") # exact match
|
|
35
|
+
# Article.international(title: "Hello", locale: :de) # exact match in German
|
|
36
|
+
# Article.international(title: "hello", match: :partial) # LIKE match (case-insensitive)
|
|
37
|
+
# Article.international(title: "Hello", match: :partial, case_sensitive: true)
|
|
38
|
+
#
|
|
39
|
+
# @param attributes [Array<Symbol>] attributes to declare as internationalized
|
|
40
|
+
# @param fallback [Boolean] whether to fallback to default locale (default: true)
|
|
41
|
+
# @param locale [Symbol] locale to query (default: current locale)
|
|
42
|
+
# @param match [Symbol] :exact or :partial (default: :exact)
|
|
43
|
+
# @param case_sensitive [Boolean] for partial matching only (default: false)
|
|
44
|
+
# @param conditions [Hash] attribute => value pairs to query
|
|
45
|
+
#
|
|
46
|
+
def international(*attributes, fallback: true, locale: nil, match: :exact, case_sensitive: false, **conditions)
|
|
47
|
+
if attributes.any? && attributes.first.is_a?(Symbol) && conditions.empty?
|
|
48
|
+
# Declaration mode: international :title, :description
|
|
49
|
+
declare_international_attributes(attributes, fallback: fallback)
|
|
50
|
+
else
|
|
51
|
+
# Query mode: Article.international(title: "Hello")
|
|
52
|
+
international_query(conditions, locale: locale, match: match, case_sensitive: case_sensitive)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Order by translated attribute
|
|
57
|
+
#
|
|
58
|
+
# @param attribute [Symbol] attribute to order by
|
|
59
|
+
# @param direction [Symbol] :asc or :desc (default: :asc)
|
|
60
|
+
# @param locale [Symbol] locale to order by (default: current locale)
|
|
61
|
+
# @return [ActiveRecord::Relation]
|
|
62
|
+
#
|
|
63
|
+
# @example
|
|
64
|
+
# Article.international_order(:title)
|
|
65
|
+
# Article.international_order(:title, :desc)
|
|
66
|
+
# Article.international_order(:title, :desc, locale: :de)
|
|
67
|
+
#
|
|
68
|
+
def international_order(attribute, direction = :asc, locale: nil)
|
|
69
|
+
unless international_attributes.include?(attribute.to_sym)
|
|
70
|
+
raise ArgumentError, "#{attribute} is not an international attribute. " \
|
|
71
|
+
"Use standard ActiveRecord methods for non-translated attributes."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
locale ||= Internationalize.locale
|
|
75
|
+
direction = direction.to_s.upcase
|
|
76
|
+
direction = "ASC" unless VALID_DIRECTIONS.include?(direction)
|
|
77
|
+
|
|
78
|
+
adapter = Adapters.resolve(connection)
|
|
79
|
+
json_col = "#{attribute}_translations"
|
|
80
|
+
order(Arel.sql("#{adapter.json_extract(json_col, locale)} #{direction}"))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Find records that have a translation for the given attributes
|
|
84
|
+
#
|
|
85
|
+
# @param attributes [Array<Symbol>] attributes to check
|
|
86
|
+
# @param locale [Symbol] locale to check (default: current locale)
|
|
87
|
+
# @return [ActiveRecord::Relation]
|
|
88
|
+
#
|
|
89
|
+
# @example
|
|
90
|
+
# Article.translated(:title)
|
|
91
|
+
# Article.translated(:title, :description, locale: :de)
|
|
92
|
+
#
|
|
93
|
+
def translated(*attributes, locale: nil)
|
|
94
|
+
locale ||= Internationalize.locale
|
|
95
|
+
adapter = Adapters.resolve(connection)
|
|
96
|
+
scope = all
|
|
97
|
+
|
|
98
|
+
attributes.each do |attr|
|
|
99
|
+
next unless international_attributes.include?(attr.to_sym)
|
|
100
|
+
|
|
101
|
+
json_col = "#{attr}_translations"
|
|
102
|
+
extract = adapter.json_extract(json_col, locale)
|
|
103
|
+
scope = scope.where("#{extract} IS NOT NULL AND #{extract} != ''")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
scope
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Find records missing a translation for the given attributes
|
|
110
|
+
#
|
|
111
|
+
# @param attributes [Array<Symbol>] attributes to check
|
|
112
|
+
# @param locale [Symbol] locale to check (default: current locale)
|
|
113
|
+
# @return [ActiveRecord::Relation]
|
|
114
|
+
#
|
|
115
|
+
# @example
|
|
116
|
+
# Article.untranslated(:title)
|
|
117
|
+
# Article.untranslated(:title, locale: :de)
|
|
118
|
+
#
|
|
119
|
+
def untranslated(*attributes, locale: nil)
|
|
120
|
+
locale ||= Internationalize.locale
|
|
121
|
+
adapter = Adapters.resolve(connection)
|
|
122
|
+
scope = all
|
|
123
|
+
|
|
124
|
+
attributes.each do |attr|
|
|
125
|
+
next unless international_attributes.include?(attr.to_sym)
|
|
126
|
+
|
|
127
|
+
json_col = "#{attr}_translations"
|
|
128
|
+
extract = adapter.json_extract(json_col, locale)
|
|
129
|
+
scope = scope.where("#{extract} IS NULL OR #{extract} = ''")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
scope
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Exclude records matching translated attribute conditions
|
|
136
|
+
#
|
|
137
|
+
# @param conditions [Hash] attribute => value pairs to exclude
|
|
138
|
+
# @param locale [Symbol] locale to use (default: current locale)
|
|
139
|
+
# @return [ActiveRecord::Relation]
|
|
140
|
+
#
|
|
141
|
+
# @example
|
|
142
|
+
# Article.international_not(title: "Draft")
|
|
143
|
+
# Article.international_not(title: "Entwurf", locale: :de)
|
|
144
|
+
#
|
|
145
|
+
def international_not(locale: nil, **conditions)
|
|
146
|
+
locale ||= Internationalize.locale
|
|
147
|
+
adapter = Adapters.resolve(connection)
|
|
148
|
+
scope = all
|
|
149
|
+
|
|
150
|
+
conditions.each do |attr, value|
|
|
151
|
+
unless international_attributes.include?(attr.to_sym)
|
|
152
|
+
raise ArgumentError, "#{attr} is not an international attribute. " \
|
|
153
|
+
"Use standard ActiveRecord methods for non-translated attributes."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
json_col = "#{attr}_translations"
|
|
157
|
+
extract = adapter.json_extract(json_col, locale)
|
|
158
|
+
scope = scope.where("#{extract} != ? OR #{extract} IS NULL", value)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
scope
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Create a new instance with translated attributes in a cleaner format
|
|
165
|
+
#
|
|
166
|
+
# @param attributes [Hash] attributes including translations as nested hashes
|
|
167
|
+
# @return [ActiveRecord::Base] new unsaved instance
|
|
168
|
+
#
|
|
169
|
+
# @example
|
|
170
|
+
# Article.international_new(
|
|
171
|
+
# title: { en: "Hello", de: "Hallo" },
|
|
172
|
+
# status: "published"
|
|
173
|
+
# )
|
|
174
|
+
#
|
|
175
|
+
def international_new(attributes = {})
|
|
176
|
+
new(convert_international_attributes(attributes))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Create and save a record with translated attributes
|
|
180
|
+
#
|
|
181
|
+
# @param attributes [Hash] attributes including translations as nested hashes
|
|
182
|
+
# @return [ActiveRecord::Base] the created record
|
|
183
|
+
#
|
|
184
|
+
# @example
|
|
185
|
+
# Article.international_create(title: { en: "Hello", de: "Hallo" })
|
|
186
|
+
#
|
|
187
|
+
def international_create(attributes = {})
|
|
188
|
+
create(convert_international_attributes(attributes))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Create and save a record with translated attributes, raising on failure
|
|
192
|
+
#
|
|
193
|
+
# @param attributes [Hash] attributes including translations as nested hashes
|
|
194
|
+
# @return [ActiveRecord::Base] the created record
|
|
195
|
+
# @raise [ActiveRecord::RecordInvalid] if validation fails
|
|
196
|
+
#
|
|
197
|
+
# @example
|
|
198
|
+
# Article.international_create!(title: { en: "Hello", de: "Hallo" })
|
|
199
|
+
#
|
|
200
|
+
def international_create!(attributes = {})
|
|
201
|
+
create!(convert_international_attributes(attributes))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
# Convert international attributes from clean format to internal format
|
|
207
|
+
#
|
|
208
|
+
# { title: { en: "Hello", de: "Hallo" } }
|
|
209
|
+
# becomes
|
|
210
|
+
# { title_translations: { "en" => "Hello", "de" => "Hallo" } }
|
|
211
|
+
#
|
|
212
|
+
# Also supports direct assignment using current locale:
|
|
213
|
+
# { title: "Achtung!" } with I18n.locale = :de
|
|
214
|
+
# becomes
|
|
215
|
+
# { title_translations: { "de" => "Achtung!" } }
|
|
216
|
+
#
|
|
217
|
+
def convert_international_attributes(attributes)
|
|
218
|
+
result = {}
|
|
219
|
+
|
|
220
|
+
attributes.each do |key, value|
|
|
221
|
+
if international_attributes.include?(key.to_sym)
|
|
222
|
+
result[:"#{key}_translations"] = if value.is_a?(Hash)
|
|
223
|
+
value.transform_keys(&:to_s)
|
|
224
|
+
else
|
|
225
|
+
{ I18n.locale.to_s => value }
|
|
226
|
+
end
|
|
227
|
+
else
|
|
228
|
+
result[key] = value
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
result
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Declares attributes as internationalized
|
|
236
|
+
def declare_international_attributes(attributes, fallback:)
|
|
237
|
+
self.international_attributes = international_attributes | attributes.map(&:to_sym)
|
|
238
|
+
|
|
239
|
+
attributes.each do |attr|
|
|
240
|
+
warn_if_missing_default(attr)
|
|
241
|
+
define_translation_accessors(attr, fallback: fallback)
|
|
242
|
+
define_locale_accessors(attr)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Warn if JSON column is missing default: {}
|
|
247
|
+
def warn_if_missing_default(attr)
|
|
248
|
+
return unless table_exists?
|
|
249
|
+
|
|
250
|
+
column = columns_hash["#{attr}_translations"]
|
|
251
|
+
return unless column
|
|
252
|
+
return if column.default.present?
|
|
253
|
+
|
|
254
|
+
warn("[Internationalize] WARNING: Column #{table_name}.#{attr}_translations " \
|
|
255
|
+
"is missing `default: {}`. This may cause errors. " \
|
|
256
|
+
"Add `default: {}` to your migration.")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Query translated attributes with exact or partial matching
|
|
260
|
+
def international_query(conditions, locale:, match:, case_sensitive:)
|
|
261
|
+
locale ||= Internationalize.locale
|
|
262
|
+
adapter = Adapters.resolve(connection)
|
|
263
|
+
scope = all
|
|
264
|
+
|
|
265
|
+
conditions.each do |attr, value|
|
|
266
|
+
unless international_attributes.include?(attr.to_sym)
|
|
267
|
+
raise ArgumentError, "#{attr} is not an international attribute. " \
|
|
268
|
+
"Use standard ActiveRecord methods for non-translated attributes."
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
json_col = "#{attr}_translations"
|
|
272
|
+
|
|
273
|
+
scope = if match == :partial
|
|
274
|
+
if case_sensitive
|
|
275
|
+
sql, pattern_type = adapter.like_sensitive(json_col, locale)
|
|
276
|
+
pattern = pattern_type == :glob ? adapter.glob_pattern(value) : adapter.like_pattern(value)
|
|
277
|
+
else
|
|
278
|
+
sql, _ = adapter.like_insensitive(json_col, locale)
|
|
279
|
+
pattern = adapter.like_pattern(value)
|
|
280
|
+
end
|
|
281
|
+
scope.where(sql, pattern)
|
|
282
|
+
else
|
|
283
|
+
scope.where("#{adapter.json_extract(json_col, locale)} = ?", value)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
scope
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Defines the main getter/setter for an attribute
|
|
291
|
+
def define_translation_accessors(attr, fallback:)
|
|
292
|
+
translations_column = "#{attr}_translations"
|
|
293
|
+
|
|
294
|
+
# Main getter - returns translation for current locale
|
|
295
|
+
# Cache default locale at definition time for faster fallback
|
|
296
|
+
default_locale_str = fallback ? Internationalize.default_locale.to_s : nil
|
|
297
|
+
|
|
298
|
+
define_method(attr) do |locale = nil|
|
|
299
|
+
locale_str = (locale || I18n.locale).to_s
|
|
300
|
+
translations = read_attribute(translations_column)
|
|
301
|
+
value = translations[locale_str]
|
|
302
|
+
|
|
303
|
+
# Short-circuit: return early if value exists or no fallback needed
|
|
304
|
+
return value if !fallback || !value.nil? || locale_str == default_locale_str
|
|
305
|
+
|
|
306
|
+
translations[default_locale_str]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Main setter - sets translation for current locale
|
|
310
|
+
define_method("#{attr}=") do |value|
|
|
311
|
+
locale_str = I18n.locale.to_s
|
|
312
|
+
translations = read_attribute(translations_column)
|
|
313
|
+
translations[locale_str] = value
|
|
314
|
+
write_attribute(translations_column, translations)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Predicate method
|
|
318
|
+
getter_method = attr.to_sym
|
|
319
|
+
define_method("#{attr}?") do
|
|
320
|
+
send(getter_method).present?
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Raw translations hash accessor
|
|
324
|
+
define_method("#{attr}_translations") do
|
|
325
|
+
read_attribute(translations_column)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Set all translations at once
|
|
329
|
+
define_method("#{attr}_translations=") do |hash|
|
|
330
|
+
write_attribute(translations_column, hash&.stringify_keys || {})
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Defines locale-specific accessors (title_en, title_de, etc.)
|
|
335
|
+
def define_locale_accessors(attr)
|
|
336
|
+
translations_column = "#{attr}_translations"
|
|
337
|
+
|
|
338
|
+
Internationalize.locales.each do |locale|
|
|
339
|
+
locale_str = locale.to_s
|
|
340
|
+
getter_method = :"#{attr}_#{locale}"
|
|
341
|
+
|
|
342
|
+
# Getter: article.title_en
|
|
343
|
+
define_method(getter_method) do
|
|
344
|
+
translations = read_attribute(translations_column)
|
|
345
|
+
translations[locale_str]
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Setter: article.title_en = "Hello"
|
|
349
|
+
define_method("#{attr}_#{locale}=") do |value|
|
|
350
|
+
translations = read_attribute(translations_column)
|
|
351
|
+
translations[locale_str] = value
|
|
352
|
+
write_attribute(translations_column, translations)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Predicate: article.title_en?
|
|
356
|
+
define_method("#{attr}_#{locale}?") do
|
|
357
|
+
send(getter_method).present?
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Instance methods
|
|
364
|
+
|
|
365
|
+
# Set translation for a specific locale
|
|
366
|
+
def set_translation(attr, locale, value)
|
|
367
|
+
column = "#{attr}_translations"
|
|
368
|
+
translations = read_attribute(column)
|
|
369
|
+
translations[locale.to_s] = value
|
|
370
|
+
write_attribute(column, translations)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Get translation for a specific locale without fallback
|
|
374
|
+
def translation_for(attr, locale)
|
|
375
|
+
translations = read_attribute("#{attr}_translations")
|
|
376
|
+
translations[locale.to_s]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Check if a translation exists for a specific locale
|
|
380
|
+
def translated?(attr, locale)
|
|
381
|
+
translation_for(attr, locale).present?
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Returns all locales that have a translation for an attribute
|
|
385
|
+
def translated_locales(attr)
|
|
386
|
+
translations = read_attribute("#{attr}_translations")
|
|
387
|
+
translations.filter_map { |k, v| k.to_sym if v.present? }
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_record"
|
|
5
|
+
|
|
6
|
+
require_relative "internationalize/version"
|
|
7
|
+
require_relative "internationalize/adapters"
|
|
8
|
+
require_relative "internationalize/model"
|
|
9
|
+
|
|
10
|
+
module Internationalize
|
|
11
|
+
class << self
|
|
12
|
+
# Configuration
|
|
13
|
+
attr_accessor :fallback_locale, :available_locales
|
|
14
|
+
|
|
15
|
+
def configure
|
|
16
|
+
yield self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns the current locale, defaulting to I18n.locale
|
|
20
|
+
def locale
|
|
21
|
+
I18n.locale
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the default locale for fallbacks
|
|
25
|
+
def default_locale
|
|
26
|
+
fallback_locale || I18n.default_locale
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns available locales, defaulting to I18n.available_locales
|
|
30
|
+
def locales
|
|
31
|
+
available_locales || I18n.available_locales
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: internationalize
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sampo Kuokkanen
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.0'
|
|
40
|
+
description: Zero-config internationalization for ActiveRecord models using JSON columns.
|
|
41
|
+
No JOINs, no N+1 queries, just fast inline translations. Supports SQLite, PostgreSQL,
|
|
42
|
+
and MySQL.
|
|
43
|
+
email:
|
|
44
|
+
- sampo.kuokkanen@gmail.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- LICENSE.txt
|
|
51
|
+
- README.md
|
|
52
|
+
- lib/generators/internationalize/translation/templates/add_translations_migration.rb.erb
|
|
53
|
+
- lib/generators/internationalize/translation_generator.rb
|
|
54
|
+
- lib/internationalize.rb
|
|
55
|
+
- lib/internationalize/adapters.rb
|
|
56
|
+
- lib/internationalize/adapters/base.rb
|
|
57
|
+
- lib/internationalize/adapters/mysql.rb
|
|
58
|
+
- lib/internationalize/adapters/postgresql.rb
|
|
59
|
+
- lib/internationalize/adapters/sqlite.rb
|
|
60
|
+
- lib/internationalize/model.rb
|
|
61
|
+
- lib/internationalize/version.rb
|
|
62
|
+
homepage: https://github.com/sampokuokkanen/internationalize
|
|
63
|
+
licenses:
|
|
64
|
+
- MIT
|
|
65
|
+
metadata:
|
|
66
|
+
homepage_uri: https://github.com/sampokuokkanen/internationalize
|
|
67
|
+
source_code_uri: https://github.com/sampokuokkanen/internationalize
|
|
68
|
+
changelog_uri: https://github.com/sampokuokkanen/internationalize/blob/main/CHANGELOG.md
|
|
69
|
+
rdoc_options: []
|
|
70
|
+
require_paths:
|
|
71
|
+
- lib
|
|
72
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: 3.1.0
|
|
77
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0'
|
|
82
|
+
requirements: []
|
|
83
|
+
rubygems_version: 3.7.2
|
|
84
|
+
specification_version: 4
|
|
85
|
+
summary: Lightweight, performant i18n for Rails with JSON column storage
|
|
86
|
+
test_files: []
|