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
@@ -91,7 +91,7 @@ module PaperTrailDiff
91
91
  selected = traversal.selected_reflections(model_class)
92
92
  inspect_targets(selected)
93
93
  inspect_checkpoint_timestamp(model_class)
94
- inspect_habtm(selected)
94
+ inspect_habtm(model_class, selected)
95
95
  DiagnosticReport.new(issues: @issues)
96
96
  end
97
97
 
@@ -126,14 +126,32 @@ module PaperTrailDiff
126
126
  end
127
127
  end
128
128
 
129
- #: (Array[Array[untyped]]) -> void
130
- def inspect_habtm(selected)
131
- paths = selected.filter_map do |path, reflection|
132
- path if reflection.macro == :has_and_belongs_to_many
133
- end
134
- return if paths.empty?
129
+ #: (untyped, Array[Array[untyped]]) -> void
130
+ def inspect_habtm(model_class, selected)
131
+ habtm = selected.select { |_, reflection| reflection.macro == :has_and_belongs_to_many }
132
+ return if habtm.empty?
133
+
134
+ inspect_transaction_metadata(habtm.map(&:first))
135
+ inspect_shared_transactions(model_class, habtm)
136
+ end
135
137
 
136
- inspect_transaction_metadata(paths)
138
+ # PT-AT does not record which owner a HABTM member belongs to, so a
139
+ # transaction that more than one owner recorded members in cannot be read.
140
+ #: (untyped, Array[Array[untyped]]) -> void
141
+ def inspect_shared_transactions(model_class, habtm)
142
+ versions = [@from_version, @to_version].select do |version|
143
+ version.respond_to?(:transaction_id) && version.transaction_id
144
+ end
145
+ habtm.each do |path, reflection|
146
+ owners = HabtmTransactionOwners.new(model_class, reflection)
147
+ versions.each do |version|
148
+ next unless owners.ambiguous?(version.transaction_id)
149
+
150
+ add_error(:habtm_transaction_shared,
151
+ 'HABTM endpoint shares its transaction with another owner of the association',
152
+ path, version.id)
153
+ end
154
+ end
137
155
  end
138
156
 
139
157
  #: () -> void
@@ -45,13 +45,22 @@ module PaperTrailDiff
45
45
 
46
46
  #: (untyped) -> bool
47
47
  def plain_model?(model)
48
- !model.primary_key.is_a?(Array) && !model.default_scopes? &&
48
+ !model.primary_key.is_a?(Array) && !scope_assigns_attributes?(model) &&
49
49
  model._initialize_callbacks.empty? &&
50
50
  %i[initialize attributes []= write_attribute id].all? do |name|
51
51
  framework_class?(model.instance_method(name).owner)
52
52
  end
53
53
  end
54
54
 
55
+ # A default scope's equality conditions are assigned to every new record,
56
+ # through the model's writers, before a reified version's attributes are.
57
+ # A subclass's own type is assigned with or without one.
58
+ #: (untyped) -> bool
59
+ def scope_assigns_attributes?(model)
60
+ model.default_scopes? &&
61
+ model.default_scoped.scope_for_create != model.unscoped.scope_for_create
62
+ end
63
+
55
64
  #: (untyped) -> bool
56
65
  def plain_attributes?(model)
57
66
  return false if encrypted?(model)
@@ -58,7 +58,8 @@ module PaperTrailDiff
58
58
  # Raised when association comparison is requested without an available PT-AT setup.
59
59
  class AssociationTrackingUnavailableError < Error; end
60
60
 
61
- # Raised when recorded PT-AT metadata cannot reliably reconstruct a selected association.
61
+ # Raised when recorded PT-AT metadata cannot reliably reconstruct a selected association,
62
+ # or a polymorphic type names no class.
62
63
  class IncompleteAssociationHistoryError < Error; end
63
64
 
64
65
  # Raised when current-ended activity cannot be reconstructed for a selected association.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # PT-AT records HABTM members under a transaction id and the association's
