internationalize 0.2.4 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfc56411ee42ac5d8377fe409418e1692acc4cb0c5cd60899acd70003f0d4dd7
4
- data.tar.gz: 9a437ba5771695b1d84b7d95e61d4df4e342725501df6dc947809245750054a9
3
+ metadata.gz: 0a7c08fb66904ff4e73e13f590f4546af940ce8f9adc59d870adf60e71f54fc0
4
+ data.tar.gz: e6e5b34d18588fc2b8d1fd18b182d73f39bffe2ad67c7f383976eaa30d8b6ee2
5
5
  SHA512:
6
- metadata.gz: 4354ea3355e467ebfc465feacdb707189b5981f6df04e8620e8b72dbf4c84f4df8e2d454e3a405f0ffd7c923ec4c8c946f119241b99ddf978d700fbeebb1ecbf
7
- data.tar.gz: 82e139593ed3296be30e601376b147cfaf2690255f25c2be69b7add169568b388ad721bd14a0f6244489987e4bbf9fc4a89f303a8c87a1f94eca99f5e524b8b0
6
+ metadata.gz: 6bf2d4d4e0cfcb36adbd787f3a839dd6bbe4290468eb2934b99af4c4541d948aef7d3f5ea71329e2f99aabe1f2530c83af8955d544d8991e0a9341b4abd393cb
7
+ data.tar.gz: 5ccafbadb398e1ad9b956990b10334c80577f248d32b4e788a94dc5ad39fffdb47fa1c22eb892216af71784ea4770b4d0bfac43ceeaac951cff63575a4ce1152
data/CHANGELOG.md CHANGED
@@ -5,7 +5,28 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.4.0] - 2025-12-02
9
+
10
+ ### Added
11
+
12
+ - `i18n_where` query method as a short, convenient alias for querying translated attributes
13
+ - `Article.i18n_where(title: "Hello")` - exact match
14
+ - `Article.i18n_where(title: "hello", match: :partial)` - LIKE match
15
+
16
+ ### Deprecated
17
+
18
+ - Using `international` for querying is now deprecated; use `i18n_where` or `international_where` instead
19
+ - A deprecation warning is now emitted when using `international` for queries
20
+
21
+ ## [0.3.0] - 2024-12-01
22
+
23
+ ### Added
24
+
25
+ - `validates_international` method for locale-specific validations:
26
+ - `uniqueness: true` - validates uniqueness per-locale (requires JSON column querying)
27
+ - `presence: { locales: [:en, :de] }` - requires translations for specific locales (useful for admin interfaces)
28
+ - Standard Rails validations (`validates :title, presence: true`) now work with virtual accessors
29
+ - `international_where` query method as a clearer alternative to `international` for queries
9
30
 
10
31
  ## [0.2.4] - 2024-11-29
11
32
 
