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,212 @@
1
+ module ActiveVersion
2
+ module Audits
3
+ module HasAudits
4
+ # Methods for combining audits
5
+ module AuditCombiner
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def combine_audits_if_needed
11
+ max_audits = evaluate_max_audits
12
+ return unless max_audits && max_audits > 0
13
+ changes_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :changes)
14
+ return unless self.class.audit_class.column_names.include?(changes_column.to_s)
15
+
16
+ # Keep combining until we're under the limit
17
+ # This handles cases where multiple combinations are needed
18
+ max_iterations = 10 # Safety limit to prevent infinite loops
19
+ iteration = 0
20
+
21
+ loop do
22
+ iteration += 1
23
+ break if iteration > max_iterations
24
+
25
+ # Force reload from database to see any updates
26
+ if persisted?
27
+ reload
28
+ end
29
+
30
+ # Clear association cache to ensure we get fresh data from database
31
+ if respond_to?(:audits)
32
+ audits.reset
33
+ if respond_to?(:association)
34
+ assoc = association(:audits)
35
+ assoc.reset if assoc.respond_to?(:loaded?) && assoc.loaded?
36
+ end
37
+ end
38
+
39
+ # Get all audits fresh from database (not from cache)
40
+ # Query directly to ensure we get updated values after SQL updates
41
+ auditable_type = audited_options[:class_name] || self.class.name
42
+ if auditable_type != self.class.name
43
+ auditable_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :auditable)
44
+ version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
45
+ all_audits = self.class.audit_class.where({"#{auditable_column}_type" => auditable_type}.merge(active_version_audit_identity_map))
46
+ .order(version_column => :asc)
47
+ .to_a
48
+ else
49
+ # Use association but ensure it's not cached
50
+ all_audits = audits.reload.to_a
51
+ end
52
+
53
+ # Filter out combined audits (those with empty changes)
54
+ # Check raw column value first (before JSON parsing) for "{}" string
55
+ active_audits = all_audits.reject do |audit|
56
+ # Check raw column value first - combined audits have "{}" as string
57
+ raw_changes = audit.read_attribute(changes_column)
58
+
59
+ # If raw value is exactly "{}", it's a combined audit
60
+ if raw_changes.is_a?(String) && raw_changes.strip == "{}"
61
+ true
62
+ else
63
+ # Otherwise check parsed value
64
+ changes = audit.audited_changes
65
+ changes.nil? || (changes.is_a?(Hash) && changes.empty?) || (changes.is_a?(String) && changes.strip.empty?)
66
+ end
67
+ end
68
+
69
+ audits_count = active_audits.length
70
+ break if audits_count <= max_audits
71
+
72
+ # Calculate how many extra audits we have
73
+ extra_count = audits_count - max_audits
74
+
75
+ # Get the oldest active audits to combine (first extra_count + 1)
76
+ # The +1 is because we'll merge into the last one in this set
77
+ audits_to_combine = active_audits.first(extra_count + 1)
78
+
79
+ # Safety check to prevent infinite loops
80
+ break if audits_to_combine.empty? || audits_to_combine.length <= 1
81
+
82
+ # Combine them (this will merge into the last audit and mark older ones as combined)
83
+ combine_audits(audits_to_combine)
84
+ end
85
+ end
86
+
87
+ def evaluate_max_audits
88
+ max_audits = case (option = audited_options[:max_audits])
89
+ when Proc then option.call
90
+ when Symbol
91
+ # Try instance method first, then class method
92
+ if respond_to?(option, true)
93
+ send(option)
94
+ elsif self.class.respond_to?(option, true)
95
+ self.class.send(option)
96
+ end
97
+ else
98
+ option
99
+ end
100
+
101
+ max_audits ||= ActiveVersion.config.max_audits
102
+ Integer(max_audits).abs if max_audits
103
+ end
104
+
105
+ def combine_audits(audits_to_combine)
106
+ return if audits_to_combine.empty?
107
+
108
+ # Ensure we have an array (might be a relation or array)
109
+ audits_array = audits_to_combine.is_a?(Array) ? audits_to_combine : audits_to_combine.to_a
110
+ return if audits_array.empty?
111
+
112
+ combine_target = audits_array.last
113
+ audit_class = combine_target.class
114
+ version_column = ActiveVersion.column_mapper.column_for(combine_target.class.source_class, :audits, :version)
115
+ changes_column = ActiveVersion.column_mapper.column_for(combine_target.class.source_class, :audits, :changes)
116
+ return unless audit_class.column_names.include?(changes_column.to_s)
117
+ context_column = ActiveVersion.column_mapper.column_for(combine_target.class.source_class, :audits, :context)
118
+
119
+ # Get changes from each audit - use read_attribute to get raw JSON, then parse
120
+ all_changes = audits_array.map do |a|
121
+ value = a.read_attribute(changes_column)
122
+ # Parse JSON if it's a string
123
+ if value.is_a?(String)
124
+ begin
125
+ JSON.parse(value)
126
+ rescue JSON::ParserError
127
+ {}
128
+ end
129
+ else
130
+ value || {}
131
+ end
132
+ end
133
+
134
+ # Merge all changes
135
+ combined_changes = all_changes.reduce({}) { |acc, changes| acc.merge(changes) }
136
+
137
+ # Get contexts from each audit
138
+ all_contexts = audits_array.map do |a|
139
+ value = a.read_attribute(context_column)
140
+ if value.is_a?(String)
141
+ begin
142
+ JSON.parse(value)
143
+ rescue JSON::ParserError
144
+ {}
145
+ end
146
+ else
147
+ value || {}
148
+ end
149
+ end.compact
150
+
151
+ # Merge contexts
152
+ combined_context = all_contexts.reduce({}) { |acc, ctx| acc.merge(ctx) } if all_contexts.any?
153
+
154
+ combine_target_version = combine_target.read_attribute(version_column)
155
+
156
+ # Update combine target and mark old audits as combined (no deletion - safer for audit logs)
157
+ audits_to_mark_combined = audits_array[0..-2] # All except the target
158
+
159
+ # Update combine target with merged changes using raw SQL to bypass readonly
160
+ conn = audit_class.connection
161
+ table_name = audit_class.table_name
162
+ updates = []
163
+ updates << "#{conn.quote_column_name(changes_column)} = #{conn.quote(combined_changes.to_json)}"
164
+ if combined_context.any?
165
+ updates << "#{conn.quote_column_name(context_column)} = #{conn.quote(combined_context.to_json)}"
166
+ end
167
+ target_id = conn.quote(combine_target.read_attribute(:id))
168
+ sql = "UPDATE #{conn.quote_table_name(table_name)} SET #{updates.join(", ")} WHERE id = #{target_id}"
169
+ conn.execute(sql)
170
+
171
+ # Mark old audits as combined (no deletion - safer for audit logs)
172
+ if audits_to_mark_combined.any?
173
+ comment_column = ActiveVersion.column_mapper.column_for(combine_target.class.source_class, :audits, :comment)
174
+ combined_comment = "[COMBINED] This audit was merged into version #{combine_target_version}"
175
+
176
+ # Use direct SQL update to bypass readonly enforcement and ensure updates work
177
+ audit_ids = audits_to_mark_combined.map { |a| a.read_attribute(:id) }
178
+
179
+ if audit_ids.any?
180
+ # Use raw SQL with proper escaping to bypass ActiveRecord's readonly checks
181
+ conn = audit_class.connection
182
+ table_name = audit_class.table_name
183
+ empty_json_str = {}.to_json # This is "{}"
184
+ empty_json = conn.quote(empty_json_str)
185
+ quoted_comment = conn.quote(combined_comment)
186
+
187
+ # Update all audits in a single SQL statement for efficiency
188
+ id_list = audit_ids.map { |id| conn.quote(id) }.join(",")
189
+ sql = "UPDATE #{conn.quote_table_name(table_name)} SET #{conn.quote_column_name(changes_column)} = #{empty_json}, #{conn.quote_column_name(comment_column)} = #{quoted_comment} WHERE id IN (#{id_list})"
190
+
191
+ # Execute the update
192
+ conn.execute(sql)
193
+ end
194
+ end
195
+
196
+ # Clear association cache to ensure fresh data is loaded after updates
197
+ if respond_to?(:audits)
198
+ # Clear the association cache
199
+ audits.reset
200
+ # Also clear any loaded association state
201
+ if respond_to?(:association)
202
+ assoc = association(:audits)
203
+ if assoc.respond_to?(:loaded?) && assoc.loaded?
204
+ assoc.reset
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,282 @@
1
+ module ActiveVersion
2
+ module Audits
3
+ module HasAudits
4
+ # Methods for writing audits to the database
5
+ module AuditWriter
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def write_audit(attrs)
11
+ # Capture comment and context before clearing them
12
+ # Use provided context from attrs if available (captured in before_update), otherwise use current
13
+ # Check if context was explicitly passed before deleting
14
+ context_was_provided = attrs.key?(:context)
15
+ provided_context = attrs.delete(:context)
16
+ provided_comment = attrs.delete(:comment)
17
+ captured_comment = provided_comment || audit_comment
18
+
19
+ # Merge instance context with global context (instance context takes precedence)
20
+ # Convert keys to strings for consistency
21
+ block_context = ActiveVersion.store_get(:active_version_block_context)
22
+ global_context = if block_context.is_a?(Hash)
23
+ persistent_context = (ActiveVersion.store_get(:active_version_persistent_context) || {}).stringify_keys
24
+ persistent_context.merge(block_context.stringify_keys)
25
+ else
26
+ (ActiveVersion.context || {}).stringify_keys
27
+ end
28
+ # Use provided context if available, otherwise use current audit_context
29
+ # Handle both Hash and other types
30
+ # If context was explicitly provided (even if nil), use it; otherwise fall back to audit_context
31
+ raw_instance_context = if context_was_provided
32
+ provided_context # Could be nil, hash, or other
33
+ else
34
+ audit_context # Fall back to instance variable
35
+ end
36
+ # Normalize instance context to a hash with string keys
37
+ instance_context = if raw_instance_context.nil?
38
+ {}
39
+ elsif raw_instance_context.is_a?(Hash)
40
+ raw_instance_context.stringify_keys
41
+ else
42
+ {}
43
+ end
44
+ # Merge: global context first, then instance context (instance takes precedence)
45
+ captured_context = global_context.dup.merge(instance_context)
46
+
47
+ self.audit_comment = nil
48
+ self.audit_context = nil
49
+
50
+ if auditing_enabled
51
+ attrs[:associated] = send(audit_associated_with) unless audit_associated_with.nil?
52
+
53
+ # Use captured values if not explicitly provided
54
+ attrs[:comment] ||= captured_comment
55
+ # Always set audited_context if it has any values or if context was explicitly provided
56
+ # This ensures instance context is merged with global context even if global context is empty
57
+ # Set it if we have any context (global or instance) or if context was explicitly provided
58
+ if captured_context.present? || context_was_provided || global_context.present?
59
+ attrs[:audited_context] = captured_context
60
+ end
61
+
62
+ run_callbacks(:audit) do
63
+ # Build insert attributes
64
+ changes_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :changes)
65
+ context_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :context)
66
+ auditable_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :auditable)
67
+ version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
68
+ batch_state = ActiveVersion.store_get(:active_version_audit_batch_state)
69
+ batch_capture_active = batch_state.is_a?(Hash) && batch_state[:target_audit_class] == audit_class
70
+
71
+ insert_attrs = {}
72
+
73
+ # Map action
74
+ insert_attrs[:action] = attrs[:action]
75
+
76
+ # Map audited_changes
77
+ changes_column_exists = audit_class.column_names.include?(changes_column.to_s)
78
+ if changes_column_exists
79
+ insert_attrs[changes_column] = attrs[:audited_changes] || attrs[changes_column]
80
+ end
81
+
82
+ # For structured table storage, mirror audited fields into dedicated columns.
83
+ if audited_options[:storage].to_sym == :mirror_columns
84
+ # Prefer callback-provided payload so update events use explicit old/new
85
+ # pairs (we persist the "new" side for table columns).
86
+ structured_payload = attrs[:audited_changes] || attrs[changes_column]
87
+ # Fallback for create/destroy or custom flows that do not pass payload.
88
+ structured_payload ||= audited_attributes
89
+ map_structured_audit_columns(insert_attrs, structured_payload)
90
+ copy_shared_source_columns(insert_attrs)
91
+ end
92
+
93
+ # Map comment
94
+ comment_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :comment)
95
+ insert_attrs[comment_column] = attrs[:comment] if attrs[:comment].present?
96
+
97
+ # Map audited_context
98
+ if attrs[:audited_context].present? || attrs[context_column].present?
99
+ insert_attrs[context_column] = attrs[:audited_context] || attrs[context_column]
100
+ end
101
+ if batch_capture_active
102
+ batch_context = batch_state.dig(:options, :context)
103
+ if batch_context.is_a?(Hash)
104
+ merged_context = insert_attrs[context_column]
105
+ merged_context = {} unless merged_context.is_a?(Hash)
106
+ insert_attrs[context_column] = merged_context.merge(batch_context.stringify_keys)
107
+ end
108
+ end
109
+
110
+ # Set polymorphic association
111
+ # In before_update, the record should already have an ID
112
+ # But ensure we have a valid ID before creating the audit
113
+ identity_map = active_version_audit_identity_map
114
+ return nil if identity_map.values.any?(&:nil?) # Skip until identity is fully available
115
+
116
+ insert_attrs.merge!(identity_map)
117
+ # Use class_name from options if provided (for dynamically created classes)
118
+ # Otherwise use the actual class name
119
+ auditable_type = audited_options[:class_name] || self.class.name
120
+ if auditable_type.nil?
121
+ 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')"
122
+ end
123
+ insert_attrs["#{auditable_column}_type"] = auditable_type
124
+
125
+ # Calculate version number
126
+ if batch_capture_active
127
+ normalized_identity = identity_map.transform_keys(&:to_s).sort.to_h
128
+ tracker_key = [auditable_type, normalized_identity]
129
+ tracker = batch_state[:version_tracker] || {}
130
+ current_version = tracker[tracker_key]
131
+
132
+ unless current_version
133
+ max_version = audit_class.where({"#{auditable_column}_type" => auditable_type}.merge(identity_map))
134
+ .maximum(version_column) || 0
135
+ current_version = max_version
136
+ end
137
+
138
+ current_version += 1
139
+ tracker[tracker_key] = current_version
140
+ batch_state[:version_tracker] = tracker
141
+ insert_attrs[version_column] = current_version
142
+ elsif attrs[:action] == "create"
143
+ insert_attrs[version_column] = 1
144
+ else
145
+ # Get max version for this auditable
146
+ max_version = audit_class.where({"#{auditable_column}_type" => auditable_type}.merge(identity_map))
147
+ .maximum(version_column) || 0
148
+ insert_attrs[version_column] = max_version + 1
149
+ end
150
+
151
+ # Set user from RequestStore or config
152
+ user_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :user)
153
+ if user_column && !insert_attrs.key?(user_column)
154
+ user = ActiveVersion::RequestStore.audited_user if defined?(ActiveVersion::RequestStore)
155
+ user ||= begin
156
+ user_method = ActiveVersion.config.current_user_method
157
+ send(user_method) if respond_to?(user_method, true)
158
+ end
159
+ if user
160
+ insert_attrs[user_column] = user.respond_to?(:id) ? user.id : user
161
+ if user_column.to_s.end_with?("_id") && user.respond_to?(:class, true)
162
+ type_column = user_column.to_s.gsub("_id", "_type")
163
+ insert_attrs[type_column] = user.class.name if audit_class.column_names.include?(type_column.to_s)
164
+ end
165
+ end
166
+ end
167
+
168
+ # Set request_uuid from RequestStore or generate
169
+ uuid_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :request_uuid)
170
+ if uuid_column && audit_class.column_names.include?(uuid_column.to_s) && !insert_attrs.key?(uuid_column)
171
+ insert_attrs[uuid_column] = ActiveVersion::RequestStore.request_uuid if defined?(ActiveVersion::RequestStore)
172
+ insert_attrs[uuid_column] ||= SecureRandom.uuid if insert_attrs[uuid_column].blank?
173
+ end
174
+
175
+ # Set remote_address from RequestStore
176
+ address_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :remote_address)
177
+ if address_column && audit_class.column_names.include?(address_column.to_s) && !insert_attrs.key?(address_column)
178
+ insert_attrs[address_column] = ActiveVersion::RequestStore.remote_address if defined?(ActiveVersion::RequestStore)
179
+ end
180
+
181
+ insert_attrs[:created_at] ||= Time.current
182
+ insert_attrs[:updated_at] ||= Time.current
183
+
184
+ if batch_capture_active
185
+ batch_state[:values] << insert_attrs
186
+ ActiveVersion.store_set(:active_version_audit_batch_state, batch_state)
187
+ return nil
188
+ end
189
+
190
+ begin
191
+ audit_class.create!(insert_attrs)
192
+ combine_audits_if_needed if attrs[:action] != "create"
193
+ audits.reset
194
+ nil
195
+ rescue ActiveRecord::RecordNotUnique => e
196
+ # Handle unique constraint violation (likely version conflict)
197
+ # Retry once with recalculated version
198
+ if e.message.include?("version") && attrs[:action] != "create"
199
+ # Recalculate version and retry
200
+ # Use class_name from options if provided (for dynamically created classes)
201
+ auditable_type_for_query = audited_options[:class_name] || self.class.name
202
+ if auditable_type_for_query.nil?
203
+ raise ConfigurationError, "Cannot determine class name for dynamically created class. Please specify class_name option in has_audits"
204
+ end
205
+ max_version = audit_class.where({"#{auditable_column}_type" => auditable_type_for_query}.merge(active_version_audit_identity_map))
206
+ .maximum(version_column) || 0
207
+ insert_attrs[version_column] = max_version + 1
208
+ begin
209
+ audit_class.create!(insert_attrs)
210
+ combine_audits_if_needed if attrs[:action] != "create"
211
+ audits.reset
212
+ nil
213
+ rescue => retry_error
214
+ handle_audit_errors(retry_error, attrs[:action])
215
+ nil
216
+ end
217
+ else
218
+ handle_audit_errors(e, attrs[:action])
219
+ nil
220
+ end
221
+ rescue => e
222
+ handle_audit_errors(e, attrs[:action])
223
+ nil
224
+ end
225
+ end
226
+ end
227
+ end
228
+
229
+ def handle_audit_errors(error, action)
230
+ ActiveVersion::Instrumentation.instrument_audit_write_failed(self, error: error, action: action)
231
+ behavior = audited_options[:error_behavior] || ActiveVersion.config.audit_error_behavior || :log
232
+
233
+ case behavior
234
+ when :log
235
+ log_audit_errors(error, action)
236
+ when :exception
237
+ raise error
238
+ when :silent
239
+ # noop
240
+ end
241
+ end
242
+
243
+ def log_audit_errors(error, action)
244
+ Rails.logger&.warn(
245
+ "Unable to create audit for #{action} of #{self.class.name}" \
246
+ "##{id}: #{error.message}"
247
+ )
248
+ end
249
+
250
+ def map_structured_audit_columns(insert_attrs, payload)
251
+ return unless payload.is_a?(Hash)
252
+
253
+ payload.each do |attr, value|
254
+ column_name = attr.to_s
255
+ next unless audit_class.column_names.include?(column_name)
256
+
257
+ insert_attrs[column_name] = value.is_a?(Array) ? value.last : value
258
+ end
259
+ end
260
+
261
+ # Ensure table-storage audits can carry source identity/partition columns
262
+ # even when those attributes did not change in this event.
263
+ def copy_shared_source_columns(insert_attrs)
264
+ shared_columns = audit_class.column_names & self.class.column_names
265
+ excluded = [
266
+ "id", "created_at", "updated_at",
267
+ ActiveVersion.column_mapper.column_for(self.class, :audits, :changes).to_s,
268
+ ActiveVersion.column_mapper.column_for(self.class, :audits, :context).to_s,
269
+ ActiveVersion.column_mapper.column_for(self.class, :audits, :version).to_s,
270
+ ActiveVersion.column_mapper.column_for(self.class, :audits, :action).to_s
271
+ ]
272
+ shared_columns -= excluded
273
+
274
+ shared_columns.each do |column_name|
275
+ next if insert_attrs.key?(column_name)
276
+ insert_attrs[column_name] = self[column_name] if has_attribute?(column_name)
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,114 @@
1
+ module ActiveVersion
2
+ module Audits
3
+ module HasAudits
4
+ # Methods for filtering and processing changes
5
+ module ChangeFilters
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def audited_changes(for_touch: false, exclude_readonly_attrs: false)
11
+ all_changes = if for_touch
12
+ # For touch operations, use changes that will be saved
13
+ respond_to?(:changes_to_save) ? changes_to_save : changes
14
+ elsif persisted?
15
+ # For updates to persisted records, use changes that will be saved
16
+ (respond_to?(:changes_to_save) && !changes_to_save.empty?) ? changes_to_save : changes
17
+ else
18
+ # For new records, use all changes
19
+ changes
20
+ end
21
+
22
+ all_changes = all_changes.except(*self.class.readonly_attributes.to_a) if exclude_readonly_attrs
23
+
24
+ # Filter by only/except options
25
+ filtered_changes = if audited_options[:only].present?
26
+ all_changes.slice(*audited_options[:only])
27
+ else
28
+ all_changes.except(*non_audited_columns)
29
+ end
30
+
31
+ # Normalize, redact, and filter
32
+ filtered_changes = normalize_enum_changes(filtered_changes)
33
+ filtered_changes = redact_values(filtered_changes)
34
+ filtered_changes = filter_encrypted_attrs(filtered_changes)
35
+ filtered_changes.to_hash
36
+ end
37
+
38
+ def audited_attributes
39
+ attrs = attributes.except(*non_audited_columns)
40
+ attrs = redact_values(attrs)
41
+ attrs = filter_encrypted_attrs(attrs)
42
+ normalize_enum_changes(attrs)
43
+ end
44
+
45
+ def non_audited_columns
46
+ @non_audited_columns ||= begin
47
+ default_ignored = [self.class.primary_key, self.class.inheritance_column] | ActiveVersion.config.ignored_attributes.map(&:to_s)
48
+ if audited_options[:only].present?
49
+ (self.class.column_names | default_ignored) - audited_options[:only]
50
+ elsif audited_options[:except].present?
51
+ default_ignored | audited_options[:except]
52
+ else
53
+ default_ignored
54
+ end
55
+ end
56
+ end
57
+
58
+ def normalize_enum_changes(changes)
59
+ return changes if ActiveVersion.config.store_synthesized_enums
60
+
61
+ self.class.defined_enums.each do |name, values|
62
+ next unless changes.has_key?(name)
63
+
64
+ changes[name] = if changes[name].is_a?(Array)
65
+ changes[name].map { |v| values[v] }
66
+ else
67
+ values[changes[name]]
68
+ end
69
+ end
70
+ changes
71
+ end
72
+
73
+ def redact_values(filtered_changes)
74
+ filter_attr_values(
75
+ audited_changes: filtered_changes,
76
+ attrs: audited_options[:redacted],
77
+ placeholder: audited_options[:redaction_value] || REDACTED
78
+ )
79
+ end
80
+
81
+ def filter_encrypted_attrs(filtered_changes)
82
+ return filtered_changes unless respond_to?(:encrypted_attributes)
83
+
84
+ filter_attr_values(
85
+ audited_changes: filtered_changes,
86
+ attrs: Array(encrypted_attributes).map(&:to_s),
87
+ placeholder: "[FILTERED]"
88
+ )
89
+ end
90
+
91
+ def filter_attr_values(audited_changes: {}, attrs: [], placeholder: "[FILTERED]")
92
+ attrs.each do |attr|
93
+ next unless audited_changes.key?(attr)
94
+
95
+ changes = audited_changes[attr]
96
+ values = changes.is_a?(Array) ? changes.map { placeholder } : placeholder
97
+
98
+ audited_changes[attr] = values
99
+ end
100
+
101
+ audited_changes
102
+ end
103
+
104
+ def prepare_sql_values(changes)
105
+ changes.each_with_object({}) do |(k, v), h|
106
+ h[k] = v.last if v.is_a?(Array)
107
+ h[k] = v unless v.is_a?(Array)
108
+ h[k] = h[k].to_json if h[k].is_a?(Hash)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end