6
+ # name, with nothing naming the record they belong to. When more than one
7
+ # record that can own that name saved a version in the transaction, their
8
+ # members are mixed together and none of them can be read back.
9
+ class HabtmTransactionOwners
10
+ #: (untyped, untyped) -> void
11
+ def initialize(owner_class, reflection)
12
+ @versions = owner_class.paper_trail.version_class
13
+ @associations = owner_class.paper_trail.version_association_class
14
+ @name = reflection.name.to_s
15
+ @owner_types = {} #: Hash[String, bool]
16
+ end
17
+
18
+ # The given transactions (one id or several) in which more than one
19
+ # possible owner saved a version, as strings.
20
+ #: (untyped) -> Array[String]
21
+ def shared(transaction_ids)
22
+ items = @versions.where(transaction_id: transaction_ids).distinct
23
+ .pluck(:transaction_id, :item_type, :item_id)
24
+ owners = items.select { |_, type, _| owner_type?(type) }
25
+ owners.group_by { |transaction_id, _, _| transaction_id.to_s }
26
+ .filter_map { |transaction_id, rows| transaction_id if rows.size > 1 }
27
+ end
28
+
29
+ # Members recorded in a shared transaction cannot be told apart. With no
30
+ # members recorded, every owner had none, so that reads correctly.
31
+ #: (untyped) -> bool
32
+ def ambiguous?(transaction_id)
33
+ shared(transaction_id).any? &&
34
+ @associations.exists?(version_id: transaction_id, foreign_key_name: @name)
35
+ end
36
+
37
+ #: (untyped) -> void
38
+ def ensure_single_owner!(transaction_id)
39
+ return unless ambiguous?(transaction_id)
40
+
41
+ message = "HABTM history is ambiguous for #{@name} at transaction #{transaction_id}: " \
42
+ "more than one record with a #{@name} association saved a version in it, " \
43
+ 'and PT-AT does not record which members belong to which record'
44
+ raise IncompleteAssociationHistoryError, message
45
+ end
46
+
47
+ private
48
+
49
+ # @rbs @versions: untyped
50
+ # @rbs @associations: untyped
51
+ # @rbs @name: String
52
+ # @rbs @owner_types: Hash[String, bool]
53
+
54
+ # Versions name the base class, so a HABTM declared on a subclass counts.
55
+ # A type that names no model may have recorded members when it existed.
56
+ #: (String) -> bool
57
+ def owner_type?(type)
58
+ @owner_types.fetch(type) do
59
+ model_class = AssociationTraversal.named_class(type)
60
+ @owner_types[type] =
61
+ !model_class.respond_to?(:reflect_on_association) ||
62
+ [model_class, *model_class.descendants].any? do |candidate|
63
+ candidate.reflect_on_association(@name)&.macro == :has_and_belongs_to_many
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -37,7 +37,7 @@ module PaperTrailDiff
37
37
  reified[reflection.name] = true
38
38
  case reflection.macro
39
39
  when :belongs_to
40
- reifier(:BelongsTo).reify(reflection, record, options, @transaction_id)
40
+ reify_belongs_to(record, reflection)
41
41
  when :has_one
42
42
  reifier(:HasOne).reify(reflection, record, options, @transaction_id)
43
43
  when :has_many
@@ -47,6 +47,26 @@ module PaperTrailDiff
47
47
  end
48
48
  end
49
49
 
50
+ #: (untyped, untyped) -> void
51
+ def reify_belongs_to(record, reflection)
52
+ ensure_versioned_target!(record, reflection) if reflection.polymorphic?
53
+ reifier(:BelongsTo).reify(reflection, record, options, @transaction_id)
54
+ end
55
+
56
+ # A polymorphic target's class is known only from the record's type
57
+ # column, so TraversalPreparer cannot check it is versioned up front.
58
+ #: (untyped, untyped) -> void
59
+ def ensure_versioned_target!(record, reflection)
60
+ model_class = AssociationTraversal.belongs_to_target(reflection, record)
61
+ return if model_class.nil? || Support.versioned?(model_class)
62
+
63
+ raise UnversionedAssociationError,
64
+ "association #{record.class.name}##{reflection.name} cannot be compared " \
65
+ "historically: it points at #{model_class.name}, which is not versioned. Add " \
66
+ "`has_paper_trail` to #{model_class.name}, or mirror the fields you need onto a " \
67
+ 'model that has it.'
68
+ end
69
+
50
70
  #: (untyped, untyped) -> void
51
71
  def reify_has_many(record, reflection)
52
72
  if reflection.options[:through]
@@ -70,7 +90,7 @@ module PaperTrailDiff
70
90
  #: (untyped, untyped) -> void
71
91
  def reify_through(record, reflection)
72
92
  source = reflection.source_reflection
