Module: ActiveVersion::Audits::HasAudits

Extended by:
ActiveSupport::Concern
Includes:
AuditCallbacks, AuditCombiner, AuditWriter, ChangeFilters
Defined in:
lib/active_version/audits/has_audits.rb,
lib/active_version/audits/has_audits/audit_writer.rb,
lib/active_version/audits/has_audits/audit_combiner.rb,
lib/active_version/audits/has_audits/change_filters.rb,
lib/active_version/audits/has_audits/audit_callbacks.rb,
lib/active_version/audits/has_audits/database_adapter_helper.rb

Overview

Concern for models that have audits

Defined Under Namespace

Modules: AuditCallbacks, AuditCombiner, AuditWriter, ChangeFilters, ClassMethods, DatabaseAdapterHelper

Constant Summary collapse

REDACTED =
"[REDACTED]"

Instance Method Summary collapse

Instance Method Details

#active_auditsObject

Get active audits (excludes combined ones - those with empty changes) Filters in Ruby for database-agnostic behavior



887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
# File 'lib/active_version/audits/has_audits.rb', line 887

def active_audits
  changes_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :changes)
  return audits.to_a unless audit_class.column_names.include?(changes_column.to_s)

  audits.to_a.reject do |audit|
    # Check raw column value first (before JSON parsing)
    # Combined audits have their changes set to "{}" (empty JSON object as string)
    raw_changes = audit.read_attribute(changes_column)

    # If raw value is "{}", it's a combined audit
    if raw_changes.is_a?(String) && raw_changes.strip == "{}"
      true
    else
      # Otherwise check parsed value
      changes = audit.audited_changes
      changes.nil? || (changes.is_a?(Hash) && changes.empty?) || (changes.is_a?(String) && changes.strip.empty?)
    end
  end
end

#active_version_audit_identity_columnsObject



844
845
846
847
848
# File 'lib/active_version/audits/has_audits.rb', line 844

def active_version_audit_identity_columns
  auditable_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :auditable).to_s
  configured = self.class.audited_options && self.class.audited_options[:identity_columns]
  Array(configured.presence || "#{auditable_column}_id").map(&:to_s)
end

#active_version_audit_identity_mapObject



850
851
852
853
854
855
856
857
858
859
860
861
862
# File 'lib/active_version/audits/has_audits.rb', line 850

def active_version_audit_identity_map
  columns = active_version_audit_identity_columns
  values = active_version_audit_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_audit_identity_valuesObject



864
865
866
867
868
869
870
871
872
873
874
875
876
# File 'lib/active_version/audits/has_audits.rb', line 864

def active_version_audit_identity_values
  resolver = self.class.audited_options && self.class.audited_options[:identity_resolver]
  return default_audit_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

#active_version_auditable_id_valueObject



836
837
838
839
840
841
842
# File 'lib/active_version/audits/has_audits.rb', line 836

def active_version_auditable_id_value
  values = active_version_audit_identity_values
  return values.values.first if values.is_a?(Hash) && values.size == 1
  return values.first if values.is_a?(Array) && values.size == 1

  values
end

#audit_revision(version: nil) ⇒ Object

Get revision at specific version (from audits) This method is separate from HasRevisions#revision to avoid conflicts



646
647
648
649
650
651
652
653
654
655
# File 'lib/active_version/audits/has_audits.rb', line 646

def audit_revision(version: nil)
  return nil unless version

  # Get all audits up to and including the specified version
  version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
  audits_list = audits.where("#{version_column} <= ?", version).order(version_column => :asc).to_a
  return nil if audits_list.empty?

  self.class.revision_with audit_class.reconstruct_attributes(audits_list), id: id
end

#audit_revision_at(date_or_time) ⇒ Object

Get revision at specific time (from audits) This method is separate from HasRevisions#revision_at to avoid conflicts



659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
# File 'lib/active_version/audits/has_audits.rb', line 659

def audit_revision_at(date_or_time)
  time_obj = ActiveVersion.parse_time_to_time(date_or_time)
  # Always raise error for future times
  raise ActiveVersion::FutureTimeError, "Future state cannot be known" if time_obj.future?

  version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
  audits_list = audits.up_until(time_obj).order(version_column => :asc).to_a
  # If no audits found for the time, return the earliest audit if it exists (for times before creation)
  if audits_list.empty?
    earliest_audit = audits.order(version_column => :asc).first
    return nil unless earliest_audit
    audits_list = [earliest_audit]
  end

  self.class.revision_with audit_class.reconstruct_attributes(audits_list), id: id
end

#audit_sql(destroy: false) ⇒ Object

Generate SQL for single audit insert



677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
# File 'lib/active_version/audits/has_audits.rb', line 677

