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,522 @@
1
+ require "securerandom"
2
+ require "active_version/audits/sql_builder"
3
+ require "active_version/audits/audit_record/callbacks"
4
+ require "active_version/audits/audit_record/serializers"
5
+
6
+ module ActiveVersion
7
+ module Audits
8
+ # Marker module for audit models
9
+ # Identifies a model as an audit record
10
+ module AuditRecord
11
+ extend ActiveSupport::Concern
12
+ include SQLBuilder
13
+ include Callbacks
14
+
15
+ class AuditSchemaDSL
16
+ def initialize(audit_class)
17
+ @audit_class = audit_class
18
+ end
19
+
20
+ def storage(value) = @audit_class.audit_storage(value)
21
+ def action_column(value) = @audit_class.audit_action_column(value)
22
+ def changes_column(value) = @audit_class.audit_changes_column(value)
23
+ def context_column(value) = @audit_class.audit_context_column(value)
24
+ def comment_column(value) = @audit_class.audit_comment_column(value)
25
+ def version_column(value) = @audit_class.audit_version_column(value)
26
+ def user_column(value) = @audit_class.audit_user_column(value)
27
+ def auditable_column(value) = @audit_class.audit_auditable_column(value)
28
+ def associated_column(value) = @audit_class.audit_associated_column(value)
29
+ def remote_address_column(value) = @audit_class.audit_remote_address_column(value)
30
+ def request_uuid_column(value) = @audit_class.audit_request_uuid_column(value)
31
+ end
32
+
33
+ included do
34
+ class_attribute :active_version_audit_schema, instance_writer: false, default: {}
35
+
36
+ # Mark this as an audit record
37
+ def self.audit_record?
38
+ true
39
+ end
40
+
41
+ # Attributes will be defined in setup_associations when connection is available
42
+ # This ensures ActiveRecord recognizes them as database columns before loading records
43
+
44
+ # Allow our audit column names even if they conflict with ActiveRecord methods
45
+ # This is necessary because 'audited_changes' is a standard audit column name
46
+ # but conflicts with ActiveRecord's internal methods
47
+ def self.dangerous_attribute_method?(name)
48
+ changes_column = audit_column_for(:changes).to_s
49
+ context_column = audit_column_for(:context).to_s
50
+ return false if name.to_s == changes_column || name.to_s == context_column
51
+ super
52
+ end
53
+
54
+ define_method(:audited_changes) do
55
+ value = read_attribute(self.class.audit_column_for(:changes))
56
+ self.class.deserialize_audit_payload(value, column_name: self.class.audit_column_for(:changes))
57
+ end
58
+
59
+ define_method(:audited_changes=) do |value|
60
+ column_name = self.class.audit_column_for(:changes)
61
+ write_attribute(column_name, self.class.serialize_audit_payload(value, column_name: column_name))
62
+ end
63
+
64
+ define_method(:audited_context) do
65
+ value = read_attribute(self.class.audit_column_for(:context))
66
+ parsed = self.class.deserialize_audit_payload(value, column_name: self.class.audit_column_for(:context))
67
+ # Return HashWithIndifferentAccess to support both symbol and string keys
68
+ if parsed.is_a?(Hash)
69
+ parsed.with_indifferent_access
70
+ else
71
+ parsed
72
+ end
73
+ end
74
+
75
+ define_method(:audited_context=) do |value|
76
+ column_name = self.class.audit_column_for(:context)
77
+ write_attribute(column_name, self.class.serialize_audit_payload(value, column_name: column_name))
78
+ end
79
+
80
+ # Get source model name (e.g., "Post" from "PostAudit")
81
+ def self.source_name
82
+ return @source_name if @source_name
83
+ return nil unless name
84
+ @source_name = name.underscore.gsub("_audit", "").to_sym
85
+ end
86
+
87
+ # Get source model class (lazy)
88
+ def self.source_class
89
+ @source_class ||= begin
90
+ klass = source_name.to_s.classify.safe_constantize
91
+ klass || raise(NameError, "Could not find source class #{source_name.to_s.classify}")
92
+ end
93
+ end
94
+
95
+ # Get identity columns (e.g., ["post_id"] or ["tenant_id", "external_id"])
96
+ def self.source_identity_columns
97
+ options = ActiveVersion.registry.config_for_model_name(source_name.to_s.classify, :audits) || {}
98
+ configured = options[:identity_columns]
99
+ return configured.map(&:to_s) if configured.is_a?(Array)
100
+ return [configured.to_s] if configured.present?
101
+
102
+ @source_identity_columns ||= begin
103
+ auditable_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :auditable)
104
+ if auditable_column.to_s.end_with?("_id")
105
+ [auditable_column.to_s]
106
+ else
107
+ ["#{auditable_column}_id"]
108
+ end
109
+ end
110
+ rescue NameError
111
+ # Source class not yet defined, use default
112
+ ["auditable_id"]
113
+ end
114
+
115
+ # Set up associations (deferred until source class exists)
116
+ def self.setup_associations
117
+ return if @associations_setup
118
+ @associations_setup = true
119
+
120
+ begin
121
+ # Define dangerous attributes as database columns to avoid DangerousAttributeError.
122
+ if connection_pool&.connected?
123
+ changes_column = audit_column_for(:changes)
124
+ context_column = audit_column_for(:context)
125
+ attribute changes_column, :text unless attribute_names.include?(changes_column.to_s)
126
+ attribute context_column, :text unless attribute_names.include?(context_column.to_s)
127
+ end
128
+ rescue *ActiveVersion::Runtime.active_record_connection_errors => e
129
+ if defined?(Rails) && Rails.respond_to?(:logger)
130
+ Rails.logger&.debug("[ActiveVersion] Deferred audit attribute setup for #{name}: #{e.class}: #{e.message}")
131
+ end
132
+ end
133
+
134
+ begin
135
+ # Set up polymorphic association only when conventional id/type columns exist.
136
+ auditable_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :auditable)
137
+ if column_names.include?("#{auditable_column}_id") && column_names.include?("#{auditable_column}_type")
138
+ send(:belongs_to, auditable_column, polymorphic: true)
139
+ end
140
+
141
+ # Set up user association (if configured)
142
+ user_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :user)
143
+ if user_column
144
+ send(:belongs_to, user_column.to_s.gsub("_id", "").to_sym, polymorphic: true, optional: true)
145
+ end
146
+
147
+ # Set up associated model (if configured)
148
+ associated_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :associated)
149
+ if associated_column
150
+ send(:belongs_to, associated_column.to_s.gsub("_id", "").to_sym, polymorphic: true, optional: true)
151
+ end
152
+ rescue NameError
153
+ # Source class not yet defined, will be set up later
154
+ end
155
+ end
156
+
157
+ # Callbacks (defined in Callbacks module)
158
+ before_create :set_version_number, :set_audit_user, :set_request_uuid, :set_remote_address, :set_audited_context
159
+ after_create :instrument_audit_created
160
+
161
+ # Readonly enforcement - audits are readonly once persisted
162
+ # Use a flag to temporarily disable readonly? during update/destroy
163
+ # so our callbacks can raise the custom error instead of ActiveRecord's ReadOnlyRecord
164
+ attr_accessor :_allow_update_for_readonly_check
165
+
166
+ def readonly?
167
+ return false if _allow_update_for_readonly_check
168
+ persisted?
169
+ end
170
+
171
+ before_update :raise_readonly_error, if: :persisted?
172
+ before_destroy :raise_readonly_error, if: :persisted?
173
+
174
+ # Override save methods to set flag before ActiveRecord checks readonly?
175
+ def save(*args, **kwargs, &block)
176
+ self._allow_update_for_readonly_check = true if persisted?
177
+ if kwargs.empty? && args.length == 1 && args[0].is_a?(Hash)
178
+ kwargs = args.pop
179
+ end
180
+ if kwargs.any?
181
+ super(**kwargs, &block)
182
+ else
183
+ super(*args, &block)
184
+ end
185
+ ensure
186
+ self._allow_update_for_readonly_check = false
187
+ end
188
+
189
+ def save!(*args, **kwargs, &block)
190
+ self._allow_update_for_readonly_check = true if persisted?
191
+ if kwargs.empty? && args.length == 1 && args[0].is_a?(Hash)
192
+ kwargs = args.pop
193
+ end
194
+ if kwargs.any?
195
+ super(**kwargs, &block)
196
+ else
197
+ super(*args, &block)
198
+ end
199
+ ensure
200
+ self._allow_update_for_readonly_check = false
201
+ end
202
+
203
+ def destroy(*args, **kwargs, &block)
204
+ self._allow_update_for_readonly_check = true if persisted?
205
+ if kwargs.any?
206
+ super(**kwargs, &block)
207
+ else
208
+ super(*args, &block)
209
+ end
210
+ ensure
211
+ self._allow_update_for_readonly_check = false
212
+ end
213
+
214
+ # Rollback handling
215
+ after_rollback :clear_rolled_back_audits
216
+
217
+ # Scopes
218
+ scope :ascending, -> {
219
+ version_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :version)
220
+ reorder(version_column => :asc)
221
+ }
222
+ scope :descending, -> {
223
+ version_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :version)
224
+ reorder(version_column => :desc)
225
+ }
226
+ scope :creates, -> { where(audit_column_for(:action) => "create") }
227
+ scope :updates, -> { where(audit_column_for(:action) => "update") }
228
+ scope :destroys, -> { where(audit_column_for(:action) => "destroy") }
229
+ scope :up_until, ->(date_or_time) { where("created_at <= ?", date_or_time) }
230
+ scope :from_version, ->(version) {
231
+ version_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :version)
232
+ where("#{version_column} >= ?", version)
233
+ }
234
+ scope :to_version, ->(version) {
235
+ version_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :version)
236
+ where("#{version_column} <= ?", version)
237
+ }
238
+ scope :auditable_finder, ->(auditable_identity, auditable_type, identity_columns = nil) {
239
+ auditable_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :auditable)
240
+ type_key = "#{auditable_column}_type"
241
+ columns = Array(identity_columns.presence || "#{auditable_column}_id").map(&:to_s)
242
+ identity_map = case auditable_identity
243
+ when Hash
244
+ auditable_identity.transform_keys(&:to_s).slice(*columns)
245
+ when Array
246
+ columns.zip(auditable_identity).to_h
247
+ else
248
+ {columns.first => auditable_identity}
249
+ end
250
+ where({type_key => auditable_type}.merge(identity_map))
251
+ }
252
+
253
+ # Serialization for audit payload columns
254
+ def self.setup_serializers
255
+ return if @serializers_setup
256
+ @serializers_setup = true
257
+ initialize_serializers
258
+ end
259
+
260
+ # Setup when class is loaded (only if name is available)
261
+ # This ensures attributes are defined before records are loaded
262
+ setup_associations if name
263
+ setup_serializers if name
264
+ end
265
+
266
+ class_methods do
267
+ def configure_audit(**options, &block)
268
+ audit_storage(options[:storage]) if options[:storage]
269
+ audit_action_column(options[:action_column]) if options[:action_column]
270
+ audit_changes_column(options[:changes_column]) if options[:changes_column]
271
+ audit_context_column(options[:context_column]) if options[:context_column]
272
+ audit_comment_column(options[:comment_column]) if options[:comment_column]
273
+ audit_version_column(options[:version_column]) if options[:version_column]
274
+ audit_user_column(options[:user_column]) if options[:user_column]
275
+ audit_auditable_column(options[:auditable_column]) if options[:auditable_column]
276
+ audit_associated_column(options[:associated_column]) if options[:associated_column]
277
+ audit_remote_address_column(options[:remote_address_column]) if options[:remote_address_column]
278
+ audit_request_uuid_column(options[:request_uuid_column]) if options[:request_uuid_column]
279
+ AuditSchemaDSL.new(self).instance_eval(&block) if block_given?
280
+ active_version_audit_schema
281
+ end
282
+
283
+ def audit_storage(value = nil) = schema_option(:storage, value)
284
+ def audit_action_column(value = nil) = schema_option(:action_column, value)
285
+ def audit_changes_column(value = nil) = schema_option(:changes_column, value)
286
+ def audit_context_column(value = nil) = schema_option(:context_column, value)
287
+ def audit_comment_column(value = nil) = schema_option(:comment_column, value)
288
+ def audit_version_column(value = nil) = schema_option(:version_column, value)
289
+ def audit_user_column(value = nil) = schema_option(:user_column, value)
290
+ def audit_auditable_column(value = nil) = schema_option(:auditable_column, value)
291
+ def audit_associated_column(value = nil) = schema_option(:associated_column, value)
292
+ def audit_remote_address_column(value = nil) = schema_option(:remote_address_column, value)
293
+ def audit_request_uuid_column(value = nil) = schema_option(:request_uuid_column, value)
294
+
295
+ def audit_storage_mode
296
+ schema_value = (active_version_audit_schema || {})[:storage]
297
+ return schema_value.to_sym if schema_value
298
+
299
+ ActiveVersion.config.audit_storage&.to_sym
300
+ end
301
+
302
+ def register_storage_provider(name, provider = nil, &factory)
303
+ register_audit_storage_provider(name, provider, &factory)
304
+ end
305
+
306
+ def register_audit_storage_provider(name, provider = nil, &factory)
307
+ entry = factory || provider
308
+ raise ArgumentError, "storage provider object or factory block is required" if entry.nil?
309
+
310
+ storage_provider_registry[name.to_sym] = entry
311
+ end
312
+
313
+ def storage_provider_registry
314
+ @storage_provider_registry ||= begin
315
+ inherited = if superclass.respond_to?(:storage_provider_registry)
316
+ superclass.storage_provider_registry
317
+ end
318
+ (inherited || default_storage_provider_registry).dup
319
+ end
320
+ end
321
+
322
+ def storage_provider_for_column(column_name)
323
+ storage_mode = audit_storage_mode&.to_sym
324
+ entry = storage_provider_registry[storage_mode]
325
+ raise ActiveVersion::ConfigurationError, "unknown audit storage mode: #{storage_mode.inspect}" unless entry
326
+
327
+ provider = entry.respond_to?(:call) ? entry.call(self, column_name.to_s) : entry
328
+ unless provider.respond_to?(:load) && provider.respond_to?(:dump)
329
+ raise ActiveVersion::ConfigurationError, "storage provider for #{storage_mode.inspect} must respond to #load and #dump"
330
+ end
331
+
332
+ provider
333
+ end
334
+
335
+ def serializer_for_column(column_name)
336
+ storage_provider_for_column(column_name)
337
+ end
338
+
339
+ def deserialize_audit_payload(value, column_name:)
340
+ serializer_for_column(column_name).load(value)
341
+ end
342
+
343
+ def serialize_audit_payload(value, column_name:)
344
+ serializer_for_column(column_name).dump(value)
345
+ end
346
+
347
+ def audit_column_for(concept)
348
+ schema = active_version_audit_schema || {}
349
+ schema_key = :"#{concept}_column"
350
+ return schema[schema_key].to_sym if schema[schema_key].present?
351
+
352
+ case concept
353
+ when :action then ActiveVersion.config.audit_action_column
354
+ when :changes then ActiveVersion.config.audit_changes_column
355
+ when :context then ActiveVersion.config.audit_context_column
356
+ when :comment then ActiveVersion.config.audit_comment_column
357
+ when :version then ActiveVersion.config.audit_version_column
358
+ when :user then ActiveVersion.config.audit_user_column
359
+ when :auditable then ActiveVersion.config.audit_auditable_column
360
+ when :associated then ActiveVersion.config.audit_associated_column
361
+ when :remote_address then ActiveVersion.config.audit_remote_address_column
362
+ when :request_uuid then ActiveVersion.config.audit_request_uuid_column
363
+ end
364
+ end
365
+
366
+ def schema_option(key, value)
367
+ schema = (active_version_audit_schema || {}).dup
368
+ return schema[key] if value.nil?
369
+ schema[key] = value.to_sym
370
+ self.active_version_audit_schema = schema
371
+ schema[key]
372
+ end
373
+
374
+ private
375
+
376
+ def default_storage_provider_registry
377
+ {
378
+ json_column: ->(_audit_class, _column_name) { AuditRecord::Serializers::Json.new },
379
+ yaml_column: ->(_audit_class, _column_name) { AuditRecord::Serializers::Yaml.new },
380
+ mirror_columns: ->(_audit_class, _column_name) { AuditRecord::Serializers::Identity.new }
381
+ }
382
+ end
383
+ end
384
+
385
+ module ClassMethods
386
+ # Track which models use this audit class
387
+ def add_audited_class(audited_class)
388
+ @audited_classes ||= {}
389
+ @audited_classes[name] ||= Set.new
390
+ @audited_classes[name] << audited_class
391
+ end
392
+
393
+ def audited_classes
394
+ @audited_classes ||= {}
395
+ @audited_classes[name] ||= Set.new
396
+ end
397
+
398
+ # Initialize serializers for ActiveRecord serialize API (< Rails 8).
399
+ # Rails 8+ relies on accessor-level serialization methods above.
400
+ def initialize_serializers
401
+ changes_column = audit_column_for(:changes)
402
+ context_column = audit_column_for(:context)
403
+ changes_serializer = serializer_for_column(changes_column)
404
+ context_serializer = serializer_for_column(context_column)
405
+
406
+ if ActiveRecord::VERSION::MAJOR >= 8
407
+ # No-op: ActiveRecord 8 removed serialize API; handled in accessors.
408
+ elsif ActiveRecord::VERSION::MAJOR >= 7 && ActiveRecord::VERSION::MINOR >= 1
409
+ serialize changes_column, coder: changes_serializer
410
+ serialize context_column, coder: context_serializer
411
+ else
412
+ serialize changes_column, changes_serializer
413
+ serialize context_column, context_serializer
414
+ end
415
+ end
416
+
417
+ # Reconstruct attributes from audit history
418
+ def reconstruct_attributes(audits)
419
+ version_column = audits.first.class.audit_column_for(:version) if audits.any? && audits.first.class.respond_to?(:audit_column_for)
420
+ audits.each_with_object({}) do |audit, all|
421
+ all.merge!(audit.new_attributes)
422
+ all[:audit_version] = audit[version_column || :version]
423
+ end
424
+ end
425
+ end
426
+
427
+ # Get new attributes from this audit
428
+ def new_attributes
429
+ changes_column = self.class.audit_column_for(:changes)
430
+ changes = send(changes_column) || {}
431
+ if changes.is_a?(Hash) && changes.any?
432
+ changes.each_with_object({}) do |(attr, values), attrs|
433
+ attrs[attr] = (action_value == "update") ? values.last : values
434
+ end
435
+ else
436
+ structured_audited_attributes
437
+ end
438
+ end
439
+
440
+ def structured_audited_attributes
441
+ source_columns = self.class.source_class.column_names
442
+ ignored = ActiveVersion.config.ignored_attributes.map(&:to_s)
443
+
444
+ source_columns.each_with_object({}) do |attr, attrs|
445
+ next if ignored.include?(attr)
446
+ next unless self.class.column_names.include?(attr)
447
+
448
+ attrs[attr] = self[attr]
449
+ end
450
+ end
451
+
452
+ # Get old attributes from this audit
453
+ def old_attributes
454
+ changes_column = self.class.audit_column_for(:changes)
455
+ changes = send(changes_column) || {}
456
+ return {} unless changes.is_a?(Hash) && changes.any?
457
+
458
+ changes.each_with_object({}) do |(attr, values), attrs|
459
+ attrs[attr] = (action_value == "update") ? values.first : nil
460
+ end
461
+ end
462
+
463
+ # Get ancestors (all audits before this one)
464
+ def ancestors
465
+ auditable_column = ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :auditable)
466
+ type_column = auditable_column.to_s.end_with?("_type") ? auditable_column.to_s : "#{auditable_column}_type"
467
+ identity_columns = self.class.source_identity_columns
468
+ auditable_identity_map = identity_columns.index_with { |column| self[column] }
469
+ auditable_type_value = self[type_column]
470
+ version_column = ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :version)
471
+ version_value = self[version_column]
472
+ self.class
473
+ .ascending
474
+ .auditable_finder(auditable_identity_map, auditable_type_value, identity_columns)
475
+ .to_version(version_value)
476
+ rescue ::NameError, ::NoMethodError
477
+ self.class.none
478
+ end
479
+
480
+ # Reconstruct object at this revision
481
+ def revision
482
+ auditable_column = ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :auditable)
483
+ auditable_record = send(auditable_column) if respond_to?(auditable_column)
484
+ return nil unless auditable_record
485
+
486
+ audits = ancestors
487
+ attributes = self.class.reconstruct_attributes(audits)
488
+ auditable_record.dup.tap do |revision|
489
+ revision.assign_attributes(attributes)
490
+ revision.instance_variable_set(:@new_record, destroyed?)
491
+ revision.instance_variable_set(:@persisted, !destroyed?)
492
+ end
493
+ rescue ::NameError, ::NoMethodError
494
+ nil
495
+ end
496
+
497
+ private
498
+
499
+ def raise_readonly_error
500
+ raise ActiveVersion::ReadonlyVersionError,
501
+ "#{self.class.name} records are readonly once persisted"
502
+ end
503
+
504
+ def clear_rolled_back_audits
505
+ # Clear association cache if this audit was rolled back
506
+
507
+ auditable_column = ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :auditable)
508
+ auditable_record = send(auditable_column) if respond_to?(auditable_column)
509
+ auditable_record&.audits&.reset
510
+ rescue ::NameError, ::NoMethodError
511
+ # Association not set up yet or auditable doesn't have audits association
512
+ # This is fine - just skip clearing the cache
513
+ end
514
+
515
+ private
516
+
517
+ def action_value
518
+ self[self.class.audit_column_for(:action)]
519
+ end
520
+ end
521
+ end
522
+ end
@@ -0,0 +1,46 @@
1
+ module ActiveVersion
2
+ module Audits
3
+ module HasAudits
4
+ # Callback methods for audit creation
5
+ module AuditCallbacks
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def audit_create
11
+ write_audit(action: "create", audited_changes: audited_attributes,
12
+ comment: audit_comment, context: audit_context)
13
+ end
14
+
15
+ def audit_update
16
+ # Use audited_changes method (like audited gem) - this runs in before_update
17
+ # This matches the audited gem pattern - capture changes BEFORE they're saved
18
+ changes = audited_changes(exclude_readonly_attrs: true)
19
+
20
+ # Skip if no changes and no comment (unless update_with_comment_only is true)
21
+ # Match audited gem behavior: only skip if changes are empty AND comment is blank
22
+ return if changes.empty? && (audit_comment.blank? || audited_options[:update_with_comment_only] == false)
23
+
24
+ # Capture audit_context before it might be cleared
25
+ # Use the accessor method which reads @audit_context
26
+ write_audit(action: "update", audited_changes: changes,
27
+ comment: audit_comment, context: audit_context)
28
+ end
29
+
30
+ def audit_touch
31
+ unless (changes = audited_changes(for_touch: true, exclude_readonly_attrs: true)).empty?
32
+ write_audit(action: "update", audited_changes: changes,
33
+ comment: audit_comment)
34
+ end
35
+ end
36
+
37
+ def audit_destroy
38
+ unless new_record?
39
+ write_audit(action: "destroy", audited_changes: audited_attributes,
40
+ comment: audit_comment)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end