73
- if source.belongs_to?
93
+ if pt_at_source?(reflection)
74
94
  reifier(:HasManyThrough).reify(reflection, record, options, @transaction_id)
75
95
  return
76
96
  end
@@ -81,9 +101,17 @@ module PaperTrailDiff
81
101
  association.loaded!
82
102
  end
83
103
 
104
+ # PT-AT's belongs_to source ignores `source_type:` and reads targets by id
105
+ # alone, so a typed through walks its join records instead.
106
+ #: (untyped) -> bool
107
+ def pt_at_source?(reflection)
108
+ reflection.source_reflection.belongs_to? && !reflection.options[:source_type]
109
+ end
110
+
84
111
  #: (untyped, untyped, untyped) -> Array[untyped]
85
112
  def through_members(record, reflection, source)
86
113
  joins = Array(record.public_send(reflection.through_reflection.name)) #: Array[untyped]
114
+ joins = joins.select { |join| AssociationTraversal.source_type_join?(reflection, join) }
87
115
  joins.flat_map do |join|
88
116
  reify_association(join, source)
89
117
  Array(join.public_send(source.name))
@@ -96,6 +124,8 @@ module PaperTrailDiff
96
124
  message = 'HABTM reconstruction requires a transaction-backed endpoint version'
97
125
  raise IncompleteAssociationHistoryError, message
98
126
  end
127
+ HabtmTransactionOwners.new(record.class, reflection)
128
+ .ensure_single_owner!(@habtm_transaction_id)
99
129
 
100
130
  paper_trail = Object.const_get(:PaperTrail) #: untyped
101
131
  target_versioned = paper_trail.request.enabled_for_model?(reflection.klass)
@@ -34,12 +34,8 @@ module PaperTrailDiff
34
34
  end
35
35
 
36
36
  @prepared_history = PreparedHistoryLoader.new(
37
- record,
38
- root_versions: root_versions,
39
- start_at: start_at,
40
- end_at: end_at,
41
- tree: @tree,
42
- traversal: @traversal
37
+ record, root_ids: [record.id], root_versions: root_versions, start_at: start_at,
38
+ end_at: end_at, tree: @tree, traversal: @traversal, live_records: []
43
39
  ).call
44
40
  end
45
41
 
@@ -52,8 +48,9 @@ module PaperTrailDiff
52
48
  end
53
49
  end
54
50
 
55
- # Missing roots have no live graph to collect. Reading their associations
56
- # here would issue a query per root and seed nonexistent rows as live state.
51
+ # Missing roots, and roots compared only between versions, have no live
52
+ # graph to collect. Reading their associations here would issue a query
53
+ # per root and seed nonexistent rows as live state.
57
54
  #: (Array[untyped], Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
58
55
  def prepare_deleted_batch(records, root_versions, start_at: nil, end_at: nil)
59
56
  return if @tree.empty? || records.empty? || root_versions.empty?
@@ -175,7 +172,7 @@ module PaperTrailDiff
175
172
 
176
173
  #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
177
174
  def snapshot_from_version(root_version, context_version, tree:, normalizer:)
178
- model_class = Endpoint.model_class(root_version)
175
+ model_class = RecordedClass.for(root_version)
179
176
  @preparer.call(model_class, historical: true)
180
177
  @traversal.ensure_habtm_history!(model_class, root_version)
