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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +132 -0
  4. data/lib/paper_trail_diff/activity_boundary.rb +20 -4
  5. data/lib/paper_trail_diff/activity_grouping.rb +26 -0
  6. data/lib/paper_trail_diff/activity_timeline_builder.rb +13 -6
  7. data/lib/paper_trail_diff/activity_transaction_grouper.rb +83 -0
  8. data/lib/paper_trail_diff/diagnostics.rb +1 -1
  9. data/lib/paper_trail_diff/errors.rb +4 -0
  10. data/lib/paper_trail_diff/nested_comparator.rb +108 -0
  11. data/lib/paper_trail_diff/paper_trail_adapter.rb +31 -12
  12. data/lib/paper_trail_diff/prepared_history_loader.rb +1 -1
  13. data/lib/paper_trail_diff/scoped_analysis.rb +31 -0
  14. data/lib/paper_trail_diff/scoped_root_selection.rb +148 -0
  15. data/lib/paper_trail_diff/support.rb +13 -0
  16. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +13 -8
  17. data/lib/paper_trail_diff/traversal_preparer.rb +1 -1
  18. data/lib/paper_trail_diff/version.rb +1 -1
  19. data/lib/paper_trail_diff.rb +101 -12
  20. data/sig/generated/paper_trail_diff/activity_boundary.rbs +13 -2
  21. data/sig/generated/paper_trail_diff/activity_grouping.rbs +19 -0
  22. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
  23. data/sig/generated/paper_trail_diff/activity_transaction_grouper.rbs +54 -0
  24. data/sig/generated/paper_trail_diff/errors.rbs +5 -0
  25. data/sig/generated/paper_trail_diff/nested_comparator.rbs +68 -0
  26. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +15 -8
  27. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +25 -0
  28. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +93 -0
  29. data/sig/generated/paper_trail_diff/support.rbs +11 -0
  30. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
  31. data/sig/generated/paper_trail_diff.rbs +58 -4
  32. metadata +14 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00ac8585544b9421fab5fa7cf677a5eddaae2867058beb2c7958ff0e8f917da5
4
- data.tar.gz: 52e079e4f84b77ffd804fd452eaf4acc03618d2e7e685969faddcfe2f8c21eba
3
+ metadata.gz: 205c52c1f80517b072fa68452447963c3d7245ba0707b049d5ebd5d2528727b3
4
+ data.tar.gz: 65cc6e544662366fc185719a3660b29c3a9796f7c003fe7eeff4c551c9114c11
5
5
  SHA512:
6
- metadata.gz: 400361a0bd6d3d55b3637a5fdcb42febf04ff62a81b75aba3848f70c62d1865f7983016c31bb34347dc50df5b2f66ccb0e44306e2a31990c3db5c04f3806813e
7
- data.tar.gz: 0b8497f1dcb9ecd6e12b09e28b7a69fe17363d251308abffb824e8d1d0033572c4bee71351785ed425dc5e46c656d092612c7cb4c1ff33e69ad270d8e2e7260b
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
- #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool) -> void
8
- def initialize(record, range:, tree:, snapshotter:, snapshots: false)
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: @snapshots
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: @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
- model_class.respond_to?(:paper_trail_options) && !model_class.paper_trail_options.nil?
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