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