181
178
  handled, snapshot = @prepared_snapshotter.call(
@@ -44,7 +44,9 @@ module PaperTrailDiff
44
44
 
45
45
  #: (untyped, Array[untyped], AssociationTree) -> void
46
46
  def preload_model_tree(model_class, records, tree)
47
- batch, individual = selected_reflections(model_class, tree).partition do |reflection, _|
47
+ reflections = selected_reflections(model_class, tree)
48
+ resolve_polymorphic_targets(records, reflections)
49
+ batch, individual = reflections.partition do |reflection, _|
48
50
  batch_safe?(records.fetch(0), reflection)
49
51
  end
50
52
  preload_batch(records, batch.map { |reflection, _| reflection.name })
@@ -62,6 +64,15 @@ module PaperTrailDiff
62
64
  end
63
65
  end
64
66
 
67
+ # Active Record raises a bare NameError for a type that names no class, so
68
+ # each type is resolved first to raise one that names the record.
69
+ #: (Array[untyped], Array[[untyped, AssociationTree]]) -> void
70
+ def resolve_polymorphic_targets(records, reflections)
71
+ reflections.map(&:first).select(&:polymorphic?).each do |reflection|
72
+ records.each { |record| AssociationTraversal.belongs_to_target(reflection, record) }
73
+ end
74
+ end
75
+
65
76
  #: (Array[untyped], Array[[untyped, AssociationTree]], individually: bool) -> void
66
77
  def preload_subtrees(records, reflections, individually:)
67
78
  reflections.each do |reflection, subtree|
@@ -90,16 +101,38 @@ module PaperTrailDiff
90
101
  active_record.const_get(:Associations).const_get(:Preloader)
91
102
  end
92
103
 
104
+ # The preloader applies a limit or offset to all owners' records at once,
105
+ # not to each owner's, so such an association is loaded per record. A
106
+ # has_one is no exception. Through a part with a limit, an offset or a
107
+ # has_one cut, the preloader keeps what the part alone would load, while
108
+ # the through's own query keeps the part's conditions only.
93
109
  #: (untyped, untyped) -> bool
94
110
  def batch_safe?(record, reflection)
95
111
  scope = reflection.scope
96
112
  return false if scope && !scope.arity.zero?
97
- return true unless reflection.collection?
113
+ return true unless reflection.collection? || reflection.has_one?
114
+ return false if cut_part?(reflection)
98
115
 
99
116
  relation = record.association(reflection.name).scope
100
117
  relation.limit_value.nil? && relation.offset_value.nil?
101
118
  end
102
119
 
120
+ #: (untyped) -> bool
121
+ def cut_part?(reflection)
122
+ reflection.collection? &&
123
+ (ScopeCondition.ranked_parts?(reflection) || through_parts(reflection).any?(&:has_one?))
124
+ end
125
+
126
+ # Every part a through passes, at any depth.
127
+ #: (untyped) -> Array[untyped]
128
+ def through_parts(reflection)
129
+ return [] unless reflection.through_reflection?
130
+
131
+ [reflection.through_reflection, reflection.source_reflection].flat_map do |part|
132
+ [part, *through_parts(part)]
133
+ end
134
+ end
135
+
103
136
  #: (Array[untyped], untyped) -> Array[untyped]
104
137
  def load_individually(records, reflection)
105
138
  unique_records(records.flat_map do |record|
@@ -36,7 +36,7 @@ module PaperTrailDiff
36
36
  subtree = tree.child(reflection.name)
37
37
  next unless subtree
38
38
 
39
- child_path = Support.association_path(path, reflection.name.to_s)
39
+ child_path = Support.association_path(path, reflection.name)
40
40
  collect(children_for(records, reflection), subtree, path: child_path, into: into)
41
41
  end
42
42
  end
@@ -38,8 +38,9 @@ module PaperTrailDiff
38
38
  if @association_tree.empty?
39
39
  Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
40
40
  else
41
- # Prepared history orders versions that share a timestamp; reading
42
- # associations straight from PT-AT would resolve them by time alone.
41
+ # Prepared history orders versions that share a timestamp and finds
42
+ # children that later moved away; reading associations straight from
43
+ # PT-AT would resolve them by time alone and miss those children.
43
44
  comparison_batch([{ from: from_endpoint, to: to_endpoint }],
44
45
  preparer: @traversal_preparer.method(:call)).call.values.fetch(0)
45
46
  end
@@ -133,7 +134,7 @@ module PaperTrailDiff
133
134
  #: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool) -> ScopedAnalysis
134
135
  def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil, historical_filter: nil, max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, include_deleted: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
135
136
  ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
136
- @batch_safety.call(scope.all.model) if scope.respond_to?(:all) && scope.respond_to?(:where)
137
+ check_scope(scope, activity)
137
138
  # @type var options: report_options
138
139
  options = { within: within, activity: activity, version_scope: version_scope,
139
140
  close_on: close_on, snapshots: snapshots, group: group }
@@ -143,18 +144,32 @@ module PaperTrailDiff
143
144
  analyzer: ->(records) { analyze_many(records, **options, within: within) },
144
145
  deleted_analyzer: lambda { |identities|
145
146
  analyze_deleted(scope.all.model, identities, options)
146
- }
147
+ },
148
+ # Checkpoints compare a root's own versions, so only activity can
149
+ # report a window in which just its associations changed.
150
+ activity_tree: (@association_tree if activity)
147
151
  ).call
148
152
  end
149
153
 
150
154
  private
