paper_trail_diff
Compare past and present records using the history saved by
PaperTrail. Show changed fields,
include changes to associated records, or build a report for many records.
Results are Ruby objects with methods for reading the changes and to_h for
turning them into hashes. The gem does not generate display text or save changes.
If you already use has_paper_trail and record.versions, you can pass those
versions directly to this gem. You do not need to call reify yourself.
Get started
Add the gem to your Rails application:
bundle add paper_trail_diffAlready have a model with has_paper_trail? Try the comparison below.
Otherwise, follow the Rails quickstart to set up PaperTrail
and create your first comparison.
To try it without Rails, download examples/demo.rb and run
ruby demo.rb. It installs its dependencies on the first run. The
demo application
shows reports in a Rails UI.
Ruby 3.1+ and PaperTrail 16 or 17 are supported, subject to PaperTrail's Ruby and ActiveRecord requirements. Comparing association history also requires paper_trail-association_tracking, an optional PaperTrail plugin. The quickstart includes its setup.
Experimental: AI-assisted coding tools made significant contributions to this project. Check results against your application's history before relying on them. Changes that were never recorded cannot be recovered.
1. Compare an earlier version with the current record
This example assumes an Article model with a title column and has_paper_trail:
article = Article.create!(title: "Draft")
article.update!(title: "Published")
# PaperTrail stores the values from BEFORE the update.
before = article.versions.last
diff = PaperTrailDiff.compare(before, article)
diff.attributes.fetch("title").to_h
# => { from: "Draft", to: "Published" }PaperTrail's version.reify returns the record as it was before that version's
change. Passing a version to compare uses that same earlier state. Passing
article uses its current saved values. Pass two versions to compare two
historical states.
PaperTrail's version.changeset describes one recorded change. compare
describes the net difference between the two states you choose. If a field
changes and then changes back, there is no net difference for that field. To
see every change instead, use PaperTrailDiff.timeline(article), which covers
the article's whole history through its current saved values.
An attribute appears in diff.attributes only when it changed. diff.empty?
tells you whether anything changed. A PaperTrail create version has no earlier
record (reify returns nil), so comparing it with the current record reports
diff.record_presence_change, meaning the record was added.
To include comments, add associations: [:comments]. If comments have replies,
associations: ["comments.replies"] includes those too. Associated models must
also use has_paper_trail, and association tracking must be enabled.
For nested results, each_change lets you read changes without walking every
association yourself:
diff.each_change do |entry|
entry.kind # e.g. :attribute_changed or :record_added
entry.association_path # e.g. ["comments", "replies"]
entry.value # the changed values or the added/removed record's data
end2. Show who changed a record and its comments
This example requires the association setup in the quickstart. PaperTrail's
whodunnit records who made a change; here we set it with PaperTrail.request.
The timeline covers the article's whole history, including comment changes made
after the article's last version.
article = Article.create!(title: "Draft")
PaperTrail.request(whodunnit: "alice") do
Article.transaction do
article.update!(title: "Published")
article.comments.create!(body: "Please review")
end
end
steps = PaperTrailDiff.activity_timeline(
article, associations: [:comments], group: :transaction, snapshots: true
)
feed = steps.reject(&:empty?).map do |step|
{
actor: step.source_boundary.whodunnit,
recorded_at: step.source_boundary.recorded_at,
changes: step.diff.to_h
}
end
payload = steps.map { |step| step.to_h(metadata: true) }Each step contains a diff. source_boundary describes the PaperTrail version
responsible for that change, including its whodunnit, event, item_type,
and item_id. Its recorded_at is the version's created_at.
Use this source information for attribution: the version used to read the
resulting state, exposed as to_boundary, may have been saved by someone else.
A change saved without a version, such as with update_columns, appears in the
step for that record's previous recorded change. To also read only what each
version recorded, pass recorded_changes: true and read
step.source_boundary.recorded_changes; see
what each event recorded.
group: :transaction combines changes from one database transaction. Its source
information comes from the first event in that group, and step.sources lists
every event in it, each with its own author.
snapshots: true also keeps copies of the compared records' data, including
unchanged fields. These read-only copies are called snapshots. Access them as
step.from_snapshot and step.to_snapshot, or include them in a hash with
step.to_h(snapshots: true). Keeping them uses more memory. Omit the option if
changed values are enough. metadata: true adds event and author information
to the hash.
3. Report on records changed since a given time
Use report with a model or ActiveRecord relation. It includes activity from
since: through the current saved state. Association tracking is only needed
if you also pass associations:.
report = PaperTrailDiff.report(
Article.all, since: 1.day.ago, limit: 100, include_deleted: true
)
rows = report.analyses.map do |identity, analysis|
{
record: identity, # ["Article", "42"]: model name and ID
changes: analysis.diff.to_h,
activity: analysis.activity_timeline.reject(&:empty?).map do |step|
step.to_h(metadata: true)
end
}
end
report.unreachable # Model names and IDs of selected records no longer in the database
report.deleted # Reports for those records, or nil when destroy history is missingreport.analyses is a hash of reports for records that still exist. Each key is
[model_name, id_as_a_string]. Each value has the net diff and an
activity_timeline. report.roots returns the selected ActiveRecord objects,
and report.analysis_for(record) the report for one of them.
include_deleted: true also builds reports from PaperTrail's destroy versions.
Read them from report.deleted using the same keys:
unavailable = report.deleted.select { |_identity, analysis| analysis.nil? }.keys
deleted_activity = report.deleted.transform_values do |analysis|
analysis&.activity_timeline
end
last_state = report.deleted.transform_values do |analysis|
analysis&.activity_timeline&.last&.diff&.record_presence_change&.from
endThe results are already computed; you do not need to query or reify each deleted
record. last_state is each record just before its deletion, which the final
step removes. Use it to show what a deleted record was, such as its title:
analysis.from_snapshot is the record when the window began, so it is nil for
one created inside the window. Omit include_deleted: if you only need records
that still exist.
Three selection rules matter:
- A record is selected when it, or a record on one of the
associations:you request, has a PaperTrail version in the time window. Withassociations: [:comments], editing only a comment selects its article, and the article's report shows the edit. - A relation such as
Article.where(status: "open")checks today's values. Deleted records have none, so they are listed separately. Withinclude_deleted: true, the gem tests each against the relation as it was when destroyed and keeps only those that match, so a report on one account's records holds only that account's deletions. That needs plain conditions on the model's own columns; a join or SQL text raisesConfigurationError. -
limit: 100is a safety ceiling, not pagination. Selecting too many records raisesBatchLimitExceededError. Withinclude_deleted: true, both existing and deleted records count toward it. Use a narrower relation or time window, or deliberately raise the ceiling.
Use the relation's order, offset, and limit to choose a page, for example
Article.order(:id).limit(20). Joined rows can repeat a record; each record is
analyzed once. See the reporting reference
for pagination, deleted records, and selecting by past values with
historical_filter:.
For a fixed past window, use analyze_scope(Article.all, within: start_time...end_time, activity: true, limit: 100). Without activity: true, analyze_scope compares
each record's own versions, so it selects only records with a version of their
own.
4. Turn a report into rows
For a history table or an export, change_rows flattens a report into one row
per change. Each row names the record, the change, and the version it came
from. This example requires the association setup in the quickstart:
report = PaperTrailDiff.report(
Article.all, since: 1.day.ago, limit: 100,
associations: [:comments], include_deleted: true
)
rows = report.change_rows.flat_map do |root, changes|
changes.map { |row| row.to_h.merge(root: root) }
endreport.change_rows is a hash keyed like report.analyses, with deleted
records last. Each value lists that record's rows in time order. Each row
covers one of two things:
-
A field:
attributenames it, andchangeis:changedfor an edit. For a record that appeared or disappeared, each of its fields that is notnilgets an:addedor:removedrow, holding its value intoorfrom. An empty string orfalsestill gets one. -
A whole record:
attributeisnil, andchangeis:addedor:removed. The record was created or deleted, a comment joined or left the article, or abelongs_topointed somewhere new, which gives a:removedrow for the old record and an:addedrow for the new one. A comment moved from another article is:addedtoo; itseventis then"update".
record is the record the row is about, or nil for the article itself, and
association_path is how it was reached, such as ["comments"]. to_h adds
the version that made the change: version_id, event, whodunnit,
recorded_at, and transaction_id. That is the version of the row's own
record, even when another record's event opens the step, as in a transaction.
A record that joined or left through another record's change, such as an
article's new author, names the step's first event. row.source is the
ActivityBoundary itself.
A write made without a version, such as update_columns, shows up in the
step of the record's previous change. Pass recorded_changes: true to
report to tell it apart: the part of a change PaperTrail did not record
gets a row of its own, with row.unversioned? true and no version_id,
event, whodunnit, or recorded_at. To list only what versions recorded:
recorded = report.change_rows.transform_values { |rows| rows.reject(&:unversioned?) }Some writes cannot be told apart, such as one to a column has_paper_trail
ignores; see change rows.
For one entry per record instead, net_changes folds the rows a block keeps
into each record's fields before and after them. This one keeps the changes
people made, leaving out writes saved without a version, which have no
author:
report = PaperTrailDiff.report(
Article.all, since: 1.day.ago, limit: 100, associations: [:comments],
snapshots: true, recorded_changes: true
)
changes = report.net_changes { |row| row.source.whodunnit }Each value lists the article, then each comment that changed, was added, or
was removed, with its before and after fields and the diff between
them. See net changes.
change_rows is also on an Analysis built with activity: true and on each
activity step. Labels, grouping, and formatting are left to your application.
Choose a method
| What you need | Method |
|---|---|
| Net difference between two versions, or a version and the current record | compare(from, to) |
The same for a list of { from:, to: } pairs, one per record |
compare_many(pairs) |
| Each change to one record, version by version | timeline(record) |
| Each change to one record and its chosen associations, with who made it | activity_timeline(record, associations: ...) |
| Net difference and timeline together | analyze(record) |
| The same, plus the activity timeline | analyze(record, activity: true) |
| Analyses for an array of records | analyze_many(records) |
| Analyses for the records a relation selects, over a time window | analyze_scope(scope, within:, limit:) |
| Recent activity through now, for the records a relation selects | report(scope, since:, limit:) |
timeline, activity_timeline, and analyze cover the whole history through
the current saved record by default. Narrow them with from: and to:
(versions) or within: (a time range). See
choosing a range.
Performance and defaults
Use the batch methods for reports instead of calling reify or a single-record
method inside a loop. They share history loading across records. Ordinary
record and direct-association histories can be read without creating a model
instance for every historical state.
Scopes and default scopes made of plain conditions, such as
-> { where(archived: false) }, are batched too, and so is a limit or
offset ordered by integer, boolean, date, or datetime columns down to the
primary key, such as -> { order(:position, :id).limit(3) }, on a direct
association or a has_many :through. History cannot read a few scopes, such
as a limit ordered by a string, a scope that takes the owner, or SQL text.
PT-AT reads those instead, one record at a time and only approximately, so
every method raises UnsupportedAssociationError naming the association.
Remove it from associations:, or pass allow_approximate: true to accept
PT-AT's answer and its cost. See
costly associations.
Callbacks and callable historical filters can also issue queries. See the
performance guide for measured behavior and limitations.
On a large version table, add these indexes, or every since: or within:
report reads all of the model's versions. See
database indexes for why.
add_index :versions, %i[item_type item_id created_at id],
name: "index_versions_on_item_and_timeline"
add_index :versions, %i[item_type created_at item_id],
name: "index_versions_on_type_and_time"-
updated_atis ignored. Passingignore:replaces the default list. - Records passed as current state must be saved and have no unsaved changes. They are reloaded from the database.
- Empty timeline steps are kept. Use
reject(&:empty?)to hide them in a feed. - Only the associations you name are included.
- Historical data cannot be recovered if PaperTrail did not record it.
See the API reference for all options and result methods, testing for development checks, and CHANGELOG.md for changes. Licensed under the MIT license.