paper_trail_diff 0.10.0 → 0.12.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +153 -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 +119 -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/value_objects.rb +66 -0
  19. data/lib/paper_trail_diff/version.rb +1 -1
  20. data/lib/paper_trail_diff.rb +103 -12
  21. data/sig/generated/paper_trail_diff/activity_boundary.rbs +13 -2
  22. data/sig/generated/paper_trail_diff/activity_grouping.rbs +19 -0
  23. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
  24. data/sig/generated/paper_trail_diff/activity_transaction_grouper.rbs +54 -0
  25. data/sig/generated/paper_trail_diff/errors.rbs +5 -0
  26. data/sig/generated/paper_trail_diff/nested_comparator.rbs +73 -0
  27. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +15 -8
  28. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +25 -0
  29. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +93 -0
  30. data/sig/generated/paper_trail_diff/support.rbs +11 -0
  31. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
  32. data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
  33. data/sig/generated/paper_trail_diff.rbs +62 -4
  34. 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: e747358edb04ac2ea49e43b11eb8b5b8e811c426b50b017ed6d1c72162bb4632
4
+ data.tar.gz: b330e3bb80e43e478a0e88ff7afeb2b6576d3550af0a54075948fb0e60dc045d
5
5
  SHA512:
6
- metadata.gz: 400361a0bd6d3d55b3637a5fdcb42febf04ff62a81b75aba3848f70c62d1865f7983016c31bb34347dc50df5b2f66ccb0e44306e2a31990c3db5c04f3806813e
7
- data.tar.gz: 0b8497f1dcb9ecd6e12b09e28b7a69fe17363d251308abffb824e8d1d0033572c4bee71351785ed425dc5e46c656d092612c7cb4c1ff33e69ad270d8e2e7260b
6
+ metadata.gz: 8bcf171950fd74de94c8118dabf3f911e8db3923ce4ed48463c505b3b5748bafbb9c39819b7af49841bf3ea3f6635e797c9536f5d845a42f0a9df9eb1018d1de
7
+ data.tar.gz: 2eb4d0d286b9daf4ed91e1d0d674c6835569ef3912a2712034fad27d31648c5eeed1e410f34fbfbec3d59808b5d82f178df84b896a233eaec570c3b24b6b52ab
data/CHANGELOG.md CHANGED
@@ -3,6 +3,95 @@
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.12.0] - 2026-08-19
7
+
8
+ ### Changed
9
+
10
+ - Report an array inside a JSON column by membership rather than as one opaque
11
+ value. `nested_changes` now returns a `PaperTrailDiff::ArrayChange` for a pair
12
+ of arrays, carrying `added`, `removed` and `reordered?` alongside the whole
13
+ `from` and `to` it already had. Position is deliberately not reported:
14
+ elements carry no identity, so an insertion at the front makes every later
15
+ index look changed and one insertion reads as several edits. Membership is
16
+ answerable without claiming any pairing. Matching is by value and respects
17
+ duplicates, so `["a", "a"]` to `["a"]` reports one removal. The one thing
18
+ membership cannot see -- the same elements in a new order -- is named rather
19
+ than passed over. An element that is itself an object is reported whole,
20
+ since saying which of its fields changed would require pairing before- and
21
+ after-elements that nothing in the value licenses.
22
+
23
+ ## [0.11.0] - 2026-08-18
24
+
25
+ ### Added
26
+
27
+ - Add `PaperTrailDiff.analyze_scope`, which selects the roots to analyze from an
28
+ ActiveRecord relation or model class instead of an array the caller assembled,
29
+ in a fixed number of queries. Also reachable as
30
+ `PaperTrailDiff.analyze_many(scope: ..., limit: ...)`. Every other option
31
+ behaves as it does for `analyze_many`.
32
+ - `analyze_scope` returns a `ScopedAnalysis`, which destructures into the usual
33
+ analyses hash and the roots the relation could not reach. A relation's
34
+ conditions are evaluated against the live table, so a root destroyed during
35
+ the window cannot be tested against them at all, even though its history is
36
+ intact and the state it held at destruction may have matched. Those roots are
37
+ named rather than dropped, so a page auditing deletions is told where to look
38
+ instead of coming up short without saying so.
39
+ - Add `PaperTrailDiff.nested_changes`, which reports the keys that changed
40
+ inside an attribute the database stores whole, such as a JSON or jsonb column
41
+ or one holding JSON as text. An attribute diff could only say the blob
42
+ changed, leaving the caller to diff it again by hand. Paths are arrays rather
43
+ than dotted strings, because a JSON key may itself contain a dot. Arrays are
44
+ reported whole rather than by index, since their elements carry no identity
45
+ and a list that merely shifted would otherwise look changed throughout. A key
46
+ that was absent reads as `NestedComparator::ABSENT` rather than nil, because
47
+ `{"a": null}` and `{}` mean different things in JSON and an audit trail that
48
+ showed them alike would be lying about one of them. Additive: the existing
49
+ attribute diff is unchanged, and a pair that is not two readable structures
50
+ reports nothing.
51
+ - Raise `PaperTrailDiff::BatchLimitExceededError` when a relation selects more
52
+ roots than `limit:` allows. `limit:` is required for `analyze_scope`: root
53
+ selection moves into the gem, so the bound on how much work one page can ask
54
+ for moves with it. It refuses rather than truncating, because a report that is
55
+ quietly short is worse than one that fails.
56
+ - Accept `group: :transaction` on `activity_timeline` and `analyze(activity:
57
+ true)`, reporting one saved transaction as one step. A parent and its
58
+ children saved together produce a version each, so one deliberate action
59
+ arrived as several steps, and some of them read as empty even though a change
60
+ was made at that boundary: a version records the state before its own event,
61
+ so a child's new value is revealed only by a later version of that child, its
62
+ destroy version, or the live row, none of which exists yet inside the
63
+ transaction. The change therefore surfaced a step or more later, folded in
64
+ with whatever that step carried. A step belongs to the transaction of the
65
+ event that opens it, since that is the event whose change it reports; a step
66
+ therefore belongs to its `from_boundary`'s transaction, and consecutive steps
67
+ sharing one are the parts of a single save. Merging compares the group's
68
+ outer states rather than combining the diffs between them, so a field set and
69
+ restored inside one transaction correctly reports as unchanged. Off by
70
+ default.
71
+ - Expose `ActivityBoundary#transaction_id`.
72
+ - A boundary that records no transaction groups with nothing. PaperTrail leaves
73
+ the column nil outside a transaction and a custom version class need not
74
+ carry it at all, so treating those as one shared transaction would merge
75
+ unrelated history into a single step.
76
+
77
+ ### Changed
78
+
79
+ - Check that the generated Appraisal gemfiles match the `Gemfile`, in CI and in
80
+ `rake release:preflight`. Every other job runs against a generated gemfile, so
81
+ a dependency changed only in the `Gemfile` was one CI never installed: the
82
+ jobs passed having exercised the old version.
83
+
84
+ ### Fixed
85
+
86
+ - Resolve "does this model record history" through one predicate,
87
+ `Support.versioned?`. Three copies existed and one of them tested only
88
+ `respond_to?(:paper_trail)`, which PaperTrail defines on every ActiveRecord
89
+ model and which therefore answers true for models that never called
90
+ `has_paper_trail`. Nothing reached that copy with an unversioned model, since
91
+ the traversal preparer rejects those first, so no released behaviour changed --
92
+ but the check looked right while being unable to fail, and reading history
93
+ through it would have raised at the version class rather than at the question.
94
+
6
95
  ## [0.10.0] - 2026-08-15