151
155
 
156
+ # Selecting by activity reads association history, so it must be available
157
+ # before the first selection query rather than once analysis starts.
158
+ #: (untyped, bool) -> void
159
+ def check_scope(scope, activity)
160
+ return unless scope.respond_to?(:all) && scope.respond_to?(:where)
161
+
162
+ @batch_safety.call(scope.all.model)
163
+ @traversal_preparer.call(scope.all.model, historical: true) if activity
164
+ end
165
+
152
166
  #: (Array[comparison_input], preparer: untyped) -> ComparisonBatch
153
167
  def comparison_batch(comparisons, preparer:)
154
168
  ComparisonBatch.new(
155
169
  comparisons,
156
170
  live_loader: @live_endpoints, preparer: preparer,
157
171
  history_preparer: @historical_store.method(:prepare_batch),
172
+ version_history_preparer: @historical_store.method(:prepare_deleted_batch),
158
173
  historical_snapshotter: method(:historical_snapshot),
159
174
  live_normalizer: method(:normalize_live_snapshot)
160
175
  )
@@ -168,7 +183,7 @@ module PaperTrailDiff
168
183
 
169
184
  #: (untyped, historical: bool) -> void
170
185
  def prepare_batch_model(model, historical:)
171
- @batch_safety.call(model, historical: historical)
186
+ @batch_safety.call(model)
172
187
  @traversal_preparer.call(model, historical: historical)
173
188
  end
174
189
 
@@ -185,7 +200,8 @@ module PaperTrailDiff
185
200
  model, identities: identities, time_range: within.nil? ? nil : TimeRange.new(within),
186
201
  version_scope: options.fetch(:version_scope),
187
202
  history_preparer: @historical_store.method(:prepare_deleted_batch),
188
- analyzer: analyzer
203
+ analyzer: analyzer,
204
+ context_required: options.fetch(:activity) && !@association_tree.empty?
189
205
  ).call
190
206
  end
191
207
 
@@ -30,7 +30,7 @@ module PaperTrailDiff
30
30
 
31
31
  #: (untyped, AssociationTree, String, untyped) -> void
32
32
  def validate_association(record, tree, path, reflection)
33
- association_path = Support.association_path(path, reflection.name.to_s)
33
+ association_path = Support.association_path(path, reflection.name)
34
34
  association = record.association(reflection.name)
35
35
  unless association.loaded?
36
36
  message = "current endpoint association is not preloaded: #{association_path}"
@@ -10,39 +10,93 @@ module PaperTrailDiff
10
10
  @range = ActivityRange.new(start_at, end_at)
11
11
  @roots = root_versions.group_by { |version| [version.item_type, version.item_id.to_s] }
12
12
  @nodes = {} #: Hash[String, Array[untyped]]
13
- @children = {} #: Hash[Array[String], untyped]
14
- @rows = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
13
+ # Rows are cached by reflection, which a subclass shares with its base.
14
+ @children = {} #: Hash[untyped, untyped]
15
+ @targets = {} #: Hash[untyped, untyped]
16
+ @rows = {} #: Hash[untyped, Hash[String, Array[untyped]]]
15
17
  end
16
18
 
17
19
  #: (untyped, Array[untyped]) -> void
18
20
  def register(model, ids)
19
- name = model.base_class.name.to_s
21
+ name = model.base_class.name
20
22
  @nodes[name] = (@nodes.fetch(name, []) | ids)
21
23
  end
22
24
 
23
25
  #: (untyped, Array[untyped]) -> Array[untyped]
24
26
  def versions(model, ids)
25
27
  ids.flat_map do |id|
