paper_trail_diff 0.13.1 → 0.14.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +287 -0
  3. data/QUICKSTART.md +9 -6
  4. data/README.md +84 -9
  5. data/docs/architecture.md +32 -9
  6. data/docs/performance.md +106 -13
  7. data/docs/reference.md +242 -29
  8. data/docs/testing.md +34 -11
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +19 -9
  10. data/lib/paper_trail_diff/activity_collection_event_applier.rb +11 -1
  11. data/lib/paper_trail_diff/activity_collection_record_updater.rb +16 -2
  12. data/lib/paper_trail_diff/activity_event_record_resolver.rb +20 -1
  13. data/lib/paper_trail_diff/activity_event_route_finder.rb +2 -1
  14. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +1 -1
  15. data/lib/paper_trail_diff/activity_range.rb +14 -2
  16. data/lib/paper_trail_diff/activity_relationship.rb +12 -0
  17. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +5 -2
  18. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +131 -0
  19. data/lib/paper_trail_diff/activity_timeline_builder.rb +30 -24
  20. data/lib/paper_trail_diff/activity_version_collector.rb +31 -20
  21. data/lib/paper_trail_diff/association_activity_edge.rb +236 -0
  22. data/lib/paper_trail_diff/association_activity_selection.rb +197 -0
  23. data/lib/paper_trail_diff/association_discovery.rb +8 -13
  24. data/lib/paper_trail_diff/association_traversal.rb +169 -45
  25. data/lib/paper_trail_diff/batch_safety.rb +23 -15
  26. data/lib/paper_trail_diff/batched_root_versions.rb +21 -39
  27. data/lib/paper_trail_diff/comparison_batch.rb +37 -10
  28. data/lib/paper_trail_diff/deleted_analysis_batch.rb +34 -16
  29. data/lib/paper_trail_diff/diagnostics.rb +26 -8
  30. data/lib/paper_trail_diff/direct_record_state_loader.rb +10 -1
  31. data/lib/paper_trail_diff/errors.rb +2 -1
  32. data/lib/paper_trail_diff/habtm_transaction_owners.rb +68 -0
  33. data/lib/paper_trail_diff/historical_association_reifier.rb +32 -2
  34. data/lib/paper_trail_diff/historical_snapshot_store.rb +6 -9
  35. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +35 -2
  36. data/lib/paper_trail_diff/live_graph_collector.rb +1 -1
  37. data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -6
  38. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +1 -1
  39. data/lib/paper_trail_diff/prepared_activity_index.rb +110 -36
  40. data/lib/paper_trail_diff/prepared_edge_loader.rb +29 -45
  41. data/lib/paper_trail_diff/prepared_history.rb +63 -54
  42. data/lib/paper_trail_diff/prepared_history_loader.rb +33 -43
  43. data/lib/paper_trail_diff/prepared_record_index.rb +26 -0
  44. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +1 -1
  45. data/lib/paper_trail_diff/prepared_state_association_reader.rb +119 -29
  46. data/lib/paper_trail_diff/recorded_changes.rb +83 -0
  47. data/lib/paper_trail_diff/recorded_class.rb +53 -0
  48. data/lib/paper_trail_diff/scope_condition.rb +331 -0
  49. data/lib/paper_trail_diff/scope_ranking.rb +196 -0
  50. data/lib/paper_trail_diff/scoped_analysis_builder.rb +4 -3
  51. data/lib/paper_trail_diff/scoped_root_selection.rb +93 -43
  52. data/lib/paper_trail_diff/snapshot_normalizer.rb +10 -3
  53. data/lib/paper_trail_diff/support.rb +9 -4
  54. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -1
  55. data/lib/paper_trail_diff/time_range.rb +9 -12
  56. data/lib/paper_trail_diff/time_version_range.rb +5 -5
  57. data/lib/paper_trail_diff/version.rb +1 -1
  58. data/lib/paper_trail_diff/version_association_candidate_scope.rb +13 -3
  59. data/lib/paper_trail_diff/version_range.rb +8 -15
  60. data/lib/paper_trail_diff/version_scope_filter.rb +11 -3
  61. data/lib/paper_trail_diff.rb +31 -2
  62. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +10 -2
  63. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +5 -0
  64. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +5 -0
  65. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +7 -0
  66. data/sig/generated/paper_trail_diff/activity_range.rbs +6 -2
  67. data/sig/generated/paper_trail_diff/activity_relationship.rbs +3 -0
  68. data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +2 -0
  69. data/sig/generated/paper_trail_diff/activity_target_candidate_loader.rbs +79 -0
  70. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +11 -5
  71. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +10 -4
  72. data/sig/generated/paper_trail_diff/association_activity_edge.rbs +148 -0
  73. data/sig/generated/paper_trail_diff/association_activity_selection.rbs +112 -0
  74. data/sig/generated/paper_trail_diff/association_discovery.rbs +3 -5
  75. data/sig/generated/paper_trail_diff/association_traversal.rbs +84 -9
  76. data/sig/generated/paper_trail_diff/batch_safety.rbs +10 -4
  77. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +2 -8
  78. data/sig/generated/paper_trail_diff/comparison_batch.rbs +22 -4
  79. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +16 -4
  80. data/sig/generated/paper_trail_diff/diagnostics.rbs +7 -2
  81. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +6 -0
  82. data/sig/generated/paper_trail_diff/errors.rbs +2 -1
  83. data/sig/generated/paper_trail_diff/habtm_transaction_owners.rbs +40 -0
  84. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +13 -0
  85. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +3 -2
  86. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +17 -0
  87. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +52 -10
  89. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +4 -12
  90. data/sig/generated/paper_trail_diff/prepared_history.rbs +33 -22
  91. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +15 -13
  92. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +15 -0
  93. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +56 -11
  94. data/sig/generated/paper_trail_diff/recorded_changes.rbs +47 -0
  95. data/sig/generated/paper_trail_diff/recorded_class.rbs +22 -0
  96. data/sig/generated/paper_trail_diff/scope_condition.rbs +185 -0
  97. data/sig/generated/paper_trail_diff/scope_ranking.rbs +117 -0
  98. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +2 -2
  99. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +41 -7
  100. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +6 -0
  101. data/sig/generated/paper_trail_diff/support.rbs +5 -2
  102. data/sig/generated/paper_trail_diff/time_range.rbs +4 -0
  103. data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -4
  104. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +7 -0
  105. data/sig/generated/paper_trail_diff/version_range.rbs +4 -2
  106. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -0
  107. data/sig/generated/paper_trail_diff.rbs +21 -2
  108. metadata +20 -4
