paper_trail_diff 0.14.0 → 0.15.0
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 +343 -0
- data/QUICKSTART.md +21 -4
- data/README.md +58 -55
- data/docs/architecture.md +32 -11
- data/docs/performance.md +116 -391
- data/docs/reference.md +345 -106
- data/docs/testing.md +63 -7
- data/lib/paper_trail_diff/activity_boundary.rb +83 -26
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
- data/lib/paper_trail_diff/activity_event.rb +41 -10
- data/lib/paper_trail_diff/activity_history.rb +34 -9
- data/lib/paper_trail_diff/activity_range.rb +18 -3
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
- data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
- data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
- data/lib/paper_trail_diff/activity_view.rb +69 -0
- data/lib/paper_trail_diff/analysis.rb +13 -1
- data/lib/paper_trail_diff/analysis_batch.rb +24 -30
- data/lib/paper_trail_diff/approximate_paths.rb +48 -0
- data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
- data/lib/paper_trail_diff/association_discovery.rb +9 -1
- data/lib/paper_trail_diff/association_key.rb +56 -0
- data/lib/paper_trail_diff/association_traversal.rb +13 -4
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
- data/lib/paper_trail_diff/batch_preload.rb +53 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
- data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
- data/lib/paper_trail_diff/change_row.rb +133 -0
- data/lib/paper_trail_diff/collection_comparator.rb +5 -32
- data/lib/paper_trail_diff/configuration.rb +35 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
- data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
- data/lib/paper_trail_diff/deprecation.rb +61 -0
- data/lib/paper_trail_diff/diagnostics.rb +84 -99
- data/lib/paper_trail_diff/endpoint.rb +26 -3
- data/lib/paper_trail_diff/errors.rb +3 -1
- data/lib/paper_trail_diff/fallback_paths.rb +38 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
- data/lib/paper_trail_diff/historical_filter.rb +19 -10
- data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
- data/lib/paper_trail_diff/nested_comparator.rb +19 -18
- data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
- data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
- data/lib/paper_trail_diff/prepared_history.rb +9 -1
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
- data/lib/paper_trail_diff/root_version_selection.rb +4 -2
- data/lib/paper_trail_diff/scope_condition.rb +76 -21
- data/lib/paper_trail_diff/scope_ranking.rb +12 -2
- data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
- data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
- data/lib/paper_trail_diff/snapshot.rb +16 -1
- data/lib/paper_trail_diff/support.rb +29 -2
- data/lib/paper_trail_diff/time_range.rb +32 -10
- data/lib/paper_trail_diff/timeline_builder.rb +7 -7
- data/lib/paper_trail_diff/timeline_range.rb +9 -1
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
- data/lib/paper_trail_diff/transaction_cut.rb +181 -0
- data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +37 -8
- data/lib/paper_trail_diff/version_query.rb +34 -10
- data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
- data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
- data/lib/paper_trail_diff/version_table.rb +60 -0
- data/lib/paper_trail_diff.rb +98 -56
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
- data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
- data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
- data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
- data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
- data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
- data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
- data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
- data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
- data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
- data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
- data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
- data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
- data/sig/generated/paper_trail_diff/errors.rbs +3 -1
- data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
- data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
- data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
- data/sig/generated/paper_trail_diff/support.rbs +15 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
- data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
- data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
- data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
- data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
- data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
- data/sig/generated/paper_trail_diff.rbs +29 -26
- metadata +46 -10
- data/lib/paper_trail_diff/activity_grouping.rb +0 -38
- data/lib/paper_trail_diff/batch_safety.rb +0 -63
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
- data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +0 -92
data/docs/reference.md
CHANGED
|
@@ -61,7 +61,7 @@ Take an article created as `"Draft"`, then updated to `"Published"`, then to
|
|
|
61
61
|
only in the table
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
Six things follow from this:
|
|
65
65
|
|
|
66
66
|
- **A `create` version stands for "no record yet".** Its `reify` returns `nil`.
|
|
67
67
|
Comparing it with a later state reports the record as added
|
|
@@ -78,7 +78,17 @@ Four things follow from this:
|
|
|
78
78
|
for that record's previous recorded change and is attributed to its author.
|
|
79
79
|
Ending a range at a version leaves out only the writes after it.
|
|
80
80
|
[`recorded_changes`](#what-a-version-recorded) reads what a version itself
|
|
81
|
-
recorded.
|
|
81
|
+
recorded, and activity steps can [include it](#what-each-event-recorded-recorded_changes-true).
|
|
82
|
+
- **A record created without a version looks as if it always existed.** A
|
|
83
|
+
row written with versioning off, or imported with `insert_all`, has no
|
|
84
|
+
`create` version, so history never reports it as added: its earliest
|
|
85
|
+
version's stored values are its state from before history began, and an
|
|
86
|
+
association includes it there. Rows from before PaperTrail was installed
|
|
87
|
+
are the same, which is usually what you want.
|
|
88
|
+
- **Pruned history is gone.** With `has_paper_trail limit:`, PaperTrail
|
|
89
|
+
deletes a record's oldest versions, and the earliest one left becomes its
|
|
90
|
+
start: states before it cannot be read, and an older version passed in is
|
|
91
|
+
no longer there to pass.
|
|
82
92
|
|
|
83
93
|
## Choosing a method
|
|
84
94
|
|
|
@@ -128,6 +138,12 @@ PaperTrailDiff.timeline(article, within: 1.week.ago..Time.current)
|
|
|
128
138
|
- `to:` defaults to the record itself, meaning its current state. Pass a
|
|
129
139
|
version instead for a result that never changes when the record is edited
|
|
130
140
|
again.
|
|
141
|
+
- A version stands for the state before its transaction, since PT-AT rebuilds
|
|
142
|
+
associations as they were before it. Child changes saved in the same
|
|
143
|
+
transaction, such as the update behind a `touch: true` parent version, fall
|
|
144
|
+
after a range that ends there and inside a range that starts there. So a
|
|
145
|
+
history split at that version reports each such change exactly once, in the
|
|
146
|
+
later part.
|
|
131
147
|
- Versions must belong to the record and be given in time order.
|
|
132
148
|
- A record with no versions has an empty history, not an error.
|
|
133
149
|
|
|
@@ -145,10 +161,33 @@ window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
|
|
|
145
161
|
steps = PaperTrailDiff.timeline(article, within: window)
|
|
146
162
|
```
|
|
147
163
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
164
|
+
A `Date` bound is a whole day in `Time.zone`, so `Date.today..Date.today` is
|
|
165
|
+
all of today and `august...september` stops before September 1. A string is a
|
|
166
|
+
time in `Time.zone` unless it names an offset. Without `Time.zone`, both are
|
|
167
|
+
read in UTC, never in the server's own zone.
|
|
168
|
+
|
|
169
|
+
A window edge that falls inside a transaction moves back to where that
|
|
170
|
+
transaction began, so the whole transaction is in the later window, as it is
|
|
171
|
+
when a range [starts or ends at a version](#choosing-a-range). This applies to
|
|
172
|
+
transactions that saved a version of the root record, such as a comment edit
|
|
173
|
+
and the `touch: true` article version saved with it. So back-to-back windows,
|
|
174
|
+
or a `report` with `since:` after an earlier report, show each change exactly
|
|
175
|
+
once, credited to the transaction that made it. Each root moves its own edges,
|
|
176
|
+
so in a batch two roots can start a few moments apart.
|
|
177
|
+
|
|
178
|
+
To show what the window's last change produced, the gem reads the state just
|
|
179
|
+
before the next change after the window:
|
|
180
|
+
|
|
181
|
+
- With `activity_timeline`, or `analyze` with `activity: true`, that is the next
|
|
182
|
+
version of the record or of any record on a requested association path. If
|
|
183
|
+
none was recorded after the window, it reads the current record instead,
|
|
184
|
+
and the last step's `to_boundary.kind` is `:current`. So a window that ended
|
|
185
|
+
long ago ends at its own end, even when only a comment changed since.
|
|
186
|
+
- `timeline`, and `analyze` without `activity:`, compare the record's own
|
|
187
|
+
versions. The last step runs to the record's next version, or to the
|
|
188
|
+
current record when it has none, and an association change made in between
|
|
189
|
+
is part of that step, credited to its author. Use `activity: true` to keep
|
|
190
|
+
a past window's associations as they were at its end.
|
|
152
191
|
|
|
153
192
|
Reading the current record has one consequence: it includes changes PaperTrail
|
|
154
193
|
never recorded, such as an `update_columns` call. Like any
|
|
@@ -158,8 +197,10 @@ in the step for that record's last recorded change.
|
|
|
158
197
|
If the window's last change destroyed the record, the window closes on that
|
|
159
198
|
deletion instead. See [a destroyed record](#a-destroyed-record).
|
|
160
199
|
|
|
161
|
-
`close_on: :current`
|
|
162
|
-
|
|
200
|
+
`close_on: :current` is deprecated and has no effect; passing it warns through
|
|
201
|
+
`PaperTrailDiff.deprecator`. Remove it. In a Rails app, register the deprecator
|
|
202
|
+
with `Rails.application.deprecators[:paper_trail_diff] = PaperTrailDiff.deprecator`
|
|
203
|
+
to handle its warnings as the app's own.
|
|
163
204
|
|
|
164
205
|
`timeline` returns no steps for a window with no versions of the root record.
|
|
165
206
|
`activity_timeline` still reports chosen associations that changed in such a
|
|
@@ -233,8 +274,9 @@ diff.to_h
|
|
|
233
274
|
deleted.
|
|
234
275
|
- A current record must be saved, not destroyed, and have no unsaved changes.
|
|
235
276
|
The gem reloads it, so in-memory edits and cached associations are ignored.
|
|
236
|
-
- Options: `associations:`, `ignore:`, and `
|
|
237
|
-
[
|
|
277
|
+
- Options: `associations:`, `ignore:`, and `allow_approximate:` (see
|
|
278
|
+
[costly associations](#costly-associations)). `reload_live_endpoints:` is
|
|
279
|
+
deprecated (see [reusing loaded records](#reusing-loaded-records)).
|
|
238
280
|
|
|
239
281
|
## compare_many
|
|
240
282
|
|
|
@@ -258,31 +300,26 @@ diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
|
|
|
258
300
|
record, so `{ from: :first, to: :first }` raises.
|
|
259
301
|
- A record with no versions gets an empty `Diff`.
|
|
260
302
|
- Each record can appear in only one pair, because results are keyed by record.
|
|
261
|
-
- Options: `associations:`, `ignore:`, `
|
|
262
|
-
|
|
303
|
+
- Options: `associations:`, `ignore:`, and `allow_approximate:` (see
|
|
304
|
+
[costly associations](#costly-associations)).
|
|
263
305
|
|
|
264
306
|
If you look up versions yourself, do it in bulk. Looking up one record at a
|
|
265
307
|
time brings back the per-record queries this method avoids.
|
|
266
308
|
|
|
267
309
|
### Reusing loaded records
|
|
268
310
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
The values then come from your objects, so keeping them fresh is up to you. A
|
|
284
|
-
requested association that is not loaded raises `UnloadedAssociationError`
|
|
285
|
-
instead of quietly running a query per record.
|
|
311
|
+
Every method reloads the current records it compares with, together with the
|
|
312
|
+
requested associations, in a query for the records and one per association
|
|
313
|
+
level. `reload_live_endpoints: false` on `compare`, `compare_many`, and
|
|
314
|
+
`activity_timeline` skips that reload and reads your loaded objects instead.
|
|
315
|
+
It is deprecated and will be removed: it saves those few queries per call (2
|
|
316
|
+
for 500 articles with their comments), and the values then come from objects
|
|
317
|
+
that may be stale. Until it is removed it still works,
|
|
318
|
+
with a warning. A requested association that is not loaded raises
|
|
319
|
+
`UnloadedAssociationError`, and one with a `limit` or `offset`, directly or in
|
|
320
|
+
a part it goes through, or a scope that takes the owner, is read again for
|
|
321
|
+
each record, because Active Record's `preload` applies a limit to all the
|
|
322
|
+
records it loads together.
|
|
286
323
|
|
|
287
324
|
## timeline
|
|
288
325
|
|
|
@@ -314,7 +351,7 @@ steps.reject(&:empty?) # hide steps with no reported changes
|
|
|
314
351
|
`item_type`, `item_id`, and `recorded_at`. `to_h(metadata: true)` adds author,
|
|
315
352
|
event, transaction ID, and record to each boundary.
|
|
316
353
|
- Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`,
|
|
317
|
-
`version_scope:`, `
|
|
354
|
+
`version_scope:`, `allow_approximate:`.
|
|
318
355
|
|
|
319
356
|
## activity_timeline
|
|
320
357
|
|
|
@@ -340,14 +377,21 @@ end
|
|
|
340
377
|
`:destroyed` (after a deletion). They also have `event`, `whodunnit`,
|
|
341
378
|
`recorded_at`, `item_type`, `item_id`, `transaction_id`, and `record`, and
|
|
342
379
|
the predicates `version?`, `current?`, and `destroyed?`.
|
|
380
|
+
- `step.sources` lists the events whose changes the step shows, oldest first.
|
|
381
|
+
That is just `source_boundary`, unless the step is a
|
|
382
|
+
[transaction group](#one-step-per-transaction-group-transaction).
|
|
383
|
+
- A record's versions are steps only while it is in the tree. A comment edited
|
|
384
|
+
on another article and moved to this one later shows up when it moves, not
|
|
385
|
+
when it was edited there.
|
|
343
386
|
- `step.to_h` returns `{ from:, to:, diff: }`. `to_h(metadata: true)` adds
|
|
344
|
-
author, event, transaction ID, and record
|
|
345
|
-
retained snapshots.
|
|
387
|
+
author, event, transaction ID, and record, and a `sources` list.
|
|
388
|
+
`to_h(snapshots: true)` adds the retained snapshots.
|
|
346
389
|
- A child's change becomes visible at the next recorded event, or at the
|
|
347
390
|
current record. You do not need to save the article after each comment change.
|
|
348
391
|
- Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`,
|
|
349
|
-
`version_scope:`, `
|
|
350
|
-
`
|
|
392
|
+
`version_scope:`, `snapshots:`, `group:`, `recorded_changes:`,
|
|
393
|
+
`allow_approximate:`. `reload_live_endpoints:` is deprecated (see
|
|
394
|
+
[reusing loaded records](#reusing-loaded-records)).
|
|
351
395
|
|
|
352
396
|
### One person's changes
|
|
353
397
|
|
|
@@ -390,7 +434,9 @@ PaperTrailDiff.activity_timeline(article, associations: [:comments], group: :tra
|
|
|
390
434
|
- A combined step compares the state before the transaction with the state after
|
|
391
435
|
it. A field set and then restored inside the transaction is not reported.
|
|
392
436
|
- Its `source_boundary` is the first event in the transaction. That event's
|
|
393
|
-
author is not necessarily the author of every grouped change.
|
|
437
|
+
author is not necessarily the author of every grouped change. `step.sources`
|
|
438
|
+
lists every event in the transaction, each with its own author, except
|
|
439
|
+
those of records that were outside the tree before and after it.
|
|
394
440
|
- Versions without a `transaction_id` are never grouped.
|
|
395
441
|
- Also accepted by `analyze`, `analyze_many`, and `analyze_scope` with
|
|
396
442
|
`activity: true`, and by `report`.
|
|
@@ -398,7 +444,9 @@ PaperTrailDiff.activity_timeline(article, associations: [:comments], group: :tra
|
|
|
398
444
|
Without grouping, a step inside a transaction can look empty. A child's new
|
|
399
445
|
value is only visible at the next recorded event, and inside a transaction that
|
|
400
446
|
event is often another part of the same save. Nothing is lost; the change
|
|
401
|
-
appears one step later.
|
|
447
|
+
appears one step later. When the range ends at the version that closes that
|
|
448
|
+
save, there is no later step, so the change is
|
|
449
|
+
[outside the range](#choosing-a-range).
|
|
402
450
|
|
|
403
451
|
### Keeping snapshots: `snapshots: true`
|
|
404
452
|
|
|
@@ -419,6 +467,49 @@ step.from_snapshot.associations["comments"].records.first.attributes["author"]
|
|
|
419
467
|
- Also accepted by `analyze`, `analyze_many`, and `analyze_scope` with
|
|
420
468
|
`activity: true`, and by `report`.
|
|
421
469
|
|
|
470
|
+
### What each event recorded: `recorded_changes: true`
|
|
471
|
+
|
|
472
|
+
A step's `diff` compares the state before and after an event. To also see what
|
|
473
|
+
PaperTrail [stored with each version](#what-a-version-recorded), such as for an
|
|
474
|
+
audit log, pass `recorded_changes: true`:
|
|
475
|
+
|
|
476
|
+
<!-- executable:reference-step-sources -->
|
|
477
|
+
```ruby
|
|
478
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
479
|
+
article, associations: [:comments], group: :transaction, recorded_changes: true
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
steps.reject(&:empty?).flat_map(&:sources).map do |source|
|
|
483
|
+
{
|
|
484
|
+
version_id: source.version_id,
|
|
485
|
+
record: source.record.to_h, # { type: "Comment", id: 7 }
|
|
486
|
+
event: source.event,
|
|
487
|
+
actor: source.whodunnit,
|
|
488
|
+
recorded_at: source.recorded_at,
|
|
489
|
+
transaction_id: source.transaction_id,
|
|
490
|
+
changes: source.recorded_changes # { "body" => <from "Old" to "New"> }
|
|
491
|
+
}
|
|
492
|
+
end
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
- Each boundary's `recorded_changes` is what
|
|
496
|
+
`PaperTrailDiff.recorded_changes(version)` returns for its version: `nil`
|
|
497
|
+
when nothing was recorded, and always `nil` for the current record. The
|
|
498
|
+
versions are already loaded, so this adds no queries, but decoding each
|
|
499
|
+
version's changes takes time: about a third more for a `report` of 200
|
|
500
|
+
articles with comments, with history stored as YAML. Pass it only when you
|
|
501
|
+
use them.
|
|
502
|
+
- Without the option, `recorded_changes` raises `ConfigurationError` instead of
|
|
503
|
+
returning `nil`, so a missing option cannot look like a version that recorded
|
|
504
|
+
nothing.
|
|
505
|
+
- `to_h(metadata: true)` includes them on each boundary and source.
|
|
506
|
+
- Names in `ignore:`, or in `ignore: { all: }`, are left out. So are names
|
|
507
|
+
ignored on the root path, `"$"`, for the root's own versions. Names ignored
|
|
508
|
+
on an association path are not left out, because a child's version does not
|
|
509
|
+
say which path reached it.
|
|
510
|
+
- Also accepted by `analyze`, `analyze_many`, and `analyze_scope` with
|
|
511
|
+
`activity: true`, and by `report`.
|
|
512
|
+
|
|
422
513
|
### A destroyed record
|
|
423
514
|
|
|
424
515
|
When the range ends at the record's `destroy` version, the activity timeline
|
|
@@ -452,6 +543,27 @@ and members were recorded there, reading the root's membership at that version
|
|
|
452
543
|
raises `IncompleteAssociationHistoryError` instead of mixing both records'
|
|
453
544
|
members. Save each HABTM owner's versions in a transaction of its own.
|
|
454
545
|
|
|
546
|
+
PT-AT records membership only when the owner saves a version, so the gem knows
|
|
547
|
+
it only at root versions. A change made between two root versions is reported
|
|
548
|
+
in the first step after the earlier one, and credited to that step's source:
|
|
549
|
+
usually the earlier root version, which someone else may have saved. A member
|
|
550
|
+
created between them is reported in the step that starts with its creation
|
|
551
|
+
instead. A change attached before a checkpoint is therefore credited to
|
|
552
|
+
whoever saved the version before the checkpoint, not to the checkpoint's
|
|
553
|
+
author. To credit a change to the person who made it, save a checkpoint just
|
|
554
|
+
before the change as well as after it: the step between the two checkpoints is
|
|
555
|
+
theirs.
|
|
556
|
+
|
|
557
|
+
PT-AT works out the membership a version records from the instance that saves
|
|
558
|
+
it: the instance's current members, with the changes that instance made undone,
|
|
559
|
+
so that the version holds the membership from before its own event. It never
|
|
560
|
+
forgets those changes, not even on `reload`. Only the first version an instance
|
|
561
|
+
saves after changing a HABTM is right; each later save from it records the
|
|
562
|
+
membership from before that change again. The gem then reports the change late,
|
|
563
|
+
or reports a member that never left as removed and added again. Load the record
|
|
564
|
+
again, with `find`, before saving it a second time, and save checkpoints from a
|
|
565
|
+
freshly loaded record, as the [checkpoint recipe](#saving-a-checkpoint) does.
|
|
566
|
+
|
|
455
567
|
## analyze
|
|
456
568
|
|
|
457
569
|
`analyze(record)` returns an `Analysis` with the net `diff` and the `timeline`
|
|
@@ -469,10 +581,18 @@ analysis.to_h(snapshots: true, metadata: true)
|
|
|
469
581
|
```
|
|
470
582
|
|
|
471
583
|
- It accepts the same ranges as `timeline`.
|
|
472
|
-
-
|
|
473
|
-
|
|
584
|
+
- When nothing in the range is reported, the analysis is empty: an empty
|
|
585
|
+
`diff` and timelines, and `nil` for `from_snapshot` and `to_snapshot`. Here
|
|
586
|
+
`nil` means no state was read, not that the record did not exist; reading
|
|
587
|
+
one would rebuild the record's whole graph, a cost per record in a batch.
|
|
588
|
+
Use `compare` with a version and the record to see a state that did not
|
|
589
|
+
change.
|
|
590
|
+
- `activity: true` also builds the activity timeline. `snapshots:`, `group:`,
|
|
591
|
+
and `recorded_changes:` apply to the activity timeline and require
|
|
592
|
+
`activity: true`.
|
|
474
593
|
- Options: `from:`, `to:`, `within:`, `associations:`, `ignore:`, `activity:`,
|
|
475
|
-
`version_scope:`, `
|
|
594
|
+
`version_scope:`, `snapshots:`, `group:`, `recorded_changes:`,
|
|
595
|
+
`allow_approximate:`.
|
|
476
596
|
|
|
477
597
|
## Reports for many records
|
|
478
598
|
|
|
@@ -496,10 +616,11 @@ results.fetch(["Order", order.id.to_s]).diff
|
|
|
496
616
|
|
|
497
617
|
- Without `within:`, each record's whole history through its current state is
|
|
498
618
|
analyzed.
|
|
499
|
-
- A record with nothing to report gets an empty `Analysis
|
|
619
|
+
- A record with nothing to report gets an empty `Analysis`, whose snapshots
|
|
620
|
+
are `nil` (see [analyze](#analyze)).
|
|
500
621
|
- Records must be saved and unique.
|
|
501
622
|
- Options: `within:`, `associations:`, `ignore:`, `activity:`, `version_scope:`,
|
|
502
|
-
`
|
|
623
|
+
`snapshots:`, `group:`, `recorded_changes:`, `allow_approximate:`.
|
|
503
624
|
|
|
504
625
|
### analyze_scope
|
|
505
626
|
|
|
@@ -537,8 +658,8 @@ report = PaperTrailDiff.report(Article.all, since: 1.day.ago, limit: 100)
|
|
|
537
658
|
```
|
|
538
659
|
|
|
539
660
|
It accepts `associations:`, `ignore:`, `snapshots:`, `group:`,
|
|
540
|
-
`include_deleted:`, `historical_filter:`, `max_filter_versions:`,
|
|
541
|
-
`
|
|
661
|
+
`recorded_changes:`, `include_deleted:`, `historical_filter:`, `max_filter_versions:`,
|
|
662
|
+
and `allow_approximate:`.
|
|
542
663
|
|
|
543
664
|
### Which records are selected
|
|
544
665
|
|
|
@@ -572,15 +693,26 @@ It accepts `associations:`, `ignore:`, `snapshots:`, `group:`,
|
|
|
572
693
|
|
|
573
694
|
### Deleted records
|
|
574
695
|
|
|
575
|
-
A
|
|
576
|
-
|
|
696
|
+
A relation's conditions read the table, and a deleted record has no row.
|
|
697
|
+
Records that changed in the window but no longer exist are listed in
|
|
577
698
|
`unreachable`, including those selected by a change to their associations. It
|
|
578
|
-
is not paginated and does not mean those records
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
699
|
+
is not paginated and, without `include_deleted:`, does not mean those records
|
|
700
|
+
matched the relation.
|
|
701
|
+
|
|
702
|
+
`include_deleted: true` tests each of them against the relation's conditions,
|
|
703
|
+
as it stood when it was destroyed, and keeps only those that match. So a
|
|
704
|
+
report on `Article.where(account_id: account.id)` returns that account's
|
|
705
|
+
deletions, not every account's. The relation's conditions must be plain
|
|
706
|
+
conditions on the model's own columns, as a [readable association
|
|
707
|
+
scope's](#costly-associations) must; anything else, such as a join or SQL
|
|
708
|
+
text, raises `ConfigurationError`. Its order, limit, and offset are ignored
|
|
709
|
+
here, since they choose a page of existing rows. A record whose destroy
|
|
710
|
+
PaperTrail did not record has no state to test and stays listed.
|
|
711
|
+
|
|
712
|
+
It then analyzes the kept records from their `destroy` versions and returns the
|
|
713
|
+
results in `deleted`, keyed the same way. A value is `nil` when no `destroy`
|
|
714
|
+
version was recorded. With this option, existing and deleted records together
|
|
715
|
+
count toward `limit:`.
|
|
584
716
|
|
|
585
717
|
```ruby
|
|
586
718
|
missing_history = report.deleted.select { |_identity, analysis| analysis.nil? }.keys
|
|
@@ -607,7 +739,10 @@ report = PaperTrailDiff.report(
|
|
|
607
739
|
|
|
608
740
|
The filter checks the record as it was when the window started and after each
|
|
609
741
|
change made inside it. A record matches if any of those states match. With a
|
|
610
|
-
hash, every key must be equal (`==`)
|
|
742
|
+
hash, every key must be equal (`==`) once both sides are cast to the
|
|
743
|
+
attribute's type, as `where` casts its values: `{ status: :published }` matches
|
|
744
|
+
an enum, and a date or time matches whether the history stored it as YAML or
|
|
745
|
+
as a string in a `json` column. Values are read from the stored history
|
|
611
746
|
without calling `reify`, and unknown or unrecorded attributes raise an error
|
|
612
747
|
instead of matching `nil`. For other checks, pass a callable such as
|
|
613
748
|
`->(article) { article.title.start_with?("Pub") }`; it receives each state as a
|
|
@@ -615,14 +750,18 @@ model instance and may run queries of its own.
|
|
|
615
750
|
|
|
616
751
|
- It looks only at the record itself, not its associations, and does not check
|
|
617
752
|
whether a value was held for the whole window.
|
|
618
|
-
-
|
|
753
|
+
- Only the history of records the relation selects is read, and the relation's
|
|
754
|
+
order, `limit`, and `offset` then page the ones that match. Deleted records,
|
|
755
|
+
which a relation cannot test, are read too, and those that match are listed
|
|
619
756
|
in `unreachable`.
|
|
620
|
-
-
|
|
621
|
-
|
|
757
|
+
- Matching records the relation selects count toward `limit:`, and matching
|
|
758
|
+
stops as soon as the limit is exceeded. Matching deleted records count only
|
|
759
|
+
with `include_deleted: true`, as without a filter.
|
|
622
760
|
- `max_filter_versions:` limits how many versions the filter loads, including
|
|
623
|
-
the one version after the window it reads for each record
|
|
624
|
-
10,000. Going over raises
|
|
625
|
-
checked. It does not
|
|
761
|
+
the one version after the window it reads for each record, and the versions
|
|
762
|
+
of deleted records. The default is 10,000. Going over raises
|
|
763
|
+
`FilterLimitExceededError` before the extra batch is checked. It does not
|
|
764
|
+
limit queries inside a callable.
|
|
626
765
|
- `version_scope:` narrows which changes are reported, not which states are
|
|
627
766
|
tested here.
|
|
628
767
|
- It is accepted by `analyze_scope` and `report`, which select records from a
|
|
@@ -642,12 +781,15 @@ default_scope { where.not(hidden: true) }
|
|
|
642
781
|
|
|
643
782
|
The gem reads such a scope once and checks each record's stored values against
|
|
644
783
|
it at every point, so a record that moves into or out of the scope is added or
|
|
645
|
-
removed at the version that moved it. It reads equality, `IN`,
|
|
646
|
-
`where.not` forms, and `and`/`or` combinations of them on integer,
|
|
647
|
-
and enum columns
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
784
|
+
removed at the version that moved it. It reads equality, `IN`, their
|
|
785
|
+
`where.not` forms, and `and`/`or`/`not` combinations of them on integer,
|
|
786
|
+
boolean, and enum columns, with SQL's rules for NULL. A `nil` test
|
|
787
|
+
(`IS NULL`, `IS NOT NULL`) is read on a column of any type, so a soft-delete
|
|
788
|
+
scope such as `where(deleted_at: nil)` is read too. String and text columns
|
|
789
|
+
are read only on SQLite and PostgreSQL, and only without a column collation:
|
|
790
|
+
MySQL and SQL Server compare strings without regard to case or trailing spaces
|
|
791
|
+
by default, which the gem cannot repeat, and neither is PostgreSQL's
|
|
792
|
+
`citext`.
|
|
651
793
|
|
|
652
794
|
A `limit` or `offset`, and the order of a `has_one`, are read the same way
|
|
653
795
|
when the order is total and the gem can repeat it:
|
|
@@ -685,14 +827,24 @@ join records fills two places, as in the query. A `has_one :through` whose
|
|
|
685
827
|
own scope or whose parts have a limit, an offset, or a `has_one` order is not
|
|
686
828
|
read.
|
|
687
829
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
830
|
+
The gem cannot read the rest: a `limit` or `offset` whose order is not total
|
|
831
|
+
or sorts by another column type (strings follow a collation) or by a datetime
|
|
832
|
+
history cannot rank exactly, scopes that take the owner
|
|
833
|
+
(`->(article) { ... }`), scopes written as SQL strings or ranges, scopes that
|
|
834
|
+
join or select, and conditions on other column types or other tables, such as
|
|
835
|
+
a string column on MySQL. For these it asks PT-AT, which costs queries per
|
|
836
|
+
record and is approximate: PT-AT rebuilds an association from its current
|
|
837
|
+
members, swapping in their older versions, and never checks the scope against
|
|
838
|
+
them. A record that entered the scope since is reported as a member it was
|
|
839
|
+
not, so a comment moved into `-> { where("position > 2") }`, or restored from
|
|
840
|
+
a soft delete, shows as changed rather than added.
|
|
841
|
+
|
|
842
|
+
So every method that reads history raises `UnsupportedAssociationError` for
|
|
843
|
+
such a path and names it. Remove it from `associations:`, rewrite its scope as
|
|
844
|
+
plain conditions on the target's own columns, or pass `allow_approximate:
|
|
845
|
+
true` to accept PT-AT's answer, which also accepts its cost of queries per
|
|
846
|
+
record in batch methods. A comparison of two current records reads no history
|
|
847
|
+
and needs no opt-in. `allow_expensive:` is deprecated and has no effect.
|
|
696
848
|
|
|
697
849
|
A polymorphic `belongs_to` is loaded together too, with one query per target
|
|
698
850
|
class. Its classes are known only from the records, though, so batch methods
|
|
@@ -713,6 +865,10 @@ paths; the parents in a path are included automatically:
|
|
|
713
865
|
diff = PaperTrailDiff.compare(from, to, associations: [:author, "comments.replies"])
|
|
714
866
|
```
|
|
715
867
|
|
|
868
|
+
`associations:` also takes what `includes` takes: a single name
|
|
869
|
+
(`associations: :comments`) or hashes of names to what to include below them
|
|
870
|
+
(`associations: [:author, { comments: :replies }]`).
|
|
871
|
+
|
|
716
872
|
Supported: `belongs_to`, `has_one`, `has_many` (including `:through`), and
|
|
717
873
|
`has_and_belongs_to_many`. Associated models must use `has_paper_trail`, and
|
|
718
874
|
historical states need PT-AT installed and enabled
|
|
@@ -786,6 +942,13 @@ comments.changed.first.associations["replies"].added
|
|
|
786
942
|
member history cannot show. Replace it with a `has_many :through` a
|
|
787
943
|
versioned join model, which can be scoped. `association_paths` leaves it
|
|
788
944
|
out.
|
|
945
|
+
- An association keyed on a column other than a primary key, such as
|
|
946
|
+
`has_many :comments, primary_key: :legacy_id`, raises
|
|
947
|
+
`UnsupportedAssociationError` up front in every method, and so does a
|
|
948
|
+
`has_many :through` with such a part. PT-AT records which record a child
|
|
949
|
+
pointed at by that record's ID, so history read that way would show other
|
|
950
|
+
records' children. `association_paths` leaves it out. Naming the primary key
|
|
951
|
+
itself, `primary_key: :id`, is accepted.
|
|
789
952
|
- A root that uses single-table inheritance gets the associations of the
|
|
790
953
|
class it had at each version, so you can request one that only a subclass
|
|
791
954
|
declares. Without an `item_subtype` column, PaperTrail stores only the base
|
|
@@ -822,13 +985,19 @@ Without `synchronize_version_creation_timestamp: false`, the checkpoint takes th
|
|
|
822
985
|
record's old `updated_at` as its time and lands in the past. Save one record per
|
|
823
986
|
transaction: HABTM membership cannot be read from a transaction in which
|
|
824
987
|
another record with the same HABTM also saved a version (see the
|
|
825
|
-
[HABTM limitation](#habtm-limitation)).
|
|
988
|
+
[HABTM limitation](#habtm-limitation)). A checkpoint records membership as it
|
|
989
|
+
is when saved, including a member attached or created earlier in the same
|
|
990
|
+
transaction. A change it captures is credited to the step before it, so its
|
|
991
|
+
author is whoever saved the version before the checkpoint, unless that is a
|
|
992
|
+
checkpoint the same person saved just before the change. Don't save the
|
|
993
|
+
instance that made the HABTM change again; it records the old membership.
|
|
826
994
|
|
|
827
995
|
### Files from ActiveStorage
|
|
828
996
|
|
|
829
997
|
`has_one_attached` and `has_many_attached` point to ActiveStorage's own models,
|
|
830
998
|
which PaperTrail does not version. Comparing them historically raises
|
|
831
|
-
`UnversionedAssociationError` rather than claiming nothing changed
|
|
999
|
+
`UnversionedAssociationError` rather than claiming nothing changed, and
|
|
1000
|
+
`association_paths` leaves them out. Versioning
|
|
832
1001
|
ActiveStorage's models does not help either, because `has_one_attached` is a
|
|
833
1002
|
scoped `has_one`.
|
|
834
1003
|
|
|
@@ -916,6 +1085,7 @@ for numeric IDs).
|
|
|
916
1085
|
| `Step` | one `timeline` step |
|
|
917
1086
|
| `ActivityStep` | one `activity_timeline` step |
|
|
918
1087
|
| `ActivityBoundary` | one end of a step |
|
|
1088
|
+
| `ChangeRow` | one row from `change_rows` |
|
|
919
1089
|
| `Analysis` | result of `analyze` |
|
|
920
1090
|
| `ScopedAnalysis` | result of `analyze_scope` and `report` |
|
|
921
1091
|
| `TraversalEntry`, `RecordReference` | entries from `each_change` and `each_entry` |
|
|
@@ -965,6 +1135,38 @@ end
|
|
|
965
1135
|
Both return an `Enumerator` without a block. They never query the database. A
|
|
966
1136
|
record reachable through two requested paths appears at both.
|
|
967
1137
|
|
|
1138
|
+
### Change rows
|
|
1139
|
+
|
|
1140
|
+
For a table or an export, `change_rows` flattens activity into one row per
|
|
1141
|
+
change, each attributed to its step's source version:
|
|
1142
|
+
|
|
1143
|
+
```ruby
|
|
1144
|
+
steps.last.change_rows # one activity step
|
|
1145
|
+
analysis.change_rows # an Analysis built with activity: true
|
|
1146
|
+
report.change_rows # { identity => rows }, deleted records last
|
|
1147
|
+
|
|
1148
|
+
row = report.change_rows.values.first.first
|
|
1149
|
+
row.change # :changed, :added, or :removed
|
|
1150
|
+
row.attribute # "body", or nil for a row about a whole record
|
|
1151
|
+
row.from # nil for :added
|
|
1152
|
+
row.to # nil for :removed
|
|
1153
|
+
row.record # a RecordReference, or nil for the root
|
|
1154
|
+
row.association_path # ["comments"], or [] for the root
|
|
1155
|
+
row.source # the step's source_boundary
|
|
1156
|
+
row.to_h # the above, plus version_id, event, whodunnit, recorded_at,
|
|
1157
|
+
# and transaction_id
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
- An edited field is one `:changed` row. A record that appeared or disappeared
|
|
1161
|
+
gets an `:added` or `:removed` row with `attribute` `nil`, then a row for each
|
|
1162
|
+
field that has a value and for each record it holds.
|
|
1163
|
+
- A replaced `belongs_to` or `has_one` target gives the old record `:removed`
|
|
1164
|
+
rows, then the new one `:added` rows.
|
|
1165
|
+
- Rows follow the diff's walking order, and an analysis's rows follow its steps
|
|
1166
|
+
in time order.
|
|
1167
|
+
- `Analysis#change_rows` raises `ConfigurationError` without `activity: true`.
|
|
1168
|
+
`report.change_rows` leaves out a deleted record without destroy history.
|
|
1169
|
+
|
|
968
1170
|
## JSON columns
|
|
969
1171
|
|
|
970
1172
|
A `json` or `jsonb` column is compared as one value. `nested_changes` reports
|
|
@@ -984,8 +1186,10 @@ PaperTrailDiff.nested_changes(from_value, to_value) # also accepts two values
|
|
|
984
1186
|
inside arrays are compared whole.
|
|
985
1187
|
- A missing key is `PaperTrailDiff::NestedComparator::ABSENT`, not `nil`, because
|
|
986
1188
|
`{"a": null}` and `{}` are different.
|
|
987
|
-
-
|
|
988
|
-
|
|
1189
|
+
- Two arrays at the top, as a `json` column can hold, return one `ArrayChange`
|
|
1190
|
+
at the empty path: `{ [] => <added [3], removed [2]> }`.
|
|
1191
|
+
- A value that is not JSON on both sides, or an object facing an array, returns
|
|
1192
|
+
`{}`; the attribute diff still has the whole change.
|
|
989
1193
|
- Keys such as `:a` and `"a"` in one object raise `AmbiguousNestedKeyError`.
|
|
990
1194
|
|
|
991
1195
|
## What a version recorded
|
|
@@ -1025,17 +1229,18 @@ Other details:
|
|
|
1025
1229
|
saves a query per version, and a deleted record's values keep their types,
|
|
1026
1230
|
such as an enum's label rather than the integer PaperTrail stored.
|
|
1027
1231
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1232
|
+
For activity steps, pass `recorded_changes: true` instead of loading the
|
|
1233
|
+
versions again. Each step's sources then carry what their versions recorded,
|
|
1234
|
+
with no extra queries; see
|
|
1235
|
+
[what each event recorded](#what-each-event-recorded-recorded_changes-true):
|
|
1030
1236
|
|
|
1031
1237
|
<!-- executable:reference-recorded-changes -->
|
|
1032
1238
|
```ruby
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
end
|
|
1239
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
1240
|
+
article, associations: [:comments], recorded_changes: true
|
|
1241
|
+
)
|
|
1037
1242
|
|
|
1038
|
-
|
|
1243
|
+
steps.last.source_boundary.recorded_changes
|
|
1039
1244
|
```
|
|
1040
1245
|
|
|
1041
1246
|
## Checking your setup
|
|
@@ -1053,16 +1258,23 @@ and `cycle`. Cycles are marked and not followed.
|
|
|
1053
1258
|
|
|
1054
1259
|
Only associations a request accepts are listed, so each path can be passed to
|
|
1055
1260
|
`associations:`. The PaperTrail versions association, a `has_many :through`
|
|
1056
|
-
that goes through a HABTM, a scoped HABTM,
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1261
|
+
that goes through a HABTM, a scoped HABTM, a HABTM below the root, an
|
|
1262
|
+
association keyed on a column other than a primary key, one whose ends keep
|
|
1263
|
+
their versions in different tables, and one whose target, or a model its
|
|
1264
|
+
`has_many :through` goes through, has no `has_paper_trail` are left out. A path
|
|
1265
|
+
history can read only approximately, such as one with a scope written as SQL
|
|
1266
|
+
text, is listed, but requesting it also takes `allow_approximate: true` (see
|
|
1267
|
+
[costly associations](#costly-associations)). A `has_many :through` Active
|
|
1268
|
+
Record cannot follow, such as one through a polymorphic `belongs_to`, raises
|
|
1269
|
+
Active Record's own error from any model discovery reaches, as requesting it
|
|
1270
|
+
does.
|
|
1271
|
+
|
|
1272
|
+
Check a comparison's endpoints for known problems before relying on
|
|
1273
|
+
association history. They are what `compare` takes: two versions, or a version
|
|
1274
|
+
and the current record.
|
|
1063
1275
|
|
|
1064
1276
|
```ruby
|
|
1065
|
-
report = PaperTrailDiff.diagnose(from_version,
|
|
1277
|
+
report = PaperTrailDiff.diagnose(from_version, article, associations: [:tags, "comments.replies"])
|
|
1066
1278
|
|
|
1067
1279
|
report.ok? # no errors among the checks that ran
|
|
1068
1280
|
report.errors.map(&:code)
|
|
@@ -1070,7 +1282,12 @@ report.warnings.map(&:code)
|
|
|
1070
1282
|
```
|
|
1071
1283
|
|
|
1072
1284
|
Association checks run only for requested associations. The version-order check
|
|
1073
|
-
always runs. `
|
|
1285
|
+
always runs. Every reason `compare` would refuse the same request is an error,
|
|
1286
|
+
with the message `compare` would raise, so `ok?` is false whenever `compare`
|
|
1287
|
+
would refuse: an unknown or unsupported association, missing association
|
|
1288
|
+
tracking, an unversioned target, or a path read only approximately. Pass
|
|
1289
|
+
`allow_approximate: true`, as you would to `compare`, and such a path is a
|
|
1290
|
+
warning instead. `ok?` does not prove the history is complete.
|
|
1074
1291
|
|
|
1075
1292
|
## Models, schemas, and edge cases
|
|
1076
1293
|
|
|
@@ -1080,14 +1297,27 @@ always runs. `ok?` does not prove the history is complete.
|
|
|
1080
1297
|
- **Non-integer primary keys**, such as UUIDs, are supported, and so is a string
|
|
1081
1298
|
`versions.item_id` column.
|
|
1082
1299
|
- **Custom version classes** (`has_paper_trail versions: { class_name: }`) are
|
|
1083
|
-
supported.
|
|
1300
|
+
supported. For association history, a model and each association you ask
|
|
1301
|
+
for must keep their versions, and their PT-AT version associations, in the
|
|
1302
|
+
same tables. PT-AT reads an association's history from its owner's tables
|
|
1303
|
+
and numbers transactions within one table, so it cannot follow an
|
|
1304
|
+
association into a different version table. Such an association raises
|
|
1305
|
+
`UnsupportedAssociationError` naming both tables, and `association_paths`
|
|
1306
|
+
leaves it out. One custom table shared by the models works like
|
|
1307
|
+
PaperTrail's own.
|
|
1308
|
+
- **A scoped versions association** (`has_paper_trail versions: { scope: }`)
|
|
1309
|
+
does not change what is read: every method reads all of a record's
|
|
1310
|
+
versions, because a history replayed without some of them would credit
|
|
1311
|
+
their changes to the next version kept. To report only some changes, pass
|
|
1312
|
+
[`version_scope:`](#reporting-only-some-changes-version_scope).
|
|
1084
1313
|
|
|
1085
1314
|
### Version order
|
|
1086
1315
|
|
|
1087
1316
|
Versions are ordered by `created_at`, then by ID when timestamps are equal. That
|
|
1088
|
-
works when IDs increase with each insert, as autoincrement IDs do
|
|
1089
|
-
IDs do not,
|
|
1090
|
-
|
|
1317
|
+
works when IDs increase with each insert, as autoincrement IDs do, in one
|
|
1318
|
+
table. UUID version IDs do not, and models with their own version tables count
|
|
1319
|
+
IDs separately, so two such versions with the same timestamp cannot be ordered;
|
|
1320
|
+
the gem raises `AmbiguousVersionOrderError` naming both instead of guessing.
|
|
1091
1321
|
Timestamps stored to the second, as on a MySQL `datetime` column, make ties
|
|
1092
1322
|
common. `diagnose` reports this as an error.
|
|
1093
1323
|
|
|
@@ -1097,11 +1327,18 @@ earlier time. Keep server clocks synchronized.
|
|
|
1097
1327
|
|
|
1098
1328
|
Association changes between versions with the same timestamp are placed using
|
|
1099
1329
|
the same order. A few association shapes fall back to PT-AT's own reifier, which
|
|
1100
|
-
uses timestamps only:
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1330
|
+
uses timestamps only: a path history reads only approximately, which takes
|
|
1331
|
+
`allow_approximate: true`, a composite foreign key, and a polymorphic
|
|
1332
|
+
`belongs_to` whose target's default scope history cannot read. The
|
|
1333
|
+
[architecture notes](architecture.md) list them. When a request includes such a
|
|
1334
|
+
path, or any polymorphic `belongs_to`, whose targets are known only per record,
|
|
1335
|
+
`diagnose` warns about tied timestamps and a comparison between two tied
|
|
1336
|
+
versions emits the `ambiguous_association_boundary.paper_trail_diff`
|
|
1337
|
+
notification. Recording timestamps below one second avoids the problem. On SQL Server, PT-AT compares
|
|
1338
|
+
those timestamps only to about 3 milliseconds (a `datetime` tick, 1/300 s),
|
|
1339
|
+
because the adapter writes the times in its queries that way, so for these
|
|
1340
|
+
shapes two versions saved closer together than that may be read in the wrong
|
|
1341
|
+
order.
|
|
1105
1342
|
|
|
1106
1343
|
### History stored as JSON
|
|
1107
1344
|
|
|
@@ -1109,7 +1346,9 @@ PaperTrail's JSON serializer, and a `json` or `jsonb` `object` column, store
|
|
|
1109
1346
|
times to milliseconds by default (`ActiveSupport::JSON::Encoding.time_precision`).
|
|
1110
1347
|
For those histories the gem compares all times at that precision, so an
|
|
1111
1348
|
unchanged timestamp is not reported as changed. A change smaller than that
|
|
1112
|
-
precision was never recorded and cannot be reported.
|
|
1349
|
+
precision was never recorded and cannot be reported. A `json` or `jsonb`
|
|
1350
|
+
column also returns times and dates as strings, which PaperTrail leaves as
|
|
1351
|
+
they are; the gem casts them back to the attribute's type.
|
|
1113
1352
|
|
|
1114
1353
|
## Instrumentation
|
|
1115
1354
|
|
|
@@ -1141,9 +1380,9 @@ All errors inherit from `PaperTrailDiff::Error`.
|
|
|
1141
1380
|
| Error | Raised when |
|
|
1142
1381
|
| --- | --- |
|
|
1143
1382
|
| `ConfigurationError` | An option is invalid |
|
|
1144
|
-
| `UnsupportedBatchPathError` |
|
|
1383
|
+
| `UnsupportedBatchPathError` | No longer raised; kept until `allow_expensive:` is removed |
|
|
1145
1384
|
| `InvalidEndpointError` | A record is unsaved, destroyed, or has unsaved changes, or `recorded_changes` is given something other than a version |
|
|
1146
|
-
| `UnloadedAssociationError` | `reload_live_endpoints: false
|
|
1385
|
+
| `UnloadedAssociationError` | The deprecated `reload_live_endpoints: false`, and an association is not loaded |
|
|
1147
1386
|
| `VersionMismatchError` | Two endpoints belong to different records |
|
|
1148
1387
|
| `ReversedEndpointsError` | Two versions are given newest first |
|
|
1149
1388
|
| `InvalidTimelineRangeError` | A range is invalid, for example `within:` with `from:`, or `:last` |
|
|
@@ -1154,8 +1393,8 @@ All errors inherit from `PaperTrailDiff::Error`.
|
|
|
1154
1393
|
| `FilterLimitExceededError` | `historical_filter:` needs more than `max_filter_versions:` |
|
|
1155
1394
|
| `AssociationTrackingUnavailableError` | Association history is requested without PT-AT |
|
|
1156
1395
|
| `UnknownAssociationError` | An association name does not exist |
|
|
1157
|
-
| `UnsupportedAssociationError` | An association's macro is not supported, a path continues past a polymorphic `belongs_to`, a `has_many :through` goes through a HABTM,
|
|
1158
|
-
| `UnversionedAssociationError` | An association's target model has no `has_paper_trail`, including the class a polymorphic `belongs_to` names |
|
|
1396
|
+
| `UnsupportedAssociationError` | An association's macro is not supported, a path continues past a polymorphic `belongs_to`, a `has_many :through` goes through a HABTM, a HABTM is scoped or below the root, an association is keyed on a column other than a primary key, or history cannot read a [costly association](#costly-associations) and `allow_approximate: true` was not passed |
|
|
1397
|
+
| `UnversionedAssociationError` | An association's target model, or a model a `has_many :through` goes through, has no `has_paper_trail`, including the class a polymorphic `belongs_to` names |
|
|
1159
1398
|
| `IncompleteAssociationHistoryError` | PT-AT did not record enough to rebuild an association, such as HABTM without a transaction or in a transaction another record with that HABTM also saved a version in, or a polymorphic type names no class |
|
|
1160
1399
|
| `UnsupportedLiveActivityError` | A HABTM activity range ends at the current record |
|
|
1161
1400
|
| `AmbiguousNestedKeyError` | `nested_changes` finds keys like `:a` and `"a"` together |
|