paper_trail_diff 0.12.0 → 0.13.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +127 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1388
  5. data/docs/architecture.md +88 -0
  6. data/docs/performance.md +309 -0
  7. data/docs/reference.md +977 -0
  8. data/docs/testing.md +146 -0
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
  10. data/lib/paper_trail_diff/activity_boundary.rb +20 -8
  11. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
  12. data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
  13. data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
  14. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
  15. data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
  16. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
  17. data/lib/paper_trail_diff/activity_grouping.rb +12 -0
  18. data/lib/paper_trail_diff/activity_history.rb +9 -3
  19. data/lib/paper_trail_diff/activity_relationship.rb +2 -0
  20. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
  21. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
  22. data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
  23. data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
  24. data/lib/paper_trail_diff/analysis.rb +9 -4
  25. data/lib/paper_trail_diff/analysis_batch.rb +28 -7
  26. data/lib/paper_trail_diff/association_traversal.rb +23 -0
  27. data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
  28. data/lib/paper_trail_diff/batch_safety.rb +55 -0
  29. data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
  30. data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
  33. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  34. data/lib/paper_trail_diff/endpoint.rb +10 -0
  35. data/lib/paper_trail_diff/engine.rb +4 -2
  36. data/lib/paper_trail_diff/errors.rb +9 -0
  37. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  38. data/lib/paper_trail_diff/historical_filter.rb +181 -0
  39. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  40. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  41. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  42. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  43. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  44. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  45. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  46. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  47. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  48. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  49. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  50. data/lib/paper_trail_diff/root_version_plan.rb +25 -2
  51. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  52. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  53. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  54. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  55. data/lib/paper_trail_diff/snapshot.rb +9 -0
  56. data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
  57. data/lib/paper_trail_diff/step.rb +10 -5
  58. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  59. data/lib/paper_trail_diff/support.rb +13 -3
  60. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  61. data/lib/paper_trail_diff/time_range.rb +22 -2
  62. data/lib/paper_trail_diff/time_version_range.rb +1 -15
  63. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  64. data/lib/paper_trail_diff/timeline_range.rb +25 -43
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  66. data/lib/paper_trail_diff/traversal.rb +1 -1
  67. data/lib/paper_trail_diff/version.rb +1 -1
  68. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  70. data/lib/paper_trail_diff/version_query.rb +33 -0
  71. data/lib/paper_trail_diff/version_range.rb +10 -38
  72. data/lib/paper_trail_diff.rb +78 -42
  73. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  74. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  75. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  76. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  77. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  78. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  79. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  80. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  81. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  82. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  83. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  84. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  85. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  86. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  87. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  88. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  89. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  90. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  91. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  92. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  93. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  94. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  95. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  96. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  97. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  98. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  99. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  100. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  101. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  102. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  103. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  104. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  105. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  106. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  107. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  108. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  109. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  110. data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  112. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  113. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  114. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  115. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  116. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
  117. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  118. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  119. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  120. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  121. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  122. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  123. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  124. data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
  125. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  126. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  127. data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
  128. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  129. data/sig/generated/paper_trail_diff.rbs +31 -25
  130. metadata +31 -5
@@ -27,10 +27,16 @@ module PaperTrailDiff
27
27
  # one of them, so absence is its own value rather than nil.
28
28
  class NestedComparator
29
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
30
+ class Absent
31
+ #: () -> String
32
+ def inspect = '#<PaperTrailDiff absent>'
33
+
34
+ #: () -> String
35
+ def to_s = 'absent'
36
+ end
37
+ private_constant :Absent
38
+
39
+ ABSENT = Absent.new.freeze
34
40
 
35
41
  #: (untyped, untyped) -> nested_changes
36
42
  def self.call(from_value, to_value)
@@ -49,9 +55,9 @@ module PaperTrailDiff
49
55
  #: () -> nested_changes
50
56
  def call
51
57
  from_structure, to_structure = structures
52
- return {} unless from_structure && to_structure
53
-
54
58
  changes = {} #: nested_changes