@@ -5,16 +5,147 @@ module PaperTrailDiff
5
5
  # Resolves and validates ActiveRecord reflections for a bounded association tree.
6
6
  class AssociationTraversal
7
7
  # The class a `belongs_to` points at for this record, reading a polymorphic
8
- # type column when there is one. Nil when that type names no class.
8
+ # type column when there is one. Nil when the record points at nothing.
9
9
  #: (untyped, untyped) -> untyped
10
10
  def self.belongs_to_target(reflection, record)
11
11
  return reflection.klass unless reflection.polymorphic?
12
12
 
13
- Object.const_get(record.public_send(reflection.foreign_type).to_s)
13
+ type = record.public_send(reflection.foreign_type).to_s
14
+ polymorphic_target(reflection, record, type) unless type.empty?
15
+ end
16
+
17
+ # A through with `source_type:` reaches only the join records whose type
18
+ # column names that class, as Active Record's `where(type => source_type)`.
19
+ # A through's `foreign_type` is its polymorphic source's.
20
+ #: (untyped, untyped) -> bool
21
+ def self.source_type_join?(reflection, join)
22
+ reflection.options[:source_type].nil? ||
23
+ source_type_member?(reflection, join.public_send(reflection.foreign_type))
24
+ end
25
+
26
+ # The same check for a join row read as stored attributes, given the
27
+ # value of its type column.
28
+ #: (untyped, untyped) -> bool
29
+ def self.source_type_member?(reflection, type)
30
+ source_type = reflection.options[:source_type]
31
+ source_type.nil? || type == source_type.to_s
32
+ end
33
+
34
+ # The class a stored type names, or nil when it names none.
35
+ # This class defines no constants of its own, so the one mutation left,
36
+ # `const_get` on it rather than on Object, finds the same class.
37
+ # mutant:disable
38
+ #: (String) -> untyped
39
+ def self.named_class(type)
40
+ Object.const_get(type)
14
41
  rescue NameError
15
42
  nil
16
43
  end
17
44
 
