Module: ConcernsOnRails::Models::Auditable

Extended by:
ActiveSupport::Concern
Defined in:
lib/concerns_on_rails/models/auditable.rb

Overview

Lightweight change history (“paper_trail-lite”) stored as JSON entries in a single text column on the same table — no extra tables, no versioning engine — so it works on any database, including SQLite.

class Product < ApplicationRecord
  include ConcernsOnRails::Auditable

  auditable_by :price, :status                       # default column :audit_log
  # auditable_by :price, into: :history,
  #              actor: -> { Current.user&.email },  # stamps "by"
  #              max_entries: 50,                    # keep the newest 50
  #              max_value_length: 120               # truncate long from/to strings
end

product.update!(price: 200)
product.audit_trail
# => [{"field"=>"price", "from"=>100, "to"=>200,
#      "at"=>"2026-06-10T12:34:56Z", "by"=>"admin@shop.com"}]
product.last_change_for(:price)            # newest entry for one field
product.audited_changes_since(1.day.ago)
product.clear_audit_trail!                 # wipe the column (skips callbacks)

Notes:

* One entry per changed field per save; creates record `"from" => nil`.
  "by" is omitted entirely when no actor is configured (or it returns nil).
* Entries are appended in the same INSERT/UPDATE via before_save — no
  extra queries. Writes that skip callbacks (update_column(s), touch,
  increment!) are NOT audited.
* Values are JSON-coerced: times → ISO8601 UTC strings, BigDecimal →
  plain numeric string, symbols → strings. With `max_value_length:`,
  String values longer than the limit are stored as the first N
  characters plus a trailing "…" (non-strings are never truncated).
* A corrupt or non-array column decodes as [] and is overwritten on the
  next tracked save. Concurrent saves of one row are last-writer-wins.
* New entries are built on the PERSISTED trail (so an aborted save
  can't duplicate entries on retry). Assigning the audit column by
  hand in the same save as a tracked change is therefore ignored —
  use clear_audit_trail! to reset it.
* Reach for paper_trail/audited when you need reify/undo, who-dunnit
  queries across models, or association tracking.

Defined Under Namespace

Modules: ClassMethods

Constant Summary collapse

LABEL =
"ConcernsOnRails::Models::Auditable".freeze
DEFAULT_INTO =
:audit_log
DEFAULT_MAX_ENTRIES =
200

Instance Method Summary collapse

Instance Method Details

#audit_trailObject

Decoded audit entries, oldest first. [] for blank/corrupt columns.



101
102
103
# File 'lib/concerns_on_rails/models/auditable.rb', line 101

def audit_trail
  auditable_decode(self[self.class.auditable_into])
end

#audited_changes_since(time) ⇒ Object

Entries recorded at or after ‘time`, oldest first. Entries whose “at” is missing or unparseable are excluded.



113
114
115
116
117
118
# File 'lib/concerns_on_rails/models/auditable.rb', line 113

def audited_changes_since(time)
  audit_trail.select do |entry|
    at = auditable_parse_time(entry["at"])
    at && at >= time
  end
end

#clear_audit_trail!Object

Wipe the trail with a single UPDATE. Deliberately uses update_column so clearing can never itself be captured or run other callbacks/validations.

Raises:

  • (ArgumentError)


122
123
124
125
126
# File 'lib/concerns_on_rails/models/auditable.rb', line 122

def clear_audit_trail!
  raise ArgumentError, "#{LABEL}: clear_audit_trail! cannot be called on a new record" if new_record?

  update_column(self.class.auditable_into, nil)
end

#last_change_for(field) ⇒ Object

The most recent entry recorded for ‘field`, or nil.



106
107
108
109
# File 'lib/concerns_on_rails/models/auditable.rb', line 106

def last_change_for(field)
  name = field.to_s
  audit_trail.reverse_each.find { |entry| entry["field"] == name }
end