active_version 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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +492 -0
  5. data/SECURITY.md +29 -0
  6. data/lib/active_version/adapters/active_record/audits.rb +36 -0
  7. data/lib/active_version/adapters/active_record/base.rb +37 -0
  8. data/lib/active_version/adapters/active_record/revisions.rb +49 -0
  9. data/lib/active_version/adapters/active_record/translations.rb +45 -0
  10. data/lib/active_version/adapters/active_record.rb +10 -0
  11. data/lib/active_version/adapters/sequel/versioning.rb +282 -0
  12. data/lib/active_version/adapters/sequel.rb +9 -0
  13. data/lib/active_version/adapters.rb +5 -0
  14. data/lib/active_version/audits/audit_record/callbacks.rb +180 -0
  15. data/lib/active_version/audits/audit_record/serializers.rb +49 -0
  16. data/lib/active_version/audits/audit_record.rb +522 -0
  17. data/lib/active_version/audits/has_audits/audit_callbacks.rb +46 -0
  18. data/lib/active_version/audits/has_audits/audit_combiner.rb +212 -0
  19. data/lib/active_version/audits/has_audits/audit_writer.rb +282 -0
  20. data/lib/active_version/audits/has_audits/change_filters.rb +114 -0
  21. data/lib/active_version/audits/has_audits/database_adapter_helper.rb +86 -0
  22. data/lib/active_version/audits/has_audits.rb +891 -0
  23. data/lib/active_version/audits/sql_builder.rb +263 -0
  24. data/lib/active_version/audits.rb +10 -0
  25. data/lib/active_version/column_mapper.rb +92 -0
  26. data/lib/active_version/configuration.rb +124 -0
  27. data/lib/active_version/database/triggers/postgresql.rb +243 -0
  28. data/lib/active_version/database.rb +7 -0
  29. data/lib/active_version/instrumentation.rb +226 -0
  30. data/lib/active_version/migrators/audited.rb +84 -0
  31. data/lib/active_version/migrators/base.rb +191 -0
  32. data/lib/active_version/migrators.rb +8 -0
  33. data/lib/active_version/query.rb +105 -0
  34. data/lib/active_version/railtie.rb +17 -0
  35. data/lib/active_version/revisions/has_revisions/revision_manipulation.rb +499 -0
  36. data/lib/active_version/revisions/has_revisions/revision_queries.rb +182 -0
  37. data/lib/active_version/revisions/has_revisions.rb +443 -0
  38. data/lib/active_version/revisions/revision_record.rb +287 -0
  39. data/lib/active_version/revisions/sql_builder.rb +266 -0
  40. data/lib/active_version/revisions.rb +10 -0
  41. data/lib/active_version/runtime.rb +148 -0
  42. data/lib/active_version/sharding/connection_router.rb +20 -0
  43. data/lib/active_version/sharding.rb +7 -0
  44. data/lib/active_version/tasks/active_version.rake +29 -0
  45. data/lib/active_version/translations/has_translations.rb +350 -0
  46. data/lib/active_version/translations/translation_record.rb +258 -0
  47. data/lib/active_version/translations.rb +9 -0
  48. data/lib/active_version/version.rb +3 -0
  49. data/lib/active_version/version_registry.rb +87 -0
  50. data/lib/active_version.rb +329 -0
  51. data/lib/generators/active_version/audits/audits_generator.rb +65 -0
  52. data/lib/generators/active_version/audits/templates/audit_model.rb.erb +16 -0
  53. data/lib/generators/active_version/audits/templates/migration_jsonb.rb.erb +33 -0
  54. data/lib/generators/active_version/audits/templates/migration_table.rb.erb +34 -0
  55. data/lib/generators/active_version/install/install_generator.rb +19 -0
  56. data/lib/generators/active_version/install/templates/initializer.rb.erb +38 -0
  57. data/lib/generators/active_version/revisions/revisions_generator.rb +71 -0
  58. data/lib/generators/active_version/revisions/templates/backfill_migration.rb.erb +19 -0
  59. data/lib/generators/active_version/revisions/templates/migration.rb.erb +20 -0
  60. data/lib/generators/active_version/revisions/templates/revision_model.rb.erb +8 -0
  61. data/lib/generators/active_version/translations/templates/migration.rb.erb +16 -0
  62. data/lib/generators/active_version/translations/templates/translation_model.rb.erb +15 -0
  63. data/lib/generators/active_version/translations/translations_generator.rb +73 -0
  64. data/lib/generators/active_version/triggers/templates/migration.rb.erb +100 -0
  65. data/lib/generators/active_version/triggers/triggers_generator.rb +74 -0
  66. data/sig/active_version/advanced.rbs +51 -0
  67. data/sig/active_version/audits.rbs +128 -0
  68. data/sig/active_version/configuration.rbs +38 -0
  69. data/sig/active_version/core.rbs +53 -0
  70. data/sig/active_version/instrumentation.rbs +17 -0
  71. data/sig/active_version/registry_and_mapping.rbs +18 -0
  72. data/sig/active_version/revisions.rbs +70 -0
  73. data/sig/active_version/runtime.rbs +29 -0
  74. data/sig/active_version/translations.rbs +43 -0
  75. data/sig/active_version.rbs +3 -0
  76. metadata +443 -0
