paper_trail_diff 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +181 -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 +4 -2
  9. data/lib/paper_trail_diff/errors.rb +8 -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 +25 -0
  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 +10 -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/traversal_preparer.rbs +10 -0
  32. data/sig/generated/paper_trail_diff.rbs +58 -4
  33. metadata +14 -4
@@ -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
@@ -68,8 +68,8 @@ module PaperTrailDiff
68
68
  ).build
69
69
  end
70
70
 
71
- #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
72
- def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
71
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
72
+ def activity_timeline(record, from:, to:, within:, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
73
73
  payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
74
74
  Instrumentation.instrument('activity_timeline', payload) do
75
75
  @traversal_preparer.call(record.class, historical: true)
@@ -77,21 +77,21 @@ module PaperTrailDiff
77
77
  reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
78
78
  steps = activity_builder(
79
79
  record, from: from, to: to, within: within, version_scope: version_scope,
80
- live_endpoint: live, snapshots: snapshots
80
+ live_endpoint: live, snapshots: snapshots, group: group
81
81
  ).build
82
82
  payload[:step_count] = steps.length
83
83
  steps
84
84
  end
85
85
  end
86
86
 
87
- #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
88
- def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
87
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
88
+ def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
89
89
  @traversal_preparer.call(record.class, historical: true)
90
90
  live = live_endpoint_for(record, close_on, within)
91
91
  if activity
92
92
  return analyze_activity(
93
93
  record, from: from, to: to, within: within, version_scope: version_scope,
94
- live_endpoint: live, snapshots: snapshots
94
+ live_endpoint: live, snapshots: snapshots, group: group
95
95
  )
96
96
  end
97
97
 
@@ -119,6 +119,24 @@ module PaperTrailDiff
119
119
  end
120
120
  end
121
121
 
122
+ # Selects the roots from a relation before running the ordinary batch, so a
123
+ # caller reporting on a population does not have to rediscover which of its
124
+ # members changed. The roots the relation could not reach come back named
125
+ # rather than dropped -- see `PaperTrailDiff.analyze_scope`.
126
+ #: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
127
+ def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
128
+ raise ConfigurationError, 'limit: is required when selecting roots by scope' if limit.nil?
129
+
130
+ selection = ScopedRootSelection.new(
131
+ scope, time_range: within.nil? ? nil : TimeRange.new(within), limit: limit
132
+ ).call
133
+ ScopedAnalysis.new(
134
+ analyses: analyze_many(selection.records, within: within, activity: activity,
135
+ version_scope: version_scope, close_on: close_on),
136
+ unreachable: selection.unreachable
137
+ )
138
+ end
139
+
122
140
  private
123
141
 
124
142
  # @rbs @association_tree: AssociationTree
@@ -133,12 +151,12 @@ module PaperTrailDiff
133
151
  # @rbs @timeline_snapshotter: TimelineSnapshotProvider
134
152
  # @rbs @activity_snapshotter: ActivitySnapshotProvider
135
153
 
136
- #: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
137
- def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
154
+ #: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
155
+ def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
138
156
  reject_live_habtm_activity!(record.class) if live_endpoint
139
157
  activity_builder(
140
158
  record, from: from, to: to, within: within, version_scope: version_scope,
141
- live_endpoint: live_endpoint, snapshots: snapshots
159
+ live_endpoint: live_endpoint, snapshots: snapshots, group: group
142
160
  ).analyze
143
161
  end
144
162
 
@@ -234,8 +252,8 @@ module PaperTrailDiff
234
252
  )
235
253
  end
236
254
 
