awesome_jsonb_translate 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: ffa6732fc05182186a76341a8c1335d428c1b74686109eefba62e64aae6723f4
4
+ data.tar.gz: 5c723ede13d6091434835a1c341804cff94f38e4cb855a6e996d2c9f61b66d49
5
+ SHA512:
6
+ metadata.gz: 21b0933074aa9548332a4ba764fbf26a7223c4e9003a38298c75a12c31d7a40fda0c2ea0719e7a3ed4352ac56a28a42d47540e2afa247447d0937595fe80f156
7
+ data.tar.gz: 9ca4739aa48e62e8e0c7ef7181bdba9c93da8bf73861aaec1f119a1c65adadc0f601bac20ef7d2b393e89f127e9472ea2151e58c5480ee74254b6c64e63087a1
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 firedev.com
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,292 @@
1
+ # Awesome JSONB Translate
2
+
3
+ This gem uses PostgreSQL's JSONB datatype and ActiveRecord models to translate model data.
4
+
5
+ - No extra columns or tables needed to operate
6
+ - Clean naming in the database model
7
+ - Everything is well tested
8
+ - Uses modern JSONB type for better performance and flexibility
9
+ - Falls back to default locale
10
+
11
+ ## Features
12
+
13
+ - [x] `v0.1.0` It works
14
+
15
+ ## Requirements
16
+
17
+ - I18n
18
+ - PostgreSQL with JSONB support (9.4+)
19
+
20
+ ## Installation
21
+
22
+ Add this line to your application's Gemfile:
23
+
24
+ ```ruby
25
+ gem 'awesome_jsonb_translate'
26
+ ```
27
+
28
+ And then execute:
29
+
30
+ $ bundle
31
+
32
+ Or install it yourself as:
33
+
34
+ $ gem install awesome_jsonb_translate
35
+
36
+ ## Usage
37
+
38
+ Include `AwesomeJsonbTranslate` in your model class.
39
+
40
+ Use `translates` in your models, to define the attributes, which should be translateable:
41
+
42
+
43
+ ```rb
44
+ class Model < ApplicationRecord
45
+ include AwesomeJsonbTranslate
46
+ translates :title, :description
47
+ end
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Examples of Supported Usage
53
+
54
+ ### Assigning and Retrieving Translations
55
+
56
+ ```ruby
57
+ p = Page.new(title_en: 'English title', title_de: 'Deutscher Titel')
58
+ p = Page.new(title: { en: 'English title', de: 'Deutscher Titel'})
59
+ p.title_en # => 'English title'
60
+ p.title_de # => 'Deutscher Titel'
61
+
62
+ I18n.with_locale(:en) { p.title } # => 'English title'
63
+ I18n.with_locale(:de) { p.title } # => 'Deutscher Titel'
64
+ ```
65
+
66
+ ### Fallbacks
67
+
68
+ It always falls back to default locale
69
+
70
+ ```ruby
71
+ # Behavior with fallbacks enabled
72
+ p = Page.new(title_en: 'English title')
73
+ I18n.with_locale(:de) { p.title } # => 'English title' (falls back to English)
74
+
75
+ # Behavior with empty string
76
+ p = Page.new(title_en: 'English title', title_de: '')
77
+ I18n.with_locale(:de) { p.title } # => 'English title' (falls back since German is empty)
78
+ ```
79
+
80
+ ### Assigning a Hash Directly
81
+
82
+ ```ruby
83
+ p = Page.new(title: { en: 'English title', de: 'Deutscher Titel' })
84
+ p.title_raw # => { 'en' => 'English title', 'de' => 'Deutscher Titel' }
85
+ ```
86
+
87
+ ### Locale Accessors
88
+
89
+ ```ruby
90
+ p.title_en = 'Updated English title'
91
+ p.title_de = 'Aktualisierter Deutscher Titel'
92
+ ```
93
+
94
+ ### Querying by Translated Value (JSONB-aware)
95
+
96
+ ```ruby
97
+ # Find records by current locale value
98
+ Page.find_by(title_en: 'English title')
99
+
100
+ # which transforms to
101
+ Page.where("title->>'en' = ?", 'English title') # queries current locale
102
+
103
+ # Use with other conditions
104
+ Page.find_by(title_en: 'English title', author: 'John')
105
+
106
+ # which transforms to
107
+ Page.where("title->>'en' = ?", 'English title').where(author: 'John')
108
+ ```
109
+
110
+ ### Finding or Initializing Records
111
+
112
+ ```ruby
113
+ # Find existing record by translated attribute
114
+ Page.find_or_initialize_by(title_en: 'English title')
115
+
116
+ # Initialize new record if not found
117
+ new_page = Page.find_or_initialize_by(title_en: 'New Page', slug: 'new')
118
+ new_page.persisted? # => false
119
+
120
+ # Find with combined attributes
121
+ Page.find_or_initialize_by(title_en: 'English title', slug: 'english-title')
122
+
123
+ # Find or create records
124
+ existing = Page.find_or_create_by(title_en: 'English title')
125
+ new_record = Page.find_or_create_by(title_en: 'Brand New', slug: 'brand-new') # Creates and saves the record
126
+ ```
127
+
128
+ ### Ordering Records
129
+
130
+ ```ruby
131
+ # Sort by translated field in current locale
132
+ Page.order("title->>'en' ASC")
133
+ ```
134
+
135
+ ### Other Useful Methods
136
+
137
+ ```ruby
138
+ # List translated attributes
139
+ Page.translated_attributes # => [:title, :content]
140
+
141
+ # List all accessor methods
142
+ Page.translated_accessors # => [:title_en, :title_de, :content_en, :content_de]
143
+
144
+ # Check translation presence
145
+ page.translated?(:title) # => true
146
+ page.translated?(:title, :fr) # => false
147
+
148
+ # Check translation availability
149
+ page.translation_available?(:title, :en) # => true
150
+
151
+ # Get all locales that have a translation
152
+ page.available_translations(:title) # => ["en", "de"]
153
+
154
+ # Get all available locales for the record
155
+ page.available_locales # => [:en, :de]
156
+ ```
157
+
158
+ ---
159
+
160
+ ```ruby
161
+ class Page < ActiveRecord::Base
162
+ include AwesomeJsonbTranslate
163
+ translates :title, :content
164
+ end
165
+ ```
166
+
167
+ Make sure that the datatype of this columns is `jsonb`:
168
+
169
+ ```ruby
170
+ class CreatePages < ActiveRecord::Migration
171
+ def change
172
+ create_table :pages do |t|
173
+ t.column :title, :jsonb
174
+ t.column :content, :jsonb
175
+ t.timestamps
176
+ end
177
+ end
178
+ end
179
+ ```
180
+
181
+ Use the model attributes per locale:
182
+
183
+ ```ruby
184
+ p = Page.create(title_en: "English title", title_de: "Deutscher Titel")
185
+
186
+ I18n.locale = :en
187
+ p.title # => English title
188
+
189
+ I18n.locale = :de
190
+ p.title # => Deutscher Titel
191
+
192
+ I18n.with_locale :en do
193
+ p.title # => English title
194
+ end
195
+ ```
196
+
197
+ The raw data is available via the suffix `_raw`:
198
+
199
+ ```ruby
200
+ p = Page.new(title: {en: 'English title', de: 'Deutscher Titel'})
201
+
202
+ p.title_raw # => {'en' => 'English title', 'de' => 'Deutscher Titel'}
203
+ ```
204
+
205
+ ### Find
206
+
207
+ `awesome_jsonb_translate` created a `find_by` helper.
208
+
209
+ ```ruby
210
+ Page.create!(:title_en => 'English title', :title_de => 'Deutscher Titel')
211
+ Page.create!(:title_en => 'Another English title', :title_de => 'Noch ein Deutscher Titel')
212
+
213
+ Page.find_by(title_en: 'English title') # => Find by a specific language
214
+ ```
215
+
216
+ ### To Param
217
+
218
+ For generating URLs with translated slugs:
219
+
220
+ ```ruby
221
+ class Page < ActiveRecord::Base
222
+ translates :title
223
+
224
+ def to_param
225
+ # Or use parameterize for URL-friendly slugs
226
+ title_en.parameterize
227
+ end
228
+ end
229
+ ```
230
+
231
+ ### Limitations
232
+
233
+ `awesome_jsonb_translate` patches ActiveRecord, which create the limitation, that a with `where` chained `first_or_create` and `first_or_create!` **doesn't work** as expected.
234
+ Here is an example, which **won't** work:
235
+
236
+ ```ruby
237
+ Page.where(title_en: 'Titre français').first_or_create!
238
+ ```
239
+
240
+ A workaround is:
241
+
242
+ ```ruby
243
+ Page.find_or_create_by(title_en: 'Titre français')
244
+ ```
245
+
246
+ ## Development
247
+
248
+ ```
249
+ bundle
250
+ bin/setup
251
+ bundle exec rspec
252
+ ```
253
+
254
+ ## Testing
255
+
256
+ To run the tests:
257
+
258
+ 1. Ensure PostgreSQL is installed and running
259
+ 2. Set up the test environment:
260
+
261
+ ```bash
262
+ bin/setup
263
+ ```
264
+
265
+ This script will:
266
+ - Install required gem dependencies
267
+ - Create the PostgreSQL test database if it doesn't exist
268
+
269
+ 3. Run the tests:
270
+
271
+ ```bash
272
+ bundle exec rspec
273
+ ```
274
+
275
+ You can also set custom database connection details with environment variables:
276
+
277
+ ```bash
278
+ DB_NAME=custom_db_name DB_USER=your_username DB_PASSWORD=your_password bundle exec rspec
279
+ ```
280
+
281
+ ## Troubleshooting
282
+
283
+ If you encounter issues running tests:
284
+
285
+ 1. Make sure PostgreSQL is installed and running
286
+ 2. Ensure the user has permissions to create databases
287
+ 3. Check that the database 'awesome_jsonb_translate_test' exists or can be created
288
+ 4. Run `bin/setup` to prepare the test environment
289
+ 5. For more detailed database errors, run with debug flag:
290
+ ```bash
291
+ DB_DEBUG=true bundle exec rspec
292
+ ```
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AwesomeJsonbTranslate
4
+ module ActiveRecord
5
+ def translates(*attrs)
6
+ include InstanceMethods
7
+
8
+ class_attribute :translated_attributes, :translated_accessors, :translation_options
9
+ self.translated_attributes = attrs.map(&:to_sym)
10
+ self.translated_accessors = translated_attributes.flat_map do |attr_name|
11
+ I18n.available_locales.map do |locale|
12
+ ["#{attr_name}_#{locale}", "#{attr_name}_#{locale}="]
13
+ end
14
+ end.flatten.map(&:to_sym)
15
+ self.translation_options = {}
16
+
17
+ attrs.each do |attr_name|
18
+ define_translation_accessors(attr_name)
19
+ define_translation_writer(attr_name)
20
+ define_translation_reader(attr_name)
21
+ define_raw_accessor(attr_name)
22
+ end
23
+
24
+ extend FindByMethods
25
+ end
26
+
27
+ def define_translation_accessors(attr_name)
28
+ I18n.available_locales.each do |locale|
29
+ define_translation_reader_for_locale(attr_name, locale)
30
+ define_translation_writer_for_locale(attr_name, locale)
31
+ end
32
+ end
33
+
34
+ def define_translation_reader_for_locale(attr_name, locale)
35
+ define_method("#{attr_name}_#{locale}") do
36
+ read_translation(attr_name, locale)
37
+ end
38
+ end
39
+
40
+ def define_translation_writer_for_locale(attr_name, locale)
41
+ define_method("#{attr_name}_#{locale}=") do |value|
42
+ write_translation(attr_name, locale, value)
43
+ end
44
+ end
45
+
46
+ def define_translation_reader(attr_name)
47
+ define_method(attr_name) do
48
+ read_translation(attr_name, I18n.locale)
49
+ end
50
+ end
51
+
52
+ def define_translation_writer(attr_name)
53
+ define_method("#{attr_name}=") do |value|
54
+ normalized_value = value.is_a?(Hash) ? value.stringify_keys : { I18n.locale.to_s => value }
55
+ write_attribute(attr_name, (send("#{attr_name}_raw") || {}).merge(normalized_value))
56
+ end
57
+ end
58
+
59
+ def define_raw_accessor(attr_name)
60
+ define_method("#{attr_name}_raw") do
61
+ raw_value = read_attribute(attr_name)
62
+ raw_value.is_a?(Hash) ? raw_value : {}
63
+ end
64
+ end
65
+
66
+ module InstanceMethods
67
+ def translated?(attr_name, locale = I18n.locale)
68
+ value = read_translation_without_fallback(attr_name, locale)
69
+ translation_available?(attr_name, locale) && value.present?
70
+ end
71
+
72
+ def translation_available?(attr_name, locale = I18n.locale)
73
+ self.class.translated_attributes.include?(attr_name.to_sym) &&
74
+ send("#{attr_name}_raw").key?(locale.to_s)
75
+ end
76
+
77
+ def available_translations(attr_name)
78
+ return [] unless self.class.translated_attributes.include?(attr_name.to_sym)
79
+
80
+ send("#{attr_name}_raw").keys
81
+ end
82
+
83
+ def available_locales
84
+ locales = self.class.translated_attributes.flat_map do |attr_name|
85
+ available_translations(attr_name)
86
+ end.uniq
87
+ locales.map(&:to_sym)
88
+ end
89
+
90
+ def read_translation(attr_name, locale)
91
+ translations = send("#{attr_name}_raw")
92
+ value = translations[locale.to_s]
93
+
94
+ if value.blank? && locale != I18n.default_locale
95
+ translations[I18n.default_locale.to_s]
96
+ else
97
+ value
98
+ end
99
+ end
100
+
101
+ def read_translation_without_fallback(attr_name, locale)
102
+ translations = send("#{attr_name}_raw")
103
+ translations[locale.to_s]
104
+ end
105
+
106
+ def write_translation(attr_name, locale, value)
107
+ translations = send("#{attr_name}_raw").dup
108
+ translations[locale.to_s] = value
109
+ write_attribute(attr_name, translations)
110
+ end
111
+ end
112
+
113
+ module FindByMethods
114
+ # Override find_by to handle translated attributes
115
+ def find_by(attributes)
116
+ # Check if any of the keys represent translated attributes
117
+ has_translated_attrs = attributes.keys.any? do |key|
118
+ translated_accessors.include?(key.to_sym)
119
+ end
120
+
121
+ # If no translated attributes, use default implementation
122
+ return super unless has_translated_attrs
123
+
124
+ translated_attrs = {}
125
+ regular_attrs = {}
126
+
127
+ attributes.each do |key, value|
128
+ key_s = key.to_s
129
+ if key_s.include?('_') && !key_s.end_with?('=')
130
+ parts = key_s.split('_')
131
+ locale = parts.last
132
+ attr_name = parts[0..-2].join('_')
133
+
134
+ if translated_attributes.include?(attr_name.to_sym)
135
+ translated_attrs[attr_name] ||= {}
136
+ translated_attrs[attr_name][locale] = value
137
+ else
138
+ regular_attrs[key] = value
139
+ end
140
+ else
141
+ regular_attrs[key] = value
142
+ end
143
+ end
144
+
145
+ scope = where(regular_attrs)
146
+
147
+ translated_attrs.each do |attr_name, locales|
148
+ locales.each do |locale, value|
149
+ scope = scope.where("#{attr_name}->>'#{locale}' = ?", value)
150
+ end
151
+ end
152
+
153
+ scope.first
154
+ end
155
+
156
+ # Override find_or_initialize_by to handle translated attributes
157
+ def find_or_initialize_by(attributes)
158
+ result = find_by(attributes)
159
+ return result if result
160
+
161
+ # If no record found, initialize with the given attributes
162
+ new_record = new
163
+ new_record.assign_attributes(attributes)
164
+ new_record
165
+ end
166
+
167
+ # Override find_or_create_by to handle translated attributes
168
+ def find_or_create_by(attributes)
169
+ result = find_by(attributes)
170
+ return result if result
171
+
172
+ # If no record found, create with the given attributes
173
+ create(attributes)
174
+ end
175
+ end
176
+ end
177
+ end
178
+
179
+ ActiveRecord::Base.extend AwesomeJsonbTranslate::ActiveRecord
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AwesomeJsonbTranslate
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+ require 'awesome_jsonb_translate/version'
5
+ require 'awesome_jsonb_translate/active_record'
6
+
7
+ module AwesomeJsonbTranslate
8
+ # Just serve as a namespace
9
+ end
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: awesome_jsonb_translate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Your Name
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-04-18 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: '5.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: i18n
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.7'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.7'
40
+ - !ruby/object:Gem::Dependency
41
+ name: pg
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ description: This gem uses PostgreSQL's JSONB datatype to store and retrieve translations
83
+ for ActiveRecord models without extra columns or tables
84
+ email:
85
+ - your.email@example.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - LICENSE.txt
91
+ - README.md
92
+ - lib/awesome_jsonb_translate.rb
93
+ - lib/awesome_jsonb_translate/active_record.rb
94
+ - lib/awesome_jsonb_translate/version.rb
95
+ homepage: https://github.com/username/awesome_jsonb_translate
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ homepage_uri: https://github.com/username/awesome_jsonb_translate
100
+ source_code_uri: https://github.com/username/awesome_jsonb_translate/tree/master
101
+ documentation_uri: https://github.com/username/awesome_jsonb_translate/blob/master/README.md
102
+ changelog_uri: https://github.com/username/awesome_jsonb_translate/blob/master/CHANGELOG.md
103
+ rubygems_mfa_required: 'true'
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: 2.6.0
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.6.2
119
+ specification_version: 4
120
+ summary: ActiveRecord translations using PostgreSQL's JSONB data type
121
+ test_files: []