45
+ # A type left by a renamed or removed model names no class. Active Record
46
+ # and PT-AT raise a bare NameError for it, which does not say where it is.
47
+ #: (untyped, untyped, String) -> untyped
48
+ def self.polymorphic_target(reflection, record, type)
49
+ model_class = named_class(type)
50
+ return model_class if model_class
51
+
52
+ raise IncompleteAssociationHistoryError,
53
+ "association #{record.class.name}##{reflection.name} cannot be read: " \
54
+ "#{record.class.name} #{record.id} has #{reflection.foreign_type} #{type}, which " \
55
+ 'names no class. If the model was renamed, replace the old name in that column ' \
56
+ 'and in the versions that recorded it.'
57
+ end
58
+ private_class_method :polymorphic_target
59
+
60
+ # Why a request cannot name this association of the model, or nil when it
61
+ # can; `root` says whether the model is the record the request compares.
62
+ # Discovery lists only the associations this accepts, so every path it
63
+ # lists can be requested. A has_many :through Active Record cannot follow
64
+ # raises Active Record's own error.
65
+ #: (untyped, untyped, root: bool) -> String?
66
+ def self.unsupported_reason(model_class, reflection, root:)
67
+ return 'PaperTrail versions' if paper_trail_versions?(model_class, reflection)
68
+ return reflection.macro.to_s unless SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro)
69
+ return habtm_reason(reflection, root) if reflection.macro == :has_and_belongs_to_many
70
+
71
+ habtm = habtm_in_chain(reflection)
72
+ return unless habtm
73
+
74
+ "it goes through #{habtm.active_record.name}##{habtm.name}, a has_and_belongs_to_many, " \
75
+ 'whose hidden join model has no history to compare'
76
+ end
77
+
78
+ # PT-AT records a HABTM's membership only when its owner saves a version,
79
+ # in rows keyed by transaction and association name, not by owner. A root
80
+ # checkpoint so holds the root's membership and no one else's: another
81
+ # record's HABTM would read the root's rows, or none.
82
+ #: (untyped) -> String
83
+ def self.habtm_below_root(reflection)
84
+ "#{reflection.active_record.name}##{reflection.name} is a has_and_belongs_to_many " \
85
+ 'below the root, and a root checkpoint records HABTM membership for the root alone'
86
+ end
87
+ private_class_method :habtm_below_root
88
+
89
+ # PT-AT records a HABTM's membership by loading the association, scope and
90
+ # target default scope applied, so a target outside the scope when the
91
+ # owner saved is never recorded. One that enters it before the next save
92
+ # is a member then that history cannot hold.
93
+ #: (untyped, bool) -> String?
94
+ def self.habtm_reason(reflection, root)
95
+ return habtm_below_root(reflection) unless root
96
+ return unless ScopeCondition.scoped?(reflection)
97
+
98
+ "#{reflection.active_record.name}##{reflection.name} is a scoped " \
99
+ 'has_and_belongs_to_many, and PaperTrail records only the members its scope ' \
100
+ 'admitted when the owner saved, so membership between saves cannot be rebuilt; ' \
101
+ 'replace it with a has_many :through a versioned join model'
102
+ end
103
+ private_class_method :habtm_reason
104
+
105
+ #: (untyped, untyped) -> bool
106
+ def self.paper_trail_versions?(model_class, reflection)
107
+ model_class.respond_to?(:versions_association_name) &&
108
+ reflection.name.to_s == model_class.versions_association_name.to_s
109
+ end
110
+ private_class_method :paper_trail_versions?
111
+
112
+ # Active Record joins a HABTM through a hidden model of its own, which has
113
+ # no versions and no reader that PT-AT or prepared history can follow, so a
114
+ # through that crosses one would fail partway through reconstruction.
115
+ #
116
+ # The through and source reflections of a through are the hidden ones, a
117
+ # has_many in place of each HABTM, so only the public reflection of the
118
+ # same name shows the HABTM. Active Record's own check, which it runs on
119
+ # first use anyway, explains a through it cannot follow before the chain
120
+ # below it is read.
121
+ #: (untyped) -> untyped
122
+ def self.habtm_in_chain(reflection)
123
+ return unless reflection.through_reflection?
124
+
125
+ reflection.check_validity!
126
+ parts = [reflection.through_reflection, reflection.source_reflection]
127
+ parts.filter_map do |part|
128
+ link = part.active_record.reflect_on_association(part.name)
129
+ link.macro == :has_and_belongs_to_many ? link : habtm_in_chain(link)
130
+ end.first
131
+ end
132
+ private_class_method :habtm_in_chain
133
+
134
+ attr_reader :tree #: AssociationTree
135
+
136
+ # Whether a row belongs to an association whose class is `model_class`.
137
+ # Children are found by key across the whole table, so an association to an
138
+ # STI subclass has to leave out its siblings, as Active Record's type
139
+ # condition does for the live association: the row's type must name the
140
+ # class or one of its descendants. A base class takes every row.
141
+ #: (untyped, Hash[String, untyped]) -> bool
142
+ def self.sti_member?(model_class, attributes)
143
+ return true if model_class.descends_from_active_record?
144
+
145
+ names = [model_class, *model_class.descendants].map(&:sti_name)
146
+ names.include?(attributes[model_class.inheritance_column])
147
+ end
148
+
18
149
  #: (AssociationTree) -> void
19
150
  def initialize(tree)
20
151
  @tree = tree
@@ -60,24 +191,23 @@ module PaperTrailDiff
60
191
  raise UnsupportedLiveActivityError, message
61
192
  end
62
193
 
194
+ # A model and a tree resolve to the same reflections wherever they are
195
+ # read: a tree is only ever walked from one place, the root tree at the
196
+ # root and a subtree below it. Beyond that, the path only names a failure,
197
+ # and a failure is not cached.
63
198
  #: (untyped, AssociationTree, path: String) -> Array[untyped]
64
199
  def reflections_for(model_class, tree, path:)
65
- key = [model_class.name.to_s, path, tree.object_id]
66
- @reflection_cache[key] ||= requested_reflections(model_class, tree, path)
200
+ @reflection_cache[[model_class, tree]] ||= requested_reflections(model_class, tree, path)
67
201
  end
68
202
 
203
+ # Active Record names keys as strings. A polymorphic child also points back
204
+ # with its type column; `type` is nil for any other.
69
205
  #: (untyped) -> Array[String]
70
206
  def incoming_relationship_columns(reflection)
71
- direct = %i[has_one has_many].include?(reflection.macro) && !reflection.options[:through]
207
+ direct = %i[has_one has_many].include?(reflection.macro) && !reflection.through_reflection?
72
208
  return [] unless direct
73
209
 
74
- foreign_keys = Array(reflection.foreign_key)
75
- # @type var foreign_keys: Array[untyped]
76
- columns = foreign_keys.map do |column| # rubocop:disable Style/SymbolProc
77
- column.to_s
78
- end
79
- columns << reflection.type.to_s if reflection.options[:as]
80
- columns
210
+ Array(reflection.foreign_key) + Array(reflection.type)
81
211
  end
82
212
 
83
213
  private
@@ -88,11 +218,12 @@ module PaperTrailDiff
88
218
  #: (untyped, AssociationTree, path: String) -> Array[Array[untyped]]
89
219
  def collect_reflections(model_class, tree, path:)
90
220
  reflections_for(model_class, tree, path: path).flat_map do |reflection|
91
- child_path = join_path(path, reflection.name.to_s)
221
+ child_path = Support.association_path(path, reflection.name)
92
222
  result = [[child_path, reflection]]
93
- subtree = tree.child(reflection.name)
94
- next result unless subtree && !subtree.empty? && !reflection.polymorphic?
223
+ next result if reflection.polymorphic?
95
224
 
225
+ # A leaf's subtree is empty and adds nothing.
226
+ subtree = tree.child(reflection.name) #: AssociationTree
96
227
  result + collect_reflections(reflection.klass, subtree, path: child_path)
97
228
  end
98
229
  end
@@ -100,55 +231,48 @@ module PaperTrailDiff
100
231
  #: (untyped, AssociationTree, path: String) -> void
101
232
  def validate_tree!(model_class, tree, path:)
102
233
  reflections_for(model_class, tree, path: path).each do |reflection|
103
- subtree = tree.child(reflection.name)
104
- next unless subtree && !subtree.empty? && !reflection.polymorphic?
234
+ subtree = tree.child(reflection.name) #: AssociationTree
235
+ next if subtree.empty?
105
236
 
106
- child_path = join_path(path, reflection.name.to_s)
237
+ child_path = Support.association_path(path, reflection.name)
238
+ reject_polymorphic_descent!(reflection, subtree, child_path)
107
239
  validate_tree!(reflection.klass, subtree, path: child_path)
108
240
  end
109
241
  end
110
242
 
243
+ # A polymorphic target's class differs from record to record, so the
244
+ # associations a path names below it may not exist on every target.
245
+ #: (untyped, AssociationTree, String) -> void
246
+ def reject_polymorphic_descent!(reflection, subtree, path)
247
+ return unless reflection.polymorphic?
248
+
249
+ raise UnsupportedAssociationError,
250
+ "unsupported association #{subtree.paths(prefix: path).first}: " \
251
+ "#{path} is polymorphic, so a path cannot continue past it"
252
+ end
253
+
111
254
  #: (untyped, AssociationTree, String) -> Array[untyped]
112
255
  def requested_reflections(model_class, tree, path)
113
256
  tree.children.map do |name, _subtree|
114
- resolve_reflection(model_class, name, join_path(path, name))
257
+ resolve_reflection(model_class, name, Support.association_path(path, name),
258
+ root: path.empty?)
115
259
  end.freeze
116
260
  end
117
261
 
118
- #: (untyped, String, String) -> untyped
119
- def resolve_reflection(model_class, name, full_path)
120
- reflection = model_class.reflect_on_association(name.to_sym)
262
+ #: (untyped, String, String, root: bool) -> untyped
263
+ def resolve_reflection(model_class, name, full_path, root:)
264
+ reflection = model_class.reflect_on_association(name)
121
265
  raise UnknownAssociationError, "unknown association: #{full_path}" unless reflection
122
266
 
123
- if paper_trail_versions?(model_class, reflection)
124
- message = "unsupported association #{full_path}: PaperTrail versions"
125
- raise UnsupportedAssociationError, message
126
- end
127
- unless SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro)
128
- raise UnsupportedAssociationError,
129
- "unsupported association #{full_path}: #{reflection.macro}"
130
- end
267
+ reason = self.class.unsupported_reason(model_class, reflection, root: root)
268
+ raise UnsupportedAssociationError, "unsupported association #{full_path}: #{reason}" if reason
131
269
 
132
270
  reflection
133
271
  end
134
272
 
135
- #: (untyped, untyped) -> bool
136
- def paper_trail_versions?(model_class, reflection)
137
- model_class.respond_to?(:versions_association_name) &&
138
- reflection.name.to_s == model_class.versions_association_name.to_s
139
- end
140
-
141
273
  #: (untyped) -> bool
142
274
  def habtm_reflection?(reflection)
143
- return true if reflection.macro == :has_and_belongs_to_many
144
- return false unless reflection.options[:through]
145
-
146
- habtm_reflection?(reflection.through_reflection)
147
- end
148
-
149
- #: (String, String) -> String
150
- def join_path(parent, name)
151
- parent.empty? ? name : "#{parent}.#{name}"
275
+ reflection.macro == :has_and_belongs_to_many
152
276
  end
153
277
  end
154
278
  end
@@ -14,12 +14,12 @@ module PaperTrailDiff
14
14
  @allow_expensive = allow_expensive
15
15
  end
16
16
 
17
- #: (untyped, ?historical: bool) -> void
18
- def call(model, historical: true)
17
+ #: (untyped) -> void
18
+ def call(model)
19
19
  return if @allow_expensive
20
20
 
21
21
  @traversal.selected_reflections(model).each do |path, reflection|
22
- check(reflection, path, historical)
22
+ check(reflection, path)
23
23
  end
24
24
  end
25
25
 
@@ -28,28 +28,36 @@ module PaperTrailDiff
28
28
  # @rbs @traversal: AssociationTraversal
29
29
  # @rbs @allow_expensive: bool
30
30
 
31
- #: (untyped, String, bool) -> void
32
- def check(reflection, path, historical)
33
- reason = reason_for(reflection, historical)
31
+ # A has_many through reads its parts' conditions only, at any depth. A
32
+ # has_one through over a ranked part is refused whole, so its parts, too,
33
+ # are checked for conditions alone.
34
+ #: (untyped, String, ?ranked: bool) -> void
35
+ def check(reflection, path, ranked: true)
36
+ reason = reason_for(reflection, ranked)
34
37
  if reason
35
38
  raise UnsupportedBatchPathError,
36
39
  "association #{path} requires per-record reconstruction (#{reason}); " \
37
40
  'remove this path or explicitly pass allow_expensive: true'
38
41
  end
39
- return unless reflection.options[:through]
42
+ return unless reflection.through_reflection?
40
43
 
41
- check(reflection.through_reflection, path, historical)
42
- check(reflection.source_reflection, path, historical)
44
+ [reflection.through_reflection, reflection.source_reflection].each do |part|
45
+ check(part, path, ranked: false)
46
+ end
43
47
  end
44
48
 
49
+ # A scope prepared history can read as conditions on stored attributes
50
+ # is batched like any other path. A polymorphic target's class, and so
51
+ # its scope, is known only per record.
45
52
  #: (untyped, bool) -> String?
46
- def reason_for(reflection, historical)
47
- return 'association scope' if reflection.scope
48
- return 'default-scoped target' if !reflection.polymorphic? && reflection.klass.default_scopes?
49
- return unless historical && reflection.options[:through]
50
- return if reflection.source_reflection.belongs_to?
53
+ def reason_for(reflection, ranked)
54
+ return ('association scope' if reflection.scope) if reflection.polymorphic?
55
+ if ranked && ScopeCondition.ranked_through?(reflection)
56
+ return 'limit, offset or has_one order inside a has_one through'
57
+ end
58
+ return if ScopeCondition.build(reflection, ranked: ranked)
51
59
 
52
- 'through source is not belongs_to'
60
+ reflection.scope ? 'association scope' : 'default-scoped target'
53
61
  end
54
62
  end
55
63
  end
@@ -6,9 +6,8 @@ module PaperTrailDiff
6
6
  # queries. Only the range forms that mean the same thing for every root are
7
7
  # supported: a shared wall-clock window, or each root's own whole history.
8
8
  class BatchedRootVersions
9
- #: (Array[untyped], time_range: TimeRange?, ?version_scope: untyped, ?live_endpoints: Hash[Array[String], untyped]?, ?context_required: bool) -> void
10
- def initialize(records, time_range:, version_scope: nil, live_endpoints: nil,
11
- context_required: false)
9
+ #: (Array[untyped], time_range: TimeRange?, version_scope: untyped, live_endpoints: Hash[Array[String], untyped]?, context_required: bool) -> void
10
+ def initialize(records, time_range:, version_scope:, live_endpoints:, context_required:)
12
11
  @records = records
