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,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
|