59
+ return changes.freeze unless from_structure && to_structure
60
+
55
61
  walk(from_structure, to_structure, [], changes)
56
62
  changes.freeze
57
63
  end
@@ -76,20 +82,23 @@ module PaperTrailDiff
76
82
 
77
83
  parsed = begin
78
84
  JSON.parse(value)
79
- rescue JSON::ParserError, TypeError
85
+ rescue JSON::ParserError
80
86
  nil
81
87
  end
82
- parsed if parsed.is_a?(Hash)
88
+ # JSON parses objects into plain Hashes, never a subclass.
89
+ parsed if parsed.instance_of?(Hash)
83
90
  end
84
91
 
85
92
  #: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], nested_changes) -> void
86
93
  def walk(from_hash, to_hash, path, changes)
94
+ from_hash = normalize_keys(from_hash)
95
+ to_hash = normalize_keys(to_hash)
87
96
  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
97
+ from_item = from_hash.fetch(key, ABSENT)
98
+ to_item = to_hash.fetch(key, ABSENT)
90
99
  next if from_item == to_item
91
100
 
92
- here = [*path, key.to_s].freeze
101
+ here = (path + [key]).freeze
93
102
  if from_item.is_a?(Hash) && to_item.is_a?(Hash)
94
103
  walk(from_item, to_item, here, changes)
95
104
  else
@@ -98,6 +107,20 @@ module PaperTrailDiff
98
107
  end
99
108
  end
100
109
 
110
+ #: (Hash[untyped, untyped]) -> Hash[String, untyped]
111
+ def normalize_keys(hash)
112
+ normalized = {} #: Hash[String, untyped]
113
+ hash.each do |key, value|
114
+ name = key.to_s
115
+ if normalized.key?(name)
116
+ raise AmbiguousNestedKeyError, "duplicate nested key after normalization: #{name.inspect}"
117
+ end
118
+
119
+ normalized[name] = value
120
+ end
121
+ normalized
122
+ end
123
+
101
124
  # Two arrays are a membership change; anything else is a change to the
102
125
  # value as a whole, including an array facing something that is not one.
103
126
  #: (untyped, untyped) -> nested_change
@@ -109,11 +132,12 @@ module PaperTrailDiff
109
132
  end
110
133
  end
111
134
 
112
- # Sorted so a report reads the same twice, and stringified because a hash
113
- # loaded from JSON and one built in Ruby can key the same field differently.
114
- #: (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
135
+ # Sorted so a report reads the same twice. Both hashes are already keyed by
136
+ # strings, because a hash loaded from JSON and one built in Ruby can key the
137
+ # same field differently.
138
+ #: (Hash[String, untyped], Hash[String, untyped]) -> Array[String]
115
139
  def keys(from_hash, to_hash)
116
- (from_hash.keys | to_hash.keys).sort_by(&:to_s)
140
+ (from_hash.keys | to_hash.keys).sort
117
141
  end
118
142
  end
119
143
  end
@@ -10,11 +10,12 @@ module PaperTrailDiff
10
10
  # The only thing a wall-clock window can close on besides a later version.
11
11
  CLOSE_ON_CURRENT = :current
12
12
 
13
- #: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
14
- def initialize(associations:, ignore:, reload_live_endpoints: true)
13
+ #: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool, ?allow_expensive: bool) -> void
14
+ def initialize(associations:, ignore:, reload_live_endpoints: true, allow_expensive: false)
15
15
  @association_tree = AssociationTree.build(associations)
16
16
  @ignore_policy = IgnorePolicy.build(ignore, association_paths: @association_tree.paths)
17
17
  @traversal = AssociationTraversal.new(@association_tree)
18
+ @batch_safety = BatchSafety.new(@traversal, allow_expensive: allow_expensive)
18
19
  @traversal_preparer = TraversalPreparer.new(tree: @association_tree, traversal: @traversal)
