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 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Internationalize
4
+ VERSION = "0.1.0"
5
+ 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: []