data/README.md CHANGED
@@ -1,21 +1,27 @@
1
1
  # Internationalize
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/internationalize.svg)](https://rubygems.org/gems/internationalize)
4
+ [![Build](https://github.com/sampokuokkanen/internationalize/workflows/CI/badge.svg)](https://github.com/sampokuokkanen/internationalize/actions)
5
+
3
6
  Lightweight, performant internationalization for Rails with JSON column storage.
4
7
 
5
8
  ## Why Internationalize?
6
9
 
7
10
  Internationalize is a focused, lightweight gem that does one thing well: JSON column translations. No backend abstraction layers, no plugin systems, no extra memory overhead.
8
11
 
9
- Unlike Globalize or Mobility which use separate translation tables requiring JOINs, Internationalize stores translations inline using JSON columns. This means:
12
+ Unlike Globalize (separate translation tables) or Mobility (JSON backend is PostgreSQL-only), Internationalize stores translations inline using JSON columns with **full SQLite, PostgreSQL, and MySQL support**:
10
13
 
11
14
  - **No JOINs** - translations live in the same table
12
15
  - **No N+1 queries** - data is always loaded with the record
13
16
  - **No backend overhead** - direct JSON column access, no abstraction layers
14
17
  - **~50% less memory** - no per-instance backend objects or plugin chains
15
18
  - **Direct method dispatch** - no `method_missing` overhead
16
- - **Multi-database support** - works with SQLite, PostgreSQL, and MySQL
19
+ - **True multi-database JSON support** - SQLite 3.38+, PostgreSQL 9.4+, MySQL 8.0+
20
+ - **ActionText support** - internationalized rich text with attachments
17
21
  - **Visible in schema.rb** - translated fields appear directly in your model's schema
18
22
 
23
+ > **Note:** Mobility's JSON/JSONB backends only work with PostgreSQL. For SQLite or MySQL, Mobility requires separate translation tables with JOINs. Internationalize provides JSON column querying across all three databases.
24
+
19
25
  ## Supported Databases
20
26
 
21
27
  | Database | JSON Column | Query Syntax |
@@ -113,13 +119,12 @@ All query methods default to the current `I18n.locale` and return ActiveRecord r
113
119
 
114
120
  ```ruby
115
121
  # Exact match on translation (uses current locale by default)
116
- Article.international(title: "Hello World")
117
- Article.international(title: "Hallo Welt", locale: :de)
122
+ Article.i18n_where(title: "Hello World")
123
+ Article.i18n_where(title: "Hallo Welt", locale: :de)
118
124
 
119
125
  # Partial match / search (case-insensitive LIKE)
120
- Article.international(title: "hello", match: :partial)
121
- Article.international(title: "Hello", match: :partial, case_sensitive: true)
122
- Article.international(title: "hallo", match: :partial, locale: :de)
126
+ Article.i18n_where(title: "hello", match: :partial)
127
+ Article.i18n_where(title: "Hello", match: :partial, case_sensitive: true)
123
128
 
124
129
  # Exclude matches
125
130
  Article.international_not(title: "Draft")
@@ -136,19 +141,15 @@ Article.translated(:title, locale: :de)
136
141
  Article.untranslated(:title, locale: :de)
137
142
 
138
143
  # Chain with ActiveRecord methods
139
- Article.international(title: "Hello World")
144
+ Article.i18n_where(title: "Hello World")
140
145
  .where(published: true)
141
146
  .includes(:author)
142
147
  .limit(10)
143
148
 
144
149
  # Combine queries
145
- Article.international(title: "hello", match: :partial)
150
+ Article.i18n_where(title: "hello", match: :partial)
146
151
  .where(status: "published")
147
152
  .merge(Article.international_order(:title, :desc))
148
-
149
- # Query across multiple locales
150
- Article.international(title: "Hello World", locale: :en)
151
- .merge(Article.international(title: "Hallo Welt", locale: :de))
152
153
  ```
153
154
 
154
155
  ### Helper Methods
@@ -173,9 +174,46 @@ I18n.locale = :de
173
174
  article.title # => "Hello" (falls back to :en)
174
175
  ```
175
176
 
177
+ ### Validations
178
+
179
+ For most validations, use standard Rails validators—they work with the virtual accessor for the current locale:
180
+
181
+ ```ruby
182
+ class Article < ApplicationRecord
183
+ include Internationalize::Model
184
+ international :title
185
+
186
+ # Standard Rails validations (recommended)
187
+ validates :title, presence: true
188
+ validates :title, length: { minimum: 3, maximum: 100 }
189
+ validates :title, format: { with: /\A[a-z0-9-]+\z/ }
190
+ end
191
+ ```
192
+
193
+ Use `validates_international` only when you need:
194
+
195
+ ```ruby
196
+ class Article < ApplicationRecord
197
+ include Internationalize::Model
198
+ international :title
199
+
200
+ # Uniqueness per-locale (requires JSON column querying)
201
+ validates_international :title, uniqueness: true
202
+
203
+ # Multi-locale presence (for admin interfaces editing all translations at once)
204
+ validates_international :title, presence: { locales: [:en, :de] }
205
+ end
206
+ ```
207
+
208
+ Errors from `validates_international` are added to locale-specific keys:
209
+
210
+ ```ruby
211
+ article.errors[:title_en] # => ["has already been taken"]
212
+ ```
213
+
176
214
  ### ActionText Support
177
215
 
178
- For rich text with attachments, use `international_rich_text` (auto-loaded when ActionText is available):
216
+ For rich text with attachments (requires ActionText):
179
217
 
180
218
  ```ruby
181
219
  class Article < ApplicationRecord
@@ -211,15 +249,20 @@ hello_world:
211
249
 
212
250
  ## Configuration
213
251
 
252
+ No configuration required. Internationalize uses your existing Rails I18n settings:
253
+
254
+ - **Locales**: `I18n.available_locales`
255
+ - **Fallback**: `I18n.default_locale`
256
+
257
+ To override locales (rarely needed):
258
+
214
259
  ```ruby
215
260
  # config/initializers/internationalize.rb
216
261
  Internationalize.configure do |config|
217
- config.available_locales = [:en, :de, :fr] # Defaults to I18n.available_locales
262
+ config.available_locales = [:en, :de, :fr]
218
263
  end
219
264
  ```
220
265
 
221
- Fallback uses `I18n.default_locale` automatically.
222
-
223
266
  ## Performance Comparison
224
267
 
225
268
  Benchmark with 1000 records, 2 translated attributes (title + body), 3 locales:
@@ -250,13 +293,13 @@ Benchmark with 1000 records, 2 translated attributes (title + body), 3 locales:
250
293
 
251
294
  ### When to consider Mobility
252
295
 
253
- For table-based translation storage, consider [Mobility](https://github.com/shioyama/mobility) instead:
296
+ Consider [Mobility](https://github.com/shioyama/mobility) if:
254
297
 
255
- - Existing projects with large tables where adding columns is expensive
256
- - When you need to add translations without modifying existing table schemas
257
- - Applications that rarely query by translated content
298
+ - You need to add translations without modifying existing table schemas
299
+ - You're on PostgreSQL and want their JSON backend (note: PostgreSQL-only)
300
+ - You need the flexibility of multiple backend strategies
258
301
 
259
- Mobility's table backend stores translations in separate tables with JOINs, which trades query performance for schema flexibility.
302
+ Mobility's table backend stores translations in separate tables with JOINs, which trades query performance for schema flexibility. Their JSON backend is PostgreSQL-only.
260
303
 
261
304
  ## License
262
305
 
@@ -1,17 +1,21 @@
1
1
  # Internationalize: Configuration
2
2
 
3
- ## Global Configuration
3
+ ## Zero Configuration
4
+
5
+ No configuration required. Internationalize uses your existing Rails I18n settings:
6
+
7
+ - `I18n.available_locales` - determines which locale accessors are generated
8
+ - `I18n.default_locale` - used for fallback when translation is missing
9
+
10
+ ## Override Locales (Rarely Needed)
4
11
 
5
12
  ```ruby
6
13
  # config/initializers/internationalize.rb
7
14
  Internationalize.configure do |config|
8
- # Override available locales (defaults to I18n.available_locales)
9
15
  config.available_locales = [:en, :de, :fr, :es]
10
16
  end
11
17
  ```
12
18
 
13
- Fallback locale is always `I18n.default_locale`.
14
-
15
19
  ## Database Setup
16
20
 
17
21
  Use the generator to create migrations:
@@ -71,10 +71,10 @@ article.title_translations # => {"en" => "Hello World", "de" => "Hallo Welt"}
71
71
 
72
72
  ```ruby
73
73
  # Exact match
74
- Article.international(title: "Hello World")
74
+ Article.i18n_where(title: "Hello World")
75
75
 
76
76
  # Partial match (LIKE)
77
- Article.international(title: "Hello", match: :partial)
77
+ Article.i18n_where(title: "Hello", match: :partial)
78
78
 
79
79
  # Order by translation
80
80
  Article.international_order(:title, :desc)
data/context/model-api.md CHANGED
@@ -49,17 +49,17 @@ All query methods default to current `I18n.locale` and return `ActiveRecord::Rel
49
49
 
50
50
  | Method | Description |
51
51
  |--------|-------------|
52
- | `international(**conditions, locale: nil)` | Exact match query |
52
+ | `i18n_where(**conditions, locale: nil, match: :exact)` | Query by translation (short alias) |
53
+ | `international_where(**conditions, locale: nil, match: :exact)` | Query by translation |
53
54
  | `international_not(**conditions, locale: nil)` | Exclude matching records |
54
- | `international_search(**conditions, locale: nil, case_sensitive: false)` | Substring search |
55
55
  | `international_order(attr, dir = :asc, locale: nil)` | Order by translation |
56
56
  | `translated(*attrs, locale: nil)` | Find records with translation |
57
57
  | `untranslated(*attrs, locale: nil)` | Find records missing translation |
58
58
 
59
59
  ```ruby
60
60
  # Examples
61
- Article.international(title: "Hello World")
62
- Article.international_search(title: "hello", locale: :de)
61
+ Article.i18n_where(title: "Hello World")
62
+ Article.i18n_where(title: "hello", match: :partial) # case-insensitive LIKE
63
63
  Article.international_order(:title, :desc)
64
64
  Article.translated(:title, locale: :de)
65
65
  ```
@@ -80,14 +80,66 @@ I18n.locale = :de
80
80
  article.title # => "Hello" (falls back to default locale)
81
81
  ```
82
82
 
83
+ ## Validations
84
+
85
+ For most validations, use standard Rails validators—they work with the virtual accessor:
86
+
87
+ ```ruby
88
+ class Article < ApplicationRecord
89
+ include Internationalize::Model
90
+ international :title
91
+
92
+ # Standard Rails validations (recommended)
93
+ validates :title, presence: true
94
+ validates :title, length: { minimum: 3, maximum: 100 }
95
+ validates :title, format: { with: /\A[a-z]+\z/ }
96
+ end
97
+ ```
98
+
99
+ Use `validates_international` only for uniqueness or multi-locale presence:
100
+
101
+ ```ruby
102
+ class Article < ApplicationRecord
103
+ include Internationalize::Model
104
+ international :title
105
+
106
+ # Uniqueness per-locale (requires JSON column querying)
107
+ validates_international :title, uniqueness: true
108
+
109
+ # Multi-locale presence (for admin interfaces editing all translations at once)
110
+ validates_international :title, presence: { locales: [:en, :de] }
111
+ end
112
+ ```
113
+
114
+ ### Validation Options
115
+
116
+ | Option | Description |
117
+ |--------|-------------|
118
+ | `uniqueness: true` | Validates uniqueness per-locale (current locale) |
119
+ | `presence: { locales: [:en, :de] }` | Requires translations for specific locales |
120
+
121
+ ### Error Keys
122
+
123
+ Standard Rails validations add errors to the virtual attribute:
124
+
125
+ ```ruby
126
+ article.errors[:title] # => ["can't be blank"]
127
+ ```
128
+
129
+ `validates_international` adds errors to locale-specific keys:
130
+
131
+ ```ruby
132
+ article.errors[:title_en] # => ["has already been taken"]
133
+ ```
134
+
83
135
  ## ActionText Support
84
136
 
85
- For rich text with attachments, use `international_rich_text` (auto-loaded when ActionText is available):
137
+ For rich text with attachments, include `Internationalize::RichText` and use `international_rich_text`:
86
138
 
87
139
  ```ruby
88
140
  class Article < ApplicationRecord
89
141
  include Internationalize::Model
90
- include Internationalize::RichText
142
+ include Internationalize::RichText # Requires ActionText
91
143
 
92
144
  international_rich_text :content
93
145
  end
data/context/query-api.md CHANGED
@@ -8,47 +8,40 @@ All query methods default to the current `I18n.locale`. Use the `locale:` option
8
8
 
9
9
  ```ruby
10
10
  # Uses current I18n.locale
11
- Article.international(title: "Hello World")
11
+ Article.i18n_where(title: "Hello World")
12
12
 
13
13
  # Explicit locale
14
- Article.international(title: "Hallo Welt", locale: :de)
14
+ Article.i18n_where(title: "Hallo Welt", locale: :de)
15
15
  ```
16
16
 
17
17
  ## Query Methods
18
18
 
19
- ### international(**conditions, locale: nil)
19
+ ### i18n_where(**conditions, locale: nil, match: :exact, case_sensitive: false)
20
20
 
21
- Exact match on translated attributes:
21
+ Query by translated attributes. Also available as `international_where`.
22
+
23
+ Exact match (default):
22
24
 
23
25
  ```ruby
24
- Article.international(title: "Hello World")
25
- Article.international(title: "Hello", status: "published")
26
- Article.international(title: "Hallo Welt", locale: :de)
26
+ Article.i18n_where(title: "Hello World")
27
+ Article.i18n_where(title: "Hello", status: "published")
28
+ Article.i18n_where(title: "Hallo Welt", locale: :de)
27
29
  ```
28
30
 
29
- ### international_not(**conditions, locale: nil)
30
-
31
- Exclude records matching conditions:
31
+ Partial match (case-insensitive LIKE):
32
32
 
33
33
  ```ruby
34
- Article.international_not(title: "Draft")
35
- Article.international_not(title: "Entwurf", locale: :de)
34
+ Article.i18n_where(title: "hello", match: :partial)
35
+ Article.i18n_where(title: "Hello", match: :partial, case_sensitive: true)
36
36
  ```
37
37
 
38
- ### international_search(**conditions, locale: nil, case_sensitive: false)
38
+ ### international_not(**conditions, locale: nil)
39
39
 
40
- Substring search (LIKE/ILIKE):
40
+ Exclude records matching conditions:
41
41
 
42
42
  ```ruby
43
- # Case-insensitive (default)
44
- Article.international_search(title: "hello")
45
- Article.international_search(title: "hello", description: "world")
46
-
47
- # Case-sensitive
48
- Article.international_search(title: "Hello", case_sensitive: true)
49
-
50
- # With explicit locale
51
- Article.international_search(title: "welt", locale: :de)
43
+ Article.international_not(title: "Draft")
44
+ Article.international_not(title: "Entwurf", locale: :de)
52
45
  ```
53
46
 
54
47
  ### international_order(attribute, direction = :asc, locale: nil)
@@ -85,7 +78,7 @@ Article.untranslated(:title, locale: :de)
85
78
  All methods return `ActiveRecord::Relation`, so they chain naturally with AR methods:
86
79
 
87
80
  ```ruby
88
- Article.international(title: "Hello World")
81
+ Article.i18n_where(title: "Hello World")
89
82
  .where(published: true)
90
83
  .order(created_at: :desc)
91
84
  .limit(10)
@@ -98,20 +91,20 @@ Use `merge` to combine multiple international queries:
98
91
 
99
92
  ```ruby
100
93
  # Search + filter + order
101
- Article.international_search(title: "hello")
102
- .merge(Article.international(status: "published"))
94
+ Article.i18n_where(title: "hello", match: :partial)
95
+ .merge(Article.i18n_where(status: "published"))
103
96
  .merge(Article.international_order(:title, :desc))
104
97
 
105
98
  # Query across multiple locales
106
- Article.international(title: "Hello World", locale: :en)
107
- .merge(Article.international(title: "Hallo Welt", locale: :de))
99
+ Article.i18n_where(title: "Hello World", locale: :en)
100
+ .merge(Article.i18n_where(title: "Hallo Welt", locale: :de))
108
101
  ```
109
102
 
110
103
  ## Examples
111
104
 
112
105
  ```ruby
113
106
  # Find published articles with German title containing "Welt"
114
- Article.international_search(title: "Welt", locale: :de)
107
+ Article.i18n_where(title: "Welt", match: :partial, locale: :de)
115
108
  .where(published: true)
116
109
  .merge(Article.international_order(:title, locale: :de))
117
110
  .limit(10)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "validations"
4
+
3
5
  module Internationalize
4
6
  # Mixin for ActiveRecord models to enable internationalization
5
7
  #
@@ -10,12 +12,13 @@ module Internationalize
10
12
  # end
11
13
  #
12
14
  # # Querying
13
- # Article.international(title: "Hello")
14
- # Article.international(title: "hello", match: :partial)
15
+ # Article.i18n_where(title: "Hello")
16
+ # Article.i18n_where(title: "hello", match: :partial)
15
17
  # Article.international_order(:title, :desc)
16
18
  #
17
19
  module Model
18
20
  extend ActiveSupport::Concern
21
+ include Internationalize::Validations
19
22
 
20
23
  VALID_DIRECTIONS = ["ASC", "DESC"].freeze
21
24
 
@@ -24,33 +27,57 @@ module Internationalize
24
27
  end
25
28
 
26
29
  class_methods do
27
- # Declares attributes as internationalized OR queries by translated attributes
30
+ # Declares attributes as internationalized
28
31
  #
29
- # When called with Symbol arguments, declares attributes as internationalized:
32
+ # @example
30
33
  # international :title, :description
31
34
  #
32
- # When called with keyword arguments, queries translated attributes:
33
- # Article.international(title: "Hello") # exact match
34
- # Article.international(title: "Hello", locale: :de) # exact match in German
35
- # Article.international(title: "hello", match: :partial) # LIKE match (case-insensitive)
36
- # Article.international(title: "Hello", match: :partial, case_sensitive: true)
37
- #
38
35
  # @param attributes [Array<Symbol>] attributes to declare as internationalized
39
- # @param locale [Symbol] locale to query (default: current locale)
40
- # @param match [Symbol] :exact or :partial (default: :exact)
41
- # @param case_sensitive [Boolean] for partial matching only (default: false)
42
- # @param conditions [Hash] attribute => value pairs to query
36
+ #
37
+ # @deprecated Using this method for querying is deprecated.
38
+ # Use {#i18n_where} or {#international_where} instead.
43
39
  #
44
40
  def international(*attributes, locale: nil, match: :exact, case_sensitive: false, **conditions)
45
41
  if attributes.any? && attributes.first.is_a?(Symbol) && conditions.empty?
46
42
  # Declaration mode: international :title, :description
47
43
  declare_international_attributes(attributes)
48
44
  else
49
- # Query mode: Article.international(title: "Hello")
45
+ # Query mode: Article.international(title: "Hello") - DEPRECATED
46
+ warn("[Internationalize] DEPRECATION WARNING: Using `international` for querying is deprecated. " \
47
+ "Use `i18n_where` or `international_where` instead. " \
48
+ "(called from #{caller(1..1).first})")
50
49
  international_query(conditions, locale: locale, match: match, case_sensitive: case_sensitive)
51
50
  end
52
51
  end
53
52
 
53
+ # Query translated attributes
54
+ #
55
+ # @param conditions [Hash] attribute => value pairs to query
56
+ # @param locale [Symbol] locale to query (default: current locale)
57
+ # @param match [Symbol] :exact or :partial (default: :exact)
58
+ # @param case_sensitive [Boolean] for partial matching only (default: false)
59
+ # @return [ActiveRecord::Relation]
60
+ #
61
+ # @example
62
+ # Article.international_where(title: "Hello")
63
+ # Article.international_where(title: "hello", match: :partial)
64
+ #
65
+ def international_where(locale: nil, match: :exact, case_sensitive: false, **conditions)
66
+ international_query(conditions, locale: locale, match: match, case_sensitive: case_sensitive)
67
+ end
68
+
69
+ # Short alias for international_where
70
+ #
71
+ # @see #international_where
72
+ #
73
+ # @example
74
+ # Article.i18n_where(title: "Hello")
75
+ # Article.i18n_where(title: "hello", match: :partial)
76
+ #
77
+ def i18n_where(locale: nil, match: :exact, case_sensitive: false, **conditions)
78
+ international_query(conditions, locale: locale, match: match, case_sensitive: case_sensitive)
79
+ end
80
+
54
81
  # Order by translated attribute
55
82
  #
56
83
  # @param attribute [Symbol] attribute to order by
@@ -334,7 +361,7 @@ module Internationalize
334
361
  hash.each_key do |key|
335
362
  unless allowed_locales.include?(key.to_s)
336
363
  raise ArgumentError, "Invalid locale '#{key}' for #{attr}_translations. " \
337
- "Allowed locales: #{allowed_locales.join(', ')}"
364
+ "Allowed locales: #{allowed_locales.join(", ")}"
338
365
  end
339
366
  end
340
367
 
@@ -351,7 +378,7 @@ module Internationalize
351
378
 
352
379
  if locale_str.include?("-")
353
380
  raise ArgumentError, "Locale '#{locale}' contains a hyphen which is invalid for Ruby method names. " \
354
- "Use underscore format instead: :#{locale_str.tr('-', '_')}"
381
+ "Use underscore format instead: :#{locale_str.tr("-", "_")}"
355
382
  end
356
383
 
357
384
  getter_method = :"#{attr}_#{locale}"
@@ -31,7 +31,7 @@ module Internationalize
31
31
  locale_str = locale.to_s
32
32
  if locale_str.include?("-")
33
33
  raise ArgumentError, "Locale '#{locale}' contains a hyphen which is invalid for Ruby method names. " \
34
- "Use underscore format instead: :#{locale_str.tr('-', '_')}"
34
+ "Use underscore format instead: :#{locale_str.tr("-", "_")}"
35
35
  end
36
36
  end
37
37
 
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Internationalize
4
+ # Validation support for internationalized attributes
5
+ #
6
+ # For simple validations (presence, length, format), use standard Rails validations
7
+ # which work with the virtual accessor for the current locale:
8
+ #
9
+ # validates :title, presence: true
10
+ # validates :title, length: { minimum: 3 }
11
+ #
12
+ # Use validates_international for:
13
+ # - Uniqueness (requires JSON column querying)
14
+ # - Multi-locale presence (admin interfaces editing all translations at once)
15
+ #
16
+ # @example Uniqueness per-locale
17
+ # validates_international :title, uniqueness: true
18
+ #
19
+ # @example Require specific locales (admin/manager interfaces)
20
+ # validates_international :title, presence: { locales: [:en, :de] }
21
+ #
22
+ module Validations
23
+ extend ActiveSupport::Concern
24
+
25
+ class_methods do
26
+ # Validates internationalized attributes
27
+ #
28
+ # @param attrs [Array<Symbol>] attribute names to validate
29
+ # @param options [Hash] validation options
30
+ # @option options [Boolean] :uniqueness validate uniqueness per-locale (current locale)
31
+ # @option options [Hash] :presence with :locales array for multi-locale presence
32
+ #
33
+ # @example Uniqueness validation (per-locale)
34
+ # validates_international :title, uniqueness: true
35
+ #
36
+ # @example Multi-locale presence (for admin interfaces)
37
+ # validates_international :title, presence: { locales: [:en, :de] }
38
+ #
39
+ def validates_international(*attrs, **options)
40
+ presence_opts = options.delete(:presence)
41
+ uniqueness_opts = options.delete(:uniqueness)
42
+
43
+ attrs.each do |attr|
44
+ validate_international_presence(attr, presence_opts) if presence_opts
45
+ validate_international_uniqueness(attr, uniqueness_opts) if uniqueness_opts
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def validate_international_presence(attr, options)
52
+ unless options.is_a?(Hash) && options[:locales]
53
+ raise ArgumentError, "validates_international presence requires locales: option. " \
54
+ "For current locale, use: validates :#{attr}, presence: true"
55
+ end
56
+
57
+ locales = options[:locales].map(&:to_s)
58
+
59
+ validate do |record|
60
+ locales.each do |locale|
61
+ value = record.send("#{attr}_#{locale}")
62
+ if value.blank?
63
+ record.errors.add("#{attr}_#{locale}", :blank)
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ def validate_international_uniqueness(attr, _options)
70
+ validate do |record|
71
+ locale = I18n.locale.to_s
72
+ value = record.send("#{attr}_#{locale}")
73
+ next if value.blank?
74
+
75
+ scope = record.class.international(attr => value, locale: locale)
76
+ scope = scope.where.not(id: record.id) if record.persisted?
77
+
78
+ if scope.exists?
79
+ record.errors.add("#{attr}_#{locale}", :taken)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Internationalize
4
- VERSION = "0.2.4"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: internationalize
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sampo Kuokkanen
@@ -64,6 +64,7 @@ files:
64
64
  - lib/internationalize/adapters/sqlite.rb
65
65
  - lib/internationalize/model.rb
66
66
  - lib/internationalize/rich_text.rb
67
+ - lib/internationalize/validations.rb
67
68
  - lib/internationalize/version.rb
68
69
  homepage: https://github.com/sampokuokkanen/internationalize
69
70
  licenses: