paper_trail_diff 0.10.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 +72 -0
- data/README.md +132 -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 +1 -1
- data/lib/paper_trail_diff/errors.rb +4 -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 +1 -1
- 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 +5 -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.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,78 @@
|
|
|
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
|
+
|
|
6
78
|
## [0.10.0] - 2026-08-15
|
|
7
79
|
|
|
8
80
|
### Added
|
data/README.md
CHANGED
|
@@ -354,6 +354,47 @@ argument, are loaded per root; other selected branches remain batched. Callers
|
|
|
354
354
|
should still use an appropriate database transaction when all live queries
|
|
355
355
|
must observe one atomic snapshot.
|
|
356
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
|
+
|
|
357
398
|
### Reuse already-preloaded current endpoints
|
|
358
399
|
|
|
359
400
|
`compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
|
|
@@ -445,6 +486,54 @@ value is the same `Analysis` that `analyze` returns for one record. A root with
|
|
|
445
486
|
no versions inside the window gets an empty `Analysis` rather than raising, so a
|
|
446
487
|
listing page needs no special case. Root identities must be unique.
|
|
447
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
|
+
|
|
448
537
|
### Reporting on a subset of root mutations
|
|
449
538
|
|
|
450
539
|
`version_scope:` narrows which root versions count as *selected mutations*, so a
|
|
@@ -665,6 +754,49 @@ boundary. Passing `to: article` is what removes the need to touch the parent
|
|
|
665
754
|
after an ordinary versioned child mutation; current state is still never
|
|
666
755
|
implicit.
|
|
667
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
|
+
|
|
668
800
|
### Reading the state behind a step
|
|
669
801
|
|
|
670
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
|
|
@@ -198,7 +198,7 @@ module PaperTrailDiff
|
|
|
198
198
|
|
|
199
199
|
#: (untyped) -> bool
|
|
200
200
|
def model_versioned?(model_class)
|
|
201
|
-
|
|
201
|
+
Support.versioned?(model_class)
|
|
202
202
|
end
|
|
203
203
|
|
|
204
204
|
#: (untyped) -> bool
|
|
@@ -32,6 +32,10 @@ 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
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module PaperTrailDiff
|
|
7
|
+
# Compares the inside of a value a database column holds whole.
|
|
8
|
+
#
|
|
9
|
+
# A JSON or jsonb column reifies to one Hash, so an ordinary attribute diff
|
|
10
|
+
# can only say that the blob changed. This says which keys changed, leaving
|
|
11
|
+
# the surrounding diff untouched.
|
|
12
|
+
#
|
|
13
|
+
# Three decisions worth knowing about.
|
|
14
|
+
#
|
|
15
|
+
# Paths are arrays, not dotted strings. A JSON key may contain a dot -- host
|
|
16
|
+
# names and locales routinely do -- and joining would make `a.b` ambiguous
|
|
17
|
+
# between one key and two.
|
|
18
|
+
#
|
|
19
|
+
# Arrays are leaves. Their elements carry no identity, so an insertion at the
|
|
20
|
+
# front makes every later index look changed; reporting "element 2 changed"
|
|
21
|
+
# would be confidently wrong about a list that merely shifted. The whole array
|
|
22
|
+
# is reported as one change, which is the same rule the collection comparator
|
|
23
|
+
# follows for records it cannot identify.
|
|
24
|
+
#
|
|
25
|
+
# An absent key is not a null one. `{"a": null}` and `{}` mean different
|
|
26
|
+
# things in JSON and an audit trail that conflated them would be lying about
|
|
27
|
+
# one of them, so absence is its own value rather than nil.
|
|
28
|
+
class NestedComparator
|
|
29
|
+
# Stands in for a key that was not there at all.
|
|
30
|
+
ABSENT = Object.new
|
|
31
|
+
def ABSENT.inspect = '#<PaperTrailDiff absent>'
|
|
32
|
+
def ABSENT.to_s = 'absent'
|
|
33
|
+
ABSENT.freeze
|
|
34
|
+
|
|
35
|
+
#: (untyped, untyped) -> Hash[Array[String], ValueChange]
|
|
36
|
+
def self.call(from_value, to_value)
|
|
37
|
+
new(from_value, to_value).call
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (untyped, untyped) -> void
|
|
41
|
+
def initialize(from_value, to_value)
|
|
42
|
+
@from_value = from_value
|
|
43
|
+
@to_value = to_value
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns the changed paths, or an empty hash when the pair is not two
|
|
47
|
+
# structures this can look inside. An empty result therefore means "nothing
|
|
48
|
+
# to report at this depth", and the caller still has the whole-value change.
|
|
49
|
+
#: () -> Hash[Array[String], ValueChange]
|
|
50
|
+
def call
|
|
51
|
+
from_structure, to_structure = structures
|
|
52
|
+
return {} unless from_structure && to_structure
|
|
53
|
+
|
|
54
|
+
changes = {} #: Hash[Array[String], ValueChange]
|
|
55
|
+
walk(from_structure, to_structure, [], changes)
|
|
56
|
+
changes.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# @rbs @from_value: untyped
|
|
62
|
+
# @rbs @to_value: untyped
|
|
63
|
+
|
|
64
|
+
# Both sides have to be readable as a Hash for a nested answer to mean
|
|
65
|
+
# anything. A column that held text on one side and JSON on the other
|
|
66
|
+
# changed wholesale, and saying so is the accurate report.
|
|
67
|
+
#: () -> [Hash[untyped, untyped]?, Hash[untyped, untyped]?]
|
|
68
|
+
def structures
|
|
69
|
+
[structure(@from_value), structure(@to_value)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#: (untyped) -> Hash[untyped, untyped]?
|
|
73
|
+
def structure(value)
|
|
74
|
+
return value if value.is_a?(Hash)
|
|
75
|
+
return unless value.is_a?(String)
|
|
76
|
+
|
|
77
|
+
parsed = begin
|
|
78
|
+
JSON.parse(value)
|
|
79
|
+
rescue JSON::ParserError, TypeError
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
parsed if parsed.is_a?(Hash)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], Hash[Array[String], ValueChange]) -> void
|
|
86
|
+
def walk(from_hash, to_hash, path, changes)
|
|
87
|
+
keys(from_hash, to_hash).each do |key|
|
|
88
|
+
from_item = from_hash.key?(key) ? from_hash[key] : ABSENT
|
|
89
|
+
to_item = to_hash.key?(key) ? to_hash[key] : ABSENT
|
|
90
|
+
next if from_item == to_item
|
|
91
|
+
|
|
92
|
+
here = [*path, key.to_s]
|
|
93
|
+
if from_item.is_a?(Hash) && to_item.is_a?(Hash)
|
|
94
|
+
walk(from_item, to_item, here, changes)
|
|
95
|
+
else
|
|
96
|
+
changes[here.freeze] = ValueChange.new(from: from_item, to: to_item)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Sorted so a report reads the same twice, and stringified because a hash
|
|
102
|
+
# loaded from JSON and one built in Ruby can key the same field differently.
|
|
103
|
+
#: (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
|
|
104
|
+
def keys(from_hash, to_hash)
|
|
105
|
+
(from_hash.keys | to_hash.keys).sort_by(&:to_s)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|