Module: ActiveVersion::Revisions::HasRevisions::RevisionManipulation

Extended by:
ActiveSupport::Concern
Included in:
ActiveVersion::Revisions::HasRevisions
Defined in:
lib/active_version/revisions/has_revisions/revision_manipulation.rb

Overview

Methods for manipulating revisions

Instance Method Summary collapse

Instance Method Details

#apply_revision_diff(version, changes) ⇒ Object



337
338
339
340
341
342
343
344
345
346
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 337

def apply_revision_diff(version, changes)
  changes.each do |k, v|
    column = k.to_s
    next if deleted_column?(column)
    next unless has_attribute?(column)

    self[column] = deserialize_value(column, v)
  end
  self
end

#build_diff(base, current) ⇒ Object



510
511
512
513
514
515
516
517
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 510

def build_diff(base, current)
  current.each_with_object({"id" => id, "changes" => {}}) do |(k, v), acc|
    next if deleted_column?(k.to_s)
    unless v == base[k]
      acc["changes"][k] = {"old" => base[k], "new" => v}
    end
  end
end

#changes_to(version: nil, data: {}, from: 0) ⇒ Object



496
497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 496

def changes_to(version: nil, data: {}, from: 0)
  return data unless version

  foreign_keys = revision_identity_columns
  version_column = revision_version_column.to_s

  revisions_scope.where("#{version_column} >= ? AND #{version_column} <= ?", from, version)
    .order(version_column => :asc)
    .each_with_object(data.dup) do |rev, acc|
      rev.attributes.except(*source_primary_key_columns, "created_at", "updated_at", version_column.to_s, *foreign_keys)
        .each { |k, v| acc[k] = v }
    end
end

#create_snapshot!(opts = {}) ⇒ Object

Create snapshot for this record



9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 9

