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

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



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

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



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

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



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

def clear_rolled_back_revisions
  revisions.reset
end

#create_revision_before_updateObject



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

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)

  # 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



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

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)


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

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



319
320
321
# File 'lib/active_version/revisions/has_revisions.rb', line 319

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.



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

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



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

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.



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

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



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

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)


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

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