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.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +492 -0
  5. data/SECURITY.md +29 -0
  6. data/lib/active_version/adapters/active_record/audits.rb +36 -0
  7. data/lib/active_version/adapters/active_record/base.rb +37 -0
  8. data/lib/active_version/adapters/active_record/revisions.rb +49 -0
  9. data/lib/active_version/adapters/active_record/translations.rb +45 -0
  10. data/lib/active_version/adapters/active_record.rb +10 -0
  11. data/lib/active_version/adapters/sequel/versioning.rb +282 -0
  12. data/lib/active_version/adapters/sequel.rb +9 -0
  13. data/lib/active_version/adapters.rb +5 -0
  14. data/lib/active_version/audits/audit_record/callbacks.rb +180 -0
  15. data/lib/active_version/audits/audit_record/serializers.rb +49 -0
  16. data/lib/active_version/audits/audit_record.rb +522 -0
  17. data/lib/active_version/audits/has_audits/audit_callbacks.rb +46 -0
  18. data/lib/active_version/audits/has_audits/audit_combiner.rb +212 -0
  19. data/lib/active_version/audits/has_audits/audit_writer.rb +282 -0
  20. data/lib/active_version/audits/has_audits/change_filters.rb +114 -0
  21. data/lib/active_version/audits/has_audits/database_adapter_helper.rb +86 -0
  22. data/lib/active_version/audits/has_audits.rb +891 -0
  23. data/lib/active_version/audits/sql_builder.rb +263 -0
  24. data/lib/active_version/audits.rb +10 -0
  25. data/lib/active_version/column_mapper.rb +92 -0
  26. data/lib/active_version/configuration.rb +124 -0
  27. data/lib/active_version/database/triggers/postgresql.rb +243 -0
  28. data/lib/active_version/database.rb +7 -0
  29. data/lib/active_version/instrumentation.rb +226 -0
  30. data/lib/active_version/migrators/audited.rb +84 -0
  31. data/lib/active_version/migrators/base.rb +191 -0
  32. data/lib/active_version/migrators.rb +8 -0
  33. data/lib/active_version/query.rb +105 -0
  34. data/lib/active_version/railtie.rb +17 -0
  35. data/lib/active_version/revisions/has_revisions/revision_manipulation.rb +499 -0
  36. data/lib/active_version/revisions/has_revisions/revision_queries.rb +182 -0
  37. data/lib/active_version/revisions/has_revisions.rb +443 -0
  38. data/lib/active_version/revisions/revision_record.rb +287 -0
  39. data/lib/active_version/revisions/sql_builder.rb +266 -0
  40. data/lib/active_version/revisions.rb +10 -0
  41. data/lib/active_version/runtime.rb +148 -0
  42. data/lib/active_version/sharding/connection_router.rb +20 -0
  43. data/lib/active_version/sharding.rb +7 -0
  44. data/lib/active_version/tasks/active_version.rake +29 -0
  45. data/lib/active_version/translations/has_translations.rb +350 -0
  46. data/lib/active_version/translations/translation_record.rb +258 -0
  47. data/lib/active_version/translations.rb +9 -0
  48. data/lib/active_version/version.rb +3 -0
  49. data/lib/active_version/version_registry.rb +87 -0
  50. data/lib/active_version.rb +329 -0
  51. data/lib/generators/active_version/audits/audits_generator.rb +65 -0
  52. data/lib/generators/active_version/audits/templates/audit_model.rb.erb +16 -0
  53. data/lib/generators/active_version/audits/templates/migration_jsonb.rb.erb +33 -0
  54. data/lib/generators/active_version/audits/templates/migration_table.rb.erb +34 -0
  55. data/lib/generators/active_version/install/install_generator.rb +19 -0
  56. data/lib/generators/active_version/install/templates/initializer.rb.erb +38 -0
  57. data/lib/generators/active_version/revisions/revisions_generator.rb +71 -0
  58. data/lib/generators/active_version/revisions/templates/backfill_migration.rb.erb +19 -0
  59. data/lib/generators/active_version/revisions/templates/migration.rb.erb +20 -0
  60. data/lib/generators/active_version/revisions/templates/revision_model.rb.erb +8 -0
  61. data/lib/generators/active_version/translations/templates/migration.rb.erb +16 -0
  62. data/lib/generators/active_version/translations/templates/translation_model.rb.erb +15 -0
  63. data/lib/generators/active_version/translations/translations_generator.rb +73 -0
  64. data/lib/generators/active_version/triggers/templates/migration.rb.erb +100 -0
  65. data/lib/generators/active_version/triggers/triggers_generator.rb +74 -0
  66. data/sig/active_version/advanced.rbs +51 -0
  67. data/sig/active_version/audits.rbs +128 -0
  68. data/sig/active_version/configuration.rbs +38 -0
  69. data/sig/active_version/core.rbs +53 -0
  70. data/sig/active_version/instrumentation.rbs +17 -0
  71. data/sig/active_version/registry_and_mapping.rbs +18 -0
  72. data/sig/active_version/revisions.rbs +70 -0
  73. data/sig/active_version/runtime.rbs +29 -0
  74. data/sig/active_version/translations.rbs +43 -0
  75. data/sig/active_version.rbs +3 -0
  76. 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