paper_trail_diff 0.9.0 → 0.11.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 +94 -0
- data/README.md +181 -0
- data/lib/paper_trail_diff/activity_boundary.rb +20 -4
- data/lib/paper_trail_diff/activity_grouping.rb +26 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +13 -6
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +83 -0
- data/lib/paper_trail_diff/diagnostics.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +8 -0
- data/lib/paper_trail_diff/nested_comparator.rb +108 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +31 -12
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -1
- data/lib/paper_trail_diff/scoped_analysis.rb +31 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +148 -0
- data/lib/paper_trail_diff/support.rb +13 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +13 -8
- data/lib/paper_trail_diff/traversal_preparer.rb +25 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff.rb +101 -12
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +13 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +19 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
- data/sig/generated/paper_trail_diff/activity_transaction_grouper.rbs +54 -0
- data/sig/generated/paper_trail_diff/errors.rbs +10 -0
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +68 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +15 -8
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +25 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +93 -0
- data/sig/generated/paper_trail_diff/support.rbs +11 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +10 -0
- data/sig/generated/paper_trail_diff.rbs +58 -4
- metadata +14 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 205c52c1f80517b072fa68452447963c3d7245ba0707b049d5ebd5d2528727b3
|
|
4
|
+
data.tar.gz: 65cc6e544662366fc185719a3660b29c3a9796f7c003fe7eeff4c551c9114c11
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 78c62fcbc641a5f2dd029e69bf656453612a6f2768b1e3268048a13ea9fb4d17ff934048e36cb0798ae3d37aa3102f3c39ef289effdd2f96087796636e1ac921
|
|
7
|
+
data.tar.gz: 3ba0a0592b1dd997c6177f2ad654bc28a93b00aebd5f83720732b7edaf08200c43aae19ac8e6eae2bbe351147343a2a8cf38b6352ca9427a9f2ad35f1be7c36d
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,100 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file. The
|
|
4
4
|
project follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.11.0] - 2026-08-18
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Add `PaperTrailDiff.analyze_scope`, which selects the roots to analyze from an
|
|
11
|
+
ActiveRecord relation or model class instead of an array the caller assembled,
|
|
12
|
+
in a fixed number of queries. Also reachable as
|
|
13
|
+
`PaperTrailDiff.analyze_many(scope: ..., limit: ...)`. Every other option
|
|
14
|
+
behaves as it does for `analyze_many`.
|
|
15
|
+
- `analyze_scope` returns a `ScopedAnalysis`, which destructures into the usual
|
|
16
|
+
analyses hash and the roots the relation could not reach. A relation's
|
|
17
|
+
conditions are evaluated against the live table, so a root destroyed during
|
|
18
|
+
the window cannot be tested against them at all, even though its history is
|
|
19
|
+
intact and the state it held at destruction may have matched. Those roots are
|
|
20
|
+
named rather than dropped, so a page auditing deletions is told where to look
|
|
21
|
+
instead of coming up short without saying so.
|
|
22
|
+
- Add `PaperTrailDiff.nested_changes`, which reports the keys that changed
|
|
23
|
+
inside an attribute the database stores whole, such as a JSON or jsonb column
|
|
24
|
+
or one holding JSON as text. An attribute diff could only say the blob
|
|
25
|
+
changed, leaving the caller to diff it again by hand. Paths are arrays rather
|
|
26
|
+
than dotted strings, because a JSON key may itself contain a dot. Arrays are
|
|
27
|
+
reported whole rather than by index, since their elements carry no identity
|
|
28
|
+
and a list that merely shifted would otherwise look changed throughout. A key
|
|
29
|
+
that was absent reads as `NestedComparator::ABSENT` rather than nil, because
|
|
30
|
+
`{"a": null}` and `{}` mean different things in JSON and an audit trail that
|
|
31
|
+
showed them alike would be lying about one of them. Additive: the existing
|
|
32
|
+
attribute diff is unchanged, and a pair that is not two readable structures
|
|
33
|
+
reports nothing.
|
|
34
|
+
- Raise `PaperTrailDiff::BatchLimitExceededError` when a relation selects more
|
|
35
|
+
roots than `limit:` allows. `limit:` is required for `analyze_scope`: root
|
|
36
|
+
selection moves into the gem, so the bound on how much work one page can ask
|
|
37
|
+
for moves with it. It refuses rather than truncating, because a report that is
|
|
38
|
+
quietly short is worse than one that fails.
|
|
39
|
+
- Accept `group: :transaction` on `activity_timeline` and `analyze(activity:
|
|
40
|
+
true)`, reporting one saved transaction as one step. A parent and its
|
|
41
|
+
children saved together produce a version each, so one deliberate action
|
|
42
|
+
arrived as several steps, and some of them read as empty even though a change
|
|
43
|
+
was made at that boundary: a version records the state before its own event,
|
|
44
|
+
so a child's new value is revealed only by a later version of that child, its
|
|
45
|
+
destroy version, or the live row, none of which exists yet inside the
|
|
46
|
+
transaction. The change therefore surfaced a step or more later, folded in
|
|
47
|
+
with whatever that step carried. A step belongs to the transaction of the
|
|
48
|
+
event that opens it, since that is the event whose change it reports; a step
|
|
49
|
+
therefore belongs to its `from_boundary`'s transaction, and consecutive steps
|
|
50
|
+
sharing one are the parts of a single save. Merging compares the group's
|
|
51
|
+
outer states rather than combining the diffs between them, so a field set and
|
|
52
|
+
restored inside one transaction correctly reports as unchanged. Off by
|
|
53
|
+
default.
|
|
54
|
+
- Expose `ActivityBoundary#transaction_id`.
|
|
55
|
+
- A boundary that records no transaction groups with nothing. PaperTrail leaves
|
|
56
|
+
the column nil outside a transaction and a custom version class need not
|
|
57
|
+
carry it at all, so treating those as one shared transaction would merge
|
|
58
|
+
unrelated history into a single step.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- Check that the generated Appraisal gemfiles match the `Gemfile`, in CI and in
|
|
63
|
+
`rake release:preflight`. Every other job runs against a generated gemfile, so
|
|
64
|
+
a dependency changed only in the `Gemfile` was one CI never installed: the
|
|
65
|
+
jobs passed having exercised the old version.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- Resolve "does this model record history" through one predicate,
|
|
70
|
+
`Support.versioned?`. Three copies existed and one of them tested only
|
|
71
|
+
`respond_to?(:paper_trail)`, which PaperTrail defines on every ActiveRecord
|
|
72
|
+
model and which therefore answers true for models that never called
|
|
73
|
+
`has_paper_trail`. Nothing reached that copy with an unversioned model, since
|
|
74
|
+
the traversal preparer rejects those first, so no released behaviour changed --
|
|
75
|
+
but the check looked right while being unable to fail, and reading history
|
|
76
|
+
through it would have raised at the version class rather than at the question.
|
|
77
|
+
|
|
78
|
+
## [0.10.0] - 2026-08-15
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
|
|
82
|
+
- Add `rake release:preflight`, which runs the full gate and then checks
|
|
83
|
+
everything that must hold before a tag: a clean working tree, a HEAD that
|
|
84
|
+
matches its upstream, an unused tag, a dated changelog section for the
|
|
85
|
+
version, and that the version is not already on RubyGems. It reports and
|
|
86
|
+
never tags, pushes, or publishes. An unreachable RubyGems is reported as
|
|
87
|
+
unknown rather than treated as "not published".
|
|
88
|
+
- Raise `PaperTrailDiff::UnversionedAssociationError` when a selected
|
|
89
|
+
association's target is not versioned and history is being reconstructed.
|
|
90
|
+
Such a comparison could only ever answer "nothing changed", which is a wrong
|
|
91
|
+
answer rather than an empty one. Live-to-live comparison reads current state
|
|
92
|
+
and is unaffected. `diagnose` now reports the same condition as an error
|
|
93
|
+
rather than a warning, so `ok?` no longer stays true for a comparison that
|
|
94
|
+
will raise.
|
|
95
|
+
- Document ActiveStorage attachments, which this reaches through:
|
|
96
|
+
`has_one_attached` points at models Rails owns and PaperTrail never versions,
|
|
97
|
+
so attachments are audited through a versioned model of your own that mirrors
|
|
98
|
+
the metadata onto ordinary columns.
|
|
99
|
+
|
|
6
100
|
## [0.9.0] - 2026-08-14
|
|
7
101
|
|
|
8
102
|
### Added
|
data/README.md
CHANGED
|
@@ -152,6 +152,55 @@ that touches a record and its children in quick succession will manage it — so
|
|
|
152
152
|
this is worth checking before trusting an association history. Recording
|
|
153
153
|
versions at sub-second precision separates them.
|
|
154
154
|
|
|
155
|
+
### ActiveStorage attachments
|
|
156
|
+
|
|
157
|
+
`has_one_attached` and `has_many_attached` point at `ActiveStorage::Attachment`
|
|
158
|
+
and `ActiveStorage::Blob`, which Rails owns and PaperTrail does not version.
|
|
159
|
+
There is no history behind them, so a historical comparison over those paths
|
|
160
|
+
raises `PaperTrailDiff::UnversionedAssociationError` rather than reporting that
|
|
161
|
+
nothing changed. Adding `has_paper_trail` to Rails' own models does not rescue
|
|
162
|
+
it either: `has_one_attached` is a scoped `has_one`, and reifying it from
|
|
163
|
+
history is ambiguous.
|
|
164
|
+
|
|
165
|
+
Audit the attachment through a model you own instead. Give it the facts worth
|
|
166
|
+
auditing as ordinary columns, and version it:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
class DocumentRevision < ApplicationRecord
|
|
170
|
+
belongs_to :attachable, polymorphic: true
|
|
171
|
+
has_one_attached :file
|
|
172
|
+
has_paper_trail
|
|
173
|
+
|
|
174
|
+
# Attaching a file writes to ActiveStorage's tables, not to this record, so
|
|
175
|
+
# nothing would be versioned without copying the facts across.
|
|
176
|
+
after_save :record_file_metadata, if: -> { file.attached? }
|
|
177
|
+
|
|
178
|
+
def record_file_metadata
|
|
179
|
+
blob = file.blob
|
|
180
|
+
return if filename == blob.filename.to_s && checksum == blob.checksum
|
|
181
|
+
|
|
182
|
+
update_columns(
|
|
183
|
+
filename: blob.filename.to_s, content_type: blob.content_type,
|
|
184
|
+
byte_size: blob.byte_size, checksum: blob.checksum
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
class Article < ApplicationRecord
|
|
192
|
+
has_many :document_revisions, as: :attachable
|
|
193
|
+
has_paper_trail
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
PaperTrailDiff.compare(before, after, associations: [:document_revisions])
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The bytes stay in ActiveStorage; the *auditable facts* live where PaperTrail
|
|
200
|
+
can see them. Nothing about the column names matters to this gem — it reports
|
|
201
|
+
whichever columns changed. What matters is that replacing a file writes to a
|
|
202
|
+
versioned record, because attaching one on its own does not.
|
|
203
|
+
|
|
155
204
|
## Choosing an entry point
|
|
156
205
|
|
|
157
206
|
| You need | Call |
|
|
@@ -305,6 +354,47 @@ argument, are loaded per root; other selected branches remain batched. Callers
|
|
|
305
354
|
should still use an appropriate database transaction when all live queries
|
|
306
355
|
must observe one atomic snapshot.
|
|
307
356
|
|
|
357
|
+
### Look inside a JSON column
|
|
358
|
+
|
|
359
|
+
A `json` or `jsonb` column reifies to one Hash, so an attribute diff can only
|
|
360
|
+
say the blob changed. `nested_changes` says which keys changed:
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
diff = PaperTrailDiff.compare(from_version, to_version)
|
|
364
|
+
|
|
365
|
+
PaperTrailDiff.nested_changes(diff.attributes.fetch("config"))
|
|
366
|
+
# => { ["theme"] => <from "dark" to "light">,
|
|
367
|
+
# ["limits", "max"] => <from 10 to 20> }
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
It reads a column that stores JSON as text as readily as a native one, and also
|
|
371
|
+
takes a bare pair: `PaperTrailDiff.nested_changes(from_value, to_value)`.
|
|
372
|
+
|
|
373
|
+
Nothing about the attribute diff itself changes. A pair that is not two readable
|
|
374
|
+
structures — text on one side and JSON on the other, or a value that does not
|
|
375
|
+
parse — reports nothing, and the caller still has the whole-value change it
|
|
376
|
+
already had.
|
|
377
|
+
|
|
378
|
+
Three things about the shape are worth knowing:
|
|
379
|
+
|
|
380
|
+
**Paths are arrays, not dotted strings.** A JSON key may itself contain a dot, so
|
|
381
|
+
`"a.b"` would be ambiguous between one key and two:
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
384
|
+
PaperTrailDiff.nested_changes({ "a.b" => 1, "a" => { "b" => 1 } },
|
|
385
|
+
{ "a.b" => 2, "a" => { "b" => 3 } })
|
|
386
|
+
# => { ["a.b"] => <from 1 to 2>, ["a", "b"] => <from 1 to 3> }
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
**Arrays are reported whole, not by index.** Their elements carry no identity, so
|
|
390
|
+
an insertion at the front would make every later index look changed. This is the
|
|
391
|
+
same rule the collection comparator follows for records it cannot identify.
|
|
392
|
+
|
|
393
|
+
**An absent key is not a null one.** `{"a": null}` and `{}` mean different things
|
|
394
|
+
in JSON, and an audit trail that showed them alike would be lying about one of
|
|
395
|
+
them, so a missing key reads as `PaperTrailDiff::NestedComparator::ABSENT`
|
|
396
|
+
rather than `nil`.
|
|
397
|
+
|
|
308
398
|
### Reuse already-preloaded current endpoints
|
|
309
399
|
|
|
310
400
|
`compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
|
|
@@ -396,6 +486,54 @@ value is the same `Analysis` that `analyze` returns for one record. A root with
|
|
|
396
486
|
no versions inside the window gets an empty `Analysis` rather than raising, so a
|
|
397
487
|
listing page needs no special case. Root identities must be unique.
|
|
398
488
|
|
|
489
|
+
### Letting a relation choose the roots
|
|
490
|
+
|
|
491
|
+
Assembling that array means querying `versions` for which roots moved, filtering,
|
|
492
|
+
and loading the records — the same selection this gem already performs.
|
|
493
|
+
`analyze_scope` does it for you:
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
analyses, unreachable = PaperTrailDiff.analyze_scope(
|
|
497
|
+
Order.where(status: "open"),
|
|
498
|
+
within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
|
|
499
|
+
associations: [:line_items],
|
|
500
|
+
limit: 500
|
|
501
|
+
)
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
`analyses` is the same frozen hash `analyze_many` returns. It is also available
|
|
505
|
+
as `PaperTrailDiff.analyze_many(scope: ..., limit: ...)`, and it accepts a model
|
|
506
|
+
class (`Order`) as readily as a relation. Every other option — `within:`,
|
|
507
|
+
`version_scope:`, `associations:`, `ignore:`, `activity:`, `close_on:` — behaves
|
|
508
|
+
exactly as it does for `analyze_many`, and the query cost stays flat in the
|
|
509
|
+
number of roots.
|
|
510
|
+
|
|
511
|
+
Two things to know before using it.
|
|
512
|
+
|
|
513
|
+
**`limit:` is required, and exceeding it raises.** Selection moves into the gem
|
|
514
|
+
here, so the bound on how much work one page can ask for has to move with it. It
|
|
515
|
+
refuses rather than truncating, because an audit report that is quietly short is
|
|
516
|
+
worse than one that fails.
|
|
517
|
+
|
|
518
|
+
**`unreachable` names roots the relation could not reach.** A relation's
|
|
519
|
+
conditions are evaluated against the live table, so a root destroyed during the
|
|
520
|
+
window cannot be tested against them at all — its history is intact, and the
|
|
521
|
+
state it held when it was destroyed may well have matched:
|
|
522
|
+
|
|
523
|
+
```ruby
|
|
524
|
+
unreachable # => [["Order", "412"]]
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
These are reported rather than dropped so that a page auditing deletions is told
|
|
528
|
+
where to look instead of silently coming up short. `analyze_many` requires live
|
|
529
|
+
records by design, so to analyze those roots reach for `activity_timeline`, which
|
|
530
|
+
reads history without needing current state.
|
|
531
|
+
|
|
532
|
+
Note also that a relation selects on **current** state, not on state during the
|
|
533
|
+
window. `where(status: "open")` means open *now*, which is a different set from
|
|
534
|
+
what was open while the window was open. If you need the historical population,
|
|
535
|
+
select on the version history instead and pass the records.
|
|
536
|
+
|
|
399
537
|
### Reporting on a subset of root mutations
|
|
400
538
|
|
|
401
539
|
`version_scope:` narrows which root versions count as *selected mutations*, so a
|
|
@@ -616,6 +754,49 @@ boundary. Passing `to: article` is what removes the need to touch the parent
|
|
|
616
754
|
after an ordinary versioned child mutation; current state is still never
|
|
617
755
|
implicit.
|
|
618
756
|
|
|
757
|
+
### Reporting one save as one step
|
|
758
|
+
|
|
759
|
+
A parent and its children saved together produce a version each, so one
|
|
760
|
+
deliberate action arrives as several steps. `group: :transaction` reports it as
|
|
761
|
+
one:
|
|
762
|
+
|
|
763
|
+
```ruby
|
|
764
|
+
PaperTrailDiff.activity_timeline(
|
|
765
|
+
article, from: :first, to: article,
|
|
766
|
+
associations: [:comments], group: :transaction
|
|
767
|
+
)
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Given a single transaction that retitled an article, revised one comment, and
|
|
771
|
+
added another, the ungrouped timeline reports three steps — the title change,
|
|
772
|
+
then a step that reads as empty, then the comment changes. Grouped, it reports
|
|
773
|
+
one step carrying the title *and* the comment changes, credited to whoever made
|
|
774
|
+
the save. Off by default; also accepted by `analyze(activity: true)`.
|
|
775
|
+
|
|
776
|
+
The middle step reads as empty for a reason worth knowing, because it is the
|
|
777
|
+
same rule the rest of this document turns on. A version records the state
|
|
778
|
+
*before* its own event, so a child's new value is revealed only by something
|
|
779
|
+
later: a further version of that child, its destroy version, or the live row.
|
|
780
|
+
Inside a transaction there is usually none of those yet, so a change made at
|
|
781
|
+
that boundary surfaces a step or more afterwards, folded in with whatever that
|
|
782
|
+
step carried. Nothing is lost — but "when" and "with what" both read wrong.
|
|
783
|
+
|
|
784
|
+
Two properties this relies on:
|
|
785
|
+
|
|
786
|
+
**A step belongs to the transaction of the event that opens it.** That is the
|
|
787
|
+
event whose change the step reports, so grouping on the closing boundary instead
|
|
788
|
+
would credit each change to whoever made the next one.
|
|
789
|
+
|
|
790
|
+
**Merging compares the group's outer states**, rather than combining the diffs
|
|
791
|
+
between them. A field set and then restored inside one transaction has not
|
|
792
|
+
changed, and only comparing the endpoints can say so.
|
|
793
|
+
|
|
794
|
+
A boundary that records no transaction groups with nothing. PaperTrail leaves
|
|
795
|
+
`transaction_id` nil outside a transaction, and a custom version class need not
|
|
796
|
+
carry the column at all; treating those as one shared transaction would merge
|
|
797
|
+
unrelated history into a single step. `ActivityBoundary#transaction_id` exposes
|
|
798
|
+
what a boundary recorded.
|
|
799
|
+
|
|
619
800
|
### Reading the state behind a step
|
|
620
801
|
|
|
621
802
|
A step's diff carries what changed. A renderer often needs what did *not* — to
|
|
@@ -12,6 +12,10 @@ module PaperTrailDiff
|
|
|
12
12
|
attr_reader :event #: String?
|
|
13
13
|
attr_reader :whodunnit #: untyped
|
|
14
14
|
attr_reader :record #: RecordReference
|
|
15
|
+
# The transaction the version was written in, when PaperTrail recorded one.
|
|
16
|
+
# Several versions saved together share it, which is what lets a timeline
|
|
17
|
+
# report one save as one step rather than as its parts.
|
|
18
|
+
attr_reader :transaction_id #: untyped
|
|
15
19
|
|
|
16
20
|
class << self
|
|
17
21
|
#: (untyped) -> ActivityBoundary
|
|
@@ -23,7 +27,8 @@ module PaperTrailDiff
|
|
|
23
27
|
item_id: version.item_id,
|
|
24
28
|
recorded_at: version.created_at,
|
|
25
29
|
event: version.event,
|
|
26
|
-
whodunnit: version.whodunnit
|
|
30
|
+
whodunnit: version.whodunnit,
|
|
31
|
+
transaction_id: transaction_id(version)
|
|
27
32
|
)
|
|
28
33
|
end
|
|
29
34
|
|
|
@@ -50,12 +55,21 @@ module PaperTrailDiff
|
|
|
50
55
|
item_id: version.item_id,
|
|
51
56
|
recorded_at: version.created_at,
|
|
52
57
|
event: version.event,
|
|
53
|
-
whodunnit: version.whodunnit
|
|
58
|
+
whodunnit: version.whodunnit,
|
|
59
|
+
transaction_id: transaction_id(version)
|
|
54
60
|
)
|
|
55
61
|
end
|
|
62
|
+
|
|
63
|
+
# A custom version class need not carry the column, and PaperTrail leaves
|
|
64
|
+
# it nil outside a transaction. Both read as "no transaction here", which
|
|
65
|
+
# groups with nothing.
|
|
66
|
+
#: (untyped) -> untyped
|
|
67
|
+
def transaction_id(version)
|
|
68
|
+
version.transaction_id if version.respond_to?(:transaction_id)
|
|
69
|
+
end
|
|
56
70
|
end
|
|
57
71
|
|
|
58
|
-
#: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
|
|
72
|
+
#: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
|
|
59
73
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
60
74
|
kind:,
|
|
61
75
|
version_id:,
|
|
@@ -63,7 +77,8 @@ module PaperTrailDiff
|
|
|
63
77
|
item_id:,
|
|
64
78
|
recorded_at:,
|
|
65
79
|
event: nil,
|
|
66
|
-
whodunnit: nil
|
|
80
|
+
whodunnit: nil,
|
|
81
|
+
transaction_id: nil
|
|
67
82
|
)
|
|
68
83
|
@kind = kind
|
|
69
84
|
@version_id = Support.immutable_copy(version_id)
|
|
@@ -72,6 +87,7 @@ module PaperTrailDiff
|
|
|
72
87
|
@recorded_at = Support.immutable_copy(recorded_at)
|
|
73
88
|
@event = Support.immutable_copy(event&.to_s)
|
|
74
89
|
@whodunnit = Support.immutable_copy(whodunnit)
|
|
90
|
+
@transaction_id = Support.immutable_copy(transaction_id)
|
|
75
91
|
@record = RecordReference.new(type: @item_type, id: @item_id)
|
|
76
92
|
freeze
|
|
77
93
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Shared by the two activity builders. They differ in how they choose
|
|
6
|
+
# boundaries -- one from an explicit version range, one from a wall-clock
|
|
7
|
+
# window -- but a transaction has to collapse the same way in both, or the
|
|
8
|
+
# same history would read differently depending on how it was asked for.
|
|
9
|
+
module ActivityGrouping
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
#: () -> bool
|
|
13
|
+
def grouping?
|
|
14
|
+
@group == :transaction
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Applied last, to finished steps, so grouping sees the same timeline the
|
|
18
|
+
# caller would otherwise have received.
|
|
19
|
+
#: (Array[ActivityStep]) -> Array[ActivityStep]
|
|
20
|
+
def group_steps(steps)
|
|
21
|
+
return steps unless grouping?
|
|
22
|
+
|
|
23
|
+
ActivityTransactionGrouper.new(steps, retain: @snapshots).call
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -4,9 +4,15 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
6
6
|
class ActivityTimelineBuilder
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
include ActivityGrouping
|
|
8
|
+
|
|
9
|
+
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
10
|
+
def initialize(record, range:, tree:, snapshotter:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists
|
|
9
11
|
@snapshots = snapshots
|
|
12
|
+
@group = group
|
|
13
|
+
# Merging a group compares its outer states, so the snapshots must survive
|
|
14
|
+
# the build even when the caller did not ask to keep them.
|
|
15
|
+
@retain = snapshots || grouping?
|
|
10
16
|
@record = record
|
|
11
17
|
@from = range.from
|
|
12
18
|
@to = range.to
|
|
@@ -155,10 +161,10 @@ module PaperTrailDiff
|
|
|
155
161
|
steps << ActivityStep.between(
|
|
156
162
|
from_boundary: previous_boundary, to_boundary: final_boundary,
|
|
157
163
|
from_snapshot: history.last_snapshot, to_snapshot: final_snapshot,
|
|
158
|
-
retain: @
|
|
164
|
+
retain: @retain
|
|
159
165
|
)
|
|
160
166
|
end
|
|
161
|
-
steps.freeze
|
|
167
|
+
group_steps(steps).freeze
|
|
162
168
|
end
|
|
163
169
|
|
|
164
170
|
# A destroyed root has no later version, but its own event states that
|
|
@@ -178,7 +184,7 @@ module PaperTrailDiff
|
|
|
178
184
|
events,
|
|
179
185
|
@snapshotter,
|
|
180
186
|
current: current,
|
|
181
|
-
snapshots: @
|
|
187
|
+
snapshots: @retain
|
|
182
188
|
).call
|
|
183
189
|
end
|
|
184
190
|
|
|
@@ -205,7 +211,8 @@ module PaperTrailDiff
|
|
|
205
211
|
range: @range,
|
|
206
212
|
tree: @tree,
|
|
207
213
|
snapshotter: @snapshotter,
|
|
208
|
-
snapshots: @snapshots
|
|
214
|
+
snapshots: @snapshots,
|
|
215
|
+
group: @group
|
|
209
216
|
)
|
|
210
217
|
end
|
|
211
218
|
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Reports one saved transaction as one activity step.
|
|
6
|
+
#
|
|
7
|
+
# A parent and its children saved together produce a version each, and the
|
|
8
|
+
# timeline reports the gap between every pair of them. One deliberate action
|
|
9
|
+
# therefore arrives as several steps, none of which is the thing the person
|
|
10
|
+
# did.
|
|
11
|
+
#
|
|
12
|
+
# A version records the state before its own event. Two things follow.
|
|
13
|
+
#
|
|
14
|
+
# The change a step reports was made by the event that *opens* it, so a step
|
|
15
|
+
# belongs to the transaction of its `from_boundary`, and consecutive steps
|
|
16
|
+
# sharing one are the parts of a single save. Grouping on the closing boundary
|
|
17
|
+
# instead would credit each change to whoever made the next one.
|
|
18
|
+
#
|
|
19
|
+
# And a child's new value is revealed only by something later -- a further
|
|
20
|
+
# version of that child, its destroy version, or the live row. Inside a
|
|
21
|
+
# transaction there is usually none of those yet, so a step can read as empty
|
|
22
|
+
# while a change was in fact made at that boundary, and the change surfaces
|
|
23
|
+
# later, folded in with whatever that step carried. Grouping puts it back
|
|
24
|
+
# together with the save it belongs to.
|
|
25
|
+
#
|
|
26
|
+
# Merging compares the group's outer snapshots rather than combining the
|
|
27
|
+
# diffs between them. A field set and then restored inside one transaction
|
|
28
|
+
# has not changed, and only a comparison of the endpoints can say so.
|
|
29
|
+
#
|
|
30
|
+
# A boundary with no transaction groups with nothing. PaperTrail leaves the
|
|
31
|
+
# column nil outside a transaction, and a custom version class need not carry
|
|
32
|
+
# it at all; treating those as one shared transaction would merge unrelated
|
|
33
|
+
# history into a single step.
|
|
34
|
+
class ActivityTransactionGrouper
|
|
35
|
+
#: (Array[ActivityStep], retain: bool) -> void
|
|
36
|
+
def initialize(steps, retain:)
|
|
37
|
+
@steps = steps
|
|
38
|
+
@retain = retain
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#: () -> Array[ActivityStep]
|
|
42
|
+
def call
|
|
43
|
+
grouped = [] #: Array[Array[ActivityStep]]
|
|
44
|
+
@steps.each do |step|
|
|
45
|
+
open_group = grouped.last
|
|
46
|
+
if open_group && continues?(open_group, step)
|
|
47
|
+
open_group << step
|
|
48
|
+
else
|
|
49
|
+
grouped << [step]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
grouped.map { |group| merge(group) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# @rbs @steps: Array[ActivityStep]
|
|
58
|
+
# @rbs @retain: bool
|
|
59
|
+
|
|
60
|
+
#: (Array[ActivityStep], ActivityStep) -> bool
|
|
61
|
+
def continues?(open_group, step)
|
|
62
|
+
transaction = step.from_boundary.transaction_id
|
|
63
|
+
return false if transaction.nil?
|
|
64
|
+
|
|
65
|
+
open_group.last.from_boundary.transaction_id == transaction
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Rebuilt rather than reused even when a group holds one step, so that every
|
|
69
|
+
# returned step retains its snapshots on the same terms.
|
|
70
|
+
#: (Array[ActivityStep]) -> ActivityStep
|
|
71
|
+
def merge(group)
|
|
72
|
+
first = group.first #: ActivityStep
|
|
73
|
+
last = group.last #: ActivityStep
|
|
74
|
+
ActivityStep.between(
|
|
75
|
+
from_boundary: first.from_boundary,
|
|
76
|
+
to_boundary: last.to_boundary,
|
|
77
|
+
from_snapshot: first.from_snapshot,
|
|
78
|
+
to_snapshot: last.to_snapshot,
|
|
79
|
+
retain: @retain
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -175,7 +175,9 @@ module PaperTrailDiff
|
|
|
175
175
|
def inspect_versioned_model(model_class, path)
|
|
176
176
|
return if model_versioned?(model_class)
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
# An error rather than a warning: `ok?` must not stay true for a
|
|
179
|
+
# comparison that cannot work, and this one raises at runtime.
|
|
180
|
+
add_error(
|
|
179
181
|
:unversioned_association_target,
|
|
180
182
|
"#{model_class.name} does not appear to have PaperTrail enabled",
|
|
181
183
|
path
|
|
@@ -196,7 +198,7 @@ module PaperTrailDiff
|
|
|
196
198
|
|
|
197
199
|
#: (untyped) -> bool
|
|
198
200
|
def model_versioned?(model_class)
|
|
199
|
-
|
|
201
|
+
Support.versioned?(model_class)
|
|
200
202
|
end
|
|
201
203
|
|
|
202
204
|
#: (untyped) -> bool
|
|
@@ -32,9 +32,17 @@ module PaperTrailDiff
|
|
|
32
32
|
# Raised when versions sharing a timestamp cannot be ordered by their ids.
|
|
33
33
|
class AmbiguousVersionOrderError < Error; end
|
|
34
34
|
|
|
35
|
+
# Raised when a relation selects more roots than the batch was told to analyze.
|
|
36
|
+
# Truncating instead would hand back a report that is short without saying so.
|
|
37
|
+
class BatchLimitExceededError < Error; end
|
|
38
|
+
|
|
35
39
|
# Raised when a requested ActiveRecord association does not exist.
|
|
36
40
|
class UnknownAssociationError < Error; end
|
|
37
41
|
|
|
42
|
+
# Raised when a selected association's target is not versioned, so its history
|
|
43
|
+
# cannot be reconstructed at all.
|
|
44
|
+
class UnversionedAssociationError < Error; end
|
|
45
|
+
|
|
38
46
|
# Raised when a requested association macro is not supported.
|
|
39
47
|
class UnsupportedAssociationError < Error; end
|
|
40
48
|
|