paper_trail_diff 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1388
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +169 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +10 -38
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/collection_comparator.rb +18 -20
- data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +176 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +39 -15
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +35 -3
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +14 -1
- data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +21 -2
- data/lib/paper_trail_diff/time_version_range.rb +2 -16
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +29 -46
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +41 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +78 -42
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +31 -25
- metadata +31 -5
data/docs/reference.md
ADDED
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
# API reference
|
|
2
|
+
|
|
3
|
+
Start with the [README](../README.md) examples or the
|
|
4
|
+
[Rails quickstart](../QUICKSTART.md). This page lists every method and option,
|
|
5
|
+
including ones most applications never need.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [How PaperTrail stores history](#how-papertrail-stores-history)
|
|
10
|
+
- [Choosing a method](#choosing-a-method)
|
|
11
|
+
- [Choosing a range](#choosing-a-range)
|
|
12
|
+
- [compare](#compare) and [compare_many](#compare_many)
|
|
13
|
+
- [timeline](#timeline)
|
|
14
|
+
- [activity_timeline](#activity_timeline)
|
|
15
|
+
- [analyze](#analyze)
|
|
16
|
+
- [Reports for many records](#reports-for-many-records):
|
|
17
|
+
`analyze_many`, `analyze_scope`, `report`
|
|
18
|
+
- [Associations](#associations)
|
|
19
|
+
- [Ignoring fields](#ignoring-fields)
|
|
20
|
+
- [Reading results](#reading-results)
|
|
21
|
+
- [JSON columns](#json-columns)
|
|
22
|
+
- [Checking your setup](#checking-your-setup)
|
|
23
|
+
- [Models, schemas, and edge cases](#models-schemas-and-edge-cases)
|
|
24
|
+
- [Instrumentation](#instrumentation)
|
|
25
|
+
- [Errors](#errors)
|
|
26
|
+
- [Limitations](#limitations)
|
|
27
|
+
|
|
28
|
+
## Words used on this page
|
|
29
|
+
|
|
30
|
+
| Word | Meaning |
|
|
31
|
+
| --- | --- |
|
|
32
|
+
| Root record | The record you ask about, such as the article in `timeline(article)` |
|
|
33
|
+
| Child | An associated record you include with `associations:`, such as a comment |
|
|
34
|
+
| Current state | The record's saved values in the database right now |
|
|
35
|
+
| Snapshot | A read-only copy of a record's data at one point, returned by this gem |
|
|
36
|
+
| Identity | A key with the model's base-class name and the ID as a string, such as `["Article", "42"]` |
|
|
37
|
+
| PT-AT | The optional [paper_trail-association_tracking](https://github.com/westonganger/paper_trail-association_tracking) plugin, needed for association history |
|
|
38
|
+
|
|
39
|
+
## How PaperTrail stores history
|
|
40
|
+
|
|
41
|
+
Each PaperTrail version stores the record's values from **before** that
|
|
42
|
+
version's change. `version.reify` returns that earlier record. This gem uses
|
|
43
|
+
the same rule: passing a version means "the record as it was before this
|
|
44
|
+
version's change". Passing a saved record means its current state.
|
|
45
|
+
|
|
46
|
+
Take an article created as `"Draft"`, then updated to `"Published"`, then to
|
|
47
|
+
`"Final"`:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
v1 v2 v3 (no version)
|
|
51
|
+
"create" "update" "update"
|
|
52
|
+
| | | |
|
|
53
|
+
──────●───────────────●─────────────────●─────────────────●──────▶ time
|
|
54
|
+
| | | |
|
|
55
|
+
└─── "Draft" ───┘ | |
|
|
56
|
+
stored in v2 | |
|
|
57
|
+
└─ "Published" ───┘ |
|
|
58
|
+
stored in v3 |
|
|
59
|
+
└──── "Final" ────┘
|
|
60
|
+
only in the table
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Three things follow from this:
|
|
64
|
+
|
|
65
|
+
- **A `create` version stands for "no record yet".** Its `reify` returns `nil`.
|
|
66
|
+
Comparing it with a later state reports the record as added
|
|
67
|
+
(`record_presence_change`), not as a list of fields changing from `nil`.
|
|
68
|
+
- **The newest values are in no version.** They exist only in the table. When
|
|
69
|
+
nothing has been recorded after a change, the gem reads the result of that
|
|
70
|
+
change from the current record.
|
|
71
|
+
- **A change shows up between two points, never at one.** That is why the
|
|
72
|
+
timeline methods return steps, each comparing one state with the next.
|
|
73
|
+
|
|
74
|
+
## Choosing a method
|
|
75
|
+
|
|
76
|
+
| What you need | Method |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| Net difference between two versions, or a version and the current record | `compare(from, to)` |
|
|
79
|
+
| The same for a list of `{ from:, to: }` pairs, one per record | `compare_many(pairs)` |
|
|
80
|
+
| Each change to one record, version by version | `timeline(record)` |
|
|
81
|
+
| Each change to one record and its chosen associations, with who made it | `activity_timeline(record, associations: ...)` |
|
|
82
|
+
| Net difference and timeline together | `analyze(record)` |
|
|
83
|
+
| The same, plus the activity timeline | `analyze(record, activity: true)` |
|
|
84
|
+
| Analyses for an array of records | `analyze_many(records)` |
|
|
85
|
+
| Analyses for the records a relation selects, over a time window | `analyze_scope(scope, within:, limit:)` |
|
|
86
|
+
| Recent activity through now, for the records a relation selects | `report(scope, since:, limit:)` |
|
|
87
|
+
|
|
88
|
+
`timeline` and `activity_timeline` differ in which versions become steps. Given
|
|
89
|
+
an article with two comment edits between two article versions:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
recorded versions A1 C1 C2 A2 A = Article version
|
|
93
|
+
| | | | C = Comment version
|
|
94
|
+
────●─────────●───────●─────────●────▶ time
|
|
95
|
+
| | | |
|
|
96
|
+
timeline └───────── 1 step ──────────┘
|
|
97
|
+
both comment changes land inside that one step
|
|
98
|
+
|
|
99
|
+
activity_timeline └── 1 ────┴── 2 ──┴─── 3 ───┘
|
|
100
|
+
each recorded version becomes its own step
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Both report the same changes. `activity_timeline` splits them up so each step
|
|
104
|
+
belongs to one recorded event, which is what you need to show who did what.
|
|
105
|
+
|
|
106
|
+
## Choosing a range
|
|
107
|
+
|
|
108
|
+
`timeline`, `activity_timeline`, and `analyze` cover the record's whole history
|
|
109
|
+
through its current state unless you say otherwise:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
PaperTrailDiff.timeline(article) # all of it
|
|
113
|
+
PaperTrailDiff.timeline(article, from: some_version) # from a version to now
|
|
114
|
+
PaperTrailDiff.timeline(article, from: :first, to: later_version)
|
|
115
|
+
PaperTrailDiff.timeline(article, within: 1.week.ago..Time.current)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
- `from:` defaults to `:first`, the record's earliest version.
|
|
119
|
+
- `to:` defaults to the record itself, meaning its current state. Pass a
|
|
120
|
+
version instead for a result that never changes when the record is edited
|
|
121
|
+
again.
|
|
122
|
+
- Versions must belong to the record and be given in time order.
|
|
123
|
+
- A record with no versions has an empty history, not an error.
|
|
124
|
+
|
|
125
|
+
`:last` is not accepted. It would mean "the state before the latest change",
|
|
126
|
+
which leaves that change out.
|
|
127
|
+
|
|
128
|
+
### Time windows
|
|
129
|
+
|
|
130
|
+
`within:` selects changes by the time PaperTrail recorded them (`created_at`).
|
|
131
|
+
It cannot be combined with `from:` or `to:`. Ruby's `..` includes the end time
|
|
132
|
+
and `...` excludes it, which is useful for back-to-back windows:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
|
|
136
|
+
steps = PaperTrailDiff.timeline(article, within: window)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
To show what the window's last change produced, the gem reads the next version
|
|
140
|
+
after the window. If there is none, nothing has been recorded since, so it reads
|
|
141
|
+
the current record instead. The step's `to_boundary.kind` is then `:current`.
|
|
142
|
+
This works the same for a window that ends now and one that ended long ago.
|
|
143
|
+
|
|
144
|
+
Reading the current record has one consequence: it includes changes PaperTrail
|
|
145
|
+
never recorded, such as an `update_columns` call. Those appear in the last step
|
|
146
|
+
of the window.
|
|
147
|
+
|
|
148
|
+
If the window's last change destroyed the record, the window closes on that
|
|
149
|
+
deletion instead. See [a destroyed record](#a-destroyed-record).
|
|
150
|
+
|
|
151
|
+
`close_on: :current` asks for the behavior above explicitly. It is the default
|
|
152
|
+
now and is still accepted.
|
|
153
|
+
|
|
154
|
+
`timeline` returns no steps for a window with no versions of the root record.
|
|
155
|
+
`activity_timeline` still reports chosen associations that changed in such a
|
|
156
|
+
window.
|
|
157
|
+
|
|
158
|
+
### Reporting only some changes: `version_scope:`
|
|
159
|
+
|
|
160
|
+
`version_scope:` narrows which of the root record's versions are reported. It
|
|
161
|
+
receives the version relation and returns a narrower one:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
|
|
165
|
+
|
|
166
|
+
PaperTrailDiff.timeline(article, within: window, version_scope: user_edits)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
It is accepted by `timeline`, `activity_timeline`, `analyze`, `analyze_many`,
|
|
170
|
+
and `analyze_scope`.
|
|
171
|
+
|
|
172
|
+
- Each reported change is compared with the version right after it, even if
|
|
173
|
+
that version is filtered out. So each step shows exactly what that one change
|
|
174
|
+
did:
|
|
175
|
+
|
|
176
|
+
```text
|
|
177
|
+
versions system → alice → system → bob → system
|
|
178
|
+
reported alice: what alice changed bob: what bob changed
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
- It looks at the root record's versions only. Someone who only edited comments
|
|
182
|
+
has no root versions to select. To find one person's changes, including to
|
|
183
|
+
associations, see [one person's changes](#one-persons-changes).
|
|
184
|
+
- `activity_timeline` uses the filter to decide where the range starts and
|
|
185
|
+
ends, but still lists every step inside it. Dropping one would move its
|
|
186
|
+
change into a neighboring step and credit the wrong person.
|
|
187
|
+
|
|
188
|
+
## compare
|
|
189
|
+
|
|
190
|
+
`compare(from, to)` returns the net difference between two states. Each side is
|
|
191
|
+
a PaperTrail version or a saved record (its current state):
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
diff = PaperTrailDiff.compare(article.versions[1], article.versions[4])
|
|
195
|
+
diff = PaperTrailDiff.compare(article.versions.last, article, associations: [:comments])
|
|
196
|
+
|
|
197
|
+
diff.attributes["title"].from # => "Draft"
|
|
198
|
+
diff.attributes["title"].to # => "Published"
|
|
199
|
+
diff.empty? # => false
|
|
200
|
+
diff.to_h
|
|
201
|
+
# => {
|
|
202
|
+
# record_presence_change: nil,
|
|
203
|
+
# attributes: { "title" => { from: "Draft", to: "Published" } },
|
|
204
|
+
# associations: {}
|
|
205
|
+
# }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- Only the two states matter. A field that changed and then changed back is not
|
|
209
|
+
reported.
|
|
210
|
+
- `record_presence_change` is set when the record did not exist on one side,
|
|
211
|
+
for example when `from` is a `create` version. It holds whole snapshots.
|
|
212
|
+
- A `destroy` version stands for the record just before deletion, so comparing
|
|
213
|
+
up to one reports the last edit, not the deletion. `activity_timeline`
|
|
214
|
+
reports deletions; see [a destroyed record](#a-destroyed-record).
|
|
215
|
+
- Two versions must be in time order. Reversed versions raise
|
|
216
|
+
`ReversedEndpointsError`, because the result would look valid with every
|
|
217
|
+
change backwards. A current record can be on either side.
|
|
218
|
+
- Both sides must belong to the same record.
|
|
219
|
+
- A current record must be saved, not destroyed, and have no unsaved changes.
|
|
220
|
+
The gem reloads it, so in-memory edits and cached associations are ignored.
|
|
221
|
+
- Options: `associations:`, `ignore:`, and `reload_live_endpoints:` (see
|
|
222
|
+
[reusing loaded records](#reusing-loaded-records)).
|
|
223
|
+
|
|
224
|
+
## compare_many
|
|
225
|
+
|
|
226
|
+
`compare_many` compares many pairs at once and shares the database work. Each
|
|
227
|
+
entry has the same `from:` and `to:` as `compare`. The result is a frozen hash
|
|
228
|
+
keyed by identity, in input order:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
orders = Order.where(id: order_ids).to_a
|
|
232
|
+
|
|
233
|
+
diffs = PaperTrailDiff.compare_many(
|
|
234
|
+
orders.map { |order| { from: :first, to: order } },
|
|
235
|
+
associations: [:line_items]
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
- `:first` means the record's earliest version. The gem finds it for every
|
|
242
|
+
record in one query per model. It needs the other side of the pair to name the
|
|
243
|
+
record, so `{ from: :first, to: :first }` raises.
|
|
244
|
+
- A record with no versions gets an empty `Diff`.
|
|
245
|
+
- Each record can appear in only one pair, because results are keyed by record.
|
|
246
|
+
- Options: `associations:`, `ignore:`, `reload_live_endpoints:`, and
|
|
247
|
+
`allow_expensive:` (see [costly associations](#costly-associations)).
|
|
248
|
+
|
|
249
|
+
If you look up versions yourself, do it in bulk. Looking up one record at a
|
|
250
|
+
time brings back the per-record queries this method avoids.
|
|
251
|
+
|
|
252
|
+
### Reusing loaded records
|
|
253
|
+
|
|
254
|
+
`compare`, `compare_many`, and `activity_timeline` reload current records by
|
|
255
|
+
default. If you already loaded them with every requested association, you can
|
|
256
|
+
skip the reload:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
orders = Order.where(id: order_ids).preload(line_items: :product).to_a
|
|
260
|
+
|
|
261
|
+
diffs = PaperTrailDiff.compare_many(
|
|
262
|
+
orders.map { |order| { from: :first, to: order } },
|
|
263
|
+
associations: ["line_items.product"],
|
|
264
|
+
reload_live_endpoints: false
|
|
265
|
+
)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The values then come from your objects, so keeping them fresh is up to you. A
|
|
269
|
+
requested association that is not loaded raises `UnloadedAssociationError`
|
|
270
|
+
instead of quietly running a query per record.
|
|
271
|
+
|
|
272
|
+
## timeline
|
|
273
|
+
|
|
274
|
+
`timeline(record)` returns one step for each pair of neighboring versions of the
|
|
275
|
+
root record, ending at its current state by default. See
|
|
276
|
+
[choosing a range](#choosing-a-range) for `from:`, `to:`, and `within:`.
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
steps = PaperTrailDiff.timeline(article)
|
|
280
|
+
|
|
281
|
+
steps.first.diff # a PaperTrailDiff::Diff
|
|
282
|
+
steps.first.empty? # true if nothing reported changed
|
|
283
|
+
steps.first.source_boundary # the version whose change this step shows
|
|
284
|
+
steps.first.to_boundary # the version (or current state) after it
|
|
285
|
+
steps.first.from_version # the original PaperTrail version objects
|
|
286
|
+
steps.first.to_version # nil when the step ends at current state
|
|
287
|
+
steps.reject(&:empty?) # hide steps with no reported changes
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
- Steps with no reported change are kept, for example when only ignored fields
|
|
291
|
+
changed. Use `reject(&:empty?)` for display.
|
|
292
|
+
- With `associations:`, each step also compares the chosen associations at the
|
|
293
|
+
two root versions. Several child changes between two root versions appear in
|
|
294
|
+
one step. Use `activity_timeline` to split them.
|
|
295
|
+
- `source_boundary` (also available as `from_boundary`) describes the version
|
|
296
|
+
responsible for the step, including `whodunnit`, `event`, and `recorded_at`.
|
|
297
|
+
- `step.to_h` returns `{ from:, to:, diff: }`, the same shape as an activity
|
|
298
|
+
step. `from` and `to` are boundary hashes with `kind`, `version_id`,
|
|
299
|
+
`item_type`, `item_id`, and `recorded_at`. `to_h(metadata: true)` adds author,
|
|
300
|
+
event, transaction ID, and record to each boundary.
|
|
301
|
+
- Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`,
|
|
302
|
+
`version_scope:`, `close_on:`.
|
|
303
|
+
|
|
304
|
+
## activity_timeline
|
|
305
|
+
|
|
306
|
+
`activity_timeline(record, associations: ...)` returns one step for each
|
|
307
|
+
recorded event of the root record **or** a chosen association. It ends at the
|
|
308
|
+
current record by default.
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
steps = PaperTrailDiff.activity_timeline(article, associations: ["comments.replies", :author])
|
|
312
|
+
|
|
313
|
+
steps.reject(&:empty?).each do |step|
|
|
314
|
+
step.source_boundary.whodunnit # who made this change
|
|
315
|
+
step.source_boundary.item_type # "Article", "Comment", ...
|
|
316
|
+
step.diff # what that one event changed
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
- Each step's `diff` is what the event at its `source_boundary` changed. Use
|
|
321
|
+
`source_boundary` for attribution. `to_boundary` is only the next point in
|
|
322
|
+
time and may belong to someone else.
|
|
323
|
+
- Boundaries have a `kind`: `:version`, `:current` (the current record), or
|
|
324
|
+
`:destroyed` (after a deletion). They also have `event`, `whodunnit`,
|
|
325
|
+
`recorded_at`, `item_type`, `item_id`, `transaction_id`, and `record`, and
|
|
326
|
+
the predicates `version?`, `current?`, and `destroyed?`.
|
|
327
|
+
- `step.to_h` returns `{ from:, to:, diff: }`. `to_h(metadata: true)` adds
|
|
328
|
+
author, event, transaction ID, and record. `to_h(snapshots: true)` adds the
|
|
329
|
+
retained snapshots.
|
|
330
|
+
- A child's change becomes visible at the next recorded event, or at the
|
|
331
|
+
current record. You do not need to save the article after each comment change.
|
|
332
|
+
- Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`,
|
|
333
|
+
`version_scope:`, `close_on:`, `reload_live_endpoints:`, `snapshots:`,
|
|
334
|
+
`group:`.
|
|
335
|
+
|
|
336
|
+
### One person's changes
|
|
337
|
+
|
|
338
|
+
Filter the steps in Ruby by `source_boundary.whodunnit`:
|
|
339
|
+
|
|
340
|
+
<!-- executable:readme-person-changes -->
|
|
341
|
+
```ruby
|
|
342
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
343
|
+
article, within: window, associations: %i[comments tags]
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
attributed = steps.reject(&:empty?)
|
|
347
|
+
by_priya = attributed.select { |step| step.source_boundary.whodunnit == "Priya Shah" }
|
|
348
|
+
|
|
349
|
+
by_priya.first.source_boundary.whodunnit # "Priya Shah"
|
|
350
|
+
by_priya.first.diff # exactly what that one event changed
|
|
351
|
+
|
|
352
|
+
authors = attributed.group_by { |step| step.source_boundary.whodunnit }
|
|
353
|
+
.transform_values(&:length)
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
This includes changes to associations, which `version_scope:` cannot see.
|
|
357
|
+
Filter after the call rather than asking the gem for fewer versions: every step
|
|
358
|
+
has to be rebuilt anyway for each one to be correct, so filtering earlier saves
|
|
359
|
+
nothing.
|
|
360
|
+
|
|
361
|
+
For many records, use `report` or `analyze_many(..., activity: true)` instead of
|
|
362
|
+
calling `activity_timeline` in a loop, so the database work is shared.
|
|
363
|
+
|
|
364
|
+
### One step per transaction: `group: :transaction`
|
|
365
|
+
|
|
366
|
+
Saving an article and its comments together creates one version each, so one
|
|
367
|
+
action shows up as several steps. `group: :transaction` combines steps from the
|
|
368
|
+
same database transaction:
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
PaperTrailDiff.activity_timeline(article, associations: [:comments], group: :transaction)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
- A combined step compares the state before the transaction with the state after
|
|
375
|
+
it. A field set and then restored inside the transaction is not reported.
|
|
376
|
+
- Its `source_boundary` is the first event in the transaction. That event's
|
|
377
|
+
author is not necessarily the author of every grouped change.
|
|
378
|
+
- Versions without a `transaction_id` are never grouped.
|
|
379
|
+
- Also accepted by `analyze`, `analyze_many`, and `analyze_scope` with
|
|
380
|
+
`activity: true`, and by `report`.
|
|
381
|
+
|
|
382
|
+
Without grouping, a step inside a transaction can look empty. A child's new
|
|
383
|
+
value is only visible at the next recorded event, and inside a transaction that
|
|
384
|
+
event is often another part of the same save. Nothing is lost; the change
|
|
385
|
+
appears one step later.
|
|
386
|
+
|
|
387
|
+
### Keeping snapshots: `snapshots: true`
|
|
388
|
+
|
|
389
|
+
A step's `diff` holds only what changed. To show unchanged fields too, such as
|
|
390
|
+
a comment's author next to an edited comment body, keep the snapshots:
|
|
391
|
+
|
|
392
|
+
```ruby
|
|
393
|
+
steps = PaperTrailDiff.activity_timeline(article, associations: [:comments], snapshots: true)
|
|
394
|
+
|
|
395
|
+
step = steps.reject(&:empty?).last
|
|
396
|
+
step.from_snapshot.associations["comments"].records.first.attributes["author"]
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
- The gem builds these snapshots anyway, so keeping them adds no queries. They
|
|
400
|
+
do use memory, because each one contains every chosen association.
|
|
401
|
+
- `from_snapshot` is `nil` for a step starting at a `create` version, because the
|
|
402
|
+
record did not exist yet.
|
|
403
|
+
- Also accepted by `analyze`, `analyze_many`, and `analyze_scope` with
|
|
404
|
+
`activity: true`, and by `report`.
|
|
405
|
+
|
|
406
|
+
### A destroyed record
|
|
407
|
+
|
|
408
|
+
When the range ends at the record's `destroy` version, the activity timeline
|
|
409
|
+
adds a final step to a `:destroyed` boundary. Its diff reports the record being
|
|
410
|
+
removed:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
removal = steps.last
|
|
414
|
+
removal.to_boundary.destroyed? # => true
|
|
415
|
+
removal.diff.record_presence_change.from # the record just before deletion
|
|
416
|
+
removal.diff.record_presence_change.to # => nil
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
A `within:` window whose last change is the deletion ends the same way, and the
|
|
420
|
+
`diff` of an `analyze` over that window ends at "no record". Removing a chosen
|
|
421
|
+
child is reported as `removed` in its parent's association.
|
|
422
|
+
|
|
423
|
+
### HABTM limitation
|
|
424
|
+
|
|
425
|
+
`has_and_belongs_to_many` join rows have no versions, so the gem cannot split
|
|
426
|
+
their changes into separate events once a range runs past the last root version
|
|
427
|
+
into the current record. That case raises `UnsupportedLiveActivityError`. End
|
|
428
|
+
the range at a version instead (see the
|
|
429
|
+
[checkpoint recipe](#saving-a-checkpoint)), or use a join model with
|
|
430
|
+
`has_many :through`.
|
|
431
|
+
|
|
432
|
+
## analyze
|
|
433
|
+
|
|
434
|
+
`analyze(record)` returns an `Analysis` with the net `diff` and the `timeline`
|
|
435
|
+
for one range, reading each version once:
|
|
436
|
+
|
|
437
|
+
```ruby
|
|
438
|
+
analysis = PaperTrailDiff.analyze(article, associations: ["comments.replies"], activity: true)
|
|
439
|
+
|
|
440
|
+
analysis.diff # net change over the range
|
|
441
|
+
analysis.timeline # the same steps timeline returns
|
|
442
|
+
analysis.activity_timeline # nil unless activity: true
|
|
443
|
+
analysis.from_snapshot # the full state at the start (nil before creation)
|
|
444
|
+
analysis.to_snapshot # the full state at the end
|
|
445
|
+
analysis.to_h(snapshots: true, metadata: true)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
- It accepts the same ranges as `timeline`.
|
|
449
|
+
- `activity: true` also builds the activity timeline. `snapshots:` and `group:`
|
|
450
|
+
apply to the activity timeline and require `activity: true`.
|
|
451
|
+
- Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`, `activity:`,
|
|
452
|
+
`version_scope:`, `close_on:`, `snapshots:`, `group:`.
|
|
453
|
+
|
|
454
|
+
## Reports for many records
|
|
455
|
+
|
|
456
|
+
Use these methods instead of calling a single-record method in a loop. They
|
|
457
|
+
load history for all records together.
|
|
458
|
+
|
|
459
|
+
### analyze_many
|
|
460
|
+
|
|
461
|
+
`analyze_many(records, ...)` analyzes an array of saved records and returns a
|
|
462
|
+
frozen hash of `Analysis` objects keyed by identity:
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
results = PaperTrailDiff.analyze_many(
|
|
466
|
+
Order.where(status: "open").to_a,
|
|
467
|
+
within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
|
|
468
|
+
associations: [:line_items]
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
results.fetch(["Order", order.id.to_s]).diff
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
- Without `within:`, each record's whole history through its current state is
|
|
475
|
+
analyzed.
|
|
476
|
+
- A record with nothing to report gets an empty `Analysis`.
|
|
477
|
+
- Records must be saved and unique.
|
|
478
|
+
- Options: `within:`, `associations:`, `ignore:`, `activity:`, `version_scope:`,
|
|
479
|
+
`close_on:`, `snapshots:`, `group:`, `allow_expensive:`.
|
|
480
|
+
|
|
481
|
+
### analyze_scope
|
|
482
|
+
|
|
483
|
+
`analyze_scope(scope, within:, limit:)` lets a model or relation choose the
|
|
484
|
+
records. It returns a `ScopedAnalysis`:
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
report = PaperTrailDiff.analyze_scope(
|
|
488
|
+
Order.where(status: "open"),
|
|
489
|
+
within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
|
|
490
|
+
associations: [:line_items],
|
|
491
|
+
limit: 500
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
report.analyses # { identity => Analysis } for records that still exist
|
|
495
|
+
report.roots # the selected records, in relation order
|
|
496
|
+
report.unreachable # identities of selected records that no longer exist
|
|
497
|
+
report.deleted # { identity => Analysis or nil }, with include_deleted: true
|
|
498
|
+
|
|
499
|
+
analyses, unreachable = report # destructures into the first two
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
It takes the same options as `analyze_many`, plus `limit:`, `historical_filter:`,
|
|
503
|
+
`max_filter_versions:`, and `include_deleted:`. `within:` can be omitted to use
|
|
504
|
+
all history.
|
|
505
|
+
|
|
506
|
+
### report
|
|
507
|
+
|
|
508
|
+
`report(scope, since:, limit:)` is `analyze_scope` with
|
|
509
|
+
`within: since..Time.now.utc` and `activity: true`. Use it for "what happened
|
|
510
|
+
recently":
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
report = PaperTrailDiff.report(Article.all, since: 1.day.ago, limit: 100)
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
It accepts `associations:`, `ignore:`, `snapshots:`, `group:`,
|
|
517
|
+
`include_deleted:`, `historical_filter:`, `max_filter_versions:`, and
|
|
518
|
+
`allow_expensive:`.
|
|
519
|
+
|
|
520
|
+
### Which records are selected
|
|
521
|
+
|
|
522
|
+
- **A record needs its own version in the window.** Editing only a comment does
|
|
523
|
+
not select its article. Once an article is selected, its comment activity is
|
|
524
|
+
included when you request `associations: [:comments]`.
|
|
525
|
+
- **A relation checks current values.** `Order.where(status: "open")` means open
|
|
526
|
+
now, not open during the window. Use `historical_filter:` to select by past
|
|
527
|
+
values.
|
|
528
|
+
- **`limit:` is a safety ceiling, not a page size.** If more records are
|
|
529
|
+
selected, the call raises `BatchLimitExceededError` instead of returning part
|
|
530
|
+
of the answer. Narrow the relation or the window, or raise the limit.
|
|
531
|
+
- **To paginate, use the relation.** Its `order`, `limit`, and `offset` choose
|
|
532
|
+
the page. Without an `order`, records are taken in ascending ID order. Add an
|
|
533
|
+
ID tie-breaker to any other order. If a join repeats a record, it is analyzed
|
|
534
|
+
once, in order of first appearance.
|
|
535
|
+
- Selection still scans the versions in the window, so keep windows reasonable
|
|
536
|
+
for large tables.
|
|
537
|
+
|
|
538
|
+
### Deleted records
|
|
539
|
+
|
|
540
|
+
A deleted record cannot be tested against a relation's conditions, because it
|
|
541
|
+
has no row. Records that changed in the window but no longer exist are listed in
|
|
542
|
+
`unreachable`. It is not paginated and does not mean those records matched the
|
|
543
|
+
relation.
|
|
544
|
+
|
|
545
|
+
`include_deleted: true` also analyzes them from their `destroy` versions and
|
|
546
|
+
returns the results in `deleted`, keyed the same way. A value is `nil` when no
|
|
547
|
+
`destroy` version was recorded. With this option, existing and deleted records
|
|
548
|
+
together count toward `limit:`.
|
|
549
|
+
|
|
550
|
+
```ruby
|
|
551
|
+
missing_history = report.deleted.select { |_identity, analysis| analysis.nil? }.keys
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
A deleted record's `diff` and `to_snapshot` end at "no record". Its `timeline`
|
|
555
|
+
lists only edits; its `activity_timeline` ends with the deletion.
|
|
556
|
+
|
|
557
|
+
### Selecting by past values: `historical_filter:`
|
|
558
|
+
|
|
559
|
+
`historical_filter:` selects records by values they had at any point in the
|
|
560
|
+
window. For example, to find articles that were titled `"Published"` at some
|
|
561
|
+
point in the last day, including ones published during it:
|
|
562
|
+
|
|
563
|
+
```ruby
|
|
564
|
+
report = PaperTrailDiff.report(
|
|
565
|
+
Article.all, since: 1.day.ago, limit: 100,
|
|
566
|
+
historical_filter: { title: "Published" }
|
|
567
|
+
)
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
The filter checks the record as it was when the window started and after each
|
|
571
|
+
change made inside it. A record matches if any of those states match. With a
|
|
572
|
+
hash, every key must be equal (`==`). Values are read from the stored history
|
|
573
|
+
without calling `reify`, and unknown or unrecorded attributes raise an error
|
|
574
|
+
instead of matching `nil`. For other checks, pass a callable such as
|
|
575
|
+
`->(article) { article.title.start_with?("Pub") }`; it receives each state as a
|
|
576
|
+
model instance and may run queries of its own.
|
|
577
|
+
|
|
578
|
+
- It looks only at the record itself, not its associations, and does not check
|
|
579
|
+
whether a value was held for the whole window.
|
|
580
|
+
- The relation still filters current rows. Matching deleted records are listed
|
|
581
|
+
in `unreachable`.
|
|
582
|
+
- Every matching record counts toward `limit:`, including deleted ones and ones
|
|
583
|
+
the relation later excludes. Matching stops as soon as the limit is exceeded.
|
|
584
|
+
- `max_filter_versions:` limits how many versions the filter loads, including
|
|
585
|
+
the one version after the window it reads for each record. The default is
|
|
586
|
+
10,000. Going over raises `FilterLimitExceededError` before the extra batch is
|
|
587
|
+
checked. It does not limit queries inside a callable.
|
|
588
|
+
- `version_scope:` narrows which changes are reported, not which states are
|
|
589
|
+
tested here.
|
|
590
|
+
- It is accepted by `analyze_scope` and `report`, which select records from a
|
|
591
|
+
relation.
|
|
592
|
+
|
|
593
|
+
### Costly associations
|
|
594
|
+
|
|
595
|
+
Most associations are loaded for all records together. A few still need queries
|
|
596
|
+
per record: associations with a scope (including `limit` or owner-dependent
|
|
597
|
+
scopes), targets with a default scope, and `has_many :through` associations
|
|
598
|
+
whose source is not a `belongs_to`. Batch methods raise
|
|
599
|
+
`UnsupportedBatchPathError` for these by default and name the association.
|
|
600
|
+
Remove it from `associations:`, or pass `allow_expensive: true` to accept the
|
|
601
|
+
per-record cost. Single-record methods always allow them.
|
|
602
|
+
|
|
603
|
+
This check covers known association cases only. Your model callbacks, custom
|
|
604
|
+
attribute types, and callable filters can still run queries. See the
|
|
605
|
+
[performance guide](performance.md) for measurements.
|
|
606
|
+
|
|
607
|
+
## Associations
|
|
608
|
+
|
|
609
|
+
Associations are only included when you name them. Use names or dot-separated
|
|
610
|
+
paths; the parents in a path are included automatically:
|
|
611
|
+
|
|
612
|
+
```ruby
|
|
613
|
+
diff = PaperTrailDiff.compare(from, to, associations: [:author, "comments.replies"])
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
Supported: `belongs_to`, `has_one`, `has_many` (including `:through`), and
|
|
617
|
+
`has_and_belongs_to_many`. Associated models must use `has_paper_trail`, and
|
|
618
|
+
historical states need PT-AT installed and enabled
|
|
619
|
+
(`PaperTrail.config.track_associations = true`). The gem never follows
|
|
620
|
+
associations you did not ask for, so cyclic models are safe.
|
|
621
|
+
|
|
622
|
+
How each kind is reported:
|
|
623
|
+
|
|
624
|
+
| Association | Record replaced, added, or removed | Same record, fields changed |
|
|
625
|
+
| --- | --- | --- |
|
|
626
|
+
| `belongs_to`, `has_one` | `relationship` (a `ValueChange` of snapshots) | `changed` |
|
|
627
|
+
| `has_many`, HABTM | `added`, `removed` (snapshots) | `changed` (`RecordChange`) |
|
|
628
|
+
|
|
629
|
+
```ruby
|
|
630
|
+
diff.associations["author"].relationship # from one author to another
|
|
631
|
+
comments = diff.associations["comments"]
|
|
632
|
+
comments.added # full snapshots of new comments
|
|
633
|
+
comments.removed # full snapshots of removed comments
|
|
634
|
+
comments.changed # RecordChange objects for edited comments
|
|
635
|
+
comments.changed.first.associations["replies"].added
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
- The same structure repeats at every level. Added and removed snapshots
|
|
639
|
+
include their chosen children.
|
|
640
|
+
- When a `belongs_to` association is included, its foreign key (and type
|
|
641
|
+
column for polymorphic associations) is left out of the attributes, because
|
|
642
|
+
the association already reports it. The same applies to the foreign key a
|
|
643
|
+
`has_one` or `has_many` child points back with. Without the association, the
|
|
644
|
+
key is an ordinary attribute.
|
|
645
|
+
- `has_many :through` uses the versioned join model's history, so join-model
|
|
646
|
+
attributes are available. HABTM reports membership and the target's
|
|
647
|
+
attributes; it has no join model, so it has no join attributes.
|
|
648
|
+
- Requesting associations for a historical state without PT-AT raises
|
|
649
|
+
`AssociationTrackingUnavailableError`. Unknown names raise
|
|
650
|
+
`UnknownAssociationError`; unsupported macros raise
|
|
651
|
+
`UnsupportedAssociationError`.
|
|
652
|
+
|
|
653
|
+
### Saving a checkpoint
|
|
654
|
+
|
|
655
|
+
Sometimes you want a version of the root record without changing it, for
|
|
656
|
+
example to fix the end of a report or to capture HABTM membership. Save one
|
|
657
|
+
inside a transaction on a freshly loaded record, and turn off PaperTrail's
|
|
658
|
+
timestamp synchronization for that model so the version records the time it
|
|
659
|
+
was saved:
|
|
660
|
+
|
|
661
|
+
```ruby
|
|
662
|
+
class Article < ApplicationRecord
|
|
663
|
+
has_paper_trail synchronize_version_creation_timestamp: false
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def checkpoint_article(article_id)
|
|
667
|
+
Article.transaction do
|
|
668
|
+
Article.find(article_id).paper_trail.save_with_version
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
Without `synchronize_version_creation_timestamp: false`, the checkpoint takes the
|
|
674
|
+
record's old `updated_at` as its time and lands in the past.
|
|
675
|
+
|
|
676
|
+
### Files from ActiveStorage
|
|
677
|
+
|
|
678
|
+
`has_one_attached` and `has_many_attached` point to ActiveStorage's own models,
|
|
679
|
+
which PaperTrail does not version. Comparing them historically raises
|
|
680
|
+
`UnversionedAssociationError` rather than claiming nothing changed. Versioning
|
|
681
|
+
ActiveStorage's models does not help either, because `has_one_attached` is a
|
|
682
|
+
scoped `has_one`.
|
|
683
|
+
|
|
684
|
+
Record the facts you care about on a model you own, and version that:
|
|
685
|
+
|
|
686
|
+
<!-- executable:reference-file-metadata -->
|
|
687
|
+
```ruby
|
|
688
|
+
class DocumentRevision < ApplicationRecord
|
|
689
|
+
belongs_to :attachable, polymorphic: true
|
|
690
|
+
has_one_attached :file
|
|
691
|
+
has_paper_trail
|
|
692
|
+
|
|
693
|
+
# Attaching a file writes to ActiveStorage's tables, not to this record, so
|
|
694
|
+
# nothing would be versioned without copying the facts across.
|
|
695
|
+
before_save :record_file_metadata, if: -> { file.attached? }
|
|
696
|
+
|
|
697
|
+
def record_file_metadata
|
|
698
|
+
blob = file.blob
|
|
699
|
+
assign_attributes(
|
|
700
|
+
filename: blob.filename.to_s, content_type: blob.content_type,
|
|
701
|
+
byte_size: blob.byte_size, checksum: blob.checksum
|
|
702
|
+
)
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
```ruby
|
|
708
|
+
class Article < ApplicationRecord
|
|
709
|
+
has_many :document_revisions, as: :attachable
|
|
710
|
+
has_paper_trail
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
PaperTrailDiff.compare(before, after, associations: [:document_revisions])
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
The callback sets the metadata before the save, so PaperTrail records it with
|
|
717
|
+
that save. Save a revision for every file change; `update_columns` and
|
|
718
|
+
blob-only changes are not recorded.
|
|
719
|
+
|
|
720
|
+
## Ignoring fields
|
|
721
|
+
|
|
722
|
+
`updated_at` is ignored by default. `ignore:` replaces that default list:
|
|
723
|
+
|
|
724
|
+
```ruby
|
|
725
|
+
PaperTrailDiff.compare(from, to, ignore: %i[updated_at lock_version])
|
|
726
|
+
PaperTrailDiff.compare(from, to, ignore: []) # compare every field
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
An array applies to the root record and every association. To ignore fields
|
|
730
|
+
only at certain paths, pass `all:` and `paths:`. `"$"` means the root record:
|
|
731
|
+
|
|
732
|
+
```ruby
|
|
733
|
+
PaperTrailDiff.compare(
|
|
734
|
+
from, to,
|
|
735
|
+
associations: ["comments.replies"],
|
|
736
|
+
ignore: {
|
|
737
|
+
all: [:updated_at],
|
|
738
|
+
paths: { "$" => [:lock_version], "comments.replies" => [:delivery_state] }
|
|
739
|
+
}
|
|
740
|
+
)
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
A path entry applies only at that exact path, not below it. Paths must be `"$"`
|
|
744
|
+
or one of the requested association paths.
|
|
745
|
+
|
|
746
|
+
Primary keys are never reported as attributes; they identify the record.
|
|
747
|
+
Columns listed in `has_paper_trail skip:` are also left out, because PaperTrail
|
|
748
|
+
never stores them.
|
|
749
|
+
|
|
750
|
+
## Reading results
|
|
751
|
+
|
|
752
|
+
All result objects are frozen and have `to_h`. Hash keys for structure are
|
|
753
|
+
symbols; attribute and association names are strings. Attribute values keep
|
|
754
|
+
their Ruby types. Collections are ordered by record type, then ID (numerically
|
|
755
|
+
for numeric IDs).
|
|
756
|
+
|
|
757
|
+
| Type | What it is |
|
|
758
|
+
| --- | --- |
|
|
759
|
+
| `Diff` | `record_presence_change`, `attributes`, `associations`, `empty?` |
|
|
760
|
+
| `ValueChange` | `from` and `to` |
|
|
761
|
+
| `RecordSnapshot` | `type`, `id`, `attributes`, `associations` |
|
|
762
|
+
| `RecordChange` | `record` (a `RecordReference`), `attributes`, `associations` |
|
|
763
|
+
| `ToOneAssociationDiff` | `kind`, `relationship`, `changed` |
|
|
764
|
+
| `CollectionAssociationDiff` | `kind`, `added`, `removed`, `changed` |
|
|
765
|
+
| `Step` | one `timeline` step |
|
|
766
|
+
| `ActivityStep` | one `activity_timeline` step |
|
|
767
|
+
| `ActivityBoundary` | one end of a step |
|
|
768
|
+
| `Analysis` | result of `analyze` |
|
|
769
|
+
| `ScopedAnalysis` | result of `analyze_scope` and `report` |
|
|
770
|
+
| `TraversalEntry`, `RecordReference` | entries from `each_change` and `each_entry` |
|
|
771
|
+
| `AssociationDescriptor` | result of `association_paths` |
|
|
772
|
+
| `DiagnosticReport`, `DiagnosticIssue` | result of `diagnose` |
|
|
773
|
+
|
|
774
|
+
A new record's fields are in `record_presence_change.to.attributes`; an edited
|
|
775
|
+
record's are in `attributes`. To avoid handling both shapes, walk the result.
|
|
776
|
+
|
|
777
|
+
### Walking a result
|
|
778
|
+
|
|
779
|
+
`each_change` lists every change as a flat sequence, depth first:
|
|
780
|
+
|
|
781
|
+
<!-- executable:readme-traversal-changes -->
|
|
782
|
+
```ruby
|
|
783
|
+
diff.each_change do |entry|
|
|
784
|
+
entry.kind # :attribute_changed, :record_added, ...
|
|
785
|
+
entry.association_path # ["comments", "replies"]
|
|
786
|
+
entry.record_path # frozen RecordReference objects
|
|
787
|
+
entry.association_kind # :has_many, :belongs_to, ...
|
|
788
|
+
entry.attribute # "body" for an attribute entry
|
|
789
|
+
entry.value # ValueChange, RecordChange, or RecordSnapshot
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
counts = diff.each_change.map(&:kind).tally
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
- A `:record_changed` entry comes before that record's field and child entries.
|
|
796
|
+
- To-one replacements are `:relationship_added`, `:relationship_removed`, or
|
|
797
|
+
`:relationship_replaced`. The root being created or removed is
|
|
798
|
+
`:record_presence_changed`.
|
|
799
|
+
|
|
800
|
+
`each_entry` also includes the fields of added and removed records, so you can
|
|
801
|
+
render a new record without a second walker:
|
|
802
|
+
|
|
803
|
+
<!-- executable:readme-traversal-entries -->
|
|
804
|
+
```ruby
|
|
805
|
+
diff.each_entry do |entry|
|
|
806
|
+
next unless entry.included_state?
|
|
807
|
+
|
|
808
|
+
entry.kind # :record_included, :attribute_included, :association_included
|
|
809
|
+
entry.state # :before or :after
|
|
810
|
+
entry.context # :included_state
|
|
811
|
+
end
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
Both return an `Enumerator` without a block. They never query the database. A
|
|
815
|
+
record reachable through two requested paths appears at both.
|
|
816
|
+
|
|
817
|
+
## JSON columns
|
|
818
|
+
|
|
819
|
+
A `json` or `jsonb` column is compared as one value. `nested_changes` reports
|
|
820
|
+
which keys inside it changed:
|
|
821
|
+
|
|
822
|
+
```ruby
|
|
823
|
+
PaperTrailDiff.nested_changes(diff.attributes.fetch("config"))
|
|
824
|
+
# => { ["theme"] => <from "dark" to "light">,
|
|
825
|
+
# ["limits", "max"] => <from 10 to 20> }
|
|
826
|
+
|
|
827
|
+
PaperTrailDiff.nested_changes(from_value, to_value) # also accepts two values
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
- Paths are arrays because a key may contain a dot.
|
|
831
|
+
- Arrays are compared by their elements, not positions. An `ArrayChange` has
|
|
832
|
+
`added`, `removed`, and `reordered?`, plus the whole `from` and `to`. Objects
|
|
833
|
+
inside arrays are compared whole.
|
|
834
|
+
- A missing key is `PaperTrailDiff::NestedComparator::ABSENT`, not `nil`, because
|
|
835
|
+
`{"a": null}` and `{}` are different.
|
|
836
|
+
- A value that is not JSON on both sides returns `{}`; the attribute diff still
|
|
837
|
+
has the whole change.
|
|
838
|
+
- Keys such as `:a` and `"a"` in one object raise `AmbiguousNestedKeyError`.
|
|
839
|
+
|
|
840
|
+
## Checking your setup
|
|
841
|
+
|
|
842
|
+
List the associations a model supports, for example to build a settings
|
|
843
|
+
screen:
|
|
844
|
+
|
|
845
|
+
```ruby
|
|
846
|
+
PaperTrailDiff.supported_association_macros
|
|
847
|
+
PaperTrailDiff.association_paths(Article, max_depth: 3).map(&:to_h)
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
Each descriptor has `path`, `kind`, `target_type`, `through`, `polymorphic`,
|
|
851
|
+
and `cycle`. Cycles are marked and not followed.
|
|
852
|
+
|
|
853
|
+
Check two versions for known problems before relying on association history:
|
|
854
|
+
|
|
855
|
+
```ruby
|
|
856
|
+
report = PaperTrailDiff.diagnose(from_version, to_version, associations: [:tags, "comments.replies"])
|
|
857
|
+
|
|
858
|
+
report.ok? # no errors among the checks that ran
|
|
859
|
+
report.errors.map(&:code)
|
|
860
|
+
report.warnings.map(&:code)
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
Association checks run only for requested associations. The version-order check
|
|
864
|
+
always runs. `ok?` does not prove the history is complete.
|
|
865
|
+
|
|
866
|
+
## Models, schemas, and edge cases
|
|
867
|
+
|
|
868
|
+
- **Single-table inheritance.** Results are keyed by the base class, as
|
|
869
|
+
PaperTrail stores it: a `Book < Publication` is `["Publication", "12"]`. Use
|
|
870
|
+
`PaperTrailDiff::Endpoint.identity(record)` to build keys.
|
|
871
|
+
- **Non-integer primary keys**, such as UUIDs, are supported, and so is a string
|
|
872
|
+
`versions.item_id` column.
|
|
873
|
+
- **Custom version classes** (`has_paper_trail versions: { class_name: }`) are
|
|
874
|
+
supported.
|
|
875
|
+
|
|
876
|
+
### Version order
|
|
877
|
+
|
|
878
|
+
Versions are ordered by `created_at`, then by ID when timestamps are equal. That
|
|
879
|
+
works when IDs increase with each insert, as autoincrement IDs do. UUID version
|
|
880
|
+
IDs do not, so two UUID versions with the same timestamp cannot be ordered; the
|
|
881
|
+
gem raises `AmbiguousVersionOrderError` naming both instead of guessing.
|
|
882
|
+
Timestamps stored to the second, as on a MySQL `datetime` column, make ties
|
|
883
|
+
common. `diagnose` reports this as an error.
|
|
884
|
+
|
|
885
|
+
`created_at` comes from your application server's clock. A clock that jumps
|
|
886
|
+
backwards, or servers whose clocks disagree, can record a later change with an
|
|
887
|
+
earlier time. Keep server clocks synchronized.
|
|
888
|
+
|
|
889
|
+
Association changes between versions with the same timestamp are placed using
|
|
890
|
+
the same order. A few association shapes fall back to PT-AT's own reifier, which
|
|
891
|
+
uses timestamps only: scoped associations, unversioned targets, and the others
|
|
892
|
+
listed in the [architecture notes](architecture.md). For those, `diagnose` warns
|
|
893
|
+
about tied timestamps, and a comparison between two tied versions emits the
|
|
894
|
+
`ambiguous_association_boundary.paper_trail_diff` notification. Recording
|
|
895
|
+
timestamps below one second avoids the problem.
|
|
896
|
+
|
|
897
|
+
### History stored as JSON
|
|
898
|
+
|
|
899
|
+
PaperTrail's JSON serializer, and a `json` or `jsonb` `object` column, store
|
|
900
|
+
times to milliseconds by default (`ActiveSupport::JSON::Encoding.time_precision`).
|
|
901
|
+
For those histories the gem compares all times at that precision, so an
|
|
902
|
+
unchanged timestamp is not reported as changed. A change smaller than that
|
|
903
|
+
precision was never recorded and cannot be reported.
|
|
904
|
+
|
|
905
|
+
## Instrumentation
|
|
906
|
+
|
|
907
|
+
The gem never logs. It emits `ActiveSupport::Notifications` events you can
|
|
908
|
+
subscribe to:
|
|
909
|
+
|
|
910
|
+
- `compare.paper_trail_diff`
|
|
911
|
+
- `compare_many.paper_trail_diff`
|
|
912
|
+
- `activity_timeline.paper_trail_diff` (includes `step_count`)
|
|
913
|
+
- `analyze_many.paper_trail_diff`
|
|
914
|
+
- `load_live_endpoints.paper_trail_diff`
|
|
915
|
+
- `prepare_history.paper_trail_diff`
|
|
916
|
+
- `ambiguous_association_boundary.paper_trail_diff`
|
|
917
|
+
|
|
918
|
+
```ruby
|
|
919
|
+
ActiveSupport::Notifications.subscribe(/\.paper_trail_diff\z/) do |name, start, finish, _id, payload|
|
|
920
|
+
Rails.logger.debug(event: name, duration_ms: ((finish - start) * 1_000).round(1), **payload)
|
|
921
|
+
end
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
Payloads contain counts, model names, association paths, and options, never
|
|
925
|
+
record data. To see the SQL for one call, subscribe to `sql.active_record`
|
|
926
|
+
around it with `ActiveSupport::Notifications.subscribed`.
|
|
927
|
+
|
|
928
|
+
## Errors
|
|
929
|
+
|
|
930
|
+
All errors inherit from `PaperTrailDiff::Error`.
|
|
931
|
+
|
|
932
|
+
| Error | Raised when |
|
|
933
|
+
| --- | --- |
|
|
934
|
+
| `ConfigurationError` | An option is invalid |
|
|
935
|
+
| `UnsupportedBatchPathError` | A batch requests a [costly association](#costly-associations) without `allow_expensive: true` |
|
|
936
|
+
| `InvalidEndpointError` | A record is unsaved, destroyed, or has unsaved changes |
|
|
937
|
+
| `UnloadedAssociationError` | `reload_live_endpoints: false` and an association is not loaded |
|
|
938
|
+
| `VersionMismatchError` | Two endpoints belong to different records |
|
|
939
|
+
| `ReversedEndpointsError` | Two versions are given newest first |
|
|
940
|
+
| `InvalidTimelineRangeError` | A range is invalid, for example `within:` with `from:`, or `:last` |
|
|
941
|
+
| `InvalidTimeRangeError` | `within:` is not a finite, time-ordered range |
|
|
942
|
+
| `IncompleteTimeRangeError` | A window's last change has no later version and the record has no saved row to read instead |
|
|
943
|
+
| `AmbiguousVersionOrderError` | Versions cannot be ordered (see [version order](#version-order)) |
|
|
944
|
+
| `BatchLimitExceededError` | More records are selected than `limit:` allows |
|
|
945
|
+
| `FilterLimitExceededError` | `historical_filter:` needs more than `max_filter_versions:` |
|
|
946
|
+
| `AssociationTrackingUnavailableError` | Association history is requested without PT-AT |
|
|
947
|
+
| `UnknownAssociationError` | An association name does not exist |
|
|
948
|
+
| `UnsupportedAssociationError` | An association's macro is not supported |
|
|
949
|
+
| `UnversionedAssociationError` | An association's target model has no `has_paper_trail` |
|
|
950
|
+
| `IncompleteAssociationHistoryError` | PT-AT did not record enough to rebuild an association, such as HABTM without a transaction |
|
|
951
|
+
| `UnsupportedLiveActivityError` | A HABTM activity range ends at the current record |
|
|
952
|
+
| `AmbiguousNestedKeyError` | `nested_changes` finds keys like `:a` and `"a"` together |
|
|
953
|
+
|
|
954
|
+
## Limitations
|
|
955
|
+
|
|
956
|
+
Results can only be as complete as the history PaperTrail and PT-AT recorded.
|
|
957
|
+
|
|
958
|
+
- Changes made with versioning off, with `update_columns`, or to `skip:` columns
|
|
959
|
+
were never recorded. A range ending at the current record shows the first two
|
|
960
|
+
in its last step, attributed to the change before them.
|
|
961
|
+
- Deleted versions, schema changes, and serializer changes can affect how old
|
|
962
|
+
versions read.
|
|
963
|
+
- Several queries against the current record are not one database snapshot.
|
|
964
|
+
Use a transaction with a suitable isolation level if that matters.
|
|
965
|
+
- HABTM membership history exists only where PT-AT recorded it in a
|
|
966
|
+
transaction. HABTM changes cannot become their own activity steps.
|
|
967
|
+
- PT-AT has its own
|
|
968
|
+
[limitations](https://github.com/westonganger/paper_trail-association_tracking#limitations),
|
|
969
|
+
especially for some `has_one` histories. The gem follows PT-AT's configured
|
|
970
|
+
error behavior.
|
|
971
|
+
- Deep association paths cost more to rebuild. Request only what you need.
|
|
972
|
+
|
|
973
|
+
Read PaperTrail's
|
|
974
|
+
[version semantics](https://github.com/paper-trail-gem/paper_trail#3-working-with-versions)
|
|
975
|
+
before relying on history for restoring data or for compliance.
|
|
976
|
+
|
|
977
|
+
For development commands, see [testing](testing.md).
|