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,
sig/active_version/revisions.rbs

Overview

Methods for manipulating revisions

Instance Method Summary collapse

Instance Method Details

#apply_revision_diff(version, changes) ⇒ Object



332
333
334
335
336
337
338
339
340
341
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 332

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



550
551
552
553
554
555
556
557
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 550

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



536
537
538
539
540
541
542
543
544
545
546
547
548
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 536

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

Parameters:

  • opts (Hash[Symbol, untyped]) (defaults to: {})

Returns:

  • (Object)


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
# 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
    begin
      revision = create_revision_record_with_version_retry!(revision_attrs, version_column_sym)
    rescue ActiveRecord::RecordInvalid => error
      revision = handle_revision_errors(
        error,
        revision_attrs: revision_attrs,
        version_column_sym: version_column_sym,
        new_version: new_version,
        invalid_record: error.record
      )
      return revision unless revision
    rescue => error
      revision = handle_revision_errors(
        error,
        revision_attrs: revision_attrs,
        version_column_sym: version_column_sym,
        new_version: new_version
      )
      return revision unless revision
    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)


328
329
330
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 328

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

#deserialize_value(column, value) ⇒ Object



343
344
345
346
347
348
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 343

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) ⇒ Hash[String, untyped]

Get diff from specific time or version

Parameters:

  • time: (Object) (defaults to: nil)
  • version: (Integer, nil) (defaults to: nil)

Returns:

  • (Hash[String, untyped])

Raises:

  • (ArgumentError)


307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 307

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



493
494
495
496
497
498
499
500
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 493

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.



355
356
357
358
359
360
361
362
363
364
365
366
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 355

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



368
369
370
371
372
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
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 368

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!Boolean

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

Returns:

  • (Boolean)


190
191
192
193
194
195
196
197
198
199
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 190

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



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

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:) ⇒ Boolean

Revert to a specific version (creates new revision)

Parameters:

  • version: (Integer)

Returns:

  • (Boolean)


149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 149

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



526
527
528
529
530
531
532
533
534
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 526

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)


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

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



502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 502

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



559
560
561
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 559

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

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

Restore record to the specified version

Parameters:

  • version (Integer)
  • append: (Boolean) (defaults to: false)

Returns:

  • (Boolean)


202
203
204
205
206
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
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 202

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) ⇒ Boolean

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

Parameters:

  • append: (Boolean) (defaults to: false)

Returns:

  • (Boolean)


177
178
179
180
181
182
183
184
185
186
187
# File 'lib/active_version/revisions/has_revisions/revision_manipulation.rb', line 177

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