19
20
  @live_endpoints = LiveEndpointProvider.new(
20
21
  tree: @association_tree, traversal: @traversal, reload: reload_live_endpoints
@@ -34,7 +35,14 @@ module PaperTrailDiff
34
35
  Instrumentation.instrument('compare', payload) do
35
36
  Endpoint.validate_pair!(from_endpoint, to_endpoint)
36
37
  notify_ambiguous_association_boundary(from_endpoint, to_endpoint)
37
- Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
38
+ if @association_tree.empty?
39
+ Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
40
+ else
41
+ # Prepared history orders versions that share a timestamp; reading
42
+ # associations straight from PT-AT would resolve them by time alone.
43
+ comparison_batch([{ from: from_endpoint, to: to_endpoint }],
44
+ preparer: @traversal_preparer.method(:call)).call.values.fetch(0)
45
+ end
38
46
  end
39
47
  end
40
48
 
@@ -44,19 +52,14 @@ module PaperTrailDiff
44
52
  count = comparisons.is_a?(Array) ? comparisons.length : 0
45
53
  payload = @instrumentation_payload.merge(comparison_count: count)
46
54
  Instrumentation.instrument('compare_many', payload) do
47
- ComparisonBatch.new(
48
- comparisons,
49
- live_loader: @live_endpoints.method(:call), preparer: @traversal_preparer.method(:call),
50
- history_preparer: @historical_store.method(:prepare_batch),
51
- historical_snapshotter: method(:historical_snapshot),
52
- live_normalizer: method(:normalize_live_snapshot)
53
- ).call
55
+ comparison_batch(comparisons, preparer: method(:prepare_batch_model)).call
54
56
  end
55
57
  end
56
58
 
57
59
  #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
58
60
  def timeline(record, from:, to:, within:, version_scope: nil, close_on: nil) # rubocop:disable Metrics/ParameterLists
59
61
  @traversal_preparer.call(record.class, historical: true)
62
+ from, to = default_range(record, from, to, within)
60
63
  TimelineBuilder.new(
61
64
  record,
62
65
  from: from,
@@ -73,8 +76,8 @@ module PaperTrailDiff
73
76
  payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
74
77
  Instrumentation.instrument('activity_timeline', payload) do
75
78
  @traversal_preparer.call(record.class, historical: true)
79
+ from, to = default_range(record, from, to, within)
76
80
  live = live_endpoint_for(record, close_on, within)
77
- reject_live_habtm_activity!(record.class) if Endpoint.record?(to) || live
78
81
  steps = activity_builder(
79
82
  record, from: from, to: to, within: within, version_scope: version_scope,
80
83
  live_endpoint: live, snapshots: snapshots, group: group
@@ -86,7 +89,9 @@ module PaperTrailDiff
86
89
 
87
90
  #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
88
91
  def analyze(record, from:, to:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
92
+ ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
89
93
  @traversal_preparer.call(record.class, historical: true)
94
+ from, to = default_range(record, from, to, within)
90
95
  live = live_endpoint_for(record, close_on, within)
91
96
  if activity
92
97
  return analyze_activity(
@@ -102,19 +107,21 @@ module PaperTrailDiff
102
107
  end
103
108
 
104
109
  # Analyzes many roots over one shared range, preparing their history once.
105
- #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
106
- def analyze_many(records, within:, activity: false, version_scope: nil, close_on: nil)
110
+ #: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Hash[identity, Analysis]
111
+ def analyze_many(records, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
112
+ ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
107
113
  count = records.is_a?(Array) ? records.length : 0
108
114
  payload = @instrumentation_payload.merge(comparison_count: count)
109
115
  Instrumentation.instrument('analyze_many', payload) do
110
116
  AnalysisBatch.new(
111
117
  records,
112
- time_range: within.nil? ? nil : TimeRange.new(within),
113
- version_scope: version_scope,
114
- close_on_current: close_on_current?(close_on, within),
115
- live_loader: @live_endpoints.method(:call),
118
+ time_range: within.nil? ? nil : TimeRange.new(within), version_scope: version_scope,
119
+ close_on_current: close_on_current?(close_on, within) || within.nil?,
120
+ context_required: activity && !@association_tree.empty?,
121
+ live_loader: method(:load_batch_endpoints),
116
122
  history_preparer: @historical_store.method(:prepare_batch),
117
- analyzer: batched_root_analyzer(activity)
123
+ analyzer: batched_root_analyzer(activity, within: within, snapshots: snapshots,
124
+ group: group)
118
125
  ).call
119
126
  end
120
127
  end
@@ -123,22 +130,65 @@ module PaperTrailDiff
123
130
  # caller reporting on a population does not have to rediscover which of its
124
131
  # members changed. The roots the relation could not reach come back named
125
132
  # 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
133
+ #: (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
+ 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
+ ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
136
+ @batch_safety.call(scope.all.model) if scope.respond_to?(:all) && scope.respond_to?(:where)
137
+ # @type var options: report_options
138
+ options = { within: within, activity: activity, version_scope: version_scope,
139
+ close_on: close_on, snapshots: snapshots, group: group }
140
+ ScopedAnalysisBuilder.new(
141
+ scope, limit: limit, within: within, historical_filter: historical_filter,
142
+ max_filter_versions: max_filter_versions, include_deleted: include_deleted,
143
+ analyzer: ->(records) { analyze_many(records, **options, within: within) },
144
+ deleted_analyzer: lambda { |identities|
145
+ analyze_deleted(scope.all.model, identities, options)
146
+ }
132
147
  ).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
148
  end
139
149
 
140
150
  private
141
151
 
152
+ #: (Array[comparison_input], preparer: untyped) -> ComparisonBatch
153
+ def comparison_batch(comparisons, preparer:)
154
+ ComparisonBatch.new(
155
+ comparisons,
156
+ live_loader: @live_endpoints, preparer: preparer,
157
+ history_preparer: @historical_store.method(:prepare_batch),
158
+ historical_snapshotter: method(:historical_snapshot),
159
+ live_normalizer: method(:normalize_live_snapshot)
160
+ )
161
+ end
162
+
163
+ #: (Array[untyped]) -> Hash[identity, untyped]
164
+ def load_batch_endpoints(records)
165
+ records.map(&:class).uniq.each { |model| @batch_safety.call(model) }
166
+ @live_endpoints.call(records)
167
+ end
168
+
169
+ #: (untyped, historical: bool) -> void
170
+ def prepare_batch_model(model, historical:)
171
+ @batch_safety.call(model, historical: historical)
172
+ @traversal_preparer.call(model, historical: historical)
173
+ end
174
+
175
+ # @rbs @batch_safety: BatchSafety
176
+
177
+ #: (untyped, Array[Array[String]], Hash[Symbol, untyped]) -> Hash[Array[String], Analysis?]
178
+ def analyze_deleted(model, identities, options)
179
+ within = options.fetch(:within)
180
+ analyzer = batched_root_analyzer(
181
+ options.fetch(:activity), within: within, snapshots: options.fetch(:snapshots),
182
+ group: options.fetch(:group)
183
+ )
184
+ DeletedAnalysisBatch.new(
185
+ model, identities: identities, time_range: within.nil? ? nil : TimeRange.new(within),
186
+ version_scope: options.fetch(:version_scope),
187
+ history_preparer: @historical_store.method(:prepare_deleted_batch),
188
+ analyzer: analyzer
189
+ ).call
190
+ end
191
+
142
192
  # @rbs @association_tree: AssociationTree
143
193
  # @rbs @live_endpoints: LiveEndpointProvider
144
194
  # @rbs @instrumentation_payload: Hash[Symbol, untyped]
@@ -153,7 +203,6 @@ module PaperTrailDiff
153
203
 
154
204
  #: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
155
205
  def analyze_activity(record, from:, to:, within:, version_scope:, live_endpoint:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
156
- reject_live_habtm_activity!(record.class) if live_endpoint
157
206
  activity_builder(
158
207
  record, from: from, to: to, within: within, version_scope: version_scope,
159
208
  live_endpoint: live_endpoint, snapshots: snapshots, group: group
@@ -181,25 +230,38 @@ module PaperTrailDiff
181
230
  )
182
231
  end
183
232
 
184
- # `close_on:` names what ends a wall-clock window, so it is meaningless for a
185
- # range whose endpoints the caller already gave explicitly.
233
+ # A window with nothing recorded after its last change ends at the live
234
+ # record, which holds the state that change produced -- whether the window
235
+ # ended a moment ago or long before. `close_on: :current` asked for this
236
+ # before it was the default and is still accepted. It names what ends a
237
+ # wall-clock window, so it is meaningless for explicit endpoints.
186
238
  #: (Symbol?, untyped) -> bool
187
239
  def close_on_current?(close_on, within)
188
- return false if close_on.nil?
189
- unless close_on == CLOSE_ON_CURRENT
240
+ unless close_on.nil? || close_on == CLOSE_ON_CURRENT
190
241
  raise ConfigurationError, "close_on: must be #{CLOSE_ON_CURRENT.inspect} or nil"
191
242
  end
192
- raise ConfigurationError, 'close_on: requires `within`' if within.nil?
243
+ raise ConfigurationError, 'close_on: requires `within`' if close_on && within.nil?
193
244
 
194
- true
245
+ !within.nil?
195
246
  end
196
247
 
197
- # A destroyed root has no current state to close on, and its own destroy
198
- # version already terminates the history.
248
+ # With neither a window nor endpoints, a range is the record's whole
249
+ # history through its current saved state; a missing endpoint takes that
250
+ # side of the same default.
251
+ #: (untyped, untyped, untyped, untyped) -> [untyped, untyped]
252
+ def default_range(record, from, to, within)
253
+ return [from, to] unless within.nil?
254
+
255
+ [from.nil? ? :first : from, to.nil? ? record : to]
256
+ end
257
+
258
+ # Only a saved row has current state to close on. A destroyed root's own
259
+ # destroy version already terminates its history, and a reified or unsaved
260
+ # instance stands for a state that is not in the table.
199
261
  #: (untyped, Symbol?, untyped) -> untyped
200
262
  def live_endpoint_for(record, close_on, within)
201
263
  return unless close_on_current?(close_on, within)
202
- return unless Endpoint.record?(record) && !record.destroyed?
264
+ return unless Endpoint.record?(record) && record.persisted?
203
265
 
204
266
  record
205
267
  end
@@ -213,14 +275,14 @@ module PaperTrailDiff
213
275
  @activity_snapshotter = build_activity_snapshotter
214
276
  end
215
277
 
216
- #: (bool) -> BatchedRootAnalyzer
217
- def batched_root_analyzer(activity)
278
+ #: (bool, within: untyped, snapshots: bool, group: Symbol?) -> BatchedRootAnalyzer
279
+ def batched_root_analyzer(activity, within:, snapshots:, group:)
218
280
  BatchedRootAnalyzer.new(
219
281
  tree: @association_tree,
220
282
  timeline_snapshotter: @timeline_snapshotter,
221
283
  activity_snapshotter: @activity_snapshotter,
222
284
  preparer: @traversal_preparer.method(:call),
223
- activity: activity
285
+ activity: activity, within: within, snapshots: snapshots, group: group
224
286
  )
225
287
  end
226
288
 
@@ -243,12 +305,13 @@ module PaperTrailDiff
243
305
  full_snapshotter: method(:snapshot_at),
244
306
  partial_snapshotter: @historical_store.method(:custom),
245
307
  association_reader: @historical_store.method(:association_reader),
246
- record_transition: @historical_store.method(:record_transition)
308
+ record_transition: @historical_store.method(:record_transition),
309
+ prepared_record_after: @historical_store.method(:record_after)
247
310
  )
248
311
  ActivitySnapshotProvider.new(
249
- snapshotter: method(:snapshot_at),
250
- refresher: refresher,
251
- preparer: @historical_store.method(:prepare)
312
+ snapshotter: method(:snapshot_at), refresher: refresher,
313
+ preparer: @historical_store.method(:prepare),
314
+ activity_index: @historical_store.method(:activity_index)
252
315
  )
253
316
  end
254
317
 
@@ -267,15 +330,6 @@ module PaperTrailDiff
267
330
  )
268
331
  end
269
332
 
270
- #: (untyped) -> void
271
- def reject_live_habtm_activity!(model_class)
272
- paths = @traversal.habtm_paths(model_class)
273
- return if paths.empty?
274
-
275
- message = "live-ended activity cannot reconstruct HABTM event boundaries: #{paths.join(', ')}"
276
- raise UnsupportedLiveActivityError, message
277
- end
278
-
279
333
  #: (untyped) -> RecordSnapshot?
280
334
  def snapshot_for_endpoint(endpoint)
281
335
  Endpoint.version?(endpoint) ? historical_snapshot(endpoint) : live_snapshot(endpoint)
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Shares association event discovery across every root in a prepared graph.
6
+ class PreparedActivityIndex
7
+ #: (PreparedRecordIndex, Array[untyped], start_at: untyped, end_at: untyped) -> void
8
+ def initialize(records, root_versions, start_at:, end_at:)
9
+ @records = records
10
+ @range = ActivityRange.new(start_at, end_at)
11
+ @roots = root_versions.group_by { |version| [version.item_type, version.item_id.to_s] }
12
+ @nodes = {} #: Hash[String, Array[untyped]]
13
+ @children = {} #: Hash[Array[String], untyped]
14
+ @rows = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
15
+ end
16
+
17
+ #: (untyped, Array[untyped]) -> void
18
+ def register(model, ids)
19
+ name = model.base_class.name.to_s
20
+ @nodes[name] = (@nodes.fetch(name, []) | ids)
21
+ end
22
+
23
+ #: (untyped, Array[untyped]) -> Array[untyped]
24
+ def versions(model, ids)
25
+ 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
+ end
29
+ end
30
+
31
+ #: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
32
+ 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)
35
+ empty = [] #: Array[untyped]
36
+ selected = historical_ids(reflection.klass, ids, historical, range)
37
+ selected |= ids.flat_map { |id| live.fetch(id.to_s, empty) } if range.current_end?
38
+ selected.uniq
39
+ end
40
+
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, []) }
46
+ end
47
+
48
+ private
49
+
50
+ # @rbs @records: PreparedRecordIndex
51
+ # @rbs @range: ActivityRange
52
+ # @rbs @roots: Hash[Array[String], Array[untyped]]
53
+ # @rbs @nodes: Hash[String, Array[untyped]]
54
+ # @rbs @children: Hash[Array[String], untyped]
55
+ # @rbs @rows: Hash[Array[String], Hash[String, Array[untyped]]]
56
+
57
+ #: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
58
+ def historical_ids(model, ids, historical, range)
59
+ empty = [] #: Array[untyped]
60
+ ids.flat_map do |id|
61
+ historical.fetch(id.to_s, empty).filter_map do |row|
62
+ row[1] if eligible_child?(model, row, range)
63
+ end
64
+ end
65
+ end
66
+
67
+ #: (untyped) -> Array[untyped]
68
+ def node_ids(model)
69
+ name = model.base_class.name.to_s
70
+ @nodes.fetch(name) do
71
+ raise ConfigurationError,
72
+ "prepared activity graph is missing #{name}; " \
73
+ 'register the model before collecting activity'
74
+ end
75
+ end
76
+
77
+ #: (untyped, untyped) -> untyped
78
+ def child_rows(model, reflection)
79
+ loader = ActivityChildCandidateLoader.new(
80
+ model, node_ids(model), reflection, @range
81
+ )
82
+ [loader.historical_rows.group_by { |row| row.first.to_s },
83
+ loader.current_pairs.group_by { |row| row.first.to_s }
84
+ .transform_values { |rows| rows.map(&:last) }]
85
+ end
86
+
87
+ # Mirrors VersionAssociationCandidateScope's before-window rule. The loaded
88
+ # series includes changes to other owners too, so an old membership cannot
89
+ # 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
93
+ return timestamp <= range.end_time if timestamp >= range.start_time
94
+ return false if event == 'destroy'
95
+
96
+ later = @records.versions_for_id(model, id)
97
+ .bsearch { |version| version.created_at > timestamp }
98
+ !later || later.created_at >= range.start_time
99
+ end
100
+
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
105
+ 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
119
+ end
120
+ end
121
+ end
@@ -50,11 +50,15 @@ module PaperTrailDiff
50
50
  def belongs_to_groups(owner_class, owner_ids, reflection)
51
51
  key = scalar_foreign_key(reflection)
52
52
  return {} unless key
53
+ unless reflection.polymorphic?
54
+ return group_for(reflection.klass,
55
+ @records.attribute_values_for(owner_class, owner_ids, key))
56
+ end
53
57
 
54
58
  grouped = {} #: Hash[String, Hash[Symbol, untyped]]
55
59
  @records.records_for(owner_class, owner_ids).each do |owner|
56
60
  id = owner.public_send(key)
57
- model_class = target_class(reflection, owner)
61
+ model_class = AssociationTraversal.belongs_to_target(reflection, owner)
58
62
  add_identity(grouped, model_class, id) if model_class && id
59
63
  end
60
64
  grouped
@@ -109,7 +113,12 @@ module PaperTrailDiff
109
113
  association_class.arel_table[:foreign_key_id],
110
114
  version_class.arel_table[:item_id]
111
115
  )
112
- group_ids_by_owner(pairs)
116
+ group_ids_by_owner(typed_child_ids(reflection.klass, pairs))
117
+ end
118
+
119
+ #: (untyped, Array[Array[untyped]]) -> Array[Array[untyped]]
120
+ def typed_child_ids(model_class, pairs)
121
+ pairs.map { |owner_id, child_id| [owner_id, Support.record_id(model_class, child_id)] }
113
122
  end
114
123
 
115
124
  #: (untyped, Array[untyped], untyped, String) -> untyped
@@ -139,15 +148,6 @@ module PaperTrailDiff
139
148
  group_ids_by_owner(relation.pluck(foreign_key, reflection.klass.primary_key))
140
149
  end
141
150
 
142
- #: (untyped, untyped) -> untyped
143
- def target_class(reflection, owner)
144
- return reflection.klass unless reflection.polymorphic?
145
-
146
- Object.const_get(owner.public_send(reflection.foreign_type).to_s)
147
- rescue NameError
148
- nil
149
- end
150
-
151
151
  #: (Hash[String, Hash[Symbol, untyped]], untyped, untyped) -> void
152
152
  def add_identity(groups, model_class, id)
153
153
  group = groups[model_class.name.to_s] ||= { model: model_class, ids: [] }
@@ -4,17 +4,21 @@
4
4
  module PaperTrailDiff
5
5
  # Batched record states and relationship candidates for one activity range.
6
6
  class PreparedHistory
7
+ attr_reader :activity_index #: PreparedActivityIndex?
8
+
7
9
  DIRECT_RESOLVERS = {
8
10
  belongs_to: :resolve_belongs_to,
9
11
  has_one: :resolve_children,
10
12
  has_many: :resolve_children,
11
- has_and_belongs_to_many: :resolve_habtm
13
+ has_and_belongs_to_many: :habtm_records
12
14
  }.freeze
13
15
  private_constant :DIRECT_RESOLVERS
14
16
 
15
- #: (PreparedRecordIndex) -> void
16
- def initialize(records)
17
+ #: (PreparedRecordIndex, ?activity_index: PreparedActivityIndex?) -> void
18
+ def initialize(records, activity_index: nil)
17
19
  @records = records
20
+ @state_reader = PreparedStateAssociationReader.new(records, method(:edge))
21
+ @activity_index = activity_index
18
22
  @edges = {} #: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
19
23
  @unsupported = {} #: Hash[Array[String], bool]
20
24
  @habtm = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
@@ -24,7 +28,7 @@ module PaperTrailDiff
24
28
  def merge_edge(owner_class, reflection, groups)
25
29
  key = edge_key(owner_class, reflection)
26
30
  empty = {} #: Hash[String, Hash[Symbol, untyped]]
27
- @edges[key] = merge_groups(@edges.fetch(key, empty), groups)
31
+ @edges[key] = Support.merge_record_groups(@edges.fetch(key, empty), groups)
28
32
  end
29
33
 
30
34
  #: (untyped, untyped) -> Hash[String, Hash[Symbol, untyped]]
@@ -48,6 +52,16 @@ module PaperTrailDiff
48
52
  @records.transition(model_class, id, version)
49
53
  end
50
54
 
55
+ #: (untyped, untyped, untyped, ?direct: bool) -> [bool, untyped]
56
+ def record_after(model_class, id, version, direct: false)
57
+ @records.record_after(model_class, id, version, direct: direct)
58
+ end
59
+
60
+ #: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
61
+ def snapshot_states(owner, reflection, boundary)
62
+ @state_reader.call(owner, reflection, boundary)
63
+ end
64
+
51
65
  #: (untyped, untyped, untyped, habtm_boundary: untyped) -> [bool, Array[untyped]]
52
66
  def resolve(record, reflection, boundary, habtm_boundary:)
53
67
  return [false, []] unless supported?(record.class, reflection)
@@ -63,6 +77,7 @@ module PaperTrailDiff
63
77
  private
64
78
 
65
79
  # @rbs @records: PreparedRecordIndex
80
+ # @rbs @state_reader: PreparedStateAssociationReader
66
81
  # @rbs @edges: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
67
82
  # @rbs @unsupported: Hash[Array[String], bool]
68
83
  # @rbs @habtm: Hash[Array[String], Hash[String, Array[untyped]]]
@@ -70,12 +85,7 @@ module PaperTrailDiff
70
85
  #: (untyped, untyped) -> bool
71
86
  def supported?(owner_class, reflection)
72
87
  !@unsupported[edge_key(owner_class, reflection)] &&
73
- reflection.scope.nil? && !default_scoped?(reflection.klass)
74
- end
75
-
76
- #: (untyped) -> bool
77
- def default_scoped?(model_class)
78
- model_class.default_scopes?
88
+ reflection.scope.nil? && !reflection.klass.default_scopes?
79
89
  end
80
90
 
81
91
  #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
@@ -94,11 +104,6 @@ module PaperTrailDiff
94
104
  child_records(record, reflection, boundary)
95
105
  end
96
106
 
97
- #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
98
- def resolve_habtm(record, reflection, boundary, habtm_boundary)
99
- habtm_records(record, reflection, boundary, habtm_boundary)
100
- end
101
-
102
107
  #: (untyped, untyped, untyped) -> Array[untyped]?
103
108
  def belongs_to_records(record, reflection, boundary)
104
109
  foreign_key = scalar_foreign_key(reflection)
@@ -107,7 +112,7 @@ module PaperTrailDiff
107
112
  id = record.public_send(foreign_key)
108
113
  return [] if id.nil?
109
114
 
110
- model_class = belongs_to_class(record, reflection)
115
+ model_class = AssociationTraversal.belongs_to_target(reflection, record)
111
116
  return unless model_class && @records.loaded?(model_class, id)
112
117
 
113
118
  Array(@records.record_before(model_class, id, boundary)).compact
@@ -189,15 +194,6 @@ module PaperTrailDiff
189
194
  key.to_s unless key.is_a?(Array)
190
195
  end
191
196
 
192
- #: (untyped, untyped) -> untyped
193
- def belongs_to_class(record, reflection)
194
- return reflection.klass unless reflection.polymorphic?
195
-
196
- Object.const_get(record.public_send(reflection.foreign_type).to_s)
197
- rescue NameError
198
- nil
199
- end
200
-
201
197
  #: (untyped, untyped) -> Array[String]
202
198
  def owner_types(reflection, owner)
203
199
  types = [owner.class.name, owner.class.base_class.name, reflection.active_record.name]
@@ -218,10 +214,5 @@ module PaperTrailDiff
218
214
  def edge_key(owner_class, reflection)
219
215
  [owner_class.base_class.name.to_s, reflection.name.to_s]
220
216
  end
221
-
222
- #: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
223
- def merge_groups(existing, incoming)
224
- Support.merge_record_groups(existing, incoming)
225
- end
226
217
  end
227
218
  end