def create_snapshot!(opts = {})
  timestamp = opts[:timestamp] || Time.current
  only_attrs = opts[:only]
  except_attrs = opts[:except]
  use_old_values = opts.fetch(:use_old_values, false)
  version_column = revision_version_column
  batch_state = ActiveVersion.store_get(:active_version_revision_batch_state)
  batch_capture_active = batch_state.is_a?(Hash) && batch_state[:target_revision_class] == self.class.revision_class

  # Check debounce time - merge with previous revision if within window
  debounce_time = opts[:debounce_time] || ActiveVersion.config.debounce_time
  if !batch_capture_active && debounce_time
    last_for_merge = last_revision_for_debounce_merge(debounce_time, timestamp)
    if last_for_merge
      merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values, last_revision: last_for_merge)
      version_column = revision_version_column
      return revisions_scope.order(version_column => :desc).first
    end
  end

  new_version = if batch_capture_active
    identity_key = active_version_revision_identity_map.transform_keys(&:to_s).sort.to_h
    tracker = batch_state[:version_tracker] || {}
    current = tracker[identity_key] || current_version
    tracker[identity_key] = current + 1
    batch_state[:version_tracker] = tracker
    current + 1
  else
    current_version + 1
  end

  # Refresh only columns with default functions (query optimization)
  refreshable_columns = refreshable_column_names
  if refreshable_columns.any?
    refreshed = self.class.select(refreshable_columns).find(id)
    refreshable_columns.each do |col|
      refreshed_value = if refreshed.respond_to?(col)
        refreshed.public_send(col)
      elsif refreshed.respond_to?(:[])
        refreshed[col]
      end
      self[col] = refreshed_value
    end
  end

  # Capture base values for the snapshot.
  # For before_update callbacks, prefer persisted values to capture old state.
  base_attrs = snapshot_base_attributes(use_old_values)

  # Filter by only/except if specified
  snapshot_attrs = if only_attrs
    base_attrs.slice(*only_attrs.map(&:to_s))
  elsif except_attrs
    base_attrs.except(*except_attrs.map(&:to_s))
  else
    base_attrs
  end

  # Replace changed attributes with their old values for callback-driven snapshots.
  if use_old_values
    changes_for_snapshot = if respond_to?(:changes_to_save) && changes_to_save.present?
      changes_to_save
    else
      changes
    end
    if changes_for_snapshot.present?
      changes_for_snapshot.each do |attr, values|
        attr_name = attr.to_s
        next unless snapshot_attrs.key?(attr_name)
        next if deleted_column?(attr_name)

        old_value = values.is_a?(Array) ? values[0] : nil
        old_value ||= attribute_was(attr_name) if respond_to?(:attribute_was)
        old_value ||= attribute_in_database(attr_name) if respond_to?(:attribute_in_database)

        snapshot_attrs[attr_name] = old_value unless old_value.nil?
      end
    end
  end

  # Filter out deleted columns
  snapshot_attrs.delete_if { |k, _v| deleted_column?(k) }
  snapshot_attrs.slice!(*revision_payload_columns)

  # version_column is already a symbol from column_mapper
  version_column_sym = version_column.is_a?(Symbol) ? version_column : version_column.to_sym

  # Build revision with explicit foreign key to ensure it's set
  revision_attrs = {
    version_column_sym => new_version,
    :created_at => timestamp,
    :updated_at => timestamp
  }
  revision_attrs.merge!(active_version_revision_identity_map.transform_keys(&:to_sym))
  # Merge snapshot attributes (convert all keys to symbols for ActiveRecord)
  snapshot_attrs.each do |k, v|
    key_sym = k.is_a?(Symbol) ? k : k.to_sym
    revision_attrs[key_sym] = v
  end

  if batch_capture_active
    batch_state[:values] << revision_attrs
    ActiveVersion.store_set(:active_version_revision_batch_state, batch_state)
    pseudo = self.class.revision_class.new(revision_attrs)
    pseudo.define_singleton_method(:persisted?) { true }
    pseudo
  else

    # Use create! instead of build + save to get better error messages
    begin
      revision = create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
    rescue ActiveRecord::RecordInvalid => e
      ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
      error_msg = "Failed to create revision: #{e.class}"
      error_msg += "\nRevision attribute keys: #{revision_attrs.keys.map(&:to_s).sort.join(", ")}"
      error_msg += "\nIdentity keys: #{active_version_revision_identity_map.keys.map(&:to_s).sort.join(", ")}"
      error_msg += "\nVersion column: #{version_column_sym}, New version: #{new_version}"
      error_msg += "\nRevision class: #{self.class.revision_class.name}"
      error_msg += "\nSource class: #{self.class.name}"
      if e.record
        error_msg += "\nRecord error fields: #{e.record.errors.attribute_names.map(&:to_s).uniq.sort.join(", ")}"
        error_msg += "\nRecord valid?: #{e.record.valid?}"
      end
      raise error_msg
    rescue => e
      ActiveVersion::Instrumentation.instrument_revision_write_failed(self, error: e)
      error_msg = "Failed to create revision: #{e.class}"
      error_msg += "\nRevision attribute keys: #{revision_attrs.keys.map(&:to_s).sort.join(", ")}"
      error_msg += "\nIdentity keys: #{active_version_revision_identity_map.keys.map(&:to_s).sort.join(", ")}"
      error_msg += "\nVersion column: #{version_column_sym}, New version: #{new_version}"
      raise error_msg
    end

    # Force reload association to ensure it's visible
    revisions.reset
    if only_attrs || except_attrs
      excluded_keys = base_attrs.keys - snapshot_attrs.keys
      filtered_keys = revision.attributes.keys - excluded_keys
      revision.instance_variable_set(:@active_version_attributes_filter, filtered_keys)
    end
    revision
  end
end

#deleted_column?(column) ⇒ Boolean

Returns:

  • (Boolean)


333
334
335
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 333

def deleted_column?(column)
  !has_attribute?(column.to_s)
end

#deserialize_value(column, value) ⇒ Object



348
349
350
351
352
353
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 348

