json_translatable 1.0.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: 1016e8df0b9e0934d8a9534ca2a9a5e03539fe14ba240b2c25b499a3b8e9edcf
4
+ data.tar.gz: 180b1f534a2c3895a4e722523aa1cdcb1727edaacf53b7581db19ec1abb6c00e
5
+ SHA512:
6
+ metadata.gz: 1297496e70aef539c0e9ee423ec203c368cc5da9bce49dd135388cc2201894144b95938b3f8638eeef3a7e00c2400e29fcbcd0f65861ac3868d835463b882c7b
7
+ data.tar.gz: 8e776d74beef3b31a213922e161cdaa6e8c38203499f835ef14cd31e879ac8976532b0cbb43c7bb2c577ded263c87852786a1638fd146635290949540f18a476
@@ -0,0 +1,215 @@
1
+ module Translatable
2
+ module Concern
3
+ extend ActiveSupport::Concern
4
+
5
+ class Error < StandardError; end
6
+ class MissingTranslationsColumnError < Error; end
7
+ class UndefinedTranslatableFieldsError < Error; end
8
+ class DatabaseColumnConflictError < Error; end
9
+
10
+ included do
11
+ class_attribute :translatable_fields, instance_writer: false, default: []
12
+ class_attribute :translatable_locales, instance_writer: false, default: []
13
+ class_attribute :translation_validations, instance_writer: false, default: []
14
+ class_attribute :translations_column_name, instance_writer: false, default: :translations
15
+
16
+ validate :validate_translations_structure
17
+ validate :validate_translation_fields
18
+
19
+ after_initialize :initialize_translations
20
+
21
+ def translate(locale = I18n.locale)
22
+ translation_class = self.class.translation_class
23
+ column_name = self.class.translations_column_name.to_s
24
+ translations_data = send(column_name) || {}
25
+
26
+ translated_data = self.class.translatable_fields.index_with do |field|
27
+ translations_data.dig(locale.to_s, field.to_s)
28
+ end
29
+
30
+ translation_class.new(translated_data.merge(locale: locale.to_sym))
31
+ end
32
+
33
+ private
34
+
35
+ def validate_translations_structure
36
+ column_name = self.class.translations_column_name.to_s
37
+ translations_data = send(column_name)
38
+ return unless translations_data.present?
39
+
40
+ translations_data.each do |locale, fields|
41
+ unless translatable_locales.map(&:to_s).include?(locale)
42
+ errors.add(column_name.to_sym, "contains invalid locale: #{locale}")
43
+ end
44
+
45
+ next unless fields.is_a?(Hash)
46
+
47
+ fields.each_key do |field|
48
+ unless translatable_fields.map(&:to_s).include?(field)
49
+ errors.add(column_name.to_sym, "contains invalid field: #{field} for locale #{locale}")
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ def validate_translation_fields
56
+ column_name = self.class.translations_column_name.to_s
57
+ translations_data = send(column_name) || {}
58
+
59
+ self.class.translation_validations.each do |validation|
60
+ field = validation[:field]
61
+ options = validation[:options]
62
+ locales = validation[:locales] || self.class.translatable_locales
63
+
64
+ locales.each do |locale|
65
+ field_value = translations_data.dig(locale.to_s, field.to_s)
66
+ error_key = "#{column_name}.#{locale}.#{field}"
67
+
68
+ if options[:custom]
69
+ options[:custom].call(self, locale, field, field_value)
70
+ end
71
+
72
+ if options[:presence] && field_value.blank?
73
+ errors.add(column_name.to_sym, :blank, translation_key: error_key)
74
+ end
75
+
76
+ if options[:length] && field_value.present?
77
+ length_options = options[:length]
78
+ if length_options[:minimum] && field_value.length < length_options[:minimum]
79
+ errors.add(column_name.to_sym, :too_short, count: length_options[:minimum], translation_key: error_key)
80
+ end
81
+ if length_options[:maximum] && field_value.length > length_options[:maximum]
82
+ errors.add(column_name.to_sym, :too_long, count: length_options[:maximum], translation_key: error_key)
83
+ end
84
+ end
85
+
86
+ if options[:format] && field_value.present?
87
+ format_options = options[:format]
88
+ if format_options[:with] && !field_value.match?(format_options[:with])
89
+ errors.add(column_name.to_sym, :invalid, translation_key: error_key)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ def initialize_translations
98
+ self.class.validate_translatable
99
+
100
+ column_name = self.class.translations_column_name.to_s
101
+ current_translations = send(column_name) || {}
102
+
103
+ self.class.translatable_locales.map(&:to_s).uniq.each do |locale|
104
+ current_translations[locale] ||= {}
105
+ self.class.translatable_fields.map(&:to_s).uniq.each do |field|
106
+ unless current_translations[locale].key?(field)
107
+ current_translations[locale][field] = nil
108
+ end
109
+ end
110
+ end
111
+
112
+ send("#{column_name}=", current_translations)
113
+ end
114
+
115
+ class_methods do
116
+ def translatable(*fields, locales: nil, column: nil)
117
+ resolved_locales = locales || Translatable.configuration.default_locales.call
118
+ resolved_column = column || Translatable.configuration.default_column_name
119
+
120
+ self.translatable_fields = fields.map(&:to_sym)
121
+ self.translatable_locales = resolved_locales.map(&:to_sym)
122
+ self.translations_column_name = resolved_column.to_sym
123
+ end
124
+
125
+ def where_translations(attributes, locales: [], case_sensitive: false)
126
+ return none if attributes.blank?
127
+
128
+ strategy = database_strategy
129
+ strategy.where_translations_scope(self, attributes, locales: locales, case_sensitive: case_sensitive)
130
+ end
131
+
132
+ def validates_translation(*fields, **options, &block)
133
+ fields.flatten.each do |field|
134
+ if !translatable_fields.include?(field.to_sym)
135
+ raise ArgumentError, "Field :#{field} is not defined as translatable. " \
136
+ "Available translatable fields: #{translatable_fields.map(&:inspect).join(', ')}"
137
+ end
138
+
139
+ valid_options = [:presence, :length, :format, :locales]
140
+ invalid_options = options.keys - valid_options
141
+ if invalid_options.any?
142
+ raise ArgumentError, "Invalid validation option(s): #{invalid_options.map(&:inspect).join(', ')}. " \
143
+ "Valid options are: #{valid_options.map(&:inspect).join(', ')}"
144
+ end
145
+
146
+ if options[:locales]
147
+ invalid_locales = options[:locales].map(&:to_sym) - translatable_locales
148
+ if invalid_locales.any?
149
+ raise ArgumentError, "Invalid locale(s): #{invalid_locales.map(&:inspect).join(', ')}. " \
150
+ "Available locales: #{translatable_locales.map(&:inspect).join(', ')}"
151
+ end
152
+ end
153
+
154
+ if block_given?
155
+ validation_options = options.merge(custom: block)
156
+ else
157
+ validation_options = options
158
+ end
159
+
160
+ locales = validation_options.delete(:locales)
161
+
162
+ self.translation_validations = self.translation_validations + [{
163
+ field: field.to_sym,
164
+ options: validation_options,
165
+ locales: locales&.map(&:to_sym)
166
+ }]
167
+ end
168
+ end
169
+
170
+ def translations_permit_list
171
+ self.translatable_locales.map do |locale|
172
+ [locale.to_s, self.translatable_fields.map(&:to_s)]
173
+ end.to_h
174
+ end
175
+
176
+ def database_strategy
177
+ @database_strategy ||= DatabaseStrategies::Base.for_adapter(connection.adapter_name)
178
+ end
179
+
180
+ def translation_class
181
+ @translation_class ||= TranslationClassGenerator.generate_for(self.name)
182
+ end
183
+
184
+ def validate_translatable
185
+ column_name = translations_column_name.to_s
186
+ strategy = database_strategy
187
+
188
+ unless self.column_names.include?(column_name) &&
189
+ self.columns_hash[column_name]&.type == strategy.column_type
190
+ raise MissingTranslationsColumnError,
191
+ "Model #{self.name} is missing a '#{column_name}' #{strategy.column_type} column. " \
192
+ "Please add it via a migration:\n#{strategy.migration_example(self.table_name, column_name)}"
193
+ end
194
+
195
+ unless self.respond_to?(:translatable_fields) && self.translatable_fields.present?
196
+ raise UndefinedTranslatableFieldsError,
197
+ "Model #{self.name} must call the class method `translatable` with field names. " \
198
+ "Example: \n" \
199
+ "class #{self.name} < ApplicationRecord\n" \
200
+ " include Translatable\n" \
201
+ " translatable :title, :content\n" \
202
+ "end"
203
+ end
204
+
205
+ conflicting_columns = self.column_names.select { |column| self.translatable_fields.include?(column.to_sym) }
206
+ if conflicting_columns.any?
207
+ raise DatabaseColumnConflictError,
208
+ "Model #{self.name} has database columns that conflict with translatable fields. " \
209
+ "Translatable fields should not exist as actual database columns." \
210
+ "\n\nConflicting columns: #{conflicting_columns.join(', ')}"
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,10 @@
1
+ module Translatable
2
+ class Configuration
3
+ attr_accessor :default_column_name, :default_locales
4
+
5
+ def initialize
6
+ @default_column_name = :translations
7
+ @default_locales = -> { I18n.available_locales }
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,30 @@
1
+ module Translatable
2
+ module DatabaseStrategies
3
+ class Base
4
+ def self.for_adapter(adapter_name)
5
+ case adapter_name.downcase
6
+ when /postgresql/
7
+ PostgreSQL.new
8
+ when /mysql/
9
+ MySQL.new
10
+ when /sqlite/
11
+ SQLite.new
12
+ else
13
+ raise ArgumentError, "Unsupported database adapter: #{adapter_name}"
14
+ end
15
+ end
16
+
17
+ def column_type
18
+ raise NotImplementedError, "Subclasses must implement column_type"
19
+ end
20
+
21
+ def migration_example(table_name, column_name)
22
+ raise NotImplementedError, "Subclasses must implement migration_example"
23
+ end
24
+
25
+ def where_translations_scope(model_class, attributes, locales: [], case_sensitive: false)
26
+ raise NotImplementedError, "#{self.class.name} does not support where_translations yet"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,41 @@
1
+ module Translatable
2
+ module DatabaseStrategies
3
+ class MySQL < Base
4
+ def column_type
5
+ :json
6
+ end
7
+
8
+ def migration_example(table_name, column_name)
9
+ "add_column :#{table_name}, :#{column_name}, :json, null: false"
10
+ end
11
+
12
+ def where_translations_scope(model_class, attributes, locales: [], case_sensitive: false)
13
+ column_name = model_class.translations_column_name
14
+ search_locales = locales.present? ? locales.map(&:to_s) : model_class.translatable_locales.map(&:to_s)
15
+
16
+ return model_class.none if attributes.blank?
17
+
18
+ conditions = []
19
+ bind_values = []
20
+
21
+ if case_sensitive
22
+ search_locales.each do |locale|
23
+ locale_attributes = { locale => attributes }
24
+ conditions << "JSON_CONTAINS(#{column_name}, ?)"
25
+ bind_values << locale_attributes.to_json
26
+ end
27
+ else
28
+ search_locales.each do |locale|
29
+ attributes.each do |field, value|
30
+ conditions << "UPPER(JSON_UNQUOTE(JSON_EXTRACT(#{column_name}, ?))) LIKE UPPER(?)"
31
+ bind_values += ["$.#{locale}.#{field}", value.to_s]
32
+ end
33
+ end
34
+ end
35
+
36
+ where_clause = conditions.join(' OR ')
37
+ model_class.where(where_clause, *bind_values)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,41 @@
1
+ module Translatable
2
+ module DatabaseStrategies
3
+ class PostgreSQL < Base
4
+ def column_type
5
+ :jsonb
6
+ end
7
+
8
+ def migration_example(table_name, column_name)
9
+ "add_column :#{table_name}, :#{column_name}, :jsonb, default: {}, null: false"
10
+ end
11
+
12
+ def where_translations_scope(model_class, attributes, locales: [], case_sensitive: false)
13
+ column_name = model_class.translations_column_name
14
+ search_locales = locales.present? ? locales.map(&:to_s) : model_class.translatable_locales.map(&:to_s)
15
+
16
+ return model_class.none if attributes.blank?
17
+
18
+ conditions = []
19
+ bind_values = []
20
+
21
+ if case_sensitive
22
+ search_locales.each do |locale|
23
+ locale_attributes = { locale => attributes }
24
+ conditions << "#{column_name} @> ?"
25
+ bind_values << locale_attributes.to_json
26
+ end
27
+ else
28
+ search_locales.each do |locale|
29
+ attributes.each do |field, value|
30
+ conditions << "(#{column_name} -> ? ->> ?) ILIKE ?"
31
+ bind_values += [locale, field.to_s, value.to_s]
32
+ end
33
+ end
34
+ end
35
+
36
+ where_clause = conditions.join(' OR ')
37
+ model_class.where(where_clause, *bind_values)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,40 @@
1
+ module Translatable
2
+ module DatabaseStrategies
3
+ class SQLite < Base
4
+ def column_type
5
+ :json
6
+ end
7
+
8
+ def migration_example(table_name, column_name)
9
+ "add_column :#{table_name}, :#{column_name}, :json, default: '{}', null: false"
10
+ end
11
+
12
+ def where_translations_scope(model_class, attributes, locales: [], case_sensitive: false)
13
+ column_name = model_class.translations_column_name
14
+ search_locales = locales.present? ? locales.map(&:to_s) : model_class.translatable_locales.map(&:to_s)
15
+
16
+ return model_class.none if attributes.blank?
17
+
18
+ conditions = []
19
+ bind_values = []
20
+
21
+ search_locales.each do |locale|
22
+ attributes.each do |field, value|
23
+ json_path = "$.#{locale}.#{field}"
24
+
25
+ if case_sensitive
26
+ conditions << "json_extract(#{column_name}, ?) = ?"
27
+ bind_values += [json_path, value.to_s]
28
+ else
29
+ conditions << "json_extract(#{column_name}, ?) LIKE ? COLLATE NOCASE"
30
+ bind_values += [json_path, value.to_s]
31
+ end
32
+ end
33
+ end
34
+
35
+ where_clause = conditions.join(' OR ')
36
+ model_class.where(where_clause, *bind_values)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,83 @@
1
+ module Translatable
2
+ class TranslationClassGenerator
3
+ def self.generate_for(model_class_name)
4
+ class_name = "#{model_class_name}Translation"
5
+
6
+ return Translatable.const_get(class_name) if Translatable.const_defined?(class_name)
7
+
8
+ model_class = Object.const_get(model_class_name)
9
+ translatable_fields = model_class.translatable_fields
10
+
11
+ translation_class = Class.new do
12
+ include Enumerable
13
+
14
+ translatable_fields.each do |field|
15
+ define_method(field) do
16
+ @attributes[field]
17
+ end
18
+ end
19
+
20
+ def initialize(attributes = {})
21
+ @attributes = attributes.dup
22
+ @locale = attributes[:locale]
23
+ end
24
+
25
+ def locale
26
+ @locale
27
+ end
28
+
29
+ def [](key)
30
+ @attributes[key.to_sym]
31
+ end
32
+
33
+ def to_h
34
+ @attributes.dup
35
+ end
36
+
37
+ def each(&block)
38
+ return enum_for(:each) unless block_given?
39
+
40
+ @attributes.each do |key, value|
41
+ next if key == :locale
42
+ yield(key, value)
43
+ end
44
+ end
45
+
46
+ def keys
47
+ @attributes.keys.reject { |k| k == :locale }
48
+ end
49
+
50
+ def values
51
+ keys.map { |key| @attributes[key] }
52
+ end
53
+
54
+ def empty?
55
+ keys.empty? || values.all?(&:blank?)
56
+ end
57
+
58
+ def size
59
+ keys.size
60
+ end
61
+
62
+ alias_method :length, :size
63
+
64
+ def inspect
65
+ fields = @attributes.reject { |k, _| k == :locale }
66
+ .map { |field, value| "#{field}: #{value.inspect.to_s[0, 30]}" }
67
+ .append("locale: #{@locale.inspect}")
68
+ .join(', ')
69
+ "#<#{self.class} #{fields}>"
70
+ end
71
+
72
+ def to_s
73
+ inspect
74
+ end
75
+ end
76
+
77
+ translation_class.define_singleton_method(:name) { class_name }
78
+
79
+ Translatable.const_set(class_name, translation_class)
80
+ translation_class
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,3 @@
1
+ module Translatable
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,26 @@
1
+ require 'active_support'
2
+ require_relative 'translatable/version'
3
+ require_relative 'translatable/configuration'
4
+ require_relative 'translatable/database_strategies/base'
5
+ require_relative 'translatable/database_strategies/postgresql'
6
+ require_relative 'translatable/database_strategies/mysql'
7
+ require_relative 'translatable/database_strategies/sqlite'
8
+ require_relative 'translatable/translation_class_generator'
9
+ require_relative 'translatable/concern'
10
+
11
+ module Translatable
12
+ extend ActiveSupport::Concern
13
+ include Translatable::Concern
14
+
15
+ class << self
16
+ attr_accessor :configuration
17
+ end
18
+
19
+ def self.configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def self.configure
24
+ yield(configuration)
25
+ end
26
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: json_translatable
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Luka Bak
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-05-31 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: i18n
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '1.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '1.8'
40
+ description: I18n Rails gem that allows storing and querying translations for ActiveRecord
41
+ models in a single JSON/JSONB column.
42
+ email: bakluka@gmail.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/translatable.rb
48
+ - lib/translatable/concern.rb
49
+ - lib/translatable/configuration.rb
50
+ - lib/translatable/database_strategies/base.rb
51
+ - lib/translatable/database_strategies/mysql.rb
52
+ - lib/translatable/database_strategies/postgresql.rb
53
+ - lib/translatable/database_strategies/sqlite.rb
54
+ - lib/translatable/translation_class_generator.rb
55
+ - lib/translatable/version.rb
56
+ homepage: https://github.com/bakluka/json_translatable
57
+ licenses:
58
+ - MIT
59
+ metadata: {}
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 2.7.0
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.6.2
75
+ specification_version: 4
76
+ summary: I18n Rails gem that allows storing and querying translations for ActiveRecord
77
+ models in a single JSON/JSONB column.
78
+ test_files: []