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,499 @@
|
|
|
1
|
+
module ActiveVersion
|
|
2
|
+
module Revisions
|
|
3
|
+
module HasRevisions
|
|
4
|
+
# Methods for manipulating revisions
|
|
5
|
+
module RevisionManipulation
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
# Create snapshot for this record
|
|
9
|
+
def create_snapshot!(opts = {})
|
|
10
|
+
timestamp = opts[:timestamp] || Time.current
|
|
11
|
+
only_attrs = opts[:only]
|
|
12
|
+
except_attrs = opts[:except]
|
|
13
|
+
use_old_values = opts.fetch(:use_old_values, false)
|
|
14
|
+
version_column = revision_version_column
|
|
15
|
+
batch_state = ActiveVersion.store_get(:active_version_revision_batch_state)
|
|
16
|
+
batch_capture_active = batch_state.is_a?(Hash) && batch_state[:target_revision_class] == self.class.revision_class
|
|
17
|
+
|
|
18
|
+
# Check debounce time - merge with previous revision if within window
|
|
19
|
+
debounce_time = opts[:debounce_time] || ActiveVersion.config.debounce_time
|
|
20
|
+
if !batch_capture_active && debounce_time && should_merge_with_previous?(debounce_time, timestamp)
|
|
21
|
+
merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values)
|
|
22
|
+
version_column = revision_version_column
|
|
23
|
+
return revisions_scope.order(version_column => :desc).first
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
new_version = if batch_capture_active
|
|
27
|
+
identity_key = active_version_revision_identity_map.transform_keys(&:to_s).sort.to_h
|
|
28
|
+
tracker = batch_state[:version_tracker] || {}
|
|
29
|
+
current = tracker[identity_key] || current_version
|
|
30
|
+
tracker[identity_key] = current + 1
|
|
31
|
+
batch_state[:version_tracker] = tracker
|
|
32
|
+
current + 1
|
|
33
|
+
else
|
|
34
|
+
current_version + 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Refresh only columns with default functions (query optimization)
|
|
38
|
+
refreshable_columns = refreshable_column_names
|
|
39
|
+
if refreshable_columns.any?
|
|
40
|
+
refreshed = self.class.select(refreshable_columns).find(id)
|
|
41
|
+
refreshable_columns.each do |col|
|
|
42
|
+
refreshed_value = if refreshed.respond_to?(col)
|
|
43
|
+
refreshed.public_send(col)
|
|
44
|
+
elsif refreshed.respond_to?(:[])
|
|
45
|
+
refreshed[col]
|
|
46
|
+
end
|
|
47
|
+
self[col] = refreshed_value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Capture base values for the snapshot.
|
|
52
|
+
# For before_update callbacks, prefer persisted values to capture old state.
|
|
53
|
+
base_attrs = snapshot_base_attributes(use_old_values)
|
|
54
|
+
|
|
55
|
+
# Filter by only/except if specified
|
|
56
|
+
snapshot_attrs = if only_attrs
|
|
57
|
+
base_attrs.slice(*only_attrs.map(&:to_s))
|
|
58
|
+
elsif except_attrs
|
|
59
|
+
base_attrs.except(*except_attrs.map(&:to_s))
|
|
60
|
+
else
|
|
61
|
+
base_attrs
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Replace changed attributes with their old values for callback-driven snapshots.
|
|
65
|
+
if use_old_values
|
|
66
|
+
changes_for_snapshot = if respond_to?(:changes_to_save) && changes_to_save.present?
|
|
67
|
+
changes_to_save
|
|
68
|
+
else
|
|
69
|
+
changes
|
|
70
|
+
end
|
|
71
|
+
if changes_for_snapshot.present?
|
|
72
|
+
changes_for_snapshot.each do |attr, values|
|
|
73
|
+
attr_name = attr.to_s
|
|
74
|
+
next unless snapshot_attrs.key?(attr_name)
|
|
75
|
+
next if deleted_column?(attr_name)
|
|
76
|
+
|
|
77
|
+
old_value = values.is_a?(Array) ? values[0] : nil
|
|
78
|
+
old_value ||= attribute_was(attr_name) if respond_to?(:attribute_was)
|
|
79
|
+
old_value ||= attribute_in_database(attr_name) if respond_to?(:attribute_in_database)
|
|
80
|
+
|
|
81
|
+
snapshot_attrs[attr_name] = old_value unless old_value.nil?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Filter out deleted columns
|
|
87
|
+
snapshot_attrs.delete_if { |k, _v| deleted_column?(k) }
|
|
88
|
+
snapshot_attrs.slice!(*revision_payload_columns)
|
|
89
|
+
|
|
90
|
+
# version_column is already a symbol from column_mapper
|
|
91
|
+
version_column_sym = version_column.is_a?(Symbol) ? version_column : version_column.to_sym
|
|
92
|
+
|
|
93
|
+
# Build revision with explicit foreign key to ensure it's set
|
|
94
|
+
revision_attrs = {
|
|
95
|
+
version_column_sym => new_version,
|
|
96
|
+
:created_at => timestamp,
|
|
97
|
+
:updated_at => timestamp
|
|
98
|
+
}
|
|
99
|
+
revision_attrs.merge!(active_version_revision_identity_map.transform_keys(&:to_sym))
|
|
100
|
+
# Merge snapshot attributes (convert all keys to symbols for ActiveRecord)
|
|
101
|
+
snapshot_attrs.each do |k, v|
|
|
102
|
+
key_sym = k.is_a?(Symbol) ? k : k.to_sym
|
|
103
|
+
revision_attrs[key_sym] = v
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if batch_capture_active
|
|
107
|
+
batch_state[:values] << revision_attrs
|
|
108
|
+
ActiveVersion.store_set(:active_version_revision_batch_state, batch_state)
|
|
109
|
+
pseudo = self.class.revision_class.new(revision_attrs)
|
|
110
|
+
pseudo.define_singleton_method(:persisted?) { true }
|
|
111
|
+
pseudo
|
|
112
|
+
else
|
|
113
|
+
|
|
114
|
+
# Use create! instead of build + save to get better error messages
|
|
115
|
+
begin
|
|
116
|
+
revision = revisions.create!(revision_attrs)
|
|
117
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
118
|
+
ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
|
|
119
|
+
error_msg = "Failed to create revision: #{e.class}"
|
|
120
|
+
error_msg += "\nRevision attribute keys: #{revision_attrs.keys.map(&:to_s).sort.join(", ")}"
|
|
121
|
+
error_msg += "\nIdentity keys: #{active_version_revision_identity_map.keys.map(&:to_s).sort.join(", ")}"
|
|
122
|
+
error_msg += "\nVersion column: #{version_column_sym}, New version: #{new_version}"
|
|
123
|
+
error_msg += "\nRevision class: #{self.class.revision_class.name}"
|
|
124
|
+
error_msg += "\nSource class: #{self.class.name}"
|
|
125
|
+
if e.record
|
|
126
|
+
error_msg += "\nRecord error fields: #{e.record.errors.attribute_names.map(&:to_s).uniq.sort.join(", ")}"
|
|
127
|
+
error_msg += "\nRecord valid?: #{e.record.valid?}"
|
|
128
|
+
end
|
|
129
|
+
raise error_msg
|
|
130
|
+
rescue => e
|
|
131
|
+
ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
|
|
132
|
+
error_msg = "Failed to create revision: #{e.class}"
|
|
133
|
+
error_msg += "\nRevision attribute keys: #{revision_attrs.keys.map(&:to_s).sort.join(", ")}"
|
|
134
|
+
error_msg += "\nIdentity keys: #{active_version_revision_identity_map.keys.map(&:to_s).sort.join(", ")}"
|
|
135
|
+
error_msg += "\nVersion column: #{version_column_sym}, New version: #{new_version}"
|
|
136
|
+
raise error_msg
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Force reload association to ensure it's visible
|
|
140
|
+
revisions.reset
|
|
141
|
+
if only_attrs || except_attrs
|
|
142
|
+
excluded_keys = base_attrs.keys - snapshot_attrs.keys
|
|
143
|
+
filtered_keys = revision.attributes.keys - excluded_keys
|
|
144
|
+
revision.instance_variable_set(:@active_version_attributes_filter, filtered_keys)
|
|
145
|
+
end
|
|
146
|
+
revision
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Revert to a specific version (creates new revision)
|
|
151
|
+
def revert_to(version:)
|
|
152
|
+
from_version = current_version
|
|
153
|
+
target_revision_record = revisions_scope.at_version(version).first
|
|
154
|
+
return false unless target_revision_record
|
|
155
|
+
|
|
156
|
+
# Get attributes from revision record (excluding metadata)
|
|
157
|
+
version_column = revision_version_column.to_s
|
|
158
|
+
foreign_keys = revision_identity_columns
|
|
159
|
+
|
|
160
|
+
attrs = target_revision_record.attributes.except(
|
|
161
|
+
*source_primary_key_columns,
|
|
162
|
+
"created_at",
|
|
163
|
+
"updated_at",
|
|
164
|
+
*foreign_keys,
|
|
165
|
+
version_column
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
update!(attrs)
|
|
169
|
+
ActiveVersion::Instrumentation.instrument_revision_reverted(
|
|
170
|
+
self,
|
|
171
|
+
from_version: from_version,
|
|
172
|
+
to_version: version,
|
|
173
|
+
strategy: :revert_to
|
|
174
|
+
)
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Restore record to the previous version (second-to-last revision, or latest if only one)
|
|
179
|
+
def undo!(append: false)
|
|
180
|
+
return false unless revisions_scope.exists?
|
|
181
|
+
|
|
182
|
+
version_column = revision_version_column
|
|
183
|
+
ensure_undo_redo_head_snapshot!(version_column)
|
|
184
|
+
previous = revisions_scope.where("#{version_column} < ?", current_version).order(version_column => :desc).first
|
|
185
|
+
return false unless previous
|
|
186
|
+
|
|
187
|
+
prev_version = previous.respond_to?(version_column) ? previous.public_send(version_column) : previous[version_column]
|
|
188
|
+
switch_to!(prev_version, append: append)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Restore record to the future version (if undo was applied)
|
|
192
|
+
def redo!
|
|
193
|
+
return false unless instance_variable_defined?(:@active_version_pointer)
|
|
194
|
+
|
|
195
|
+
version_column = revision_version_column
|
|
196
|
+
next_rev = revisions_scope.where("#{version_column} > ?", current_version).order(version_column => :asc).first
|
|
197
|
+
return false unless next_rev
|
|
198
|
+
|
|
199
|
+
next_version = next_rev.respond_to?(version_column) ? next_rev.public_send(version_column) : next_rev[version_column]
|
|
200
|
+
switch_to!(next_version)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Restore record to the specified version
|
|
204
|
+
def switch_to!(version, append: false)
|
|
205
|
+
from_version = current_version
|
|
206
|
+
revision = at_version(version)
|
|
207
|
+
return false unless revision
|
|
208
|
+
version_column = revision_version_column
|
|
209
|
+
|
|
210
|
+
if append && version < current_version
|
|
211
|
+
# Create new version with old data instead of reverting
|
|
212
|
+
# This creates a new revision with the target version's data
|
|
213
|
+
target_revision = revisions_scope.where("#{version_column} = ?", version).first
|
|
214
|
+
return false unless target_revision
|
|
215
|
+
|
|
216
|
+
foreign_keys = revision_identity_columns
|
|
217
|
+
attrs = target_revision.attributes.except(
|
|
218
|
+
*source_primary_key_columns,
|
|
219
|
+
"created_at",
|
|
220
|
+
"updated_at",
|
|
221
|
+
*foreign_keys,
|
|
222
|
+
version_column.to_s
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Filter out deleted columns
|
|
226
|
+
attrs.delete_if { |k, _v| deleted_column?(k) }
|
|
227
|
+
attrs.slice!(*revision_payload_columns)
|
|
228
|
+
|
|
229
|
+
# Apply the old attributes to the record first (this will be the "old" state for the new revision)
|
|
230
|
+
# Then update to create a new revision that stores the current state (v3) as old, and has v2 as new
|
|
231
|
+
# Actually, we want the new revision to have v2's data, so we need to set the record to v2 first
|
|
232
|
+
# Then when we update, it will create a revision with v3 (current) as old and v2 as new
|
|
233
|
+
# But that's not what we want either...
|
|
234
|
+
|
|
235
|
+
# The correct approach: Set record to target state, then create snapshot manually with those attributes
|
|
236
|
+
# But create_snapshot! captures the OLD state before changes...
|
|
237
|
+
|
|
238
|
+
# Create revision directly with target attributes, then update record
|
|
239
|
+
version_column_sym = version_column.is_a?(Symbol) ? version_column : version_column.to_sym
|
|
240
|
+
new_version = current_version + 1
|
|
241
|
+
|
|
242
|
+
revision_attrs = {
|
|
243
|
+
version_column_sym => new_version,
|
|
244
|
+
:created_at => Time.current
|
|
245
|
+
}
|
|
246
|
+
revision_attrs.merge!(active_version_revision_identity_map.transform_keys(&:to_sym))
|
|
247
|
+
# Add the target version's attributes
|
|
248
|
+
attrs.each do |k, v|
|
|
249
|
+
key_sym = k.is_a?(Symbol) ? k : k.to_sym
|
|
250
|
+
revision_attrs[key_sym] = v
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Create the revision using the association (it will set foreign_key automatically)
|
|
254
|
+
revision = revisions.create!(revision_attrs)
|
|
255
|
+
# Ensure it was created and persisted
|
|
256
|
+
raise "Failed to create revision" unless revision.persisted?
|
|
257
|
+
# Reload the post to clear all caches and see the new revision
|
|
258
|
+
reload if persisted?
|
|
259
|
+
# Now update the record to match the target version's state.
|
|
260
|
+
# This won't create another revision because we're in without_revisions.
|
|
261
|
+
else
|
|
262
|
+
# To get the state AT version N, use the revision record at version N
|
|
263
|
+
# Revision N stores the state BEFORE version N+1, which is the state AT version N
|
|
264
|
+
revision_record = revisions_scope.where("#{version_column} = ?", version).first
|
|
265
|
+
|
|
266
|
+
if revision_record
|
|
267
|
+
# Use the revision record which has the state at version N
|
|
268
|
+
foreign_keys = revision_identity_columns
|
|
269
|
+
attrs = revision_record.attributes.except(
|
|
270
|
+
*source_primary_key_columns,
|
|
271
|
+
"created_at",
|
|
272
|
+
"updated_at",
|
|
273
|
+
*foreign_keys,
|
|
274
|
+
version_column.to_s
|
|
275
|
+
)
|
|
276
|
+
elsif version == current_version
|
|
277
|
+
# Version N is the current version, use current attributes
|
|
278
|
+
attrs = attributes.except(*source_primary_key_columns, "created_at", "updated_at")
|
|
279
|
+
else
|
|
280
|
+
# Fallback: use the revision object from at_version
|
|
281
|
+
foreign_keys = revision_identity_columns
|
|
282
|
+
attrs = revision.attributes.except(
|
|
283
|
+
*source_primary_key_columns,
|
|
284
|
+
"created_at",
|
|
285
|
+
"updated_at",
|
|
286
|
+
*foreign_keys,
|
|
287
|
+
version_column.to_s
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Filter out deleted columns
|
|
292
|
+
attrs.delete_if { |k, _v| deleted_column?(k) }
|
|
293
|
+
attrs.slice!(*revision_payload_columns)
|
|
294
|
+
|
|
295
|
+
end
|
|
296
|
+
assign_attributes(attrs)
|
|
297
|
+
self.class.without_revisions { save! }
|
|
298
|
+
instance_variable_set(:@active_version_pointer, version)
|
|
299
|
+
ActiveVersion::Instrumentation.instrument_revision_switch_applied(
|
|
300
|
+
self,
|
|
301
|
+
from_version: from_version,
|
|
302
|
+
to_version: version,
|
|
303
|
+
append: append
|
|
304
|
+
)
|
|
305
|
+
true
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Get diff from specific time or version
|
|
309
|
+
def diff_from(time: nil, version: nil)
|
|
310
|
+
raise ArgumentError, "Time or version must be specified" if time.nil? && version.nil?
|
|
311
|
+
|
|
312
|
+
version_column = revision_version_column
|
|
313
|
+
from_version = if version
|
|
314
|
+
revisions_scope.find_by(version_column => version)
|
|
315
|
+
else
|
|
316
|
+
find_revision_by_time(ActiveVersion.parse_time(time))
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
from_version ||= revisions_scope.order(version_column => :asc).first
|
|
320
|
+
return {"id" => id, "changes" => {}} unless from_version
|
|
321
|
+
|
|
322
|
+
from_version_number = from_version.respond_to?(version_column) ? from_version.public_send(version_column) : from_version[version_column]
|
|
323
|
+
base = changes_to(version: from_version_number)
|
|
324
|
+
current_attrs = attributes.except(*source_primary_key_columns, "created_at", "updated_at")
|
|
325
|
+
current = base.merge(current_attrs)
|
|
326
|
+
|
|
327
|
+
build_diff(base, current)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def deleted_column?(column)
|
|
331
|
+
!has_attribute?(column.to_s)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def apply_revision_diff(version, changes)
|
|
335
|
+
changes.each do |k, v|
|
|
336
|
+
column = k.to_s
|
|
337
|
+
next if deleted_column?(column)
|
|
338
|
+
next unless has_attribute?(column)
|
|
339
|
+
|
|
340
|
+
self[column] = deserialize_value(column, v)
|
|
341
|
+
end
|
|
342
|
+
self
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def deserialize_value(column, value)
|
|
346
|
+
return value unless has_attribute?(column)
|
|
347
|
+
@attributes[column.to_s].type.deserialize(value)
|
|
348
|
+
rescue
|
|
349
|
+
value # Fallback to raw value
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def should_merge_with_previous?(debounce_time, timestamp)
|
|
353
|
+
return false unless revisions_scope.exists?
|
|
354
|
+
|
|
355
|
+
version_column = ActiveVersion.column_mapper.column_for(self.class, :revisions, :version)
|
|
356
|
+
last_revision = revisions_scope.order(version_column => :desc).first
|
|
357
|
+
return false unless last_revision
|
|
358
|
+
|
|
359
|
+
time_diff = timestamp.to_f - last_revision.created_at.to_f
|
|
360
|
+
time_diff <= debounce_time
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values)
|
|
364
|
+
version_column = ActiveVersion.column_mapper.column_for(self.class, :revisions, :version)
|
|
365
|
+
last_revision = revisions_scope.order(version_column => :desc).first
|
|
366
|
+
return unless last_revision
|
|
367
|
+
|
|
368
|
+
# Update last revision with current or persisted attributes (filtered by only/except)
|
|
369
|
+
base_attrs = snapshot_base_attributes(use_old_values)
|
|
370
|
+
|
|
371
|
+
snapshot_attrs = if only_attrs
|
|
372
|
+
base_attrs.slice(*only_attrs.map(&:to_s))
|
|
373
|
+
elsif except_attrs
|
|
374
|
+
base_attrs.except(*except_attrs.map(&:to_s))
|
|
375
|
+
else
|
|
376
|
+
base_attrs
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Replace changed attributes with their old values
|
|
380
|
+
if use_old_values
|
|
381
|
+
changes_for_snapshot = if respond_to?(:changes_to_save) && changes_to_save.present?
|
|
382
|
+
changes_to_save
|
|
383
|
+
else
|
|
384
|
+
changes
|
|
385
|
+
end
|
|
386
|
+
if changes_for_snapshot.present?
|
|
387
|
+
changes_for_snapshot.each do |attr, values|
|
|
388
|
+
attr_name = attr.to_s
|
|
389
|
+
next unless snapshot_attrs.key?(attr_name)
|
|
390
|
+
next if deleted_column?(attr_name)
|
|
391
|
+
|
|
392
|
+
old_value = values.is_a?(Array) ? values[0] : nil
|
|
393
|
+
old_value ||= attribute_was(attr_name) if respond_to?(:attribute_was)
|
|
394
|
+
old_value ||= attribute_in_database(attr_name) if respond_to?(:attribute_in_database)
|
|
395
|
+
snapshot_attrs[attr_name] = old_value unless old_value.nil?
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Filter out deleted columns
|
|
401
|
+
snapshot_attrs.delete_if { |k, _v| deleted_column?(k) }
|
|
402
|
+
snapshot_attrs.slice!(*revision_payload_columns)
|
|
403
|
+
|
|
404
|
+
# When using except, we need to preserve excluded attributes from the existing revision
|
|
405
|
+
except_attrs&.each do |attr|
|
|
406
|
+
attr_str = attr.to_s
|
|
407
|
+
if last_revision.has_attribute?(attr_str) && !snapshot_attrs.key?(attr_str)
|
|
408
|
+
existing_value = last_revision.read_attribute(attr_str)
|
|
409
|
+
snapshot_attrs[attr_str] = existing_value unless existing_value.nil?
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Update last revision without triggering readonly protection (string keys for update_all)
|
|
414
|
+
update_hash = snapshot_attrs.transform_keys(&:to_s).merge("updated_at" => timestamp)
|
|
415
|
+
last_revision.class.where(id: last_revision.id).update_all(update_hash)
|
|
416
|
+
revisions.reset
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def refreshable_column_names
|
|
420
|
+
@refreshable_column_names ||=
|
|
421
|
+
self.class.columns
|
|
422
|
+
.select(&:default_function)
|
|
423
|
+
.reject { |column| source_primary_key_columns.include?(column.name) }
|
|
424
|
+
.map(&:name)
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def ensure_undo_redo_head_snapshot!(version_column)
|
|
428
|
+
return if instance_variable_defined?(:@active_version_pointer)
|
|
429
|
+
|
|
430
|
+
# Capture current state as a concrete head revision so redo can move forward.
|
|
431
|
+
head = create_snapshot!(use_old_values: false, debounce_time: -1)
|
|
432
|
+
head_version = head.respond_to?(version_column) ? head.public_send(version_column) : head[version_column]
|
|
433
|
+
instance_variable_set(:@active_version_pointer, head_version)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def snapshot_base_attributes(use_old_values)
|
|
437
|
+
attrs = if use_old_values
|
|
438
|
+
if respond_to?(:attributes_in_database)
|
|
439
|
+
# Build a full snapshot: start from current attributes, then
|
|
440
|
+
# overlay persisted ("old") values for changed columns.
|
|
441
|
+
# Using only attributes_in_database would keep only changed keys
|
|
442
|
+
# and can drop required NOT NULL columns on revision rows.
|
|
443
|
+
merged = attributes.dup
|
|
444
|
+
attributes_in_database.each do |key, value|
|
|
445
|
+
merged[key] = value
|
|
446
|
+
end
|
|
447
|
+
merged
|
|
448
|
+
else
|
|
449
|
+
attributes.each_with_object({}) do |(k, v), h|
|
|
450
|
+
h[k] = respond_to?(:attribute_in_database) ? attribute_in_database(k) : v
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
else
|
|
454
|
+
attributes
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
attrs.except(*source_primary_key_columns, "created_at", "updated_at").dup
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def revision_payload_columns
|
|
461
|
+
@revision_payload_columns ||= begin
|
|
462
|
+
revision_class = self.class.revision_class
|
|
463
|
+
foreign_keys = Array(revision_class.source_foreign_key).map(&:to_s)
|
|
464
|
+
version_column = revision_version_column.to_s
|
|
465
|
+
|
|
466
|
+
revision_class.column_names - ["id", "created_at", "updated_at", *foreign_keys, version_column]
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def changes_to(version: nil, data: {}, from: 0)
|
|
471
|
+
return data unless version
|
|
472
|
+
|
|
473
|
+
foreign_keys = revision_identity_columns
|
|
474
|
+
version_column = revision_version_column.to_s
|
|
475
|
+
|
|
476
|
+
revisions_scope.where("#{version_column} >= ? AND #{version_column} <= ?", from, version)
|
|
477
|
+
.order(version_column => :asc)
|
|
478
|
+
.each_with_object(data.dup) do |rev, acc|
|
|
479
|
+
rev.attributes.except(*source_primary_key_columns, "created_at", "updated_at", version_column.to_s, *foreign_keys)
|
|
480
|
+
.each { |k, v| acc[k] = v }
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def build_diff(base, current)
|
|
485
|
+
current.each_with_object({"id" => id, "changes" => {}}) do |(k, v), acc|
|
|
486
|
+
next if deleted_column?(k.to_s)
|
|
487
|
+
unless v == base[k]
|
|
488
|
+
acc["changes"][k] = {"old" => base[k], "new" => v}
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def source_primary_key_columns
|
|
494
|
+
@source_primary_key_columns ||= Array(self.class.primary_key).map(&:to_s)
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
module ActiveVersion
|
|
2
|
+
module Revisions
|
|
3
|
+
module HasRevisions
|
|
4
|
+
# Methods for querying revisions
|
|
5
|
+
module RevisionQueries
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
# Get revision at specific version
|
|
9
|
+
# Returns a reconstructed instance of the model at that version
|
|
10
|
+
def revision(version: nil)
|
|
11
|
+
return nil unless version
|
|
12
|
+
|
|
13
|
+
at_version(version)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Get revision at specific time
|
|
17
|
+
def revision_at(time: nil)
|
|
18
|
+
return nil unless time
|
|
19
|
+
|
|
20
|
+
time_obj = ActiveVersion.parse_time_to_time(time)
|
|
21
|
+
raise ActiveVersion::FutureTimeError, "Future state cannot be known" if time_obj.future?
|
|
22
|
+
|
|
23
|
+
version_column = revision_version_column
|
|
24
|
+
revision_entry = revisions_scope.where("created_at <= ?", time_obj).order(version_column => :desc).first
|
|
25
|
+
# If requested time is before the first revision, return the earliest revision if present.
|
|
26
|
+
revision_entry ||= revisions_scope.order(version_column => :asc).first
|
|
27
|
+
revision_entry
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Return a copy of record at specified time (read-only)
|
|
31
|
+
def at(time: nil, version: nil)
|
|
32
|
+
if version
|
|
33
|
+
return self if !revisions_scope.exists? && ActiveVersion.config.return_self_if_no_revisions
|
|
34
|
+
return at_version(version)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
time = ActiveVersion.parse_time(time)
|
|
38
|
+
|
|
39
|
+
# Validate future time
|
|
40
|
+
if time.future?
|
|
41
|
+
unless ActiveVersion.config.return_self_if_no_revisions
|
|
42
|
+
raise ActiveVersion::FutureTimeError, "Future state cannot be known"
|
|
43
|
+
end
|
|
44
|
+
return self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Check if revisions exist
|
|
48
|
+
unless revisions_scope.exists?
|
|
49
|
+
return ActiveVersion.config.return_self_if_no_revisions ? self : nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
return nil unless exists_at_time?(time)
|
|
53
|
+
return self if current_at_time?(time)
|
|
54
|
+
|
|
55
|
+
revision_entry = find_revision_by_time(time)
|
|
56
|
+
build_revision_dup(revision_entry, time) if revision_entry
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Return a copy of record at specified time (read-only) or raise error
|
|
60
|
+
def at!(time: nil, version: nil)
|
|
61
|
+
result = at(time: time, version: version)
|
|
62
|
+
raise ActiveRecord::RecordNotFound, "No revision found at #{time || version}" unless result
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get revision at specific version (read-only)
|
|
67
|
+
def at_version!(version)
|
|
68
|
+
result = at_version(version)
|
|
69
|
+
raise ActiveRecord::RecordNotFound, "No revision found at version #{version}" unless result
|
|
70
|
+
result
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get current version number (or version we're at after switch_to!/undo!)
|
|
74
|
+
def current_version
|
|
75
|
+
ptr = instance_variable_get(:@active_version_pointer)
|
|
76
|
+
return ptr if ptr
|
|
77
|
+
version_column = revision_version_column
|
|
78
|
+
revisions_scope.maximum(version_column) || 0
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Get revision at specific version
|
|
82
|
+
def at_version(version)
|
|
83
|
+
return nil unless version
|
|
84
|
+
version_column = revision_version_column
|
|
85
|
+
revision_entry = revisions_scope.find_by(version_column => version)
|
|
86
|
+
return nil unless revision_entry
|
|
87
|
+
|
|
88
|
+
build_revision_dup(revision_entry)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Enumerate all versions (lazy enumerator returning revision instances)
|
|
92
|
+
def versions(reverse: false, include_self: false)
|
|
93
|
+
version_column = revision_version_column
|
|
94
|
+
version_list = revisions_scope.order(version_column => (reverse ? :desc : :asc)).pluck(version_column)
|
|
95
|
+
|
|
96
|
+
# If include_self, prepare current state as a revision instance
|
|
97
|
+
current_self = nil
|
|
98
|
+
if include_self
|
|
99
|
+
current_self = dup
|
|
100
|
+
current_self.instance_variable_set(:@new_record, false)
|
|
101
|
+
current_self.instance_variable_set(:@persisted, true)
|
|
102
|
+
current_self.readonly!
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
Enumerator.new do |yielder|
|
|
106
|
+
version_list.each do |v|
|
|
107
|
+
revision = at_version(v)
|
|
108
|
+
yielder << revision if revision
|
|
109
|
+
end
|
|
110
|
+
if include_self
|
|
111
|
+
yielder << current_self if current_self
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def exists_at_time?(time)
|
|
119
|
+
created_at <= time
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def current_at_time?(time)
|
|
123
|
+
return true if time >= updated_at
|
|
124
|
+
return false unless revisions_scope.any?
|
|
125
|
+
|
|
126
|
+
revisions_scope.maximum(:created_at) <= time
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def find_revision_by_time(time)
|
|
130
|
+
version_column = revision_version_column
|
|
131
|
+
revisions_scope.where("created_at <= ?", time).order(version_column => :desc).first
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def revisions_scope
|
|
135
|
+
revision_class = self.class.revision_class
|
|
136
|
+
revision_class.where(active_version_revision_identity_map)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_revision_dup(revision_entry, time = nil)
|
|
140
|
+
return nil unless revision_entry
|
|
141
|
+
|
|
142
|
+
dup.tap do |revision|
|
|
143
|
+
version_column = revision_version_column
|
|
144
|
+
foreign_keys = Array(self.class.revision_class.source_foreign_key).map(&:to_s)
|
|
145
|
+
|
|
146
|
+
attrs = revision_entry.attributes.except(
|
|
147
|
+
"id",
|
|
148
|
+
"created_at",
|
|
149
|
+
"updated_at",
|
|
150
|
+
version_column.to_s,
|
|
151
|
+
*foreign_keys
|
|
152
|
+
)
|
|
153
|
+
# Filter out deleted columns
|
|
154
|
+
attrs.delete_if { |k, _v| deleted_column?(k) }
|
|
155
|
+
|
|
156
|
+
revision.assign_attributes(attrs)
|
|
157
|
+
revision.instance_variable_set(:@new_record, false)
|
|
158
|
+
revision.instance_variable_set(:@persisted, true)
|
|
159
|
+
revision.readonly!
|
|
160
|
+
|
|
161
|
+
# Clear association proxies to prevent stale references
|
|
162
|
+
clear_association_proxies(revision)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def deleted_column?(column)
|
|
167
|
+
return true unless @attributes
|
|
168
|
+
!@attributes.key?(column.to_s)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def clear_association_proxies(revision)
|
|
172
|
+
revision.instance_variables.each do |ivar|
|
|
173
|
+
proxy = revision.instance_variable_get(ivar)
|
|
174
|
+
if !proxy.nil? && proxy.respond_to?(:proxy_respond_to?)
|
|
175
|
+
revision.instance_variable_set(ivar, nil)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|