active_version 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.md +21 -0
- data/README.md +492 -0
- data/SECURITY.md +29 -0
- data/lib/active_version/adapters/active_record/audits.rb +36 -0
- data/lib/active_version/adapters/active_record/base.rb +37 -0
- data/lib/active_version/adapters/active_record/revisions.rb +49 -0
- data/lib/active_version/adapters/active_record/translations.rb +45 -0
- data/lib/active_version/adapters/active_record.rb +10 -0
- data/lib/active_version/adapters/sequel/versioning.rb +282 -0
- data/lib/active_version/adapters/sequel.rb +9 -0
- data/lib/active_version/adapters.rb +5 -0
- data/lib/active_version/audits/audit_record/callbacks.rb +180 -0
- data/lib/active_version/audits/audit_record/serializers.rb +49 -0
- data/lib/active_version/audits/audit_record.rb +522 -0
- data/lib/active_version/audits/has_audits/audit_callbacks.rb +46 -0
- data/lib/active_version/audits/has_audits/audit_combiner.rb +212 -0
- data/lib/active_version/audits/has_audits/audit_writer.rb +282 -0
- data/lib/active_version/audits/has_audits/change_filters.rb +114 -0
- data/lib/active_version/audits/has_audits/database_adapter_helper.rb +86 -0
- data/lib/active_version/audits/has_audits.rb +891 -0
- data/lib/active_version/audits/sql_builder.rb +263 -0
- data/lib/active_version/audits.rb +10 -0
- data/lib/active_version/column_mapper.rb +92 -0
- data/lib/active_version/configuration.rb +124 -0
- data/lib/active_version/database/triggers/postgresql.rb +243 -0
- data/lib/active_version/database.rb +7 -0
- data/lib/active_version/instrumentation.rb +226 -0
- data/lib/active_version/migrators/audited.rb +84 -0
- data/lib/active_version/migrators/base.rb +191 -0
- data/lib/active_version/migrators.rb +8 -0
- data/lib/active_version/query.rb +105 -0
- data/lib/active_version/railtie.rb +17 -0
- data/lib/active_version/revisions/has_revisions/revision_manipulation.rb +499 -0
- data/lib/active_version/revisions/has_revisions/revision_queries.rb +182 -0
- data/lib/active_version/revisions/has_revisions.rb +443 -0
- data/lib/active_version/revisions/revision_record.rb +287 -0
- data/lib/active_version/revisions/sql_builder.rb +266 -0
- data/lib/active_version/revisions.rb +10 -0
- data/lib/active_version/runtime.rb +148 -0
- data/lib/active_version/sharding/connection_router.rb +20 -0
- data/lib/active_version/sharding.rb +7 -0
- data/lib/active_version/tasks/active_version.rake +29 -0
- data/lib/active_version/translations/has_translations.rb +350 -0
- data/lib/active_version/translations/translation_record.rb +258 -0
- data/lib/active_version/translations.rb +9 -0
- data/lib/active_version/version.rb +3 -0
- data/lib/active_version/version_registry.rb +87 -0
- data/lib/active_version.rb +329 -0
- data/lib/generators/active_version/audits/audits_generator.rb +65 -0
- data/lib/generators/active_version/audits/templates/audit_model.rb.erb +16 -0
- data/lib/generators/active_version/audits/templates/migration_jsonb.rb.erb +33 -0
- data/lib/generators/active_version/audits/templates/migration_table.rb.erb +34 -0
- data/lib/generators/active_version/install/install_generator.rb +19 -0
- data/lib/generators/active_version/install/templates/initializer.rb.erb +38 -0
- data/lib/generators/active_version/revisions/revisions_generator.rb +71 -0
- data/lib/generators/active_version/revisions/templates/backfill_migration.rb.erb +19 -0
- data/lib/generators/active_version/revisions/templates/migration.rb.erb +20 -0
- data/lib/generators/active_version/revisions/templates/revision_model.rb.erb +8 -0
- data/lib/generators/active_version/translations/templates/migration.rb.erb +16 -0
- data/lib/generators/active_version/translations/templates/translation_model.rb.erb +15 -0
- data/lib/generators/active_version/translations/translations_generator.rb +73 -0
- data/lib/generators/active_version/triggers/templates/migration.rb.erb +100 -0
- data/lib/generators/active_version/triggers/triggers_generator.rb +74 -0
- data/sig/active_version/advanced.rbs +51 -0
- data/sig/active_version/audits.rbs +128 -0
- data/sig/active_version/configuration.rbs +38 -0
- data/sig/active_version/core.rbs +53 -0
- data/sig/active_version/instrumentation.rbs +17 -0
- data/sig/active_version/registry_and_mapping.rbs +18 -0
- data/sig/active_version/revisions.rbs +70 -0
- data/sig/active_version/runtime.rbs +29 -0
- data/sig/active_version/translations.rbs +43 -0
- data/sig/active_version.rbs +3 -0
- metadata +443 -0
|
@@ -0,0 +1,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
|