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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.md +21 -0
- data/README.md +492 -0
- data/SECURITY.md +29 -0
- data/lib/active_version/adapters/active_record/audits.rb +36 -0
- data/lib/active_version/adapters/active_record/base.rb +37 -0
- data/lib/active_version/adapters/active_record/revisions.rb +49 -0
- data/lib/active_version/adapters/active_record/translations.rb +45 -0
- data/lib/active_version/adapters/active_record.rb +10 -0
- data/lib/active_version/adapters/sequel/versioning.rb +282 -0
- data/lib/active_version/adapters/sequel.rb +9 -0
- data/lib/active_version/adapters.rb +5 -0
- data/lib/active_version/audits/audit_record/callbacks.rb +180 -0
- data/lib/active_version/audits/audit_record/serializers.rb +49 -0
- data/lib/active_version/audits/audit_record.rb +522 -0
- data/lib/active_version/audits/has_audits/audit_callbacks.rb +46 -0
- data/lib/active_version/audits/has_audits/audit_combiner.rb +212 -0
- data/lib/active_version/audits/has_audits/audit_writer.rb +282 -0
- data/lib/active_version/audits/has_audits/change_filters.rb +114 -0
- data/lib/active_version/audits/has_audits/database_adapter_helper.rb +86 -0
- data/lib/active_version/audits/has_audits.rb +891 -0
- data/lib/active_version/audits/sql_builder.rb +263 -0
- data/lib/active_version/audits.rb +10 -0
- data/lib/active_version/column_mapper.rb +92 -0
- data/lib/active_version/configuration.rb +124 -0
- data/lib/active_version/database/triggers/postgresql.rb +243 -0
- data/lib/active_version/database.rb +7 -0
- data/lib/active_version/instrumentation.rb +226 -0
- data/lib/active_version/migrators/audited.rb +84 -0
- data/lib/active_version/migrators/base.rb +191 -0
- data/lib/active_version/migrators.rb +8 -0
- data/lib/active_version/query.rb +105 -0
- data/lib/active_version/railtie.rb +17 -0
- data/lib/active_version/revisions/has_revisions/revision_manipulation.rb +499 -0
- data/lib/active_version/revisions/has_revisions/revision_queries.rb +182 -0
- data/lib/active_version/revisions/has_revisions.rb +443 -0
- data/lib/active_version/revisions/revision_record.rb +287 -0
- data/lib/active_version/revisions/sql_builder.rb +266 -0
- data/lib/active_version/revisions.rb +10 -0
- data/lib/active_version/runtime.rb +148 -0
- data/lib/active_version/sharding/connection_router.rb +20 -0
- data/lib/active_version/sharding.rb +7 -0
- data/lib/active_version/tasks/active_version.rake +29 -0
- data/lib/active_version/translations/has_translations.rb +350 -0
- data/lib/active_version/translations/translation_record.rb +258 -0
- data/lib/active_version/translations.rb +9 -0
- data/lib/active_version/version.rb +3 -0
- data/lib/active_version/version_registry.rb +87 -0
- data/lib/active_version.rb +329 -0
- data/lib/generators/active_version/audits/audits_generator.rb +65 -0
- data/lib/generators/active_version/audits/templates/audit_model.rb.erb +16 -0
- data/lib/generators/active_version/audits/templates/migration_jsonb.rb.erb +33 -0
- data/lib/generators/active_version/audits/templates/migration_table.rb.erb +34 -0
- data/lib/generators/active_version/install/install_generator.rb +19 -0
- data/lib/generators/active_version/install/templates/initializer.rb.erb +38 -0
- data/lib/generators/active_version/revisions/revisions_generator.rb +71 -0
- data/lib/generators/active_version/revisions/templates/backfill_migration.rb.erb +19 -0
- data/lib/generators/active_version/revisions/templates/migration.rb.erb +20 -0
- data/lib/generators/active_version/revisions/templates/revision_model.rb.erb +8 -0
- data/lib/generators/active_version/translations/templates/migration.rb.erb +16 -0
- data/lib/generators/active_version/translations/templates/translation_model.rb.erb +15 -0
- data/lib/generators/active_version/translations/translations_generator.rb +73 -0
- data/lib/generators/active_version/triggers/templates/migration.rb.erb +100 -0
- data/lib/generators/active_version/triggers/triggers_generator.rb +74 -0
- data/sig/active_version/advanced.rbs +51 -0
- data/sig/active_version/audits.rbs +128 -0
- data/sig/active_version/configuration.rbs +38 -0
- data/sig/active_version/core.rbs +53 -0
- data/sig/active_version/instrumentation.rbs +17 -0
- data/sig/active_version/registry_and_mapping.rbs +18 -0
- data/sig/active_version/revisions.rbs +70 -0
- data/sig/active_version/runtime.rbs +29 -0
- data/sig/active_version/translations.rbs +43 -0
- data/sig/active_version.rbs +3 -0
- 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,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
|