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,49 @@
|
|
|
1
|
+
module ActiveVersion
|
|
2
|
+
module Adapters
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Revisions
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
# Add has_revisions method to ActiveRecord::Base
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
# Declare that a model has revisions
|
|
13
|
+
def has_revisions(options = {})
|
|
14
|
+
include ActiveVersion::Revisions::HasRevisions
|
|
15
|
+
extend ActiveVersion::Revisions::SQLBuilder::ClassMethods
|
|
16
|
+
|
|
17
|
+
# Delegate to the concern implementation so callbacks/options are
|
|
18
|
+
# installed consistently for the DSL path.
|
|
19
|
+
ActiveVersion::Revisions::HasRevisions::ClassMethods
|
|
20
|
+
.instance_method(:has_revisions)
|
|
21
|
+
.bind_call(self, options)
|
|
22
|
+
|
|
23
|
+
# Register revision class
|
|
24
|
+
revision_class_name = "#{name}Revision"
|
|
25
|
+
if const_defined?(revision_class_name)
|
|
26
|
+
revision_class = const_get(revision_class_name)
|
|
27
|
+
if options[:table_name] && revision_class.respond_to?(:table_name=)
|
|
28
|
+
revision_class.table_name = options[:table_name].to_s
|
|
29
|
+
end
|
|
30
|
+
ActiveVersion.registry.register_version_class(self, :revisions, revision_class)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Include revisions adapter in ActiveRecord::Base
|
|
40
|
+
ActiveSupport.on_load(:active_record) do
|
|
41
|
+
include ActiveVersion::Adapters::ActiveRecord::Revisions
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# If ActiveRecord::Base is already loaded, include immediately
|
|
45
|
+
if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:include)
|
|
46
|
+
unless ActiveRecord::Base.included_modules.include?(ActiveVersion::Adapters::ActiveRecord::Revisions)
|
|
47
|
+
ActiveSupport.on_load(:active_record) { include ActiveVersion::Adapters::ActiveRecord::Revisions }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module ActiveVersion
|
|
2
|
+
module Adapters
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module Translations
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
# Add has_translations method to ActiveRecord::Base
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
# Declare that a model has translations
|
|
13
|
+
def has_translations(options = {})
|
|
14
|
+
include ActiveVersion::Translations::HasTranslations
|
|
15
|
+
|
|
16
|
+
# Store options
|
|
17
|
+
ActiveVersion.registry.register(self, :translations, options)
|
|
18
|
+
|
|
19
|
+
# Register translation class
|
|
20
|
+
translation_class_name = "#{name}Translation"
|
|
21
|
+
if const_defined?(translation_class_name)
|
|
22
|
+
translation_class = const_get(translation_class_name)
|
|
23
|
+
if options[:table_name] && translation_class.respond_to?(:table_name=)
|
|
24
|
+
translation_class.table_name = options[:table_name].to_s
|
|
25
|
+
end
|
|
26
|
+
ActiveVersion.registry.register_version_class(self, :translations, translation_class)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Include translations adapter in ActiveRecord::Base
|
|
36
|
+
ActiveSupport.on_load(:active_record) do
|
|
37
|
+
include ActiveVersion::Adapters::ActiveRecord::Translations
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# If ActiveRecord::Base is already loaded, include immediately
|
|
41
|
+
if defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:include)
|
|
42
|
+
unless ActiveRecord::Base.included_modules.include?(ActiveVersion::Adapters::ActiveRecord::Translations)
|
|
43
|
+
ActiveSupport.on_load(:active_record) { include ActiveVersion::Adapters::ActiveRecord::Translations }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module ActiveVersion
|
|
4
|
+
module Adapters
|
|
5
|
+
module Sequel
|
|
6
|
+
# Sequel plugin that provides ActiveVersion-style model DSL and lifecycle hooks.
|
|
7
|
+
module Versioning
|
|
8
|
+
DEFAULT_CONFIG = {
|
|
9
|
+
revision_model: nil,
|
|
10
|
+
audit_model: nil,
|
|
11
|
+
translation_model: nil,
|
|
12
|
+
foreign_key: nil,
|
|
13
|
+
tracked_columns: [],
|
|
14
|
+
translation_columns: []
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.apply(model, **options)
|
|
18
|
+
configure(model, **options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.configure(model, **options)
|
|
22
|
+
base = model.instance_variable_get(:@active_version_sequel_config) || {}
|
|
23
|
+
model.instance_variable_set(:@active_version_sequel_config, DEFAULT_CONFIG.merge(base).merge(options))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module ClassMethods
|
|
27
|
+
def active_version(**options)
|
|
28
|
+
ActiveVersion::Adapters::Sequel::Versioning.configure(self, **options)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def active_version_config
|
|
32
|
+
config = instance_variable_get(:@active_version_sequel_config)
|
|
33
|
+
return config if config
|
|
34
|
+
|
|
35
|
+
if superclass.respond_to?(:active_version_config)
|
|
36
|
+
superclass.active_version_config.dup
|
|
37
|
+
else
|
|
38
|
+
ActiveVersion::Adapters::Sequel::Versioning::DEFAULT_CONFIG.dup
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def has_versioning?(version_type)
|
|
43
|
+
case version_type.to_sym
|
|
44
|
+
when :revisions then !active_version_config[:revision_model].nil?
|
|
45
|
+
when :audits then !active_version_config[:audit_model].nil?
|
|
46
|
+
when :translations then !active_version_config[:translation_model].nil?
|
|
47
|
+
else false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
module InstanceMethods
|
|
53
|
+
def active_version_foreign_key
|
|
54
|
+
self.class.active_version_config[:foreign_key] || :"#{model_name.singular}_id"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def active_version_tracked_columns
|
|
58
|
+
self.class.active_version_config[:tracked_columns].map(&:to_sym)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def active_version_translation_columns
|
|
62
|
+
columns = self.class.active_version_config[:translation_columns]
|
|
63
|
+
columns = active_version_tracked_columns if columns.nil? || columns.empty?
|
|
64
|
+
columns.map(&:to_sym)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def active_version_revisions_dataset
|
|
68
|
+
model = self.class.active_version_config[:revision_model]
|
|
69
|
+
return nil unless model
|
|
70
|
+
|
|
71
|
+
model.where(active_version_foreign_key => pk)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def active_version_audits_dataset
|
|
75
|
+
model = self.class.active_version_config[:audit_model]
|
|
76
|
+
return nil unless model
|
|
77
|
+
|
|
78
|
+
model.where(active_version_foreign_key => pk)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def active_version_translations_dataset
|
|
82
|
+
model = self.class.active_version_config[:translation_model]
|
|
83
|
+
return nil unless model
|
|
84
|
+
|
|
85
|
+
model.where(active_version_foreign_key => pk)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def active_version_revisions
|
|
89
|
+
dataset = active_version_revisions_dataset
|
|
90
|
+
return [] unless dataset
|
|
91
|
+
|
|
92
|
+
dataset.order(:version).all
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def active_version_audits
|
|
96
|
+
dataset = active_version_audits_dataset
|
|
97
|
+
return [] unless dataset
|
|
98
|
+
|
|
99
|
+
dataset.order(:version).all
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def active_version_translations
|
|
103
|
+
dataset = active_version_translations_dataset
|
|
104
|
+
return [] unless dataset
|
|
105
|
+
|
|
106
|
+
dataset.order(:locale).all
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def active_version_translation(locale)
|
|
110
|
+
dataset = active_version_translations_dataset
|
|
111
|
+
return nil unless dataset
|
|
112
|
+
|
|
113
|
+
dataset.where(locale: locale.to_s.downcase).first
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def active_version_translate(attr, locale:)
|
|
117
|
+
translation = active_version_translation(locale)
|
|
118
|
+
return self[attr] unless translation
|
|
119
|
+
|
|
120
|
+
translated_value = translation[attr]
|
|
121
|
+
(translated_value.nil? || translated_value.to_s.empty?) ? self[attr] : translated_value
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def active_version_set_translation!(locale:, **attrs)
|
|
125
|
+
locale_value = locale.to_s.downcase
|
|
126
|
+
model = self.class.active_version_config[:translation_model]
|
|
127
|
+
raise ActiveVersion::ConfigurationError, "translation_model is not configured for #{self.class.name}" unless model
|
|
128
|
+
|
|
129
|
+
payload = attrs.transform_keys(&:to_sym).slice(*active_version_translation_columns)
|
|
130
|
+
existing = active_version_translation(locale_value)
|
|
131
|
+
|
|
132
|
+
if existing
|
|
133
|
+
existing.update(payload)
|
|
134
|
+
ActiveVersion::Instrumentation.instrument_translation_updated(existing, self)
|
|
135
|
+
existing
|
|
136
|
+
else
|
|
137
|
+
created = model.create(payload.merge(active_version_foreign_key => pk, :locale => locale_value))
|
|
138
|
+
ActiveVersion::Instrumentation.instrument_translation_created(created, self)
|
|
139
|
+
created
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def active_version_destroy_translation!(locale:)
|
|
144
|
+
translation = active_version_translation(locale)
|
|
145
|
+
return false unless translation
|
|
146
|
+
|
|
147
|
+
ActiveVersion::Instrumentation.instrument_translation_destroyed(translation, self)
|
|
148
|
+
translation.delete
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def before_update
|
|
153
|
+
@active_version_previous_snapshot = active_version_previous_snapshot_from_db
|
|
154
|
+
super
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def before_destroy
|
|
158
|
+
@active_version_previous_snapshot = active_version_snapshot
|
|
159
|
+
super
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def after_create
|
|
163
|
+
super
|
|
164
|
+
active_version_create_revision_and_audit!(:create, previous_snapshot: nil)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def after_update
|
|
168
|
+
super
|
|
169
|
+
active_version_create_revision_and_audit!(:update, previous_snapshot: @active_version_previous_snapshot)
|
|
170
|
+
@active_version_previous_snapshot = nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def after_destroy
|
|
174
|
+
super
|
|
175
|
+
active_version_create_destroy_audit!(@active_version_previous_snapshot)
|
|
176
|
+
ensure
|
|
177
|
+
@active_version_previous_snapshot = nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
def pk
|
|
183
|
+
self[self.class.primary_key]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def model_name
|
|
187
|
+
self.class.name.split("::").last.downcase
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def active_version_snapshot
|
|
191
|
+
active_version_tracked_columns.each_with_object({}) do |column, out|
|
|
192
|
+
out[column] = self[column]
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def active_version_previous_snapshot_from_db
|
|
197
|
+
persisted = self.class.where(self.class.primary_key => pk).first
|
|
198
|
+
return active_version_snapshot unless persisted
|
|
199
|
+
|
|
200
|
+
active_version_tracked_columns.each_with_object({}) do |column, out|
|
|
201
|
+
out[column] = persisted[column]
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def active_version_change_set(previous_snapshot)
|
|
206
|
+
current = active_version_snapshot
|
|
207
|
+
previous = previous_snapshot || Hash.new(nil)
|
|
208
|
+
current.each_with_object({}) do |(column, value), out|
|
|
209
|
+
old_value = previous[column]
|
|
210
|
+
next if old_value == value
|
|
211
|
+
|
|
212
|
+
out[column.to_s] = [old_value, value]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def active_version_next_version
|
|
217
|
+
max_revision = active_version_revisions_dataset&.max(:version) || 0
|
|
218
|
+
max_audit = active_version_audits_dataset&.max(:version) || 0
|
|
219
|
+
[max_revision, max_audit].max + 1
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def active_version_create_revision_and_audit!(action, previous_snapshot:)
|
|
223
|
+
change_set = active_version_change_set(previous_snapshot)
|
|
224
|
+
return if action.to_s == "update" && change_set.empty?
|
|
225
|
+
|
|
226
|
+
version = active_version_next_version
|
|
227
|
+
active_version_insert_revision!(version)
|
|
228
|
+
active_version_insert_audit!(action: action, version: version, changes: change_set)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def active_version_create_destroy_audit!(previous_snapshot)
|
|
232
|
+
return unless self.class.active_version_config[:audit_model]
|
|
233
|
+
|
|
234
|
+
previous = previous_snapshot || active_version_snapshot
|
|
235
|
+
change_set = previous.each_with_object({}) do |(column, value), out|
|
|
236
|
+
out[column.to_s] = [value, nil]
|
|
237
|
+
end
|
|
238
|
+
version = active_version_next_version
|
|
239
|
+
active_version_insert_audit!(action: "destroy", version: version, changes: change_set)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def active_version_insert_revision!(version)
|
|
243
|
+
revision_model = self.class.active_version_config[:revision_model]
|
|
244
|
+
return unless revision_model
|
|
245
|
+
|
|
246
|
+
payload = active_version_snapshot.merge(
|
|
247
|
+
active_version_foreign_key => pk,
|
|
248
|
+
:version => version
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
revision = revision_model.create(payload)
|
|
252
|
+
ActiveVersion::Instrumentation.instrument_revision_created(revision, self)
|
|
253
|
+
revision
|
|
254
|
+
rescue => e
|
|
255
|
+
ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
|
|
256
|
+
raise
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def active_version_insert_audit!(action:, version:, changes:)
|
|
260
|
+
audit_model = self.class.active_version_config[:audit_model]
|
|
261
|
+
return unless audit_model
|
|
262
|
+
|
|
263
|
+
payload = {
|
|
264
|
+
active_version_foreign_key => pk,
|
|
265
|
+
:version => version,
|
|
266
|
+
:action => action,
|
|
267
|
+
:audited_changes => JSON.generate(changes),
|
|
268
|
+
:audited_context => JSON.generate(ActiveVersion.context || {})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
audit = audit_model.create(payload)
|
|
272
|
+
ActiveVersion::Instrumentation.instrument_audit_created(audit, self)
|
|
273
|
+
audit
|
|
274
|
+
rescue => e
|
|
275
|
+
ActiveVersion::Instrumentation.instrument_audit_write_failed(self, error: e, action: action)
|
|
276
|
+
raise
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
module ActiveVersion
|
|
2
|
+
module Audits
|
|
3
|
+
module AuditRecord
|
|
4
|
+
# Callback methods for setting audit attributes
|
|
5
|
+
module Callbacks
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def set_version_number
|
|
11
|
+
auditable_column = nil
|
|
12
|
+
begin
|
|
13
|
+
source_class = self.class.source_class
|
|
14
|
+
version_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :version)
|
|
15
|
+
auditable_column = ActiveVersion.column_mapper.column_for(source_class, :audits, :auditable)
|
|
16
|
+
rescue NameError
|
|
17
|
+
version_column = ActiveVersion.config.audit_version_column
|
|
18
|
+
auditable_column = ActiveVersion.config.audit_auditable_column
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Skip if version is already set (e.g., from audit_writer)
|
|
22
|
+
# Version should always be a positive integer when set, so check if it's not nil
|
|
23
|
+
return if !self[version_column].nil?
|
|
24
|
+
|
|
25
|
+
type_column = auditable_column.to_s.end_with?("_type") ? auditable_column.to_s : "#{auditable_column}_type"
|
|
26
|
+
identity_columns = source_identity_columns(auditable_column)
|
|
27
|
+
|
|
28
|
+
if action == "create"
|
|
29
|
+
self[version_column] = 1
|
|
30
|
+
else
|
|
31
|
+
auditable_type_value = self[type_column]
|
|
32
|
+
return if auditable_type_value.nil? || identity_columns.any? { |column| self[column].nil? }
|
|
33
|
+
|
|
34
|
+
scope = self.class.where(type_column => auditable_type_value)
|
|
35
|
+
identity_columns.each do |column|
|
|
36
|
+
scope = scope.where(column => self[column])
|
|
37
|
+
end
|
|
38
|
+
max_version = scope.maximum(version_column).to_i
|
|
39
|
+
self[version_column] = max_version + 1
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def set_audit_user
|
|
44
|
+
user_column = begin
|
|
45
|
+
ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :user)
|
|
46
|
+
rescue NameError
|
|
47
|
+
ActiveVersion.config.audit_user_column
|
|
48
|
+
end
|
|
49
|
+
return unless user_column
|
|
50
|
+
|
|
51
|
+
# Try to get user from RequestStore first (like audited)
|
|
52
|
+
user = ActiveVersion::RequestStore.audited_user if defined?(ActiveVersion::RequestStore)
|
|
53
|
+
user ||= begin
|
|
54
|
+
user_method = ActiveVersion.config.current_user_method
|
|
55
|
+
if respond_to?(user_method, true)
|
|
56
|
+
send(user_method)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if user
|
|
61
|
+
self[user_column] = user.respond_to?(:id) ? user.id : user
|
|
62
|
+
# Set polymorphic type if user is polymorphic
|
|
63
|
+
if user_column.to_s.end_with?("_id") && user.respond_to?(:class)
|
|
64
|
+
type_column = user_column.to_s.gsub("_id", "_type")
|
|
65
|
+
self[type_column] = user.class.name if self.class.column_names.include?(type_column)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def set_request_uuid
|
|
71
|
+
uuid_column = begin
|
|
72
|
+
ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :request_uuid)
|
|
73
|
+
rescue NameError
|
|
74
|
+
ActiveVersion.config.audit_request_uuid_column
|
|
75
|
+
end
|
|
76
|
+
return unless uuid_column && self.class.column_names.include?(uuid_column.to_s)
|
|
77
|
+
|
|
78
|
+
# Try RequestStore first, then generate UUID
|
|
79
|
+
self[uuid_column] = ActiveVersion::RequestStore.request_uuid if defined?(ActiveVersion::RequestStore)
|
|
80
|
+
self[uuid_column] ||= SecureRandom.uuid if self[uuid_column].blank?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def set_remote_address
|
|
84
|
+
address_column = begin
|
|
85
|
+
ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :remote_address)
|
|
86
|
+
rescue NameError
|
|
87
|
+
ActiveVersion.config.audit_remote_address_column
|
|
88
|
+
end
|
|
89
|
+
return unless address_column && self.class.column_names.include?(address_column.to_s)
|
|
90
|
+
|
|
91
|
+
# Try RequestStore first
|
|
92
|
+
if defined?(ActiveVersion::RequestStore)
|
|
93
|
+
self[address_column] = ActiveVersion::RequestStore.remote_address
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def set_audited_context
|
|
98
|
+
context_column = begin
|
|
99
|
+
ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :context)
|
|
100
|
+
rescue NameError
|
|
101
|
+
ActiveVersion.config.audit_context_column
|
|
102
|
+
end
|
|
103
|
+
return unless context_column
|
|
104
|
+
|
|
105
|
+
# Context should already be set during creation via write_audit
|
|
106
|
+
# This callback ensures it's set if it wasn't set during creation
|
|
107
|
+
return if self[context_column].present?
|
|
108
|
+
|
|
109
|
+
# Fallback: use global context if no context was set
|
|
110
|
+
global_context = ActiveVersion.context || {}
|
|
111
|
+
self[context_column] = global_context if global_context.any?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def instrument_audit_created
|
|
115
|
+
auditable_column = ActiveVersion.column_mapper.column_for(self.class.source_class, :audits, :auditable)
|
|
116
|
+
auditable_record = nil
|
|
117
|
+
|
|
118
|
+
if respond_to?(auditable_column)
|
|
119
|
+
auditable_record = send(auditable_column)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# If association didn't return a record, try to load it directly
|
|
123
|
+
unless auditable_record
|
|
124
|
+
type_column = auditable_column.to_s.end_with?("_type") ? auditable_column.to_s : "#{auditable_column}_type"
|
|
125
|
+
identity_columns = source_identity_columns(auditable_column)
|
|
126
|
+
identity_map = source_identity_map_for_lookup(identity_columns, auditable_column)
|
|
127
|
+
auditable_type_value = self[type_column]
|
|
128
|
+
|
|
129
|
+
if identity_map.values.none?(&:nil?) && auditable_type_value
|
|
130
|
+
# Handle dynamically created classes
|
|
131
|
+
if auditable_type_value.nil?
|
|
132
|
+
# Try superclass for dynamically created classes
|
|
133
|
+
source_class = self.class.source_class
|
|
134
|
+
auditable_type_value = source_class.superclass&.name if source_class.superclass
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if auditable_type_value
|
|
138
|
+
auditable_klass = auditable_type_value.constantize
|
|
139
|
+
auditable_record = if identity_map.size > 1
|
|
140
|
+
auditable_klass.find_by(identity_map)
|
|
141
|
+
else
|
|
142
|
+
auditable_klass.find_by(id: identity_map.values.first)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
ActiveVersion::Instrumentation.instrument_audit_created(self, auditable_record)
|
|
149
|
+
rescue ::NameError, ::NoMethodError, ActiveRecord::RecordNotFound
|
|
150
|
+
# Association not set up yet or record not found, still instrument with nil
|
|
151
|
+
ActiveVersion::Instrumentation.instrument_audit_created(self, nil)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def source_identity_columns(auditable_column)
|
|
155
|
+
configured = if self.class.respond_to?(:source_identity_columns)
|
|
156
|
+
self.class.source_identity_columns
|
|
157
|
+
end
|
|
158
|
+
Array(configured.presence || "#{auditable_column}_id").map(&:to_s)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def source_identity_map_for_lookup(identity_columns, auditable_column)
|
|
162
|
+
prefix = "#{auditable_column}_"
|
|
163
|
+
|
|
164
|
+
identity_columns.index_with do |column|
|
|
165
|
+
self[column]
|
|
166
|
+
end.each_with_object({}) do |(column, value), acc|
|
|
167
|
+
source_column = if column == "#{auditable_column}_id"
|
|
168
|
+
"id"
|
|
169
|
+
elsif column.start_with?(prefix)
|
|
170
|
+
column.delete_prefix(prefix)
|
|
171
|
+
else
|
|
172
|
+
column
|
|
173
|
+
end
|
|
174
|
+
acc[source_column] = value
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "yaml"
|
|
3
|
+
|
|
4
|
+
module ActiveVersion
|
|
5
|
+
module Audits
|
|
6
|
+
module AuditRecord
|
|
7
|
+
module Serializers
|
|
8
|
+
class Identity
|
|
9
|
+
def load(value) = value
|
|
10
|
+
def dump(value) = value
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Json
|
|
14
|
+
def load(value)
|
|
15
|
+
return {} if value.nil?
|
|
16
|
+
return value unless value.is_a?(String)
|
|
17
|
+
|
|
18
|
+
JSON.parse(value)
|
|
19
|
+
rescue JSON::ParserError
|
|
20
|
+
value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def dump(value)
|
|
24
|
+
return value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
25
|
+
|
|
26
|
+
JSON.generate(value)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class Yaml
|
|
31
|
+
PERMITTED_CLASSES = [Time, Date, DateTime, Symbol].freeze
|
|
32
|
+
|
|
33
|
+
def load(value)
|
|
34
|
+
return {} if value.nil?
|
|
35
|
+
return value unless value.is_a?(String)
|
|
36
|
+
|
|
37
|
+
YAML.safe_load(value, permitted_classes: PERMITTED_CLASSES, aliases: false)
|
|
38
|
+
rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::AliasesNotEnabled, ArgumentError
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dump(value)
|
|
43
|
+
value.to_yaml
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|