def deserialize_value(column, value)
  return value unless has_attribute?(column)
  @attributes[column.to_s].type.deserialize(value)
rescue
  value  # Fallback to raw value
end

#diff_from(time: nil, version: nil) ⇒ Object

Get diff from specific time or version

Raises:

  • (ArgumentError)


312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 312

def diff_from(time: nil, version: nil)
  raise ArgumentError, "Time or version must be specified" if time.nil? && version.nil?

  version_column = revision_version_column
  from_version = if version
    revisions_scope.find_by(version_column => version)
  else
    find_revision_by_time(ActiveVersion.parse_time(time))
  end

  from_version ||= revisions_scope.order(version_column => :asc).first
  return {"id" => id, "changes" => {}} unless from_version

  from_version_number = from_version.respond_to?(version_column) ? from_version.public_send(version_column) : from_version[version_column]
  base = changes_to(version: from_version_number)
  current_attrs = attributes.except(*source_primary_key_columns, "created_at", "updated_at")
  current = base.merge(current_attrs)

  build_diff(base, current)
end

#ensure_undo_redo_head_snapshot!(version_column) ⇒ Object



453
454
455
456
457
458
459
460
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 453

def ensure_undo_redo_head_snapshot!(version_column)
  return if instance_variable_defined?(:@active_version_pointer)

  # Capture current state as a concrete head revision so redo can move forward.
  head = create_snapshot!(use_old_values: false, debounce_time: -1)
  head_version = head.respond_to?(version_column) ? head.public_send(version_column) : head[version_column]
  instance_variable_set(:@active_version_pointer, head_version)
end

#last_revision_for_debounce_merge(debounce_time, timestamp) ⇒ Object

Latest revision row if timestamp falls within debounce_time seconds after that revision; one query.



360
361
362
363
364
365
366
367
368
369
370
371
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 360

def last_revision_for_debounce_merge(debounce_time, timestamp)
  return nil unless debounce_time

  version_column = ActiveVersion.column_mapper.column_for(self.class, :revisions, :version)
  last_revision = revisions_scope.order(version_column => :desc).first
  return nil unless last_revision

  time_diff = timestamp.to_f - last_revision.created_at.to_f
  return nil if time_diff > debounce_time

  last_revision
end

#merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values, last_revision: nil) ⇒ Object



373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 373

def merge_with_previous_revision!(timestamp, only_attrs, except_attrs, use_old_values, last_revision: nil)
  version_column = ActiveVersion.column_mapper.column_for(self.class, :revisions, :version)
  last_revision ||= revisions_scope.order(version_column => :desc).first
  return unless last_revision

  # Update last revision with current or persisted attributes (filtered by only/except)
  base_attrs = snapshot_base_attributes(use_old_values)

  snapshot_attrs = if only_attrs
    base_attrs.slice(*only_attrs.map(&:to_s))
  elsif except_attrs
    base_attrs.except(*except_attrs.map(&:to_s))
  else
    base_attrs
  end

  # Replace changed attributes with their old values
  if use_old_values
    changes_for_snapshot = if respond_to?(:changes_to_save) && changes_to_save.present?
      changes_to_save
    else
      changes
    end
    if changes_for_snapshot.present?
      changes_for_snapshot.each do |attr, values|
        attr_name = attr.to_s
        next unless snapshot_attrs.key?(attr_name)
        next if deleted_column?(attr_name)

        old_value = values.is_a?(Array) ? values[0] : nil
        old_value ||= attribute_was(attr_name) if respond_to?(:attribute_was)
        old_value ||= attribute_in_database(attr_name) if respond_to?(:attribute_in_database)
        snapshot_attrs[attr_name] = old_value unless old_value.nil?
      end
    end
  end

  # Filter out deleted columns
  snapshot_attrs.delete_if { |k, _v| deleted_column?(k) }
  snapshot_attrs.slice!(*revision_payload_columns)

  # When using except, we need to preserve excluded attributes from the existing revision
  except_attrs&.each do |attr|
    attr_str = attr.to_s
    if last_revision.has_attribute?(attr_str) && !snapshot_attrs.key?(attr_str)
      existing_value = last_revision.read_attribute(attr_str)
      snapshot_attrs[attr_str] = existing_value unless existing_value.nil?
    end
  end

  # Update last revision without triggering readonly protection (string keys for update_all)
  update_hash = snapshot_attrs.transform_keys(&:to_s).merge("updated_at" => timestamp)
  last_revision.class.where(id: last_revision.id).update_all(update_hash)
  revisions.reset
