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,49 @@
1
+ module ActiveVersion
2
+ module Adapters
3
+ module ActiveRecord
4
+ module Revisions
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # Add has_revisions method to ActiveRecord::Base
9
+ end
10
+
11
+ module ClassMethods
12
+ # Declare that a model has revisions
13
+ def has_revisions(options = {})
14
+ include ActiveVersion::Revisions::HasRevisions
15
+ extend ActiveVersion::Revisions::SQLBuilder::ClassMethods
16
+
17
+ # Delegate to the concern implementation so callbacks/options are
18
+ # installed consistently for the DSL path.
19
+ ActiveVersion::Revisions::HasRevisions::ClassMethods
20
+ .instance_method(:has_revisions)
21
+ .bind_call(self, options)
22
+
23
+ # Register revision class
24
+ revision_class_name = "#{name}Revision"
25
+ if const_defined?(revision_class_name)
26
+ revision_class = const_get(revision_class_name)
27
+ if options[:table_name] && revision_class.respond_to?(:table_name=)
28
+ revision_class.table_name = options[:table_name].to_s
29
+ end
30
+ ActiveVersion.registry.register_version_class(self, :revisions, revision_class)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ # Include revisions adapter in ActiveRecord::Base
40
+ ActiveSupport.on_load(:active_record) do
41
+ include ActiveVersion::Adapters::ActiveRecord::Revisions
42
+ end
43
+
44
+ # If ActiveRecord::Base is already loaded, include immediately
45
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:include)
46
+ unless ActiveRecord::Base.included_modules.include?(ActiveVersion::Adapters::ActiveRecord::Revisions)
47
+ ActiveSupport.on_load(:active_record) { include ActiveVersion::Adapters::ActiveRecord::Revisions }
48
+ end
49
+ end
@@ -0,0 +1,45 @@
1
+ module ActiveVersion
2
+ module Adapters
3
+ module ActiveRecord
4
+ module Translations
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # Add has_translations method to ActiveRecord::Base
9
+ end
10
+
11
+ module ClassMethods
12
+ # Declare that a model has translations
13
+ def has_translations(options = {})
14
+ include ActiveVersion::Translations::HasTranslations
15
+
16
+ # Store options
17
+ ActiveVersion.registry.register(self, :translations, options)
18
+
19
+ # Register translation class
20
+ translation_class_name = "#{name}Translation"
21
+ if const_defined?(translation_class_name)
22
+ translation_class = const_get(translation_class_name)
23
+ if options[:table_name] && translation_class.respond_to?(:table_name=)
24
+ translation_class.table_name = options[:table_name].to_s
25
+ end
26
+ ActiveVersion.registry.register_version_class(self, :translations, translation_class)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ # Include translations adapter in ActiveRecord::Base
36
+ ActiveSupport.on_load(:active_record) do
37
+ include ActiveVersion::Adapters::ActiveRecord::Translations
38
+ end
39
+
40
+ # If ActiveRecord::Base is already loaded, include immediately
41
+ if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:include)
42
+ unless ActiveRecord::Base.included_modules.include?(ActiveVersion::Adapters::ActiveRecord::Translations)
43
+ ActiveSupport.on_load(:active_record) { include ActiveVersion::Adapters::ActiveRecord::Translations }
44
+ end
45
+ end
@@ -0,0 +1,10 @@
1
+ require "active_version/adapters/active_record/base"
2
+
3
+ module ActiveVersion
4
+ module Adapters
5
+ module ActiveRecord
6
+ # ActiveRecord-specific adapter implementations
7
+ # This module will be extended with Translations, Revisions, and Audits
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,282 @@
1
+ require "json"
2
+
3
+ module ActiveVersion
4
+ module Adapters
5
+ module Sequel
6
+ # Sequel plugin that provides ActiveVersion-style model DSL and lifecycle hooks.
7
+ module Versioning
8
+ DEFAULT_CONFIG = {
9
+ revision_model: nil,
10
+ audit_model: nil,
11
+ translation_model: nil,
12
+ foreign_key: nil,
13
+ tracked_columns: [],
14
+ translation_columns: []
15
+ }.freeze
16
+
17
+ def self.apply(model, **options)
18
+ configure(model, **options)
19
+ end
20
+
21
+ def self.configure(model, **options)
22
+ base = model.instance_variable_get(:@active_version_sequel_config) || {}
23
+ model.instance_variable_set(:@active_version_sequel_config, DEFAULT_CONFIG.merge(base).merge(options))
24
+ end
25
+
26
+ module ClassMethods
27
+ def active_version(**options)
28
+ ActiveVersion::Adapters::Sequel::Versioning.configure(self, **options)
29
+ end
30
+
31
+ def active_version_config
32
+ config = instance_variable_get(:@active_version_sequel_config)
33
+ return config if config
34
+
35
+ if superclass.respond_to?(:active_version_config)
36
+ superclass.active_version_config.dup
37
+ else
38
+ ActiveVersion::Adapters::Sequel::Versioning::DEFAULT_CONFIG.dup
39
+ end
40
+ end
41
+
42
+ def has_versioning?(version_type)
43
+ case version_type.to_sym
44
+ when :revisions then !active_version_config[:revision_model].nil?
45
+ when :audits then !active_version_config[:audit_model].nil?
46
+ when :translations then !active_version_config[:translation_model].nil?
47
+ else false
48
+ end
49
+ end
50
+ end
51
+
52
+ module InstanceMethods
53
+ def active_version_foreign_key
54
+ self.class.active_version_config[:foreign_key] || :"#{model_name.singular}_id"
55
+ end
56
+
57
+ def active_version_tracked_columns
58
+ self.class.active_version_config[:tracked_columns].map(&:to_sym)
59
+ end
60
+
61
+ def active_version_translation_columns
62
+ columns = self.class.active_version_config[:translation_columns]
63
+ columns = active_version_tracked_columns if columns.nil? || columns.empty?
64
+ columns.map(&:to_sym)
65
+ end
66
+
67
+ def active_version_revisions_dataset
68
+ model = self.class.active_version_config[:revision_model]
69
+ return nil unless model
70
+
71
+ model.where(active_version_foreign_key => pk)
72
+ end
73
+
74
+ def active_version_audits_dataset
75
+ model = self.class.active_version_config[:audit_model]
76
+ return nil unless model
77
+
78
+ model.where(active_version_foreign_key => pk)
79
+ end
80
+
81
+ def active_version_translations_dataset
82
+ model = self.class.active_version_config[:translation_model]
83
+ return nil unless model
84
+
85
+ model.where(active_version_foreign_key => pk)
86
+ end
87
+
88
+ def active_version_revisions
89
+ dataset = active_version_revisions_dataset
90
+ return [] unless dataset
91
+
92
+ dataset.order(:version).all
93
+ end
94
+
95
+ def active_version_audits
96
+ dataset = active_version_audits_dataset
97
+ return [] unless dataset
98
+
99
+ dataset.order(:version).all
100
+ end
101
+
102
+ def active_version_translations
103
+ dataset = active_version_translations_dataset
104
+ return [] unless dataset
105
+
106
+ dataset.order(:locale).all
107
+ end
108
+
109
+ def active_version_translation(locale)
110
+ dataset = active_version_translations_dataset
111
+ return nil unless dataset
112
+
113
+ dataset.where(locale: locale.to_s.downcase).first
114
+ end
115
+
116
+ def active_version_translate(attr, locale:)
117
+ translation = active_version_translation(locale)
118
+ return self[attr] unless translation
119
+
120
+ translated_value = translation[attr]
121
+ (translated_value.nil? || translated_value.to_s.empty?) ? self[attr] : translated_value
122
+ end
123
+
124
+ def active_version_set_translation!(locale:, **attrs)
125
+ locale_value = locale.to_s.downcase
126
+ model = self.class.active_version_config[:translation_model]
127
+ raise ActiveVersion::ConfigurationError, "translation_model is not configured for #{self.class.name}" unless model
128
+
129
+ payload = attrs.transform_keys(&:to_sym).slice(*active_version_translation_columns)
130
+ existing = active_version_translation(locale_value)
131
+
132
+ if existing
133
+ existing.update(payload)
134
+ ActiveVersion::Instrumentation.instrument_translation_updated(existing, self)
135
+ existing
136
+ else
137
+ created = model.create(payload.merge(active_version_foreign_key => pk, :locale => locale_value))
138
+ ActiveVersion::Instrumentation.instrument_translation_created(created, self)
139
+ created
140
+ end
141
+ end
142
+
143
+ def active_version_destroy_translation!(locale:)
144
+ translation = active_version_translation(locale)
145
+ return false unless translation
146
+
147
+ ActiveVersion::Instrumentation.instrument_translation_destroyed(translation, self)
148
+ translation.delete
149
+ true
150
+ end
151
+
152
+ def before_update
153
+ @active_version_previous_snapshot = active_version_previous_snapshot_from_db
154
+ super
155
+ end
156
+
157
+ def before_destroy
158
+ @active_version_previous_snapshot = active_version_snapshot
159
+ super
160
+ end
161
+
162
+ def after_create
163
+ super
164
+ active_version_create_revision_and_audit!(:create, previous_snapshot: nil)
165
+ end
166
+
167
+ def after_update
168
+ super
169
+ active_version_create_revision_and_audit!(:update, previous_snapshot: @active_version_previous_snapshot)
170
+ @active_version_previous_snapshot = nil
171
+ end
172
+
173
+ def after_destroy
174
+ super
175
+ active_version_create_destroy_audit!(@active_version_previous_snapshot)
176
+ ensure
177
+ @active_version_previous_snapshot = nil
178
+ end
179
+
180
+ private
181
+
182
+ def pk
183
+ self[self.class.primary_key]
184
+ end
185
+
186
+ def model_name
187
+ self.class.name.split("::").last.downcase
188
+ end
189
+
190
+ def active_version_snapshot
191
+ active_version_tracked_columns.each_with_object({}) do |column, out|
192
+ out[column] = self[column]
193
+ end
194
+ end
195
+
196
+ def active_version_previous_snapshot_from_db
197
+ persisted = self.class.where(self.class.primary_key => pk).first
198
+ return active_version_snapshot unless persisted
199
+
200
+ active_version_tracked_columns.each_with_object({}) do |column, out|
201
+ out[column] = persisted[column]
202
+ end
203
+ end
204
+
205
+ def active_version_change_set(previous_snapshot)
206
+ current = active_version_snapshot
207
+ previous = previous_snapshot || Hash.new(nil)
208
+ current.each_with_object({}) do |(column, value), out|
209
+ old_value = previous[column]
210
+ next if old_value == value
211
+
212
+ out[column.to_s] = [old_value, value]
213
+ end
214
+ end
215
+
216
+ def active_version_next_version
217
+ max_revision = active_version_revisions_dataset&.max(:version) || 0
218
+ max_audit = active_version_audits_dataset&.max(:version) || 0
219
+ [max_revision, max_audit].max + 1
220
+ end
221
+
222
+ def active_version_create_revision_and_audit!(action, previous_snapshot:)
223
+ change_set = active_version_change_set(previous_snapshot)
224
+ return if action.to_s == "update" && change_set.empty?
225
+
226
+ version = active_version_next_version
227
+ active_version_insert_revision!(version)
228
+ active_version_insert_audit!(action: action, version: version, changes: change_set)
229
+ end
230
+
231
+ def active_version_create_destroy_audit!(previous_snapshot)
232
+ return unless self.class.active_version_config[:audit_model]
233
+
234
+ previous = previous_snapshot || active_version_snapshot
235
+ change_set = previous.each_with_object({}) do |(column, value), out|
236
+ out[column.to_s] = [value, nil]
237
+ end
238
+ version = active_version_next_version
239
+ active_version_insert_audit!(action: "destroy", version: version, changes: change_set)
240
+ end
241
+
242
+ def active_version_insert_revision!(version)
243
+ revision_model = self.class.active_version_config[:revision_model]
244
+ return unless revision_model
245
+
246
+ payload = active_version_snapshot.merge(
247
+ active_version_foreign_key => pk,
248
+ :version => version
249
+ )
250
+
251
+ revision = revision_model.create(payload)
252
+ ActiveVersion::Instrumentation.instrument_revision_created(revision, self)
253
+ revision
254
+ rescue => e
255
+ ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
256
+ raise
257
+ end
258
+
259
+ def active_version_insert_audit!(action:, version:, changes:)
260
+ audit_model = self.class.active_version_config[:audit_model]
261
+ return unless audit_model
262
+
263
+ payload = {
264
+ active_version_foreign_key => pk,
265
+ :version => version,
266
+ :action => action,
267
+ :audited_changes => JSON.generate(changes),
268
+ :audited_context => JSON.generate(ActiveVersion.context || {})
269
+ }
270
+
271
+ audit = audit_model.create(payload)
272
+ ActiveVersion::Instrumentation.instrument_audit_created(audit, self)
273
+ audit
274
+ rescue => e
275
+ ActiveVersion::Instrumentation.instrument_audit_write_failed(self, error: e, action: action)
276
+ raise
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,9 @@
1
+ module ActiveVersion
2
+ module Adapters
3
+ module Sequel
4
+ # Namespace for Sequel integration.
5
+ end
6
+ end
7
+ end
8
+
9
+ require "active_version/adapters/sequel/versioning"
@@ -0,0 +1,5 @@
1
+ module ActiveVersion
2
+ module Adapters
3
+ # Base namespace for ActiveRecord adapters
4
+ end
5
+ end
@@ -0,0 +1,180 @@
1
+ module ActiveVersion
2
+ module Audits
3
+ module AuditRecord
4
+ # Callback methods for setting audit attributes
5
+ module Callbacks
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def set_version_number
11
+ auditable_column = nil
12
+ begin
13
+ source_class = self.class.source_class
14
+ version_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :version)
15
+ auditable_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :auditable)
16
+ rescue NameError
17
+ version_column = ActiveVersion.config.audit_version_column
18
+ auditable_column = ActiveVersion.config.audit_auditable_column
19
+ end
20
+
21
+ # Skip if version is already set (e.g., from audit_writer)
22
+ # Version should always be a positive integer when set, so check if it's not nil
23
+ return if !self[version_column].nil?
24
+
25
+ type_column = auditable_column.to_s.end_with?("_type") ? auditable_column.to_s : "#{auditable_column}_type"
26
+ identity_columns = source_identity_columns(auditable_column)
27
+
28
+ if action == "create"
29
+ self[version_column] = 1
30
+ else
31
+ auditable_type_value = self[type_column]
32
+ return if auditable_type_value.nil? || identity_columns.any? { |column| self[column].nil? }
33
+
34
+ scope = self.class.where(type_column => auditable_type_value)
35
+ identity_columns.each do |column|
36
+ scope = scope.where(column => self[column])
37
+ end
38
+ max_version = scope.maximum(version_column).to_i
39
+ self[version_column] = max_version + 1
40
+ end
41
+ end
42
+
43
+ def set_audit_user
44
+ user_column = begin
45
+ ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :user)
46
+ rescue NameError
47
+ ActiveVersion.config.audit_user_column
48
+ end
49
+ return unless user_column
50
+
51
+ # Try to get user from RequestStore first (like audited)
52
+ user = ActiveVersion::RequestStore.audited_user if defined?(ActiveVersion::RequestStore)
53
+ user ||= begin
54
+ user_method = ActiveVersion.config.current_user_method
55
+ if respond_to?(user_method, true)
56
+ send(user_method)
57
+ end
58
+ end
59
+
60
+ if user
61
+ self[user_column] = user.respond_to?(:id) ? user.id : user
62
+ # Set polymorphic type if user is polymorphic
63
+ if user_column.to_s.end_with?("_id") && user.respond_to?(:class)
64
+ type_column = user_column.to_s.gsub("_id", "_type")
65
+ self[type_column] = user.class.name if self.class.column_names.include?(type_column)
66
+ end
67
+ end
68
+ end
69
+
70
+ def set_request_uuid
71
+ uuid_column = begin
72
+ ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :request_uuid)
73
+ rescue NameError
74
+ ActiveVersion.config.audit_request_uuid_column
75
+ end
76
+ return unless uuid_column && self.class.column_names.include?(uuid_column.to_s)
77
+
78
+ # Try RequestStore first, then generate UUID
79
+ self[uuid_column] = ActiveVersion::RequestStore.request_uuid if defined?(ActiveVersion::RequestStore)
80
+ self[uuid_column] ||= SecureRandom.uuid if self[uuid_column].blank?
81
+ end
82
+
83
+ def set_remote_address
84
+ address_column = begin
85
+ ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :remote_address)
86
+ rescue NameError
87
+ ActiveVersion.config.audit_remote_address_column
88
+ end
89
+ return unless address_column && self.class.column_names.include?(address_column.to_s)
90
+
91
+ # Try RequestStore first
92
+ if defined?(ActiveVersion::RequestStore)
93
+ self[address_column] = ActiveVersion::RequestStore.remote_address
94
+ end
95
+ end
96
+
97
+ def set_audited_context
98
+ context_column = begin
99
+ ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :context)
100
+ rescue NameError
101
+ ActiveVersion.config.audit_context_column
102
+ end
103
+ return unless context_column
104
+
105
+ # Context should already be set during creation via write_audit
106
+ # This callback ensures it's set if it wasn't set during creation
107
+ return if self[context_column].present?
108
+
109
+ # Fallback: use global context if no context was set
110
+ global_context = ActiveVersion.context || {}
111
+ self[context_column] = global_context if global_context.any?
112
+ end
113
+
114
+ def instrument_audit_created
115
+ auditable_column = ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :auditable)
116
+ auditable_record = nil
117
+
118
+ if respond_to?(auditable_column)
119
+ auditable_record = send(auditable_column)
120
+ end
121
+
122
+ # If association didn't return a record, try to load it directly
123
+ unless auditable_record
124
+ type_column = auditable_column.to_s.end_with?("_type") ? auditable_column.to_s : "#{auditable_column}_type"
125
+ identity_columns = source_identity_columns(auditable_column)
126
+ identity_map = source_identity_map_for_lookup(identity_columns, auditable_column)
127
+ auditable_type_value = self[type_column]
128
+
129
+ if identity_map.values.none?(&:nil?) && auditable_type_value
130
+ # Handle dynamically created classes
131
+ if auditable_type_value.nil?
132
+ # Try superclass for dynamically created classes
133
+ source_class = self.class.source_class
134
+ auditable_type_value = source_class.superclass&.name if source_class.superclass
135
+ end
136
+
137
+ if auditable_type_value
138
+ auditable_klass = auditable_type_value.constantize
139
+ auditable_record = if identity_map.size > 1
140
+ auditable_klass.find_by(identity_map)
141
+ else
142
+ auditable_klass.find_by(id: identity_map.values.first)
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ ActiveVersion::Instrumentation.instrument_audit_created(self, auditable_record)
149
+ rescue ::NameError, ::NoMethodError, ActiveRecord::RecordNotFound
150
+ # Association not set up yet or record not found, still instrument with nil
151
+ ActiveVersion::Instrumentation.instrument_audit_created(self, nil)
152
+ end
153
+
154
+ def source_identity_columns(auditable_column)
155
+ configured = if self.class.respond_to?(:source_identity_columns)
156
+ self.class.source_identity_columns
157
+ end
158
+ Array(configured.presence || "#{auditable_column}_id").map(&:to_s)
159
+ end
160
+
161
+ def source_identity_map_for_lookup(identity_columns, auditable_column)
162
+ prefix = "#{auditable_column}_"
163
+
164
+ identity_columns.index_with do |column|
165
+ self[column]
166
+ end.each_with_object({}) do |(column, value), acc|
167
+ source_column = if column == "#{auditable_column}_id"
168
+ "id"
169
+ elsif column.start_with?(prefix)
170
+ column.delete_prefix(prefix)
171
+ else
172
+ column
173
+ end
174
+ acc[source_column] = value
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,49 @@
1
+ require "json"
2
+ require "yaml"
3
+
4
+ module ActiveVersion
5
+ module Audits
6
+ module AuditRecord
7
+ module Serializers
8
+ class Identity
9
+ def load(value) = value
10
+ def dump(value) = value
11
+ end
12
+
13
+ class Json
14
+ def load(value)
15
+ return {} if value.nil?
16
+ return value unless value.is_a?(String)
17
+
18
+ JSON.parse(value)
19
+ rescue JSON::ParserError
20
+ value
21
+ end
22
+
23
+ def dump(value)
24
+ return value unless value.is_a?(Hash) || value.is_a?(Array)
25
+
26
+ JSON.generate(value)
27
+ end
28
+ end
29
+
30
+ class Yaml
31
+ PERMITTED_CLASSES = [Time, Date, DateTime, Symbol].freeze
32
+
33
+ def load(value)
34
+ return {} if value.nil?
35
+ return value unless value.is_a?(String)
36
+
37
+ YAML.safe_load(value, permitted_classes: PERMITTED_CLASSES, aliases: false)
38
+ rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::AliasesNotEnabled, ArgumentError
39
+ value
40
+ end
41
+
42
+ def dump(value)
43
+ value.to_yaml
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end