26
- key = [model.base_class.name.to_s, id.to_s]
27
- @roots[key] || @records.versions_for_id(model, id)
28
+ @roots[[model.base_class.name, id.to_s]] || @records.versions_for_id(model, id)
29
+ end
30
+ end
31
+
32
+ # Children are found by key across the whole table, so an association to
33
+ # an STI subclass finds the rows of its siblings too. Only a row that was
34
+ # of the class at some point belongs to it, and of that row only the
35
+ # versions it had the class before or after.
36
+ #: (untyped, Array[untyped]) -> Array[untyped]
37
+ def member_ids(model, ids)
38
+ return ids if model.descends_from_active_record?
39
+
40
+ ids.select { |id| member_class?(model, @records.model_classes(model, id)) }
41
+ end
42
+
43
+ #: (untyped, Array[untyped], ActivityRange) -> Array[untyped]
44
+ def member_versions(model, ids, range)
45
+ all = versions(model, ids).select { |version| range.include?(version) }
46
+ return all if model.descends_from_active_record?
47
+
48
+ all.select do |version|
49
+ member_class?(model, @records.model_classes(model, version.item_id, version))
50
+ end
51
+ end
52
+
53
+ # The versions of a through's join rows that are on its path. Under
54
+ # `source_type:`, a row is on it only while its type column names that
55
+ # class, so only the versions it had that type before or after count. A
56
+ # version saved before the type column existed has no type to read.
57
+ #: (Hash[Symbol, untyped], untyped) -> Array[untyped]
58
+ def join_versions(group, reflection)
59
+ versions = group.fetch(:versions)
60
+ source_type = reflection.options[:source_type]
61
+ return versions unless source_type
62
+
63
+ column = reflection.foreign_type
64
+ versions.select do |version|
65
+ @records.states_around(group.fetch(:model), version.item_id, version).any? do |state|
66
+ state.attributes[column] == source_type.to_s
67
+ end
28
68
  end
29
69
  end
30
70
 
31
71
  #: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
32
72
  def child_ids(model, ids, reflection, range)
33
- key = [model.base_class.name.to_s, reflection.name.to_s]
34
- historical, live = @children[key] ||= child_rows(model, reflection)
73
+ historical, live = @children[reflection] ||= child_rows(model, reflection)
35
74
  empty = [] #: Array[untyped]
36
75
  selected = historical_ids(reflection.klass, ids, historical, range)
37
76
  selected |= ids.flat_map { |id| live.fetch(id.to_s, empty) } if range.current_end?
38
77
  selected.uniq
39
78
  end
40
79
 
41
- #: (untyped, Array[untyped], untyped, ?transactions: bool) -> Array[untyped]
42
- def association_rows(model, selected_versions, reflection, transactions: false)
43
- key = [model.base_class.name.to_s, reflection.name.to_s]
44
- rows = @rows[key] ||= load_association_rows(model, reflection, transactions)
45
- version_ids(selected_versions, transactions).flat_map { |id| rows.fetch(id.to_s, []) }
80
+ # The `belongs_to` targets each owner pointed at in the range: at its start
81
+ # and after each change inside it, plus the live row at a current end.
82
+ #: (untyped, Array[untyped], untyped, ActivityRange) -> Array[ActivityTargetCandidateLoader::Row]
83
+ def target_rows(model, ids, reflection, range)
84
+ historical, live = @targets[reflection] ||= target_candidates(model, reflection)
85
+ rows = eligible_rows(model, rows_for(historical, ids), range)
86
+ range.current_end? ? rows + rows_for(live, ids) : rows
87
+ end
88
+
89
+ #: (untyped, untyped) -> bool
90
+ def loaded?(model, id)
91
+ @records.loaded?(model, id)
92
+ end
93
+
94
+ # PT-AT records HABTM membership once per transaction, under the
95
+ # association's name, so the rows are keyed by transaction id.
96
+ #: (untyped, Array[untyped], untyped) -> Array[untyped]
97
+ def habtm_rows(model, selected_versions, reflection)
98
+ rows = @rows[reflection] ||= load_habtm_rows(model, reflection)
99
+ transaction_ids(selected_versions).flat_map { |id| rows.fetch(id.to_s, []) }
46
100
  end
47
101
 
48
102
  private
@@ -51,22 +105,29 @@ module PaperTrailDiff
51
105
  # @rbs @range: ActivityRange
52
106
  # @rbs @roots: Hash[Array[String], Array[untyped]]
53
107
  # @rbs @nodes: Hash[String, Array[untyped]]
54
- # @rbs @children: Hash[Array[String], untyped]
55
- # @rbs @rows: Hash[Array[String], Hash[String, Array[untyped]]]
108
+ # @rbs @children: Hash[untyped, untyped]
109
+ # @rbs @targets: Hash[untyped, untyped]
110
+ # @rbs @rows: Hash[untyped, Hash[String, Array[untyped]]]
111
+
112
+ #: (untyped, Array[untyped]) -> bool
113
+ def member_class?(model, classes)
114
+ classes.any? { |model_class| model_class <= model }
115
+ end
56
116
 
57
117
  #: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
58
118
  def historical_ids(model, ids, historical, range)