end

#redo!Object

Restore record to the future version (if undo was applied)



195
196
197
198
199
200
201
202
203
204
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 195

def redo!
  return false unless instance_variable_defined?(:@active_version_pointer)

  version_column = revision_version_column
  next_rev = revisions_scope.where("#{version_column} > ?", current_version).order(version_column => :asc).first
  return false unless next_rev

  next_version = next_rev.respond_to?(version_column) ? next_rev.public_send(version_column) : next_rev[version_column]
  switch_to!(next_version)
end

#refreshable_column_namesObject



445
446
447
448
449
450
451
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 445

def refreshable_column_names
  @refreshable_column_names ||=
    self.class.columns
      .select(&:default_function)
      .reject { |column| source_primary_key_columns.include?(column.name) }
      .map(&:name)
end

#revert_to(version:) ⇒ Object

Revert to a specific version (creates new revision)



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 154

def revert_to(version:)
  from_version = current_version
  target_revision_record = revisions_scope.at_version(version).first
  return false unless target_revision_record

  # Get attributes from revision record (excluding metadata)
  version_column = revision_version_column.to_s
  foreign_keys = revision_identity_columns

  attrs = target_revision_record.attributes.except(
    *source_primary_key_columns,
    "created_at",
    "updated_at",
    *foreign_keys,
    version_column
  )

  update!(attrs)
  ActiveVersion::Instrumentation.instrument_revision_reverted(
    self,
    from_version: from_version,
    to_version: version,
    strategy: :revert_to
  )
  true
end

#revision_payload_columnsObject



486
487
488
489
490
491
492
493
494
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 486

def revision_payload_columns
  @revision_payload_columns ||= begin
    revision_class = self.class.revision_class
    foreign_keys = Array(revision_class.source_foreign_key).map(&:to_s)
    version_column = revision_version_column.to_s

    revision_class.column_names - ["id", "created_at", "updated_at", *foreign_keys, version_column]
  end
end

#should_merge_with_previous?(debounce_time, timestamp) ⇒ Boolean

Returns:

  • (Boolean)


355
356
357
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 355

def should_merge_with_previous?(debounce_time, timestamp)
  !last_revision_for_debounce_merge(debounce_time, timestamp).nil?
end

#snapshot_base_attributes(use_old_values) ⇒ Object



462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 462

def snapshot_base_attributes(use_old_values)
  attrs = if use_old_values
    if respond_to?(:attributes_in_database)
      # Build a full snapshot: start from current attributes, then
      # overlay persisted ("old") values for changed columns.
      # Using only attributes_in_database would keep only changed keys
      # and can drop required NOT NULL columns on revision rows.
      merged = attributes.dup
      attributes_in_database.each do |key, value|
        merged[key] = value
      end
      merged
    else
      attributes.each_with_object({}) do |(k, v), h|
        h[k] = respond_to?(:attribute_in_database) ? attribute_in_database(k) : v
      end
    end
  else
    attributes
  end

  attrs.except(*source_primary_key_columns, "created_at", "updated_at").dup
end

#source_primary_key_columnsObject



519
520
521
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 519

def source_primary_key_columns
  @source_primary_key_columns ||= Array(self.class.primary_key).map(&:to_s)
end

#switch_to!(version, append: false) ⇒ Object

Restore record to the specified version



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 207