7
96
 
8
97
  ### Added
data/README.md CHANGED
@@ -354,6 +354,68 @@ 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 by membership, not by position.** Elements carry no
390
+ identity, so an insertion at the front would make every later index look
391
+ changed — one insertion described as several edits. An `ArrayChange` says what
392
+ was added and removed, matching by value and respecting duplicates:
393
+
394
+ ```ruby
395
+ change = PaperTrailDiff.nested_changes(
396
+ { "keywords" => %w[apollo nasa] },
397
+ { "keywords" => %w[saturn apollo nasa] }
398
+ ).fetch(["keywords"])
399
+
400
+ change.added # => ["saturn"]
401
+ change.removed # => []
402
+ change.reordered? # => false
403
+ change.from # => ["apollo", "nasa"] (the whole values remain)
404
+ ```
405
+
406
+ Membership cannot see one thing, so it is named rather than passed over: the
407
+ same elements in a new order report `reordered?` as true with nothing added or
408
+ removed.
409
+
410
+ An element that is itself an object is reported whole. Saying which field of
411
+ which object changed would need a pairing of before-elements with
412
+ after-elements, and nothing in the value licenses that pairing.
413
+
414
+ **An absent key is not a null one.** `{"a": null}` and `{}` mean different things
415
+ in JSON, and an audit trail that showed them alike would be lying about one of
416
+ them, so a missing key reads as `PaperTrailDiff::NestedComparator::ABSENT`
417
+ rather than `nil`.
418
+
357
419
  ### Reuse already-preloaded current endpoints