@@ -0,0 +1,148 @@
1
+ module ActiveVersion
2
+ module Runtime
3
+ REQUIRED_ADAPTER_METHODS = %i[base_connection connection_for].freeze
4
+
5
+ class NullAdapter
6
+ def base_connection
7
+ raise ActiveVersion::ConfigurationError, "No runtime adapter available"
8
+ end
9
+
10
+ def connection_for(model_class, _version_type)
11
+ if model_class.respond_to?(:connection)
12
+ model_class.connection
13
+ else
14
+ raise ActiveVersion::ConfigurationError,
15
+ "#{model_class} does not expose .connection. Configure ActiveVersion.runtime_adapter for this backend."
16
+ end
17
+ end
18
+
19
+ def supports_transactional_context?(connection)
20
+ postgresql_connection?(connection)
21
+ end
22
+
23
+ def supports_current_transaction_id?(connection)
24
+ postgresql_connection?(connection)
25
+ end
26
+
27
+ def supports_partition_catalog_checks?(connection)
28
+ postgresql_connection?(connection)
29
+ end
30
+
31
+ private
32
+
33
+ def postgresql_connection?(connection)
34
+ connection.respond_to?(:adapter_name) &&
35
+ connection.adapter_name.to_s.casecmp("postgresql").zero?
36
+ end
37
+ end
38
+
39
+ class ActiveRecordAdapter
40
+ def base_connection
41
+ ::ActiveRecord::Base.connection
42
+ end
43
+
44
+ def connection_for(model_class, _version_type)
45
+ model_class.connection
46
+ end
47
+
48
+ def supports_transactional_context?(connection)
49
+ postgresql_connection?(connection)
50
+ end
51
+
52
+ def supports_current_transaction_id?(connection)
53
+ postgresql_connection?(connection)
54
+ end
55
+
56
+ def supports_partition_catalog_checks?(connection)
57
+ postgresql_connection?(connection)
58
+ end
59
+
60
+ private
61
+
62
+ def postgresql_connection?(connection)
63
+ connection.respond_to?(:adapter_name) &&
64
+ connection.adapter_name.to_s.casecmp("postgresql").zero?
65
+ end
66
+ end
67
+
68
+ class << self
69
+ def required_adapter_methods
70
+ REQUIRED_ADAPTER_METHODS.dup
71
+ end
72
+
73
+ def valid_adapter?(runtime_adapter)
74
+ missing_adapter_methods(runtime_adapter).empty?
75
+ end
76
+
77
+ def adapter
78
+ @adapter ||= default_adapter
79
+ end
80
+
81
+ def adapter=(runtime_adapter)
82
+ candidate = runtime_adapter || default_adapter
83
+ validate_adapter!(candidate)
84
+ @adapter = candidate
85
+ end
86
+
87
+ def reset_adapter!
88
+ @adapter = default_adapter
89
+ end
90
+
91
+ def active_record_connection_errors
92
+ return [] unless defined?(::ActiveRecord)
93
+
94
+ [
95
+ ::ActiveRecord::ConnectionNotEstablished,
96
+ ::ActiveRecord::NoDatabaseError,
97
+ ::ActiveRecord::StatementInvalid,
98
+ ::ActiveRecord::ConnectionNotDefined
99
+ ]
100
+ end
101
+
102
+ def supports_transactional_context?(connection)
103
+ return adapter.supports_transactional_context?(connection) if adapter.respond_to?(:supports_transactional_context?)
104
+
105
+ postgresql_connection?(connection)
106
+ end
107
+
108
+ def supports_current_transaction_id?(connection)
109
+ return adapter.supports_current_transaction_id?(connection) if adapter.respond_to?(:supports_current_transaction_id?)
110
+
111
+ postgresql_connection?(connection)
112
+ end
113
+
114
+ def supports_partition_catalog_checks?(connection)
115
+ return adapter.supports_partition_catalog_checks?(connection) if adapter.respond_to?(:supports_partition_catalog_checks?)
116
+
117
+ postgresql_connection?(connection)
118
+ end
119
+
120
+ private
121
+
122
+ def validate_adapter!(runtime_adapter)
123
+ missing_methods = missing_adapter_methods(runtime_adapter)
124
+ return if missing_methods.empty?
125
+
126
+ raise ActiveVersion::ConfigurationError,
127
+ "runtime_adapter must respond to #{REQUIRED_ADAPTER_METHODS.join(", ")}; missing: #{missing_methods.join(", ")}"
128
+ end
129
+
130
+ def missing_adapter_methods(runtime_adapter)
131
+ REQUIRED_ADAPTER_METHODS.reject { |method_name| runtime_adapter.respond_to?(method_name) }
132
+ end
133
+
134
+ def postgresql_connection?(connection)
135
+ connection.respond_to?(:adapter_name) &&
136
+ connection.adapter_name.to_s.casecmp("postgresql").zero?
137
+ end
138
+
139
+ def default_adapter
140
+ if defined?(::ActiveRecord::Base)
141
+ ActiveRecordAdapter.new
142
+ else
143
+ NullAdapter.new
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,20 @@
1
+ module ActiveVersion
2
+ module Sharding
3
+ # No-op router. Connection routing is application-owned.
4
+ class ConnectionRouter
5
+ class << self
6
+ def connection_for(model_class, version_type)
7
+ :default
8
+ end
9
+
10
+ def adapter_for(model_class, version_type)
11
+ ActiveVersion.adapter_for(model_class, version_type)
12
+ end
13
+
14
+ def with_connection(model_class, version_type, &block)
15
+ ActiveVersion.with_connection(model_class, version_type, &block)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,7 @@
1
+ require "active_version/sharding/connection_router"
2
+
3
+ module ActiveVersion
4
+ module Sharding
5
+ # Connection routing is intentionally owned by application code.
6
+ end
7
+ end
@@ -0,0 +1,29 @@
1
+ namespace :active_version do
2
+ desc "Display ActiveVersion configuration"
3
+ task :config do
4
+ puts "ActiveVersion Configuration:"
5
+ puts " Auditing Enabled: #{ActiveVersion.auditing_enabled}"
6
+ puts " Audit Storage: #{ActiveVersion.config.audit_storage}"
7
+ puts " Current User Method: #{ActiveVersion.config.current_user_method}"
8
+ puts ""
9
+ puts "Registered Models:"
10
+ ActiveVersion.registry.models_for_version_type(:translations).each do |model|
11
+ puts " - #{model.name} (translations)"
12
+ end
13
+ ActiveVersion.registry.models_for_version_type(:revisions).each do |model|
14
+ puts " - #{model.name} (revisions)"
15
+ end
16
+ ActiveVersion.registry.models_for_version_type(:audits).each do |model|
17
+ puts " - #{model.name} (audits)"
18
+ end
19
+ end
20
+
21
+ desc "Validate ActiveVersion configuration"
22
+ task :validate do
23
+ ActiveVersion.config.validate!
24
+ puts "✓ Configuration is valid"
25
+ rescue ActiveVersion::ConfigurationError => e
26
+ puts "✗ Configuration error: #{e.message}"
27
+ exit 1
28
+ end
29
+ end
@@ -0,0 +1,350 @@
1
+ module ActiveVersion
2
+ module Translations
3
+ # Concern for models that have translations
4
+ module HasTranslations
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ class_attribute :translation_options, instance_writer: false, default: {}
9
+
10
+ def self.normalize_translation_options(options)
11
+ {
12
+ foreign_key: normalize_identity_columns(options[:foreign_key]),
13
+ identity_resolver: options[:identity_resolver],
14
+ table_name: options[:table_name]
15
+ }.compact
16
+ end
17
+
18
+ def self.normalize_identity_columns(value)
19
+ return nil if value.nil?
20
+ return value.map(&:to_s) if value.is_a?(Array)
21
+
22
+ value.to_s
23
+ end
24
+
25
+ def self.has_translations(options = {})
26
+ self.translation_options = normalize_translation_options(options)
27
+ ActiveVersion.registry.register(self, :translations, translation_options)
28
+ @translation_associations_setup = false
29
+ setup_translation_associations if respond_to?(:setup_translation_associations)
30
+ end
31
+
32
+ # Class methods
33
+ def self.translation_record?
34
+ false
35
+ end
36
+
37
+ # Get translation class name
38
+ def self.translation_class
39
+ @translation_class ||= begin
40
+ class_name = "#{name}Translation"
41
+ klass = class_name.safe_constantize || begin
42
+ table_based_name = "#{table_name.to_s.classify}Translation"
43
+ table_based_name.safe_constantize || raise(NameError, "Could not find translation class #{class_name}")
44
+ end
45
+ apply_translation_table_name!(klass)
46
+ klass
47
+ end
48
+ end
49
+
50
+ # Get translation class name as string
51
+ def self.translation_class_name
52
+ translation_class&.name.to_s.presence || "#{name}Translation"
53
+ rescue NameError
54
+ "#{name}Translation"
55
+ end
56
+
57
+ def self.apply_translation_table_name!(klass)
58
+ options = ActiveVersion.registry.config_for(self, :translations) || {}
59
+ custom_table_name = options[:table_name]
60
+ return klass unless custom_table_name && klass.respond_to?(:table_name=)
61
+
62
+ klass.table_name = custom_table_name.to_s
63
+ klass
64
+ end
65
+
66
+ def self.register_translation_column_mappings_from_destination(translation_klass)
67
+ return unless translation_klass.respond_to?(:locale_column_name)
68
+ locale_column = translation_klass.locale_column_name
69
+ return unless locale_column
70
+ ActiveVersion.column_mapper.register(self, :translations, :locale, locale_column)
71
+ end
72
+
73
+ # Set up associations (deferred to avoid constantize errors during module inclusion)
74
+ def self.setup_translation_associations
75
+ return if @translation_associations_setup
76
+ @translation_associations_setup = true
77
+
78
+ begin
79
+ inverse = nil
80
+ begin
81
+ inverse_name = name.underscore.to_sym
82
+ translation_klass = translation_class
83
+ register_translation_column_mappings_from_destination(translation_klass)
84
+ translation_klass.setup_associations(force: true) if translation_klass.respond_to?(:setup_associations)
85
+ if translation_klass.respond_to?(:source_name) && translation_klass.source_name == inverse_name
86
+ inverse = inverse_name
87
+ end
88
+ rescue NameError
89
+ inverse = nil
90
+ end
91
+
92
+ assoc_options = {
93
+ class_name: translation_class_name,
94
+ dependent: :destroy,
95
+ autosave: true,
96
+ inverse_of: inverse || false
97
+ }
98
+ assoc_options[:foreign_key] = translation_klass.source_foreign_key if translation_klass&.respond_to?(:source_foreign_key)
99
+ resolver = translation_klass&.source_primary_key
100
+ if resolver.is_a?(Array)
101
+ assoc_options[:primary_key] = resolver.map(&:to_s)
102
+ elsif resolver.is_a?(String) && resolver.present?
103
+ assoc_options[:primary_key] = resolver
104
+ end
105
+
106
+ has_many :translations, **assoc_options
107
+
108
+ # Nested attributes (must be after association is set up)
109
+ accepts_nested_attributes_for(:translations,
110
+ reject_if: :all_blank,
111
+ allow_destroy: true)
112
+ rescue NameError
113
+ # Translation class not yet defined, will be set up later
114
+ end
115
+ end
116
+
117
+ # Callbacks
118
+ before_validation :copy_values_from_translation, if: :respond_to_copy_values?
119
+
120
+ after_create :update_default_translation
121
+
122
+ # Register with version registry
123
+ ActiveVersion.registry.register(self, :translations, translation_options || {})
124
+
125
+ # Call setup after class is fully loaded
126
+ setup_translation_associations if name
127
+ end
128
+
129
+ module ClassMethods
130
+ # Scope for translated attribute
131
+ def scope_for_translated_attribute(attribute_name, value, locale: I18n.locale)
132
+ foreign_key = if translation_class.respond_to?(:source_foreign_key)
133
+ translation_class.source_foreign_key
134
+ else
135
+ "#{name.underscore}_id"
136
+ end
137
+ identity_columns = Array(foreign_key).map(&:to_s)
138
+ scope = translation_class.where(locale: locale).where(attribute_name => value)
139
+
140
+ if identity_columns.one?
141
+ ids = scope.select(identity_columns.first)
142
+ where(id: ids)
143
+ else
144
+ ids = scope.pluck(*identity_columns)
145
+ where(primary_key => ids)
146
+ end
147
+ end
148
+
149
+ # Auto-generate scopes for translated attributes
150
+ def translated_scopes(*attribute_names)
151
+ attribute_names.each do |attribute_name|
152
+ define_singleton_method(:"for_translated_#{attribute_name}") do |value, locale: I18n.locale|
153
+ scope_for_translated_attribute(attribute_name, value, locale: locale)
154
+ end
155
+
156
+ define_singleton_method(:"find_by_translated_#{attribute_name}") do |value, locale: I18n.locale|
157
+ scope_for_translated_attribute(attribute_name, value, locale: locale)
158
+ .first || find_by(attribute_name => value)
159
+ end
160
+ end
161
+ end
162
+
163
+ # Auto-generate copy methods for translated attributes
164
+ def translated_copies(*attribute_names)
165
+ define_method(:copy_values_from_translation) do
166
+ attribute_names.each do |attribute_name|
167
+ if respond_to?(:will_save_change_to_attribute?) && will_save_change_to_attribute?(attribute_name)
168
+ next
169
+ end
170
+ if respond_to?(:attribute_changed?) && attribute_changed?(attribute_name)
171
+ next
172
+ end
173
+ next if self[attribute_name].present?
174
+
175
+ value = translate(attribute_name, locale: I18n.default_locale).presence
176
+ value ||= translations.first&.send(attribute_name)
177
+ self[attribute_name] = value if value.present?
178
+ end
179
+ end
180
+ end
181
+ end
182
+
183
+ # Translate an attribute to a specific locale
184
+ def translate(attr_name, locale: nil, presence_check: nil, fallback: true)
185
+ locale ||= I18n.locale
186
+ locale_column = ActiveVersion.column_mapper.column_for(self.class, :translations, :locale)
187
+ # Ensure the column exists in the translation class, fallback to default if not
188
+ translation_class = self.class.translation_class
189
+ unless translation_class.column_names.include?(locale_column.to_s)
190
+ locale_column = ActiveVersion.config.translation_locale_column
191
+ end
192
+
193
+ translation_records = if persisted?
194
+ translation_class.where(active_version_translation_identity_map)
195
+ else
196
+ translations
197
+ end
198
+
199
+ # Find translation for requested locale
200
+ translation = translation_records.find do |t|
201
+ t.send(locale_column) == locale &&
202
+ t.attr_present_for_locale?(locale, attr_name, presence_check)
203
+ end
204
+
205
+ return translation&.send(attr_name) if translation
206
+
207
+ # Fallback chain
208
+ if fallback
209
+ # Try default locale
210
+ translation = translation_records.find do |t|
211
+ t.send(locale_column) == I18n.default_locale &&
212
+ t.attr_present_for_locale?(I18n.default_locale, attr_name, presence_check)
213
+ end
214
+ if translation
215
+ ActiveVersion::Instrumentation.instrument_translation_fallback_used(
216
+ self,
217
+ attr: attr_name,
218
+ requested_locale: locale,
219
+ resolved_locale: I18n.default_locale
220
+ )
221
+ return translation.send(attr_name)
222
+ end
223
+
224
+ # Try any translation
225
+ translation = translation_records.first
226
+ if translation
227
+ ActiveVersion::Instrumentation.instrument_translation_fallback_used(
228
+ self,
229
+ attr: attr_name,
230
+ requested_locale: locale,
231
+ resolved_locale: translation.send(locale_column)
232
+ )
233
+ return translation.send(attr_name)
234
+ end
235
+
236
+ # Try source record
237
+ if respond_to?(attr_name)
238
+ ActiveVersion::Instrumentation.instrument_translation_fallback_used(
239
+ self,
240
+ attr: attr_name,
241
+ requested_locale: locale,
242
+ resolved_locale: :source
243
+ )
244
+ return send(attr_name)
245
+ end
246
+ end
247
+
248
+ nil
249
+ end
250
+
251
+ # Get translation record for a locale
252
+ def translation(locale: nil)
253
+ locale ||= I18n.locale
254
+ locale_column = ActiveVersion.column_mapper.column_for(self.class, :translations, :locale)
255
+ # Ensure the column exists in the translation class, fallback to default if not
256
+ translation_class = self.class.translation_class
257
+ unless translation_class.column_names.include?(locale_column.to_s)
258
+ locale_column = ActiveVersion.config.translation_locale_column
259
+ end
260
+ # First try in-memory association
261
+ result = translations.find { |t| t.send(locale_column) == locale }
262
+ return result if result
263
+
264
+ # If not found in memory, query database (in case translation was just created)
265
+ return nil unless persisted?
266
+ translation_class.where(
267
+ active_version_translation_identity_map.merge(locale_column => locale)
268
+ ).first
269
+ end
270
+
271
+ # Update default translation after create
272
+ def update_default_translation
273
+ locale_column = ActiveVersion.column_mapper.column_for(self.class, :translations, :locale)
274
+ # Ensure the column exists in the translation class
275
+ translation_class = self.class.translation_class
276
+ unless translation_class.column_names.include?(locale_column.to_s)
277
+ # Fall back to default if custom column doesn't exist
278
+ locale_column = ActiveVersion.config.translation_locale_column
279
+ end
280
+ translation = translation_class.find_or_initialize_by(
281
+ active_version_translation_identity_map.merge(locale_column => I18n.default_locale)
282
+ )
283
+ return if translation.persisted?
284
+
285
+ # Copy attributes from source to default translation
286
+ translation.assign_attributes(
287
+ attributes.slice(
288
+ *translation.attributes.keys.excluding(
289
+ "id",
290
+ "created_at",
291
+ "updated_at",
292
+ "locale",
293
+ locale_column.to_s,
294
+ *Array(translation.class.source_foreign_key).map(&:to_s)
295
+ )
296
+ )
297
+ )
298
+ translation.save
299
+ translations.reset
300
+ end
301
+
302
+ private
303
+
304
+ public
305
+
306
+ def active_version_translation_identity_map
307
+ columns = translation_identity_columns
308
+ values = active_version_translation_identity_values
309
+
310
+ case values
311
+ when Hash
312
+ values.transform_keys(&:to_s).slice(*columns)
313
+ when Array
314
+ columns.zip(values).to_h
315
+ else
316
+ {columns.first => values}
317
+ end
318
+ end
319
+
320
+ def active_version_translation_identity_values
321
+ resolver = self.class.translation_options && self.class.translation_options[:identity_resolver]
322
+ return default_translation_identity_values if resolver.nil?
323
+
324
+ case resolver
325
+ when Proc
326
+ resolver.arity.zero? ? instance_exec(&resolver) : resolver.call(self)
327
+ when Array
328
+ resolver.map { |column| public_send(column) }
329
+ else
330
+ public_send(resolver)
331
+ end
332
+ end
333
+
334
+ def translation_identity_columns
335
+ Array(self.class.translation_class.source_foreign_key).map(&:to_s)
336
+ end
337
+
338
+ def default_translation_identity_values
339
+ columns = translation_identity_columns
340
+ return id if columns.one?
341
+
342
+ Array(self.class.primary_key).map { |column| self[column] }
343
+ end
344
+
345
+ def respond_to_copy_values?
346
+ respond_to?(:copy_values_from_translation, true)
347
+ end
348
+ end
349
+ end
350
+ end