237
- #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
238
- def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil, snapshots: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
255
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> ActivityTimelineBuilder
256
+ def activity_builder(record, from:, to:, within:, version_scope: nil, live_endpoint: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
239
257
  ActivityTimelineBuilder.new(
240
258
  record,
241
259
  range: TimelineRange.new(
@@ -244,7 +262,8 @@ module PaperTrailDiff
244
262
  ),
245
263
  tree: @association_tree,
246
264
  snapshotter: @activity_snapshotter,
247
- snapshots: snapshots
265
+ snapshots: snapshots,
266
+ group: group
248
267
  )
249
268
  end
250
269
 
@@ -129,7 +129,7 @@ module PaperTrailDiff
129
129
 
130
130
  #: (untyped) -> bool
131
131
  def versioned?(model_class)
132
- model_class.respond_to?(:paper_trail)
132
+ Support.versioned?(model_class)
133
133
  end
134
134
 
135
135
  #: (untyped, untyped) -> Array[String]
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # What the relation form of `analyze_many` returns: the analyses, plus the
6
+ # roots the relation could not reach.
7
+ #
8
+ # `to_ary` is defined so the pair destructures, which keeps the common case
9
+ # reading like the plain Hash the record form returns:
10
+ #
11
+ # analyses, unreachable = PaperTrailDiff.analyze_many(scope: ..., limit: 500)
12
+ #
13
+ # It deliberately does not pretend to be a Hash beyond that. Somebody
14
+ # iterating this object should have to decide which half they meant.
15
+ class ScopedAnalysis
16
+ attr_reader :analyses #: Hash[Array[String], Analysis]
17
+ attr_reader :unreachable #: Array[Array[String]]
18
+
19
+ #: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
20
+ def initialize(analyses:, unreachable:)
21
+ @analyses = analyses
22
+ @unreachable = Support.immutable_copy(unreachable)
23
+ freeze
24
+ end
25
+
26
+ #: () -> [Hash[Array[String], Analysis], Array[Array[String]]]
27
+ def to_ary
28
+ [analyses, unreachable]
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Chooses the roots a batch will analyze from a relation instead of from an
6
+ # array the caller assembled, so a reporting page does not reimplement the
7
+ # root selection this gem already performs.
8
+ #
9
+ # Two populations are deliberately kept apart, because conflating them would
10
+ # make the report wrong in a way nothing announces:
11
+ #
12
+ # - A root whose live row does not satisfy the relation is filtered out. That
13
+ # is what the caller asked for, and it needs no reporting.
14
+ # - A root whose live row is gone cannot be filtered at all. The relation's
15
+ # conditions read the live table, and a destroyed root has nothing there to
16
+ # read -- even though its history is intact, and the state it held when it
17
+ # was destroyed may well have satisfied those conditions. Reifying every
18
+ # candidate to find out would cost the batched query plan this class exists
19
+ # to provide.
20
+ #
21
+ # So the second population is returned by name rather than dropped. A caller
22
+ # that does not care can ignore it; one auditing deletions is told where to
23
+ # look instead of silently coming up short.
24
+ #
25
+ # Note that a relation filters on current state, not on state during the
26
+ # window. `where(status: 'published')` selects what is published now, which is
27
+ # not the same set as what was published while the window was open.
28
+ class ScopedRootSelection
29
+ # A plain class rather than Data.define, which arrived in Ruby 3.2 while this
30
+ # gem supports 3.1.
31
+ class Result
32
+ attr_reader :records #: Array[untyped]
33
+ attr_reader :unreachable #: Array[Array[String]]
34
+
35
+ #: (records: Array[untyped], unreachable: Array[Array[String]]) -> void
36
+ def initialize(records:, unreachable:)
37
+ @records = records
38
+ @unreachable = unreachable
39
+ freeze
40
+ end
41
+ end
42
+
43
+ #: (untyped, time_range: TimeRange?, limit: Integer) -> void
44
+ def initialize(scope, time_range:, limit:)
45
+ @scope = normalize_scope(scope)
46
+ @time_range = time_range
47
+ @limit = validate_limit(limit)
48
+ end
49
+
50
+ #: () -> Result
51
+ def call
52
+ candidates = versioned_ids
53
+ return Result.new(records: [], unreachable: []) if candidates.empty?
54
+
55
+ live = live_ids(candidates)
56
+ Result.new(
57
+ records: selected_records(live),
58
+ unreachable: (candidates - live).map { |id| identity(id) }.freeze
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ # @rbs @scope: untyped
65
+ # @rbs @time_range: TimeRange?
66
+ # @rbs @limit: Integer
67
+
68
+ # Accepts a model class as readily as a relation: `Article` and
69
+ # `Article.where(...)` both name a population, and `all` is what makes them
70
+ # the same kind of thing.
71
+ #: (untyped) -> untyped
72
+ def normalize_scope(scope)
73
+ unless scope.respond_to?(:all) && scope.respond_to?(:where)
74
+ raise ConfigurationError, 'scope: must be an ActiveRecord relation or model class'
75
+ end
76
+
77
+ relation = scope.all
78
+ return relation if Support.versioned?(relation.model)
79
+
80
+ raise UnversionedAssociationError,
81
+ "scope: #{relation.model.name} is not versioned, so it has no history to select from"
82
+ end
83
+
84
+ #: (Integer) -> Integer
85
+ def validate_limit(limit)
86
+ return limit if limit.is_a?(Integer) && limit.positive?
87
+
88
+ raise ConfigurationError, 'limit: must be a positive Integer'
89
+ end
90
+
91
+ # Every root whose history moved inside the window, destroyed ones included.
92
+ # This is the gem's own notion of "which roots changed", answered from the
93
+ # version table alone so that it does not depend on rows still existing.
94
+ #: () -> Array[String]
95
+ def versioned_ids
96
+ relation = version_class.where(item_type: item_type)
97
+ range = @time_range
98
+ relation = range.scope(relation) if range
99
+ relation.distinct.pluck(:item_id).map(&:to_s).uniq
100
+ end
101
+
102
+ # Which of those candidates still have a row, asked without the relation's
103
+ # conditions so that "filtered out" and "no longer exists" stay separable.
104
+ #: (Array[String]) -> Array[String]
105
+ def live_ids(candidates)
106
+ base_class.unscoped.where(primary_key => candidates).pluck(primary_key).map(&:to_s)
107
+ end
108
+
109
+ # Loading one past the limit is what turns an oversized page into an error
110
+ # rather than a silently truncated report.
111
+ #: (Array[String]) -> Array[untyped]
112
+ def selected_records(live)
113
+ return [] if live.empty?
114
+
115
+ records = @scope.where(primary_key => live).limit(@limit + 1).to_a
116
+ return records.freeze unless records.length > @limit
117
+
118
+ raise BatchLimitExceededError,
119
+ "scope: selected more than #{@limit} roots; narrow the window or the " \
120
+ 'relation, or raise limit: to the page size you intend to analyze'
121
+ end
122
+
123
+ #: (String) -> Array[String]
124
+ def identity(id)
125
+ [item_type, id].freeze
126
+ end
127
+
128
+ #: () -> String
129
+ def item_type
130
+ base_class.name.to_s
131
+ end
132
+
133
+ #: () -> untyped
134
+ def base_class
135
+ @scope.model.base_class
136
+ end
137
+
138
+ #: () -> untyped
139
+ def primary_key
140
+ @scope.model.primary_key
141
+ end
142
+
143
+ #: () -> untyped
144
+ def version_class
145
+ @scope.model.paper_trail.version_class
146
+ end
147
+ end
148
+ end
@@ -81,6 +81,19 @@ module PaperTrailDiff
81
81
  versions.each_cons(2).find { |left, right| left.created_at == right.created_at }
82
82
  end
83
83
 
84
+ # Whether a model records history at all.
85
+ #
86
+ # PaperTrail defines `paper_trail` on every ActiveRecord model, so asking
87
+ # whether a class responds to it says nothing -- it is true for models that
88
+ # never called `has_paper_trail`, and reading history from one of those fails
89
+ # at the version class rather than at the question. Only configured options
90
+ # distinguish the two, which is why this lives in one place: the predicate is
91
+ # easy to write in a form that looks right and always answers true.
92
+ #: (untyped) -> bool
93
+ def versioned?(model_class)
94
+ model_class.respond_to?(:paper_trail_options) && !model_class.paper_trail_options.nil?
95
+ end
96
+
84
97
  #: (untyped) -> bool
85
98
  def sequential_id?(id)
86
99
  id.is_a?(Integer) || id.to_s.match?(/\A\d+\z/)
@@ -4,9 +4,15 @@
4
4
  module PaperTrailDiff
5
5
  # Builds activity views for mutations selected by a wall-clock range.
6
6
  class TimeActivityTimelineBuilder
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
  @range = range
12
18
  @tree = tree
@@ -92,15 +98,14 @@ module PaperTrailDiff
92
98
  events,
93
99
  @snapshotter,
94
100
  include_step: ->(event) { @range.include?(event.version) },
95
- snapshots: @snapshots
101
+ snapshots: @retain
96
102
  ).call
97
103
  end
98
104
 
99
105
  #: (ActivityHistory, ActivityStep?) -> Array[ActivityStep]
100
106
  def activity_steps(history, closing)
101
- return history.steps unless closing
102
-
103
- (history.steps + [closing]).freeze
107
+ steps = closing ? history.steps + [closing] : history.steps
108
+ group_steps(steps).freeze
104
109
  end
105
110
 
106
111
  # Root versions inside the window that the plan does not report are context
@@ -136,7 +141,7 @@ module PaperTrailDiff
136
141
  from_boundary: ActivityBoundary.from_version(version),
137
142
  to_boundary: ActivityBoundary.destroyed(version),
138
143
  from_snapshot: history.root_snapshots[ActivityRootSteps.version_key(version)],
139
- to_snapshot: nil, retain: @snapshots
144
+ to_snapshot: nil, retain: @retain
140
145
  )