358
420
 
359
421
  `compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
@@ -445,6 +507,54 @@ value is the same `Analysis` that `analyze` returns for one record. A root with
445
507
  no versions inside the window gets an empty `Analysis` rather than raising, so a
446
508
  listing page needs no special case. Root identities must be unique.
447
509
 
510
+ ### Letting a relation choose the roots
511
+
512
+ Assembling that array means querying `versions` for which roots moved, filtering,
513
+ and loading the records — the same selection this gem already performs.
514
+ `analyze_scope` does it for you:
515
+
516
+ ```ruby
517
+ analyses, unreachable = PaperTrailDiff.analyze_scope(
518
+ Order.where(status: "open"),
519
+ within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
520
+ associations: [:line_items],
521
+ limit: 500
522
+ )
523
+ ```
524
+
525
+ `analyses` is the same frozen hash `analyze_many` returns. It is also available
526
+ as `PaperTrailDiff.analyze_many(scope: ..., limit: ...)`, and it accepts a model
527
+ class (`Order`) as readily as a relation. Every other option — `within:`,
528
+ `version_scope:`, `associations:`, `ignore:`, `activity:`, `close_on:` — behaves
529
+ exactly as it does for `analyze_many`, and the query cost stays flat in the
530
+ number of roots.
531
+
532
+ Two things to know before using it.
533
+
534
+ **`limit:` is required, and exceeding it raises.** Selection moves into the gem
535
+ here, so the bound on how much work one page can ask for has to move with it. It
536
+ refuses rather than truncating, because an audit report that is quietly short is
537
+ worse than one that fails.
538
+
539
+ **`unreachable` names roots the relation could not reach.** A relation's
540
+ conditions are evaluated against the live table, so a root destroyed during the
541
+ window cannot be tested against them at all — its history is intact, and the
542
+ state it held when it was destroyed may well have matched:
543
+
544
+ ```ruby
545
+ unreachable # => [["Order", "412"]]
546
+ ```
547
+
548
+ These are reported rather than dropped so that a page auditing deletions is told
549
+ where to look instead of silently coming up short. `analyze_many` requires live
550
+ records by design, so to analyze those roots reach for `activity_timeline`, which
551
+ reads history without needing current state.
552
+
553
+ Note also that a relation selects on **current** state, not on state during the
554
+ window. `where(status: "open")` means open *now*, which is a different set from
555
+ what was open while the window was open. If you need the historical population,
556
+ select on the version history instead and pass the records.
557
+
448
558
  ### Reporting on a subset of root mutations
449
559
 
450
560
  `version_scope:` narrows which root versions count as *selected mutations*, so a
@@ -665,6 +775,49 @@ boundary. Passing `to: article` is what removes the need to touch the parent
665
775
  after an ordinary versioned child mutation; current state is still never
666
776
  implicit.
667
777
 
778
+ ### Reporting one save as one step
779
+
780
+ A parent and its children saved together produce a version each, so one
781
+ deliberate action arrives as several steps. `group: :transaction` reports it as
782
+ one:
783
+
784
+ ```ruby
785
+ PaperTrailDiff.activity_timeline(
786
+ article, from: :first, to: article,
787
+ associations: [:comments], group: :transaction
788
+ )
789
+ ```
790
+
791
+ Given a single transaction that retitled an article, revised one comment, and
792
+ added another, the ungrouped timeline reports three steps — the title change,
793
+ then a step that reads as empty, then the comment changes. Grouped, it reports
794
+ one step carrying the title *and* the comment changes, credited to whoever made
795
+ the save. Off by default; also accepted by `analyze(activity: true)`.
796
+
797
+ The middle step reads as empty for a reason worth knowing, because it is the
798
+ same rule the rest of this document turns on. A version records the state
799
+ *before* its own event, so a child's new value is revealed only by something
800
+ later: a further version of that child, its destroy version, or the live row.
801
+ Inside a transaction there is usually none of those yet, so a change made at
802
+ that boundary surfaces a step or more afterwards, folded in with whatever that
803
+ step carried. Nothing is lost — but "when" and "with what" both read wrong.
804
+
805
+ Two properties this relies on:
806
+
807
+ **A step belongs to the transaction of the event that opens it.** That is the
808
+ event whose change the step reports, so grouping on the closing boundary instead
809
+ would credit each change to whoever made the next one.
810
+
811
+ **Merging compares the group's outer states**, rather than combining the diffs
812
+ between them. A field set and then restored inside one transaction has not
813
+ changed, and only comparing the endpoints can say so.
814
+
815
+ A boundary that records no transaction groups with nothing. PaperTrail leaves
816
+ `transaction_id` nil outside a transaction, and a custom version class need not
817
+ carry the column at all; treating those as one shared transaction would merge
818
+ unrelated history into a single step. `ActivityBoundary#transaction_id` exposes
819
+ what a boundary recorded.
820
+
668
821
  ### Reading the state behind a step
