Module: ActiveVersion::Revisions::HasRevisions

Extended by:
ActiveSupport::Concern
Includes:
RevisionManipulation, RevisionQueries
Defined in:
lib/active_version/revisions/has_revisions.rb,
lib/active_version/revisions/has_revisions/revision_queries.rb,
lib/active_version/revisions/has_revisions/revision_manipulation.rb,
sig/active_version/revisions.rbs

Overview

Concern for models that have revisions

Defined Under Namespace

Modules: ClassMethods, RevisionManipulation, RevisionQueries

Instance Method Summary collapse

Methods included from RevisionManipulation

#apply_revision_diff, #build_diff, #changes_to, #create_snapshot!, #deleted_column?, #deserialize_value, #diff_from, #ensure_undo_redo_head_snapshot!, #last_revision_for_debounce_merge, #merge_with_previous_revision!, #redo!, #refreshable_column_names, #revert_to, #revision_payload_columns, #should_merge_with_previous?, #snapshot_base_attributes, #source_primary_key_columns, #switch_to!, #undo!

Methods included from RevisionQueries

#at, #at!, #at_version, #at_version!, #current_version, #revision, #revision_at, #versions

Instance Method Details

#active_version_revision_identity_mapObject



289
290
291
292
293
294
295
296
297
298
299
300
301
# File 'lib/active_version/revisions/has_revisions.rb', line 289

def active_version_revision_identity_map
  columns = revision_identity_columns
  values = active_version_revision_identity_values

  case values
  when Hash
    values.transform_keys(&:to_s).slice(*columns)
  when Array
    columns.zip(values).to_h
  else
    {columns.first => values}
  end
end

#active_version_revision_identity_valuesObject



303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/active_version/revisions/has_revisions.rb', line 303

def active_version_revision_identity_values
  resolver = self.class.revision_options && self.class.revision_options[:identity_resolver]
  return default_revision_identity_values if resolver.nil?

  case resolver
  when Proc
    resolver.arity.zero? ? instance_exec(&resolver) : resolver.call(self)
  when Array
    resolver.map { |column| public_send(column) }
  else
    public_send(resolver)
  end
end

#clear_rolled_back_revisionsObject



426
427
428
# File 'lib/active_version/revisions/has_revisions.rb', line 426

def clear_rolled_back_revisions
  revisions.reset
end

#create_revision_before_updateObject



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
# File 'lib/active_version/revisions/has_revisions.rb', line 394

def create_revision_before_update
  pointer = instance_variable_get(:@active_version_pointer)
  truncated_forward_history = false
  if pointer
    version_column = revision_version_column
    # Classic linear undo/redo behavior: editing after undo drops forward history.
    revisions_scope.where("#{version_column} > ?", pointer).delete_all
    revisions.reset
    remove_instance_variable(:@active_version_pointer)
    truncated_forward_history = true
  end
  # Check if we should create revision
  return unless should_create_revision?
  return if truncated_forward_history && latest_revision_matches_current_state?

  result = create_snapshot!(use_old_values: true)
  return unless result

  # Ensure revision is persisted and visible in association
  unless result.persisted?
    error_msg = "Failed to create revision: #{result.errors.full_messages.join(", ")}" if result.errors.any?
    error_msg ||= "Revision was not persisted after save!"
    error_msg += "\nRevision: #{result.inspect}"
    error_msg += "\nRevision valid?: #{result.valid?}"
    raise error_msg
  end

  # Clear association cache to ensure revision is visible
  revisions.reset
  result
end

#default_revision_identity_valuesObject



321
322
323
324
325
326
# File 'lib/active_version/revisions/has_revisions.rb', line 321

def default_revision_identity_values
  columns = revision_identity_columns
  return id if columns.one?

  source_primary_key_columns.map { |column| self[column] }
end

#latest_revision_matches_current_state?Boolean

Returns:

  • (Boolean)


430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/active_version/revisions/has_revisions.rb', line 430

def latest_revision_matches_current_state?
  version_column = revision_version_column
  latest = revisions_scope.order(version_column => :desc).first
  return false unless latest

  base_attrs = snapshot_base_attributes(true)
  revision_payload_columns.all? do |column|
    next true if deleted_column?(column)
    next true unless base_attrs.key?(column.to_s)

    latest.read_attribute(column.to_s) == base_attrs[column.to_s]
  end
end

#revision_identity_columnsObject



317
318
319
# File 'lib/active_version/revisions/has_revisions.rb', line 317

def revision_identity_columns
  Array(self.class.revision_class.source_foreign_key).map(&:to_s)
end

#revision_sql(version: nil, upsert: false, use_old_values: false, only: nil, except: nil, timestamp: Time.current) ⇒ Object