141
146
  end
142
147
 
@@ -151,7 +156,7 @@ module PaperTrailDiff
151
156
  ActivityStep.between(
152
157
  from_boundary: previous,
153
158
  to_boundary: ActivityBoundary.current(record, captured_at: captured_at),
154
- from_snapshot: history.last_snapshot, to_snapshot: snapshot, retain: @snapshots
159
+ from_snapshot: history.last_snapshot, to_snapshot: snapshot, retain: @retain
155
160
  )
156
161
  end
157
162
 
@@ -17,6 +17,7 @@ module PaperTrailDiff
17
17
 
18
18
  ensure_association_tracking! if historical
19
19
  @traversal.validate!(model_class)
20
+ ensure_versioned_targets!(model_class) if historical
20
21
  end
21
22
 
22
23
  private
@@ -24,6 +25,30 @@ module PaperTrailDiff
24
25
  # @rbs @tree: AssociationTree
25
26
  # @rbs @traversal: AssociationTraversal
26
27
 
28
+ # A model PaperTrail never versioned has no history to reconstruct, so a
29
+ # comparison over it can only ever answer "nothing changed" -- which is a
30
+ # wrong answer rather than an empty one. Live-to-live comparison reads
31
+ # current state and is unaffected, so this applies to historical work only.
32
+ #: (untyped) -> void
33
+ def ensure_versioned_targets!(model_class)
34
+ @traversal.selected_reflections(model_class).each do |path, reflection|
35
+ # A polymorphic target is not known until a row names it; `diagnose`
36
+ # reports that separately rather than guessing here.
37
+ next if reflection.polymorphic?
38
+ next if versioned?(reflection.klass)
39
+
40
+ raise UnversionedAssociationError,
41
+ "association #{path} cannot be compared historically: " \
42
+ "#{reflection.klass.name} is not versioned. Add `has_paper_trail` to it, " \
43
+ 'or mirror the fields you need onto a model that has it.'
44
+ end
45
+ end
46
+
47
+ #: (untyped) -> bool
48
+ def versioned?(model_class)
49
+ Support.versioned?(model_class)
50
+ end
51
+
27
52
  #: () -> void
28
53
  def ensure_association_tracking!
29
54
  paper_trail = Object.const_get(:PaperTrail) #: untyped
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.9.0'
5
+ VERSION = '0.11.0'
6
6
  end