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,891 @@
1
+ require "securerandom"
2
+ require "active_version/audits/has_audits/change_filters"
3
+ require "active_version/audits/has_audits/audit_callbacks"
4
+ require "active_version/audits/has_audits/audit_writer"
5
+ require "active_version/audits/has_audits/audit_combiner"
6
+
7
+ module ActiveVersion
8
+ module Audits
9
+ # Concern for models that have audits
10
+ module HasAudits
11
+ extend ActiveSupport::Concern
12
+ include ChangeFilters
13
+ include AuditCallbacks
14
+ include AuditWriter
15
+ include AuditCombiner
16
+
17
+ REDACTED = "[REDACTED]"
18
+
19
+ included do
20
+ # Class methods
21
+ def self.audit_record?
22
+ false
23
+ end
24
+
25
+ # Get audit class name
26
+ def self.audit_class
27
+ # Check class attribute first (set by set_audit)
28
+ return superclass.audit_class if respond_to?(:superclass) && superclass.respond_to?(:audit_class) && superclass.audit_class
29
+ return @audit_class if @audit_class
30
+
31
+ # Check if class attribute was set via class_attribute
32
+ attr_value = read_inheritable_attribute(:audit_class) if respond_to?(:read_inheritable_attribute)
33
+ return attr_value if attr_value
34
+
35
+ if audited_options && audited_options[:as]
36
+ klass = case audited_options[:as]
37
+ when String, Symbol
38
+ audited_options[:as].to_s.safe_constantize
39
+ when Class
40
+ audited_options[:as]
41
+ end
42
+ apply_audit_table_name!(klass)
43
+ else
44
+ class_name = "#{name}Audit"
45
+ # Try safe_constantize first (searches global namespace)
46
+ audit_class = if Object.const_defined?(class_name)
47
+ Object.const_get(class_name)
48
+ else
49
+ class_name.safe_constantize
50
+ end
51
+ if audit_class
52
+ apply_audit_table_name!(audit_class)
53
+ elsif const_defined?(class_name, false)
54
+ apply_audit_table_name!(const_get(class_name))
55
+ else
56
+ # Default audit class (would be set in config)
57
+ nil
58
+ end
59
+ end
60
+ end
61
+
62
+ # Class attributes
63
+ class_attribute :audit_associated_with, instance_writer: false
64
+ class_attribute :audited_options, instance_writer: false
65
+ class_attribute :audit_class, instance_writer: false
66
+
67
+ # Instance attributes
68
+ attr_accessor :audit_version, :audit_comment, :audit_context
69
+
70
+ # Define callbacks
71
+ define_callbacks :audit
72
+ set_callback :audit, :after, :after_audit, if: lambda { respond_to?(:after_audit, true) }
73
+ set_callback :audit, :around, :around_audit, if: lambda { respond_to?(:around_audit, true) }
74
+ end
75
+
76
+ module ClassMethods
77
+ # Declare that a model has audits
78
+ def has_audits(options = {})
79
+ # For dynamically created classes, require class_name to be explicitly specified
80
+ is_dynamic = name.nil?
81
+ if is_dynamic && !options[:class_name]
82
+ raise ConfigurationError, "Dynamically created classes must specify class_name option. Example: has_audits as: PostAudit, class_name: 'Post'"
83
+ end
84
+ if is_dynamic
85
+ explicit_name = options[:class_name].to_s
86
+ define_singleton_method(:name) { explicit_name } if explicit_name.present? && name.nil?
87
+ end
88
+
89
+ # For dynamically created classes, always call set_audit to ensure callbacks are set up
90
+ # For regular classes, update options if already audited
91
+ if audited? && !is_dynamic
92
+ update_audited_options(options)
93
+ else
94
+ set_audit(options)
95
+ # Verify association was set up
96
+ unless reflect_on_association(:audits)
97
+ raise ConfigurationError, "has_audits failed to set up association for #{name || options[:class_name]}. Audit class should be: #{audit_class.inspect}"
98
+ end
99
+ end
100
+ end
101
+
102
+ # Check if model is audited (has been set up with has_audits)
103
+ def audited?
104
+ # Check if audited_options is set, which means set_audit has been called
105
+ audited_options.present?
106
+ end
107
+
108
+ # Disable auditing for a block
109
+ def without_auditing
110
+ auditing_was_enabled = class_auditing_enabled?
111
+ disable_auditing
112
+ yield
113
+ ensure
114
+ enable_auditing if auditing_was_enabled
115
+ end
116
+
117
+ # Enable auditing for a block
118
+ def with_auditing
119
+ auditing_was_enabled = class_auditing_enabled?
120
+ enable_auditing
121
+ yield
122
+ ensure
123
+ disable_auditing unless auditing_was_enabled
124
+ end
125
+
126
+ # Get revisions (reconstructed from audits)
127
+ def revisions(from_version = 1)
128
+ return [] unless audits.from_version(from_version).exists?
129
+
130
+ version_column = ActiveVersion.column_mapper.column_for(self, :audits, :version)
131
+ all_audits = audits.to_a
132
+ targeted_audits = all_audits.select do |audit|
133
+ audit.read_attribute(version_column).to_i >= from_version
134
+ end
135
+
136
+ previous_attributes = audit_class.reconstruct_attributes(all_audits - targeted_audits)
137
+
138
+ targeted_audits.map do |audit|
139
+ previous_attributes.merge!(audit.new_attributes)
140
+ revision_with(previous_attributes)
141
+ end
142
+ end
143
+
144
+ # Get revision at specific time
145
+ def revision_at(date_or_time)
146
+ time_obj = ActiveVersion.parse_time_to_time(date_or_time)
147
+ # Don't raise error for future times, just return nil (let HasRevisions handle it)
148
+ return nil if time_obj.future?
149
+
150
+ version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
151
+ # Reload audits to ensure we get fresh data from database
152
+ audits.reset if respond_to?(:audits) && audits.loaded?
153
+ # Query audits up to and including the time
154
+ # Use < instead of <= to exclude audits created exactly at the time (they represent state after that time)
155
+ # But we want to include audits created at or before the time, so we need to use <=
156
+ # Actually, we want audits created at or before the time, so <= is correct
157
+ audits_list = audits.where("created_at <= ?", time_obj).order(version_column => :asc).to_a
158
+ return nil if audits_list.empty?
159
+
160
+ revision_with audit_class.reconstruct_attributes(audits_list)
161
+ end
162
+
163
+ private
164
+
165
+ def set_audit(options)
166
+ normalized = normalize_audited_options(options)
167
+ # Store base value in instance variable FIRST, before setting class_attribute
168
+ # This ensures class_audited_options can find it
169
+ @audited_options_base = normalized.dup
170
+ self.audited_options = normalized
171
+
172
+ # Override audited_options to merge thread-local config
173
+ # class_attribute methods can't be easily overridden, so we need to use alias_method
174
+ unless respond_to?(:audited_options_without_thread_local, true)
175
+ alias_method :audited_options_without_thread_local, :audited_options
176
+ define_singleton_method :audited_options do
177
+ # Get base class-level options (without thread-local)
178
+ # Use send to call private method in correct context
179
+ class_level = send(:class_audited_options)
180
+ key = send(:audited_current_options_key)
181
+ thread_local = ActiveVersion.store_get(key)
182
+
183
+ # Start with class-level options (deep copy to avoid reference issues)
184
+ result = if class_level.is_a?(Hash)
185
+ class_level.deep_dup
186
+ else
187
+ {}
188
+ end
189
+
190
+ # Merge thread-local over class-level (thread-local takes precedence)
191
+ if thread_local.is_a?(Hash) && !thread_local.empty?
192
+ thread_local.each do |k, v|
193
+ result[k] = v
194
+ end
195
+ end
196
+
197
+ result
198
+ end
199
+ end
200
+
201
+ self.audit_associated_with = audited_options[:associated_with]
202
+
203
+ # Determine audit class
204
+ resolved_audit_class = if audited_options[:as]
205
+ case audited_options[:as]
206
+ when String, Symbol
207
+ audited_options[:as].to_s.safe_constantize
208
+ when Class
209
+ audited_options[:as]
210
+ end
211
+ else
212
+ # Try to construct class name from model name
213
+ class_name = "#{name}Audit"
214
+ # Try to find the class using safe_constantize first (searches global namespace)
215
+ # Use Object.const_get if defined, otherwise safe_constantize
216
+ audit_class = if Object.const_defined?(class_name)
217
+ Object.const_get(class_name)
218
+ else
219
+ class_name.safe_constantize
220
+ end
221
+
222
+ unless audit_class
223
+ # Try const_defined? with inherit=true to search parent classes
224
+ if const_defined?(class_name, true)
225
+ audit_class = const_get(class_name)
226
+ elsif name&.include?("::")
227
+ # Try to find in parent namespace (e.g., if Post is in a module)
228
+ parent_namespace = name.deconstantize
229
+ full_class_name = "#{parent_namespace}::#{class_name}"
230
+ audit_class = full_class_name.safe_constantize
231
+ end
232
+ end
233
+
234
+ unless audit_class
235
+ # Fall back to audit class based on table name when model name differs.
236
+ table_based_name = "#{table_name.to_s.classify}Audit"
237
+ audit_class = table_based_name.safe_constantize
238
+ end
239
+
240
+ audit_class
241
+ end
242
+
243
+ # Fall back to global default audit class when no model-specific audit class exists
244
+ if resolved_audit_class.nil? && ActiveVersion.config.respond_to?(:default_audit_class) && ActiveVersion.config.default_audit_class
245
+ default = ActiveVersion.config.default_audit_class
246
+ resolved_audit_class = case default
247
+ when String, Symbol then default.to_s.safe_constantize
248
+ when Class then default
249
+ end
250
+ end
251
+
252
+ unless resolved_audit_class
253
+ raise ConfigurationError, "No audit class found for #{name}. Please specify using :as option or create #{name}Audit. Tried: #{name}Audit"
254
+ end
255
+
256
+ # Set both class attribute and instance variable
257
+ apply_audit_table_name!(resolved_audit_class)
258
+ register_audit_column_mappings_from_destination(resolved_audit_class)
259
+ normalized = infer_audit_storage_and_columns(resolved_audit_class, normalized)
260
+ self.audited_options = normalized
261
+ @audited_options_base = normalized.dup
262
+ self.audit_class = resolved_audit_class
263
+ @audit_class = resolved_audit_class # Also set instance variable for the custom method
264
+
265
+ # Ensure audit class associations are set up
266
+ resolved_audit_class.setup_associations if resolved_audit_class.respond_to?(:setup_associations)
267
+ # Set up associations using explicit class_name to avoid class loading issues.
268
+ begin
269
+ has_many :audits,
270
+ as: :auditable,
271
+ class_name: resolved_audit_class.name.to_s,
272
+ inverse_of: false
273
+ rescue => e
274
+ raise ConfigurationError, "Failed to set up has_many association for #{name || normalized[:class_name] || "dynamically created class"}: #{e.class} - #{e.message}"
275
+ end
276
+
277
+ # Ensure the association is set up
278
+ unless reflect_on_association(:audits)
279
+ raise ConfigurationError, "Failed to set up audits association for #{name}. Audit class: #{resolved_audit_class.name}, resolved_audit_class: #{resolved_audit_class.inspect}, association found: #{reflect_on_association(:audits).inspect}"
280
+ end
281
+
282
+ # Register audit class
283
+ resolved_audit_class.add_audited_class(self)
284
+
285
+ # Register with version registry
286
+ ActiveVersion.registry.register(self, :audits, audited_options)
287
+ ActiveVersion.registry.register_version_class(self, :audits, resolved_audit_class)
288
+
289
+ # Set up callbacks
290
+ # Allow manual callback installation with on: [] or auto: false
291
+ auto = options.fetch(:auto, true)
292
+
293
+ if options[:on] == [] || auto == false
294
+ # User will install manually via audit_on_* methods
295
+ else
296
+ # Install callbacks automatically with conditional checks
297
+ if audited_options[:on].include?(:update)
298
+ before_update :audit_update, if: :should_audit?, prepend: true
299
+ end
300
+ if audited_options[:on].include?(:create)
301
+ after_create :audit_create, if: :should_audit?
302
+ end
303
+ if audited_options[:on].include?(:touch) && ::ActiveRecord::VERSION::MAJOR >= 6
304
+ after_touch :audit_touch, if: :should_audit?
305
+ end
306
+ if audited_options[:on].include?(:destroy)
307
+ before_destroy :audit_destroy, if: :should_audit?
308
+ end
309
+ end
310
+
311
+ # Add rollback handling
312
+ after_rollback :clear_rolled_back_audits
313
+
314
+ # Comment required validation
315
+ if audited_options[:comment_required]
316
+ validate :presence_of_audit_comment
317
+ before_destroy :require_comment if audited_options[:on].include?(:destroy)
318
+ end
319
+
320
+ enable_auditing
321
+ end
322
+
323
+ # Override audited_options to merge thread-local config
324
+ # This overrides the class_attribute reader to merge thread-local overrides
325
+ def update_audited_options(new_options)
326
+ normalized = normalize_audited_options(new_options)
327
+ resolved_audit_class = audit_class
328
+ register_audit_column_mappings_from_destination(resolved_audit_class) if resolved_audit_class
329
+ normalized = infer_audit_storage_and_columns(resolved_audit_class, normalized) if resolved_audit_class
330
+ self.audited_options = normalized
331
+ # Store base value in instance variable for class_audited_options to access
332
+ @audited_options_base = normalized.dup
333
+ self.audit_associated_with = audited_options[:associated_with]
334
+ end
335
+
336
+ def normalize_audited_options(options)
337
+ {
338
+ on: Array.wrap(options[:on] || [:create, :update, :destroy]),
339
+ only: options.key?(:only) ? Array.wrap(options[:only]).map(&:to_s) : nil,
340
+ except: Array.wrap(options[:except] || []).map(&:to_s),
341
+ max_audits: options[:max_audits],
342
+ redacted: Array.wrap(options[:redacted] || []).map(&:to_s),
343
+ redaction_value: options[:redaction_value] || REDACTED,
344
+ associated_with: options[:associated_with],
345
+ if: options[:if],
346
+ unless: options[:unless],
347
+ auto: options.fetch(:auto, true),
348
+ comment_required: options[:comment_required] || false,
349
+ identity_resolver: options[:identity_resolver],
350
+ identity_columns: normalize_identity_columns(options[:identity_columns]),
351
+ storage: options.key?(:storage) ? options[:storage] : nil,
352
+ as: options[:as],
353
+ class_name: options[:class_name], # For dynamically created classes, specify the class name to use
354
+ error_behavior: options[:error_behavior],
355
+ table_name: options[:table_name]
356
+ }
357
+ end
358
+
359
+ def normalize_identity_columns(value)
360
+ return nil if value.nil?
361
+ return value.map(&:to_s) if value.is_a?(Array)
362
+
363
+ value.to_s
364
+ end
365
+
366
+ public
367
+
368
+ def with_audited_options(options = {})
369
+ thread_key = audited_current_options_key
370
+ current = ActiveVersion.store_get(thread_key)
371
+ # Store only the thread-local overrides (merge with existing if any)
372
+ # Only normalize the provided keys, don't set defaults for missing keys
373
+ # Normalize options - convert to hash and process each key
374
+ # Use paper_trail's simple pattern: options.to_h.each
375
+ normalized = {}
376
+ # Convert options to hash (paper_trail pattern: simple to_h call)
377
+ # Handle both Hash and objects that respond to to_h
378
+ opts_hash = if options.respond_to?(:to_h)
379
+ options.to_h
380
+ elsif options.is_a?(Hash)
381
+ options
382
+ else
383
+ {}
384
+ end
385
+
386
+ opts_hash.each do |k, v|
387
+ next if v.nil?
388
+ key = k.is_a?(Symbol) ? k : k.to_sym
389
+ # Normalize based on key type
390
+ normalized[key] = case key
391
+ when :only, :except, :redacted
392
+ Array.wrap(v).map(&:to_s)
393
+ when :on
394
+ Array.wrap(v)
395
+ when :max_audits, :redaction_value, :associated_with, :if, :unless, :auto, :comment_required, :storage, :as, :error_behavior
396
+ v
397
+ when :identity_columns
398
+ normalize_identity_columns(v)
399
+ else
400
+ # Allow any other keys to pass through (for extensibility)
401
+ v
402
+ end
403
+ end
404
+
405
+ # Merge normalized options with existing thread-local overrides
406
+ # paper_trail pattern: merge into existing, then set
407
+ thread_local_overrides = (current || {}).dup
408
+ thread_local_overrides.merge!(normalized)
409
+ # Set thread-local value - ensure it's a hash so it can be read back
410
+ # Store the merged overrides in Thread.current (use dup to avoid reference issues)
411
+ ActiveVersion.store_set(thread_key, thread_local_overrides.is_a?(Hash) ? thread_local_overrides.dup : {})
412
+ yield
413
+ ensure
414
+ ActiveVersion.store_set(thread_key, current)
415
+ end
416
+
417
+ def instance_methods(all = true)
418
+ methods = super
419
+ methods -= [:audit_revision, :audit_revision_at]
420
+ methods
421
+ end
422
+
423
+ private
424
+
425
+ # Get the base class_attribute value without thread-local merging
426
+ def class_audited_options
427
+ # Try to get from instance variable first (most direct)
428
+ if instance_variable_defined?(:@audited_options_base)
429
+ value = instance_variable_get(:@audited_options_base)
430
+ return value.dup if value&.is_a?(Hash)
431
+ end
432
+ # If not set, try superclass
433
+ if respond_to?(:superclass) && superclass
434
+ if superclass.instance_variable_defined?(:@audited_options_base)
435
+ value = superclass.instance_variable_get(:@audited_options_base)
436
+ if value&.is_a?(Hash)
437
+ # Store it for future use
438
+ @audited_options_base = value.dup
439
+ return value.dup
440
+ end
441
+ end
442
+ # Try calling superclass method if it exists
443
+ if superclass.respond_to?(:class_audited_options, true)
444
+ value = superclass.send(:class_audited_options)
445
+ if value&.is_a?(Hash)
446
+ # Store it for future use
447
+ @audited_options_base = value.dup
448
+ return value.dup
449
+ end
450
+ end
451
+ end
452
+ # Return empty hash if nothing found
453
+ # This ensures merge works correctly even if base is not set
454
+ {}
455
+ end
456
+
457
+ def audited_current_options_key
458
+ # Use a consistent key format for thread-local storage
459
+ # This key must match between with_audited_options and audited_options
460
+ # For dynamically created classes, use class_name from options (avoid recursion by checking @audited_options_base)
461
+ class_name = if instance_variable_defined?(:@audited_options_base) && @audited_options_base.is_a?(Hash)
462
+ @audited_options_base[:class_name] || name
463
+ else
464
+ name
465
+ end
466
+ if class_name.nil?
467
+ class_name = "dynamic_#{object_id}"
468
+ end
469
+ "active_version_#{class_name}_audited_options"
470
+ end
471
+
472
+ def class_auditing_enabled?
473
+ @class_auditing_enabled != false
474
+ end
475
+ public :class_auditing_enabled?
476
+
477
+ def disable_auditing
478
+ @class_auditing_enabled = false
479
+ end
480
+
481
+ def enable_auditing
482
+ @class_auditing_enabled = true
483
+ end
484
+
485
+ def apply_audit_table_name!(klass)
486
+ return klass unless klass&.respond_to?(:table_name=)
487
+ return klass unless audited_options && audited_options[:table_name]
488
+
489
+ klass.table_name = audited_options[:table_name].to_s
490
+ klass
491
+ end
492
+
493
+ def register_audit_column_mappings_from_destination(audit_klass)
494
+ return unless audit_klass.respond_to?(:audit_column_for)
495
+
496
+ %i[action changes context comment version user auditable associated remote_address request_uuid].each do |concept|
497
+ column = audit_klass.audit_column_for(concept)
498
+ next if column.nil?
499
+ ActiveVersion.column_mapper.register(self, :audits, concept, column)
500
+ end
501
+ end
502
+
503
+ def infer_audit_storage_and_columns(audit_klass, options)
504
+ inferred = options.dup
505
+ changes_column = ActiveVersion.column_mapper.column_for(self, :audits, :changes).to_s
506
+
507
+ begin
508
+ explicit_storage = if audit_klass.respond_to?(:active_version_audit_schema)
509
+ (audit_klass.active_version_audit_schema || {})[:storage]
510
+ end
511
+ inferred[:storage] ||= explicit_storage&.to_sym
512
+ inferred[:storage] ||= audit_klass.column_names.include?(changes_column) ? ActiveVersion.config.audit_storage : :mirror_columns
513
+
514
+ if inferred[:storage].to_sym == :mirror_columns && inferred[:only].nil?
515
+ inferred[:only] = infer_table_audited_columns(audit_klass)
516
+ else
517
+ inferred[:only] ||= []
518
+ end
519
+ rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
520
+ inferred[:storage] ||= ActiveVersion.config.audit_storage
521
+ inferred[:only] ||= []
522
+ end
523
+
524
+ inferred
525
+ end
526
+
527
+ def infer_table_audited_columns(audit_klass)
528
+ model_columns = column_names.map(&:to_s)
529
+ audit_columns = audit_klass.column_names.map(&:to_s)
530
+ intersection = model_columns & audit_columns
531
+
532
+ metadata_columns = [
533
+ "id", "created_at", "updated_at",
534
+ inheritance_column.to_s,
535
+ primary_key.to_s,
536
+ ActiveVersion.column_mapper.column_for(self, :audits, :changes).to_s,
537
+ ActiveVersion.column_mapper.column_for(self, :audits, :context).to_s,
538
+ ActiveVersion.column_mapper.column_for(self, :audits, :action).to_s,
539
+ ActiveVersion.column_mapper.column_for(self, :audits, :version).to_s,
540
+ ActiveVersion.column_mapper.column_for(self, :audits, :comment).to_s,
541
+ ActiveVersion.column_mapper.column_for(self, :audits, :request_uuid).to_s,
542
+ ActiveVersion.column_mapper.column_for(self, :audits, :remote_address).to_s
543
+ ]
544
+
545
+ intersection - metadata_columns - ActiveVersion.config.ignored_attributes.map(&:to_s)
546
+ end
547
+
548
+ # Manual callback installation methods
549
+ def audit_on_create
550
+ after_create :audit_create
551
+ end
552
+
553
+ def audit_on_update
554
+ before_update :audit_update, if: :should_audit?, prepend: true
555
+ end
556
+
557
+ def audit_on_destroy
558
+ before_destroy :audit_destroy, if: :should_audit?
559
+ end
560
+
561
+ def audit_on_touch
562
+ after_touch :audit_touch if ::ActiveRecord::VERSION::MAJOR >= 6
563
+ end
564
+
565
+ public :audit_on_create, :audit_on_update, :audit_on_destroy, :audit_on_touch
566
+
567
+ def revision_with(attributes, id: nil)
568
+ # Create a new instance with reconstructed attributes
569
+ # This ensures we start with a clean slate
570
+ attrs_to_assign = attributes.except(:audit_version).stringify_keys
571
+
572
+ # Filter out deleted columns
573
+ attrs_to_assign.slice!(*column_names)
574
+
575
+ revision = new
576
+ revision.assign_attributes(attrs_to_assign)
577
+
578
+ # Set id and persisted state after attributes are set
579
+ revision.id = id if id
580
+ revision.instance_variable_set(:@new_record, false)
581
+ revision.instance_variable_set(:@persisted, true)
582
+
583
+ # Mark as readonly to prevent database reads and ensure attributes stay in memory
584
+ revision.readonly!
585
+
586
+ # Ensure attributes are in the @attributes hash and not being read from DB
587
+ # Clear any cached values that might trigger database reads
588
+ revision.instance_variable_set(:@attributes_cache, {})
589
+ revision.clear_changes_information
590
+
591
+ # Clear association proxies to prevent stale references
592
+ clear_association_proxies(revision)
593
+
594
+ revision
595
+ end
596
+ public :revision_with
597
+
598
+ def clear_association_proxies(revision)
599
+ revision.instance_variables.each do |ivar|
600
+ proxy = revision.instance_variable_get(ivar)
601
+ if !proxy.nil? && proxy.respond_to?(:proxy_respond_to?)
602
+ revision.instance_variable_set(ivar, nil)
603
+ end
604
+ end
605
+ end
606
+ end
607
+
608
+ # Get revision at specific version (from audits)
609
+ # This method is separate from HasRevisions#revision to avoid conflicts
610
+ def audit_revision(version: nil)
611
+ return nil unless version
612
+
613
+ # Get all audits up to and including the specified version
614
+ version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
615
+ audits_list = audits.where("#{version_column} <= ?", version).order(version_column => :asc).to_a
616
+ return nil if audits_list.empty?
617
+
618
+ self.class.revision_with audit_class.reconstruct_attributes(audits_list), id: id
619
+ end
620
+
621
+ # Get revision at specific time (from audits)
622
+ # This method is separate from HasRevisions#revision_at to avoid conflicts
623
+ def audit_revision_at(date_or_time)
624
+ time_obj = ActiveVersion.parse_time_to_time(date_or_time)
625
+ # Always raise error for future times
626
+ raise ActiveVersion::FutureTimeError, "Future state cannot be known" if time_obj.future?
627
+
628
+ version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
629
+ audits_list = audits.up_until(time_obj).order(version_column => :asc).to_a
630
+ # If no audits found for the time, return the earliest audit if it exists (for times before creation)
631
+ if audits_list.empty?
632
+ earliest_audit = audits.order(version_column => :asc).first
633
+ return nil unless earliest_audit
634
+ audits_list = [earliest_audit]
635
+ end
636
+
637
+ self.class.revision_with audit_class.reconstruct_attributes(audits_list), id: id
638
+ end
639
+
640
+ # Generate SQL for single audit insert
641
+ def audit_sql(destroy: false)
642
+ # Allow SQL generation even if no changes (for testing/documentation purposes)
643
+ # In production, this would typically only be called when there are changes
644
+
645
+ action = if new_record?
646
+ "create"
647
+ elsif destroy
648
+ "destroy"
649
+ else
650
+ "update"
651
+ end
652
+
653
+ attrs = {
654
+ action: action,
655
+ audited_changes: audited_changes,
656
+ comment: audit_comment
657
+ }
658
+ attrs[:associated] = send(audit_associated_with) unless audit_associated_with.nil?
659
+
660
+ # Build attributes for SQL generation (avoid dangerous attribute error)
661
+ changes_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :changes)
662
+ context_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :context)
663
+ auditable_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :auditable)
664
+ version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
665
+ comment_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :comment)
666
+
667
+ # Build changes hash manually to avoid dangerous attribute error
668
+ changes = {
669
+ action: attrs[:action]
670
+ }
671
+ if audit_class.column_names.include?(changes_column.to_s)
672
+ changes[changes_column] = attrs[:audited_changes]
673
+ elsif audited_options[:storage].to_sym == :mirror_columns
674
+ audited_attributes.each do |attr, value|
675
+ next unless audit_class.column_names.include?(attr.to_s)
676
+
677
+ changes[attr.to_sym] = value
678
+ end
679
+ end
680
+ changes[comment_column] = attrs[:comment] if attrs[:comment].present?
681
+ changes[context_column] = attrs[:audited_context] if attrs[:audited_context].present?
682
+ changes.merge!(active_version_audit_identity_map)
683
+ changes["#{auditable_column}_type"] = self.class.name
684
+ changes[version_column] = (attrs[:action] == "create") ? 1 : (audits.maximum(version_column) || 0) + 1
685
+ changes[:created_at] = Time.current
686
+ changes[:updated_at] = Time.current
687
+
688
+ # Prepare SQL-safe values
689
+ changes = prepare_sql_values(changes)
690
+ changes["created_at"] ||= Time.current
691
+
692
+ # Build SQL using Arel
693
+ stmt = Arel::InsertManager.new
694
+ table = Arel::Table.new(audit_class.table_name)
695
+ stmt.into(table)
696
+ changes.keys.each { |key| stmt.columns << table[key] }
697
+ stmt.values = stmt.create_values(changes.values)
698
+ sql = stmt.to_sql
699
+
700
+ # Instrument SQL generation
701
+ ActiveVersion::Instrumentation.instrument_audit_sql_generated(self, sql)
702
+
703
+ sql
704
+ end
705
+
706
+ # Get own and associated audits
707
+ def own_and_associated_audits
708
+ audit_class.unscoped.where(auditable: self)
709
+ .or(audit_class.unscoped.where(associated: self))
710
+ .order(created_at: :desc)
711
+ end
712
+
713
+ # Temporarily disable auditing
714
+ def without_auditing(&block)
715
+ self.class.without_auditing(&block)
716
+ end
717
+
718
+ # Temporarily enable auditing
719
+ def with_auditing(&block)
720
+ self.class.with_auditing(&block)
721
+ end
722
+
723
+ private
724
+
725
+ def presence_of_audit_comment
726
+ if comment_required_state?
727
+ errors.add(:audit_comment, :blank) if audit_comment.blank?
728
+ end
729
+ end
730
+
731
+ def comment_required_state?
732
+ auditing_enabled &&
733
+ audited_changes.present? &&
734
+ ((audited_options[:on].include?(:create) && new_record?) ||
735
+ (audited_options[:on].include?(:update) && persisted? && changed?))
736
+ end
737
+
738
+ def require_comment
739
+ if auditing_enabled && audit_comment.blank?
740
+ errors.add(:audit_comment, :blank)
741
+ throw(:abort)
742
+ end
743
+ end
744
+
745
+ public
746
+
747
+ def should_audit?
748
+ # Check class-level enabled state
749
+ return false unless self.class.class_auditing_enabled?
750
+
751
+ # Check global enabled state
752
+ return false unless ActiveVersion.auditing_enabled
753
+
754
+ # Check if/unless conditions
755
+ return false unless run_conditional_check(audited_options[:if])
756
+ return false unless run_conditional_check(audited_options[:unless], matching: false)
757
+
758
+ true
759
+ end
760
+
761
+ def auditing_enabled
762
+ should_audit?
763
+ end
764
+
765
+ def clear_rolled_back_audits
766
+ audits.reset
767
+ end
768
+
769
+ # Override audits method to handle dynamically created classes
770
+ # Uses class_name from options if provided
771
+ # Returns standard ActiveRecord relation
772
+ # Use active_audits for filtered results
773
+ def audits
774
+ # Use class_name from options if provided (for dynamically created classes)
775
+ auditable_type = audited_options[:class_name] || self.class.name
776
+ if auditable_type.nil?
777
+ raise ConfigurationError, "Cannot determine class name for dynamically created class. Please specify class_name option in has_audits (e.g., has_audits as: PostAudit, class_name: 'Post')"
778
+ end
779
+
780
+ # If class_name is different from actual class name, query directly
781
+ uses_custom_auditable_id = audited_options[:identity_resolver].present? ||
782
+ Array(active_version_audit_identity_columns).length > 1
783
+ if auditable_type != self.class.name || uses_custom_auditable_id
784
+ auditable_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :auditable)
785
+ version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
786
+ self.class.audit_class.where({"#{auditable_column}_type" => auditable_type}.merge(active_version_audit_identity_map))
787
+ .order(version_column => :asc)
788
+ else
789
+ # Use normal association for classes with proper names
790
+ super
791
+ end
792
+ end
793
+
794
+ def active_version_auditable_id_value
795
+ values = active_version_audit_identity_values
796
+ return values.values.first if values.is_a?(Hash) && values.size == 1
797
+ return values.first if values.is_a?(Array) && values.size == 1
798
+
799
+ values
800
+ end
801
+
802
+ def active_version_audit_identity_columns
803
+ auditable_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :auditable).to_s
804
+ configured = self.class.audited_options && self.class.audited_options[:identity_columns]
805
+ Array(configured.presence || "#{auditable_column}_id").map(&:to_s)
806
+ end
807
+
808
+ def active_version_audit_identity_map
809
+ columns = active_version_audit_identity_columns
810
+ values = active_version_audit_identity_values
811
+
812
+ case values
813
+ when Hash
814
+ values.transform_keys(&:to_s).slice(*columns)
815
+ when Array
816
+ columns.zip(values).to_h
817
+ else
818
+ {columns.first => values}
819
+ end
820
+ end
821
+
822
+ def active_version_audit_identity_values
823
+ resolver = self.class.audited_options && self.class.audited_options[:identity_resolver]
824
+ return default_audit_identity_values if resolver.nil?
825
+
826
+ case resolver
827
+ when Proc
828
+ resolver.arity.zero? ? instance_exec(&resolver) : resolver.call(self)
829
+ when Array
830
+ resolver.map { |column| public_send(column) }
831
+ else
832
+ public_send(resolver)
833
+ end
834
+ end
835
+
836
+ def default_audit_identity_values
837
+ columns = active_version_audit_identity_columns
838
+ return id if columns.one?
839
+
840
+ Array(self.class.primary_key).map { |column| self[column] }
841
+ end
842
+
843
+ # Get active audits (excludes combined ones - those with empty changes)
844
+ # Filters in Ruby for database-agnostic behavior
845
+ def active_audits
846
+ changes_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :changes)
847
+ return audits.to_a unless audit_class.column_names.include?(changes_column.to_s)
848
+
849
+ audits.to_a.reject do |audit|
850
+ # Check raw column value first (before JSON parsing)
851
+ # Combined audits have their changes set to "{}" (empty JSON object as string)
852
+ raw_changes = audit.read_attribute(changes_column)
853
+
854
+ # If raw value is "{}", it's a combined audit
855
+ if raw_changes.is_a?(String) && raw_changes.strip == "{}"
856
+ true
857
+ else
858
+ # Otherwise check parsed value
859
+ changes = audit.audited_changes
860
+ changes.nil? || (changes.is_a?(Hash) && changes.empty?) || (changes.is_a?(String) && changes.strip.empty?)
861
+ end
862
+ end
863
+ end
864
+
865
+ def run_conditional_check(condition, matching: true)
866
+ return true if condition.blank?
867
+ return condition.call(self) == matching if condition.respond_to?(:call)
868
+ return send(condition) == matching if respond_to?(condition.to_sym, true)
869
+
870
+ true
871
+ end
872
+
873
+ def revision_with(attributes)
874
+ # Create a new instance with reconstructed attributes
875
+ # Keep it as a new record to prevent database reads
876
+ attrs_to_assign = attributes.except(:audit_version).stringify_keys
877
+ revision = self.class.new(attrs_to_assign)
878
+
879
+ # Set id but keep as new_record to prevent database reads
880
+ revision.id = id
881
+ revision.instance_variable_set(:@new_record, true)
882
+ revision.instance_variable_set(:@persisted, false)
883
+
884
+ # Mark as readonly to prevent modifications
885
+ revision.readonly!
886
+
887
+ revision
888
+ end
889
+ end
890
+ end
891
+ end