59
119
  empty = [] #: Array[untyped]
60
120
  ids.flat_map do |id|
61
121
  historical.fetch(id.to_s, empty).filter_map do |row|
62
- row[1] if eligible_child?(model, row, range)
122
+ _owner, child_id, timestamp, event = row
123
+ child_id if eligible_version?(model, child_id, timestamp, event, range)
63
124
  end
64
125
  end
65
126
  end
66
127
 
67
128
  #: (untyped) -> Array[untyped]
68
129
  def node_ids(model)
69
- name = model.base_class.name.to_s
130
+ name = model.base_class.name
70
131
  @nodes.fetch(name) do
71
132
  raise ConfigurationError,
72
133
  "prepared activity graph is missing #{name}; " \
@@ -84,12 +145,31 @@ module PaperTrailDiff
84
145
  .transform_values { |rows| rows.map(&:last) }]
85
146
  end
86
147
 
148
+ #: (Hash[String, Array[ActivityTargetCandidateLoader::Row]], Array[untyped]) -> Array[ActivityTargetCandidateLoader::Row]
149
+ def rows_for(grouped, ids)
150
+ ids.flat_map { |id| grouped[id.to_s] || [] }
151
+ end
152
+
153
+ #: (untyped, Array[ActivityTargetCandidateLoader::Row], ActivityRange) -> Array[ActivityTargetCandidateLoader::Row]
154
+ def eligible_rows(model, rows, range)
155
+ rows.select do |row|
156
+ eligible_version?(model, row.owner_id, row.created_at, row.event, range) &&
157
+ range.recorded_before_end?(row.created_at, row.version_id)
158
+ end
159
+ end
160
+
161
+ #: (untyped, untyped) -> untyped
162
+ def target_candidates(model, reflection)
163
+ loader = ActivityTargetCandidateLoader.new(model, node_ids(model), reflection, @range)
164
+ [loader.historical_rows.group_by { |row| row.owner_id.to_s },
165
+ loader.current_rows.group_by { |row| row.owner_id.to_s }]
166
+ end
167
+
87
168
  # Mirrors VersionAssociationCandidateScope's before-window rule. The loaded
88
169
  # series includes changes to other owners too, so an old membership cannot
89
170
  # survive a later pre-window reassignment just because that owner is absent.
90
- #: (untyped, Array[untyped], ActivityRange) -> bool
91
- def eligible_child?(model, row, range)
92
- _owner, id, timestamp, event = row
171
+ #: (untyped, untyped, untyped, String?, ActivityRange) -> bool
172
+ def eligible_version?(model, id, timestamp, event, range)
93
173
  return timestamp <= range.end_time if timestamp >= range.start_time
94
174
  return false if event == 'destroy'
95
175
 
@@ -98,24 +178,18 @@ module PaperTrailDiff
98
178
  !later || later.created_at >= range.start_time
99
179
  end
100
180
 
101
- #: (untyped, untyped, bool) -> Hash[String, Array[untyped]]
102
- def load_association_rows(model, reflection, transactions)
103
- all_versions = versions(model, node_ids(model))
104
- foreign_key = transactions ? reflection.name.to_s : reflection.foreign_key.to_s
181
+ #: (untyped, untyped) -> Hash[String, Array[untyped]]
182
+ def load_habtm_rows(model, reflection)
105
183
  model.paper_trail.version_association_class.where(
106
- version_id: version_ids(all_versions, transactions), foreign_key_name: foreign_key
107
- ).to_a.group_by { |row| row.version_id.to_s }
108
- end
109
-
110
- #: (Array[untyped], bool) -> Array[untyped]
111
- def version_ids(versions, transactions)
112
- if transactions
113
- versions.filter_map do |version|
114
- version.transaction_id if version.respond_to?(:transaction_id)
115
- end.uniq
116
- else
117
- versions.map(&:id)
118
- end
184
+ version_id: transaction_ids(versions(model, node_ids(model))),
185
+ foreign_key_name: reflection.name
186
+ ).group_by { |row| row.version_id.to_s }
187
+ end
188
+
189
+ # A version without a transaction id names no rows, so it can stay in.
190
+ #: (Array[untyped]) -> Array[untyped]
191
+ def transaction_ids(versions)
192
+ versions.map { |version| version.transaction_id if version.respond_to?(:transaction_id) }.uniq
119
193
  end
120
194
  end
121
195
  end