669
822
 
670
823
  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,119 @@
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 reported by membership, not by position -- see ArrayChange. Their
20
+ # elements carry no identity, so an insertion at the front makes every later
21
+ # index look changed, and one insertion reads as several edits. What is added
22
+ # and removed can be answered without claiming any pairing; what cannot is the
23
+ # same elements in a new order, so that is named rather than passed over.
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) -> nested_changes
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
+ #: () -> nested_changes
50
+ def call
51
+ from_structure, to_structure = structures
52
+ return {} unless from_structure && to_structure
53
+
54
+ changes = {} #: nested_changes
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], nested_changes) -> 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].freeze
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] = change_for(from_item, to_item)
97
+ end
98
+ end
99
+ end
100
+
101
+ # Two arrays are a membership change; anything else is a change to the
102
+ # value as a whole, including an array facing something that is not one.
103
+ #: (untyped, untyped) -> nested_change
104
+ def change_for(from_item, to_item)
105
+ if from_item.is_a?(Array) && to_item.is_a?(Array)
106
+ ArrayChange.new(from: from_item, to: to_item)
107
+ else
108
+ ValueChange.new(from: from_item, to: to_item)
109
+ end
110
+ end
111
+
112
+ # Sorted so a report reads the same twice, and stringified because a hash
113
+ # loaded from JSON and one built in Ruby can key the same field differently.
114
+ #: (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
115
+ def keys(from_hash, to_hash)
116
+ (from_hash.keys | to_hash.keys).sort_by(&:to_s)
117
+ end
118
+ end
119
+ end