Generate SQL for a single revision insert/upsert. Useful for delayed write pipelines where revision rows are inserted later.



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
# File 'lib/active_version/revisions/has_revisions.rb', line 238

def revision_sql(version: nil, upsert: false, use_old_values: false, only: nil, except: nil, timestamp: Time.current)
  return "" unless persisted?

  version_column = revision_version_column
  revision_class = self.class.revision_class

  base_attrs = snapshot_base_attributes(use_old_values)
  snapshot_attrs = if only
    base_attrs.slice(*Array.wrap(only).map(&:to_s))
  elsif except
    base_attrs.except(*Array.wrap(except).map(&:to_s))
  else
    base_attrs
  end
  snapshot_attrs.delete_if { |k, _v| deleted_column?(k) }

  next_version = version || (current_version + 1)
  revision_attrs = snapshot_attrs.merge(
    active_version_revision_identity_map.transform_keys(&:to_s)
  ).merge(
    version_column.to_s => next_version,
    "created_at" => timestamp,
    "updated_at" => timestamp
  )

  connection = revision_class.connection
  table_name = connection.quote_table_name(revision_class.table_name)
  columns = revision_attrs.keys
  column_list = columns.map { |col| connection.quote_column_name(col) }.join(", ")
  values_list = columns.map { |col| connection.quote(revision_sql_value(revision_attrs[col])) }.join(", ")

  sql = "INSERT INTO #{table_name} (#{column_list}) VALUES (#{values_list})"
  return sql unless upsert

  conflict_cols = revision_identity_columns.map(&:to_s) + [version_column.to_s]
  updatable_columns = columns - conflict_cols - ["id", "created_at"]
  if updatable_columns.empty?
    "#{sql} ON CONFLICT (#{conflict_cols.map { |col| connection.quote_column_name(col) }.join(", ")}) DO NOTHING"
  else
    assignments = updatable_columns.map do |col|
      qcol = connection.quote_column_name(col)
      "#{qcol} = EXCLUDED.#{qcol}"
    end.join(", ")
    "#{sql} ON CONFLICT (#{conflict_cols.map { |col| connection.quote_column_name(col) }.join(", ")}) DO UPDATE SET #{assignments}"
  end
end

#revision_sql_value(value) ⇒ Object



328
329
330
331
332
333
334
335
336
337
338
339
# File 'lib/active_version/revisions/has_revisions.rb', line 328

def revision_sql_value(value)
  case value
  when Hash, Array
    JSON.generate(value)
  when Time, DateTime
    value.utc
  when Date
    value.to_time.utc
  else
    value
  end
end

#revision_version_columnObject

Get the effective version column for revisions. Respects custom column mappings but falls back to the default revision version column when the mapped column does not exist in the revision table. This mirrors the behavior used for translations and makes custom mappings opt‑in at the schema level.



346
347
348
349
350
351
352
353
354
355
# File 'lib/active_version/revisions/has_revisions.rb', line 346

def revision_version_column
  column = ActiveVersion.column_mapper.column_for(self.class, :revisions, :version)

  revision_class = self.class.revision_class
  unless revision_class.column_names.include?(column.to_s)
    column = ActiveVersion.config.revision_version_column
  end

  column
end

#run_conditional_check(condition, matching: true) ⇒ Object



387
388
389
390
391
392
# File 'lib/active_version/revisions/has_revisions.rb', line 387

def run_conditional_check(condition, matching: true)
  return true if condition.blank?
  return condition.call(self) == matching if condition.respond_to?(:call)
  return send(condition) == matching if respond_to?(condition.to_sym, true)
  true
end

#should_create_revision?Boolean

Returns:

  • (Boolean)


357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/active_version/revisions/has_revisions.rb', line 357

def should_create_revision?
  # Check basic conditions
  # In before_update callbacks, we're already in an update, so assume there are changes
  # The changes hash might be empty at this point, but we're updating so there must be changes
  unless persisted?
    return false
  end

  # Check class-level enabled state (default to true if not set)
  class_enabled = if self.class.instance_variable_defined?(:@class_revision_enabled)
    self.class.instance_variable_get(:@class_revision_enabled)
  else
    true # Default to enabled
  end
  return false unless class_enabled != false

  # Check global enabled state
  # Note: We use config.auditing_enabled for both audits and revisions
  return false unless ActiveVersion.config.auditing_enabled

  # Get revision options (with defaults if not set)
  options = self.class.revision_options || {auto: true, on: [:update]}

  # Check if/unless conditions
  return false unless run_conditional_check(options[:if])
  return false unless run_conditional_check(options[:unless], matching: false)

  true
end