13
12
  # An activity view needs a root to reconstruct from even in a window with
14
13
  # no root version, exactly as a single-record activity timeline does.
@@ -21,8 +20,6 @@ module PaperTrailDiff
21
20
  # Returns a plan per record identity.
22
21
  #: () -> Hash[Array[String], RootVersionPlan]
23
22
  def call
24
- return {} if @records.empty?
25
-
26
23
  selected = {} #: Hash[Array[String], RootVersionPlan]
27
24
  @records.group_by(&:class).each do |model_class, records|
28
25
  select_model(model_class, records, selected)
@@ -40,10 +37,9 @@ module PaperTrailDiff
40
37
 
41
38
  #: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
42
39
  def select_model(model_class, records, selected)
43
- ids = records.map(&:id)
44
- in_range, chosen, trailing = model_versions(model_class, ids)
40
+ in_range, chosen, trailing = model_versions(model_class, records)
45
41
  records.each do |record|
46
- key = identity(model_class, record.id)
42
+ key = Endpoint.identity(record)
47
43
  selected[key] = versions_for(
48
44
  in_range.fetch(record.id.to_s, []), chosen, trailing[record.id.to_s],
49
45
  live_endpoint(key, record)
@@ -52,13 +48,13 @@ module PaperTrailDiff
52
48
  end
53
49
 
54
50
  #: (untyped, Array[untyped]) -> [Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped]]
55
- def model_versions(model_class, ids)
51
+ def model_versions(model_class, records)
56
52
  range = @time_range
57
53
  empty = {} #: Hash[String, untyped]
58
54
  [
59
- grouped_versions(model_class, ids, range),
60
- chosen_version_ids(model_class, ids, range),
61
- range ? trailing_versions(model_class, ids, range) : empty
55
+ grouped_versions(model_class, records, range),
56
+ chosen_version_ids(model_class, records, range),
57
+ range ? trailing_versions(model_class, records, range) : empty
62
58
  ]
63
59
  end
64
60
 
@@ -89,48 +85,34 @@ module PaperTrailDiff
89
85
  # One extra query names the selected mutations without discarding the
90
86
  # unfiltered versions the successor lookup still needs.
91
87
  #: (untyped, Array[untyped], TimeRange?) -> Set[untyped]?
92
- def chosen_version_ids(model_class, ids, range)
93
- scope = @version_scope
94
- return unless scope
95
-
96
- # A narrowed relation is the expected return. Active Support also gives
97
- # `pluck` to plain enumerables, so an array of versions works too.
98
- filtered = scope.call(range_scope(model_class, ids, range))
99
- Set.new(filtered.pluck(:id))
88
+ def chosen_version_ids(model_class, records, range)
89
+ VersionScopeFilter.new(@version_scope).chosen_ids(range_scope(model_class, records, range))
100
90
  end
101
91
 
102
92
  #: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
103
- def grouped_versions(model_class, ids, range)
104
- ordered(range_scope(model_class, ids, range)).group_by { |version| version.item_id.to_s }
93
+ def grouped_versions(model_class, records, range)
94
+ VersionQuery.chronological(range_scope(model_class, records, range))
95
+ .group_by { |version| version.item_id.to_s }
105
96
  end
106
97
 
107
98
  #: (untyped, Array[untyped], TimeRange?) -> untyped
108
- def range_scope(model_class, ids, range)
109
- scope = base_scope(model_class, ids)
99
+ def range_scope(model_class, records, range)
100
+ scope = base_scope(model_class, records)
110
101
  range ? range.scope(scope) : scope
111
102
  end
112
103
 
113
104
  #: (untyped, Array[untyped], TimeRange) -> Hash[String, untyped]
114
- def trailing_versions(model_class, ids, range)
115
- range.first_trailing_versions(base_scope(model_class, ids))
105
+ def trailing_versions(model_class, records, range)
106
+ range.first_trailing_versions(base_scope(model_class, records))
116
107
  end
117
108
 
118
109
  #: (untyped, Array[untyped]) -> untyped
119
- def base_scope(model_class, ids)
110
+ def base_scope(model_class, records)
111
+ # Active Record reads each record's id.
120
112
  model_class.paper_trail.version_class.where(
121
- item_type: model_class.base_class.name.to_s,
122
- item_id: ids
113
+ item_type: model_class.base_class.name,
114
+ item_id: records
123
115
  )
124
116
  end
125
-
126
- #: (untyped) -> Array[untyped]
127
- def ordered(relation)
128
- VersionQuery.chronological(relation).to_a
129
- end
130
-
131
- #: (untyped, untyped) -> Array[String]
132
- def identity(model_class, id)
133
- [model_class.base_class.name.to_s, id.to_s]
134
- end
135
117
  end
136
118
  end
@@ -4,12 +4,16 @@
4
4
  module PaperTrailDiff
5
5
  # Validates and executes independent comparisons with shared live endpoint loading.
6
6
  class ComparisonBatch
7
- #: (Array[comparison_input], live_loader: untyped, preparer: untyped, history_preparer: untyped, historical_snapshotter: untyped, live_normalizer: untyped) -> void
7
+ # `history_preparer` prepares roots that have a live endpoint to collect a
8
+ # graph from; `version_history_preparer` prepares roots compared only
9
+ # between versions.
10
+ #: (Array[comparison_input], live_loader: untyped, preparer: untyped, history_preparer: untyped, version_history_preparer: untyped, historical_snapshotter: untyped, live_normalizer: untyped) -> void
8
11
  def initialize( # rubocop:disable Metrics/ParameterLists
9
12
  comparisons,
10
13
  live_loader:,
11
14
  preparer:,
12
15
  history_preparer:,
16
+ version_history_preparer:,
13
17
  historical_snapshotter:,
14
18
  live_normalizer:
15
19
  )
@@ -17,6 +21,7 @@ module PaperTrailDiff
17
21
  @live_loader = live_loader
18
22
  @preparer = preparer
19
23
  @history_preparer = history_preparer
24
+ @version_history_preparer = version_history_preparer
20
25
  @historical_snapshotter = historical_snapshotter
21
26
  @live_normalizer = live_normalizer
22
27
  end
@@ -35,6 +40,7 @@ module PaperTrailDiff
35
40
  # @rbs @live_loader: untyped
36
41
  # @rbs @preparer: untyped
37
42
  # @rbs @history_preparer: untyped
43
+ # @rbs @version_history_preparer: untyped
38
44
  # @rbs @historical_snapshotter: untyped
39
45
  # @rbs @live_normalizer: untyped
40
46
 
@@ -97,20 +103,41 @@ module PaperTrailDiff
97
103
 
98
104
  #: (Array[[untyped, untyped]], Hash[identity, untyped]) -> void
99
105
  def prepare_historical_batches!(pairs, live_records)
100
- grouped = historical_versions(pairs, live_records).group_by do |version|
101
- Endpoint.model_class(version)
106
+ loaded, unloaded = historical_versions(pairs).partition do |version|
107
+ live_records.key?(Endpoint.identity(version))
102
108
  end
103
- grouped.each_value do |versions|
109
+ loaded.group_by { |version| RecordedClass.for(version) }.each_value do |versions|
104
110
  records = versions.map { |version| live_records.fetch(Endpoint.identity(version)) }.uniq
105
111
  @history_preparer.call(records, versions)
106
112
  end
113
+ prepare_version_roots!(unloaded)
114
+ end
115
+
116
+ # A root compared only between versions has no live endpoint, and its row
117
+ # may be gone. Without prepared history, PT-AT finds a has_many's children
118
+ # by the keys each version recorded after its change, so it misses a child
119
+ # that later moved to another owner. Such roots are prepared from identity
120
+ # alone, like deleted ones. Their history still reads the live rows it
121
+ # needs: a record's current row is its state after its last version.
122
+ #: (Array[untyped]) -> void
123
+ def prepare_version_roots!(versions)
124
+ versions.group_by { |version| RecordedClass.for(version) }.each do |model, group|
125
+ ids = group.map(&:item_id).uniq(&:to_s)
126
+ @version_history_preparer.call(ids.map { |id| historical_root(model, id) }, group)
127
+ end
107
128
  end
108
129
 
109
- #: (Array[[untyped, untyped]], Hash[identity, untyped]) -> Array[untyped]
110
- def historical_versions(pairs, live_records)
111
- selected = pairs.flatten.select do |endpoint|
112
- Endpoint.version?(endpoint) && live_records.key?(Endpoint.identity(endpoint))
113
- end
130
+ # The record supplies identity and model metadata only.
131
+ #: (untyped, untyped) -> untyped
132
+ def historical_root(model, id)
133
+ record = model.new
134
+ record[model.primary_key] = id
135
+ record
136
+ end
137
+
138
+ #: (Array[[untyped, untyped]]) -> Array[untyped]
139
+ def historical_versions(pairs)
140
+ selected = pairs.flatten.select { |endpoint| Endpoint.version?(endpoint) }
114
141
  selected.uniq { |version| [version.class.name, version.id] }
115
142
  end
116
143
 
@@ -167,7 +194,7 @@ module PaperTrailDiff
167
194
  requirements = {} #: Hash[untyped, bool]
168
195
  pairs.flatten.each_with_object(requirements) do |endpoint, collected|
169
196
  historical = Endpoint.version?(endpoint)
170
- model_class = historical ? Endpoint.model_class(endpoint) : endpoint.class
197
+ model_class = historical ? RecordedClass.for(endpoint) : endpoint.class
171
198
  collected[model_class] = collected.fetch(model_class, false) || historical
172
199
  end
173
200
  end
@@ -4,11 +4,15 @@
4
4
  module PaperTrailDiff
5
5
  # Analyzes missing roots together without pretending that they have live rows.
6
6
  class DeletedAnalysisBatch
7
- #: (untyped, identities: Array[Array[String]], time_range: TimeRange?, analyzer: untyped, history_preparer: untyped, ?version_scope: untyped) -> void
7
+ # `context_required` reads a root that did not change in the window from
8
+ # the version after it, so its associations' activity can be reported.
9
+ #: (untyped, identities: Array[Array[String]], time_range: TimeRange?, analyzer: untyped, history_preparer: untyped, version_scope: untyped, context_required: bool) -> void
8
10
  def initialize( # rubocop:disable Metrics/ParameterLists
9
- model, identities:, time_range:, analyzer:, history_preparer:, version_scope: nil
11
+ model, identities:, time_range:, analyzer:, history_preparer:, version_scope:,
12
+ context_required:
10
13
  )
11
14
  @model = model
15
+ @context_required = context_required
12
16
  @identities = identities
13
17
  @time_range = time_range
14
18
  @analyzer = analyzer
@@ -18,13 +22,9 @@ module PaperTrailDiff
18
22
 
19
23
  #: () -> Hash[Array[String], Analysis?]
20
24
  def call
21
- empty = {} #: Hash[Array[String], Analysis?]
22
- return empty.freeze if @identities.empty?
23
-
24
- records = historical_roots
25
- selected = BatchedRootVersions.new(
26
- records, time_range: @time_range, version_scope: @version_scope
27
- ).call
25
+ destroys = destroy_versions
26
+ records = historical_roots(destroys)
27
+ selected = plans(records, destroys)
28
28
  prepare(records, selected)
29
29
  analyses = records.to_h do |record|
30
30
  key = Endpoint.identity(record)
@@ -42,19 +42,39 @@ module PaperTrailDiff
42
42
  end
43
43
 
44
44
  # @rbs @model: untyped
45
+ # @rbs @context_required: bool
45
46
  # @rbs @identities: Array[Array[String]]
46
47
  # @rbs @time_range: TimeRange?
47
48
  # @rbs @analyzer: untyped
48
49
  # @rbs @history_preparer: untyped
49
50
  # @rbs @version_scope: untyped
50
51
 
52
+ # A root destroyed before the window had no state inside it to read, so
53
+ # only the others take the version after the window as context.
54
+ #: (Array[untyped], Array[untyped]) -> Hash[Array[String], RootVersionPlan]
55
+ def plans(records, destroys)
56
+ start = @time_range&.begin_time
57
+ early = destroys.select { |version| start && version.created_at < start }
58
+ .map { |version| version.item_id.to_s }
59
+ before, during = records.partition { |record| early.include?(record.id.to_s) }
60
+ plans_for(during, @context_required).merge(plans_for(before, false))
61
+ end
62
+
63
+ #: (Array[untyped], bool) -> Hash[Array[String], RootVersionPlan]
64
+ def plans_for(records, context_required)
65
+ BatchedRootVersions.new(
66
+ records, time_range: @time_range, version_scope: @version_scope,
67
+ live_endpoints: nil, context_required: context_required
68
+ ).call
69
+ end
70
+
51
71
  # These records supply identity and model metadata only. Every reported
52
72
  # value comes from the batched historical plan; no per-root reify or live
53
73
  # lookup is needed to get into the comparison engine.
54
- #: () -> Array[untyped]
55
- def historical_roots
74
+ #: (Array[untyped]) -> Array[untyped]
75
+ def historical_roots(destroys)
56
76
  loader = PreparedVersionStateLoader.new
57
- destroy_versions.map do |version|
77
+ destroys.map do |version|
58
78
  model = loader.call(version)&.model_class || Endpoint.model_class(version)
59
79
  record = model.new
60
80
  record[model.primary_key] = version.item_id
@@ -67,10 +87,8 @@ module PaperTrailDiff
67
87
  destroys = @model.paper_trail.version_class.where(
68
88
  item_type: @model.base_class.name, item_id: @identities.map(&:last), event: 'destroy'
69
89
  )
70
- versions = VersionQuery.chronological(destroys)
71
- versions.group_by { |version| version.item_id.to_s }.values.map do |history|
72
- Support.chronological_sort(history).last
73
- end
90
+ # Oldest first, so each item keeps its latest destroy.
91
+ VersionQuery.chronological(destroys).index_by(&:item_id).values
74
92
  end
75
93
 
76
94
  #: (Array[untyped]) -> Hash[Symbol, untyped]