def audit_sql(destroy: false)
  # Allow SQL generation even if no changes (for testing/documentation purposes)
  # In production, this would typically only be called when there are changes

  action = if new_record?
    "create"
  elsif destroy
    "destroy"
  else
    "update"
  end

  attrs = {
    action: action,
    audited_changes: audited_changes,
    comment: audit_comment
  }
  attrs[:associated] = send(audit_associated_with) unless audit_associated_with.nil?

  # Build attributes for SQL generation (avoid dangerous attribute error)
  changes_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :changes)
  context_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :context)
  auditable_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :auditable)
  version_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :version)
  comment_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :comment)

  # Build changes hash manually to avoid dangerous attribute error
  changes = {
    action: attrs[:action]
  }
  if audit_class.column_names.include?(changes_column.to_s)
    changes[changes_column] = attrs[:audited_changes]
  elsif audited_options[:storage].to_sym == :mirror_columns
    audited_attributes.each do |attr, value|
      next unless audit_class.column_names.include?(attr.to_s)

      changes[attr.to_sym] = value
    end
  end
  changes[comment_column] = attrs[:comment] if attrs[:comment].present?
  changes[context_column] = attrs[:audited_context] if attrs[:audited_context].present?
  changes.merge!(active_version_audit_identity_map)
  changes["#{auditable_column}_type"] = self.class.name
  changes[version_column] = (attrs[:action] == "create") ? 1 : (audits.maximum(version_column) || 0) + 1
  changes[:created_at] = Time.current
  changes[:updated_at] = Time.current

  # Prepare SQL-safe values
  changes = prepare_sql_values(changes)
  changes["created_at"] ||= Time.current

  # Build SQL using Arel
  stmt = Arel::InsertManager.new
  table = Arel::Table.new(audit_class.table_name)
  stmt.into(table)
  changes.keys.each { |key| stmt.columns << table[key] }
  stmt.values = stmt.create_values(changes.values)
  sql = stmt.to_sql

  # Instrument SQL generation
  ActiveVersion::Instrumentation.instrument_audit_sql_generated(self, sql)

  sql
end

#auditing_enabledObject



797
798
799
# File 'lib/active_version/audits/has_audits.rb', line 797

def auditing_enabled
  should_audit?
end

#auditsObject

Override audits method to handle dynamically created classes Uses class_name from options if provided Returns standard ActiveRecord relation Use active_audits for filtered results

Raises:



809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
# File 'lib/active_version/audits/has_audits.rb', line 809

def audits
  # Use class_name from options if provided (for dynamically created classes)
  auditable_type = audited_options[:class_name] || self.class.name
  if auditable_type.nil?
    raise ConfigurationError, "Cannot determine class name for dynamically created class. Please specify class_name option in has_audits (e.g., has_audits as: PostAudit, class_name: 'Post')"
  end

  uses_custom_auditable_id = audited_options[:identity_resolver].present? ||
    Array(active_version_audit_identity_columns).length > 1

  audit_klass =
    if !uses_custom_auditable_id && self.class.reflect_on_association(:audits)
      association(:audits).klass
    else
      self.class.audit_class
    end
  audit_klass ||= self.class.send(:resolve_audit_class_option, audited_options[:as]) if self.class.respond_to?(:resolve_audit_class_option, true)
  raise ConfigurationError, "No audit class configured for #{self.class.name}" unless audit_klass

  if !uses_custom_auditable_id && auditable_type == self.class.name && self.class.reflect_on_association(:audits)
    return super
  end

  auditable_column = ActiveVersion.column_mapper.column_for(self.class, :audits, :auditable)
  audit_klass.where({"#{auditable_column}_type" => auditable_type}.merge(active_version_audit_identity_map))
end

#clear_rolled_back_auditsObject



801
802
803
# File 'lib/active_version/audits/has_audits.rb', line 801

def clear_rolled_back_audits
  association(:audits).reset if association_cached?(:audits)
end

#default_audit_identity_valuesObject



878
879
880
881
882
883
# File 'lib/active_version/audits/has_audits.rb', line 878

def default_audit_identity_values
  columns = active_version_audit_identity_columns
  return id if columns.one?

  Array(self.class.primary_key).map { |column| self[column] }
end

#own_and_associated_auditsObject

Get own and associated audits



743
744
745
746
747
# File 'lib/active_version/audits/has_audits.rb', line 743

def own_and_associated_audits
  audit_class.unscoped.where(auditable: self)
    .or(audit_class.unscoped.where(associated: self))
    .order(created_at: :desc)
end

#revision_with(attributes) ⇒ Object



915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
# File 'lib/active_version/audits/has_audits.rb', line 915

def revision_with(attributes)
  # Create a new instance with reconstructed attributes
  # Keep it as a new record to prevent database reads
  attrs_to_assign = attributes.except(:audit_version).stringify_keys
  revision = self.class.new(attrs_to_assign)

  # Set id but keep as new_record to prevent database reads
  revision.id = id
  revision.instance_variable_set(:@new_record, true)
  revision.instance_variable_set(:@persisted, false)

  # Mark as readonly to prevent modifications
  revision.readonly!

  revision
end

#run_conditional_check(condition, matching: true) ⇒ Object



907
908
909
910
911
912
913
# File 'lib/active_version/audits/has_audits.rb', line 907

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_audit?Boolean

Returns:

  • (Boolean)


783
784
785
786
787
788
789
790
791
792
793
794
795
# File 'lib/active_version/audits/has_audits.rb', line 783

def should_audit?
  # Check class-level enabled state
  return false unless self.class.class_auditing_enabled?

  # Check global enabled state
  return false unless ActiveVersion.auditing_enabled

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

  true
end

#with_auditing(&block) ⇒ Object

Temporarily enable auditing



755
756
757
# File 'lib/active_version/audits/has_audits.rb', line 755

def with_auditing(&block)
  self.class.with_auditing(&block)
end

#without_auditing(&block) ⇒ Object

Temporarily disable auditing



750
751
752
# File 'lib/active_version/audits/has_audits.rb', line 750

def without_auditing(&block)
  self.class.without_auditing(&block)
end