def switch_to!(version, append: false)
  from_version = current_version
  revision = at_version(version)
  return false unless revision
  version_column = revision_version_column

  if append && version < current_version
    # Create new version with old data instead of reverting
    # This creates a new revision with the target version's data
    target_revision = revisions_scope.where("#{version_column} = ?", version).first
    return false unless target_revision

    foreign_keys = revision_identity_columns
    attrs = target_revision.attributes.except(
      *source_primary_key_columns,
      "created_at",
      "updated_at",
      *foreign_keys,
      version_column.to_s
    )

    # Filter out deleted columns
    attrs.delete_if { |k, _v| deleted_column?(k) }
    attrs.slice!(*revision_payload_columns)

    # Apply the old attributes to the record first (this will be the "old" state for the new revision)
    # Then update to create a new revision that stores the current state (v3) as old, and has v2 as new
    # Actually, we want the new revision to have v2's data, so we need to set the record to v2 first
    # Then when we update, it will create a revision with v3 (current) as old and v2 as new
    # But that's not what we want either...

    # The correct approach: Set record to target state, then create snapshot manually with those attributes
    # But create_snapshot! captures the OLD state before changes...

    # Create revision directly with target attributes, then update record
    version_column_sym = version_column.is_a?(Symbol) ? version_column : version_column.to_sym
    new_version = current_version + 1

    revision_attrs = {
      version_column_sym => new_version,
      :created_at => Time.current
    }
    revision_attrs.merge!(active_version_revision_identity_map.transform_keys(&:to_sym))
    # Add the target version's attributes
    attrs.each do |k, v|
      key_sym = k.is_a?(Symbol) ? k : k.to_sym
      revision_attrs[key_sym] = v
    end

    # Create the revision using the association (it will set foreign_key automatically)
    revision = create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
    # Ensure it was created and persisted
    raise "Failed to create revision" unless revision.persisted?
    # Reload the post to clear all caches and see the new revision
    reload if persisted?
    # Now update the record to match the target version's state.
    # This won't create another revision because we're in without_revisions.
  else
    # To get the state AT version N, use the revision record at version N
    # Revision N stores the state BEFORE version N+1, which is the state AT version N
    revision_record = revisions_scope.where("#{version_column} = ?", version).first

    if revision_record
      # Use the revision record which has the state at version N
      foreign_keys = revision_identity_columns
      attrs = revision_record.attributes.except(
        *source_primary_key_columns,
        "created_at",
        "updated_at",
        *foreign_keys,
        version_column.to_s
      )
    elsif version == current_version
      # Version N is the current version, use current attributes
      attrs = attributes.except(*source_primary_key_columns, "created_at", "updated_at")
    else
      # Fallback: use the revision object from at_version
      foreign_keys = revision_identity_columns
      attrs = revision.attributes.except(
        *source_primary_key_columns,
        "created_at",
        "updated_at",
        *foreign_keys,
        version_column.to_s
      )
    end

    # Filter out deleted columns
    attrs.delete_if { |k, _v| deleted_column?(k) }
    attrs.slice!(*revision_payload_columns)

  end
  assign_attributes(attrs)
  self.class.without_revisions { save! }
  instance_variable_set(:@active_version_pointer, version)
  ActiveVersion::Instrumentation.instrument_revision_switch_applied(
    self,
    from_version: from_version,
    to_version: version,
    append: append
  )
  true
end

#undo!(append: false) ⇒ Object

Restore record to the previous version (second-to-last revision, or latest if only one)



182
183
184
185
186
187
188
189
190
191
192
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 182

def undo!(append: false)
  return false unless revisions_scope.exists?

  version_column = revision_version_column
  ensure_undo_redo_head_snapshot!(version_column)
  previous = revisions_scope.where("#{version_column} < ?", current_version).order(version_column => :desc).first
  return false unless previous

  prev_version = previous.respond_to?(version_column) ? previous.public_send(version_column) : previous[version_column]
  switch_to!(prev_version, append: append)
end