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
-
#audit_trail ⇒ Object
Decoded audit entries, oldest first.
-
#audited_changes_since(time) ⇒ Object
Entries recorded at or after ‘time`, oldest first.
-
#clear_audit_trail! ⇒ Object
Wipe the trail with a single UPDATE.
-
#last_change_for(field) ⇒ Object
The most recent entry recorded for ‘field`, or nil.
Instance Method Details
#audit_trail ⇒ Object
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.
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 |