paper_trail_diff 0.12.0 → 0.13.1

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +158 -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 +169 -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 +10 -38
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/collection_comparator.rb +18 -20
  33. data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
  34. data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
  35. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  36. data/lib/paper_trail_diff/endpoint.rb +10 -0
  37. data/lib/paper_trail_diff/engine.rb +4 -2
  38. data/lib/paper_trail_diff/errors.rb +9 -0
  39. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  40. data/lib/paper_trail_diff/historical_filter.rb +176 -0
  41. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  42. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  43. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  44. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  45. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  46. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  47. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  48. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  49. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  50. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  51. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  52. data/lib/paper_trail_diff/root_version_plan.rb +35 -3
  53. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  54. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  55. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  56. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  57. data/lib/paper_trail_diff/snapshot.rb +14 -1
  58. data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
  59. data/lib/paper_trail_diff/step.rb +10 -5
  60. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  61. data/lib/paper_trail_diff/support.rb +13 -3
  62. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  63. data/lib/paper_trail_diff/time_range.rb +21 -2
  64. data/lib/paper_trail_diff/time_version_range.rb +2 -16
  65. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  66. data/lib/paper_trail_diff/timeline_range.rb +29 -46
  67. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  68. data/lib/paper_trail_diff/traversal.rb +1 -1
  69. data/lib/paper_trail_diff/version.rb +1 -1
  70. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  71. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  72. data/lib/paper_trail_diff/version_query.rb +41 -0
  73. data/lib/paper_trail_diff/version_range.rb +10 -38
  74. data/lib/paper_trail_diff.rb +78 -42
  75. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  76. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  77. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  78. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  79. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  80. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  81. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  82. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  83. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  84. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  85. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  86. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  87. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  88. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  90. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  91. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  92. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  93. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  94. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  95. data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
  96. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
  97. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  98. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  99. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  100. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  102. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  105. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  106. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  107. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  108. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  110. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  111. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  112. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  113. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  114. data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
  115. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  116. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  117. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  118. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  119. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  120. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
  121. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  122. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  123. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  124. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  125. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  126. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  127. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  128. data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
  129. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  130. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  131. data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
  132. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  133. data/sig/generated/paper_trail_diff.rbs +31 -25
  134. metadata +31 -5
@@ -8,6 +8,7 @@ module PaperTrailDiff
8
8
 
9
9
  #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
10
10
  def initialize(record, range:, tree:, snapshotter:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists
11
+ ActivityGrouping.validate!(group)
11
12
  @snapshots = snapshots
12
13
  @group = group
13
14
  # Merging a group compares its outer states, so the snapshots must survive
@@ -36,10 +37,7 @@ module PaperTrailDiff
36
37
  def analyze
37
38
  return time_builder.analyze if @range.time?
38
39
  return Analysis.empty if @range.unresolved?
39
-
40
- unless Endpoint.version?(@to)
41
- raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
42
- end
40
+ return analyze_current if Endpoint.record?(@to)
43
41
 
44
42
  plan = @range.select_plan
45
43
  root_versions = plan.reconstruction_versions
@@ -73,27 +71,48 @@ module PaperTrailDiff
73
71
  root_versions = @range.select_plan.reconstruction_versions
74
72
  prepare_history(root_versions)
75
73
  events = collect_events(root_versions)
76
- build_event_steps(root_versions, events)
74
+ history = event_history(root_versions, events)
75
+ activity_steps(history, events, destroyed_boundary(root_versions), nil)
77
76
  end
78
77
 
79
78
  #: () -> Array[ActivityStep]
80
79
  def build_to_current
81
- validate_current_range!
82
- current_snapshot, captured_at = capture_current
83
- root_versions = VersionRange.new(@record, from: @from, to: @from).select_through_latest
84
- # Descendants can move after the last root version, so the prepared range
85
- # ends at the captured instant rather than at that version.
86
- prepare_history(root_versions, end_at: captured_at)
87
- events = collect_events(root_versions, range_end: captured_at)
88
- build_event_steps(
89
- root_versions,
90
- events,
91
- current: @to,
92
- final_boundary: ActivityBoundary.current(@to, captured_at: captured_at),
93
- final_snapshot: current_snapshot
80
+ _plan, events, history, snapshot, boundary = current_history
81
+ activity_steps(history, events, boundary, snapshot)
82
+ end
83
+
84
+ #: () -> Analysis
85
+ def analyze_current
86
+ plan, events, history, snapshot, boundary = current_history
87
+ final = boundary ? snapshot : history.last_snapshot
88
+ Analysis.new(
89
+ diff: Engine.compare(history.first_snapshot, final),
90
+ timeline: ActivityRootSteps.call(
91
+ plan, history.root_snapshots, closing_snapshot: snapshot,
92
+ captured_at: boundary&.recorded_at
93
+ ),
94
+ activity_timeline: activity_steps(history, events, boundary, snapshot),
95
+ from_snapshot: history.first_snapshot, to_snapshot: final
94
96
  )
95
97
  end
96
98
 
99
+ # The explicit live endpoint and a batch's live closing endpoint use the
100
+ # same selected plan. A version filter can close earlier, on its successor.
101
+ #: () -> [RootVersionPlan, Array[ActivityEvent], ActivityHistory, RecordSnapshot?, ActivityBoundary?]
102
+ def current_history
103
+ validate_current_range!
104
+ plan = @range.select_plan
105
+ return [plan, [], ActivityHistory.empty, nil, nil] if plan.empty?
106
+
107
+ snapshot, captured_at = capture_current if plan.closing_record
108
+ versions = plan.reconstruction_versions
109
+ prepare_history(versions, end_at: captured_at)
110
+ events = collect_events(versions, range_end: captured_at || versions.last)
111
+ history = event_history(versions, events, current: plan.closing_record)
112
+ boundary = ActivityBoundary.current(@to, captured_at: captured_at) if plan.closing_record
113
+ [plan, events, history, snapshot, boundary]
114
+ end
115
+
97
116
  #: () -> [RecordSnapshot?, untyped]
98
117
  def capture_current
99
118
  [@snapshotter.call(@to, @to), Time.now.utc]
@@ -107,6 +126,7 @@ module PaperTrailDiff
107
126
 
108
127
  Endpoint.validate_pair!(@from, @to)
109
128
  Endpoint.validate_pair!(@record, @to)
129
+ AssociationTraversal.new(@tree).reject_live_habtm_activity!(@record.class)
110
130
  end
111
131
 
112
132
  #: (Array[untyped], ?range_start: untyped, ?range_end: untyped) -> Array[ActivityEvent]
@@ -120,6 +140,7 @@ module PaperTrailDiff
120
140
  root_versions: root_versions,
121
141
  tree: @tree,
122
142
  traversal: AssociationTraversal.new(@tree),
143
+ prepared: (@snapshotter.activity_index if @snapshotter.respond_to?(:activity_index)),
123
144
  range_start: range_start,
124
145
  range_end: range_end
125
146
  ).call
@@ -133,23 +154,6 @@ module PaperTrailDiff
133
154
  @snapshotter.prepare(@record, root_versions, end_at: end_at)
134
155
  end
135
156
 
136
- #: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
137
- def build_event_steps(
138
- root_versions,
139
- events,
140
- current: nil,
141
- final_boundary: nil,
142
- final_snapshot: nil
143
- )
144
- history = event_history(root_versions, events, current: current)
145
- activity_steps(
146
- history,
147
- events,
148
- final_boundary || destroyed_boundary(root_versions),
149
- final_snapshot
150
- )
151
- end
152
-
153
157
  # Appends the transition into an explicit closing boundary, which is either
154
158
  # a requested current record or the absence a final root destroy leaves.
155
159
  #: (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
@@ -4,15 +4,17 @@
4
4
  module PaperTrailDiff
5
5
  # Collects root and selected-descendant versions relevant to one root history range.
6
6
  class ActivityVersionCollector
7
- #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_start: untyped, ?range_end: untyped) -> void
7
+ #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_start: untyped, ?range_end: untyped, ?prepared: PreparedActivityIndex?) -> void
8
8
  def initialize( # rubocop:disable Metrics/ParameterLists
9
9
  record,
10
10
  root_versions:,
11
11
  tree:,
12
12
  traversal:,
13
13
  range_start: root_versions.first,
14
- range_end: root_versions.last
14
+ range_end: root_versions.last,
15
+ prepared: nil
15
16
  )
17
+ @prepared = prepared
16
18
  @record = record
17
19
  @root_versions = root_versions
18
20
  @tree = tree
@@ -32,6 +34,7 @@ module PaperTrailDiff
32
34
 
33
35
  private
34
36
 
37
+ # @rbs @prepared: PreparedActivityIndex?
35
38
  # @rbs @record: untyped
36
39
  # @rbs @root_versions: Array[untyped]
37
40
  # @rbs @tree: AssociationTree
@@ -59,7 +62,7 @@ module PaperTrailDiff
59
62
  @registry.add(group.fetch(:versions), branch: branch)
60
63
  next if subtree.empty?
61
64
 
62
- child_path = join_path(path, reflection.name.to_s)
65
+ child_path = Support.association_path(path, reflection.name.to_s)
63
66
  collect_tree(group.fetch(:model), group.fetch(:ids), subtree,
64
67
  path: child_path, branch: branch)
65
68
  end
@@ -97,6 +100,10 @@ module PaperTrailDiff
97
100
 
98
101
  #: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
99
102
  def belongs_to_groups(parent_class, parent_versions, reflection)
103
+ if (prepared = @prepared)
104
+ rows = prepared.association_rows(parent_class, parent_versions, reflection)
105
+ return identity_groups(rows, reflection)
106
+ end
100
107
  rows = parent_class.paper_trail.version_association_class.where(
101
108
  version_id: parent_versions.map(&:id),
102
109
  foreign_key_name: reflection.foreign_key.to_s
@@ -106,9 +113,12 @@ module PaperTrailDiff
106
113
 
107
114
  #: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
108
115
  def direct_child_groups(parent_class, parent_ids, reflection)
109
- item_ids = ActivityChildCandidateLoader.new(
110
- parent_class, parent_ids, reflection, @range
111
- ).call
116
+ item_ids = if (prepared = @prepared)
117
+ prepared.child_ids(parent_class, parent_ids, reflection, @range)
118
+ else
119
+ ActivityChildCandidateLoader.new(parent_class, parent_ids, reflection,
120
+ @range).call
121
+ end
112
122
  return {} if item_ids.empty?
113
123
 
114
124
  { reflection.klass.name => group(reflection.klass, item_ids) }
@@ -116,6 +126,11 @@ module PaperTrailDiff
116
126
 
117
127
  #: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
118
128
  def habtm_groups(parent_class, parent_versions, reflection)
129
+ if (prepared = @prepared)
130
+ rows = prepared.association_rows(parent_class, parent_versions, reflection,
131
+ transactions: true)
132
+ return identity_groups(rows, reflection)
133
+ end
119
134
  transaction_ids = parent_versions.filter_map do |version|
120
135
  version.transaction_id if version.respond_to?(:transaction_id)
121
136
  end
@@ -133,7 +148,7 @@ module PaperTrailDiff
133
148
  identities.each do |model_class, class_rows|
134
149
  next unless model_class
135
150
 
136
- ids = class_rows.map(&:foreign_key_id).compact.uniq
151
+ ids = class_rows.filter_map(&:foreign_key_id).uniq
137
152
  groups[model_class.name] = group(model_class, ids)
138
153
  end
139
154
  groups
@@ -162,13 +177,17 @@ module PaperTrailDiff
162
177
  def versions_for(model_class, ids)
163
178
  return [] if ids.empty?
164
179
 
180
+ if (prepared = @prepared)
181
+ return prepared.versions(model_class, ids).select { |version| @range.include?(version) }
182
+ end
183
+
165
184
  version_class = model_class.paper_trail.version_class
166
185
  @range.scope(
167
186
  version_class.where(
168
187
  item_type: model_class.base_class.name,
169
188
  item_id: ids
170
189
  )
171
- ).to_a.select { |version| in_range?(version) }
190
+ ).to_a.select { |version| @range.include?(version) }
172
191
  end
173
192
 
174
193
  #: (untyped, untyped) -> untyped
@@ -179,15 +198,5 @@ module PaperTrailDiff
179
198
  rescue NameError
180
199
  nil
181
200
  end
182
-
183
- #: (untyped) -> bool
184
- def in_range?(version)
185
- @range.include?(version)
186
- end
187
-
188
- #: (String, String) -> String
189
- def join_path(parent, name)
190
- parent.empty? ? name : "#{parent}.#{name}"
191
- end
192
201
  end
193
202
  end
@@ -34,10 +34,15 @@ module PaperTrailDiff
34
34
  # Reconstructed endpoint states are opt-in, because they carry the whole
35
35
  # selected graph whether or not anything changed, which dwarfs the rest of
36
36
  # the payload for a wide graph.
37
- #: (?snapshots: bool) -> Hash[Symbol, untyped]
38
- def to_h(snapshots: false)
39
- value = { diff: diff.to_h, timeline: Support.serialize(timeline) }
40
- value[:activity_timeline] = Support.serialize(activity_timeline) if activity_timeline
37
+ #: (?snapshots: bool, ?metadata: bool) -> Hash[Symbol, untyped]
38
+ def to_h(snapshots: false, metadata: false)
39
+ value = { diff: diff.to_h, timeline: timeline.map { |step| step.to_h(metadata: metadata) } }
40
+ activity = activity_timeline
41
+ if activity
42
+ value[:activity_timeline] = activity.map do |step|
43
+ step.to_h(metadata: metadata, snapshots: snapshots)
44
+ end
45
+ end
41
46
  return value unless snapshots
42
47
 
43
48
  value.merge(
@@ -6,12 +6,13 @@ module PaperTrailDiff
6
6
  # versions and preparing their association history once for the whole batch
7
7
  # rather than once per root.
8
8
  class AnalysisBatch
9
- #: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool) -> void
9
+ #: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool, ?context_required: bool) -> void
10
10
  def initialize( # rubocop:disable Metrics/ParameterLists
11
11
  records, time_range:, live_loader:, history_preparer:, analyzer:, version_scope: nil,
12
- close_on_current: false
12
+ close_on_current: false, context_required: false
13
13
  )
14
14
  @records = records
15
+ @context_required = context_required
15
16
  @time_range = time_range
16
17
  @version_scope = validated_scope(version_scope)
17
18
  @close_on_current = close_on_current
@@ -26,7 +27,8 @@ module PaperTrailDiff
26
27
  loaded = @live_loader.call(records)
27
28
  selected = BatchedRootVersions.new(
28
29
  records, time_range: @time_range, version_scope: @version_scope,
29
- live_endpoints: (loaded if @close_on_current)
30
+ live_endpoints: (loaded if @close_on_current),
31
+ context_required: @context_required
30
32
  ).call
31
33
  prepare(records, selected, loaded)
32
34
  records.to_h do |record|
@@ -42,6 +44,7 @@ module PaperTrailDiff
42
44
  # @rbs @time_range: TimeRange?
43
45
  # @rbs @version_scope: untyped
44
46
  # @rbs @close_on_current: bool
47
+ # @rbs @context_required: bool
45
48
  # @rbs @live_loader: untyped
46
49
  # @rbs @history_preparer: untyped
47
50
  # @rbs @analyzer: untyped
@@ -75,20 +78,38 @@ module PaperTrailDiff
75
78
  def prepare(records, selected, loaded)
76
79
  records.group_by(&:class).each_value do |grouped|
77
80
  versions = grouped.flat_map do |record|
78
- selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).versions
81
+ selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).reconstruction_versions
79
82
  end
80
83
  next if versions.empty?
81
84
 
82
85
  roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
83
- @history_preparer.call(roots, versions)
86
+ live = closes_live?(grouped, selected)
87
+ @history_preparer.call(roots, versions, **preparation_range(versions, live: live))
84
88
  end
85
89
  end
86
90
 
91
+ #: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> bool
92
+ def closes_live?(records, selected)
93
+ records.any? do |record|
94
+ !selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).closing_record.nil?
95
+ end
96
+ end
97
+
98
+ # A plan closing on the live record reads history up to now; the others
99
+ # stop at the window, so a past window does not load everything since.
100
+ #: (Array[untyped], ?live: bool) -> Hash[Symbol, untyped]
101
+ def preparation_range(versions, live: @close_on_current)
102
+ last = versions.map(&:created_at).max
103
+ end_at = [last, @time_range&.end_time, (Time.now.utc if live)].compact.max
104
+ { start_at: @time_range&.begin_time || versions.map(&:created_at).min, end_at: end_at }
105
+ end
106
+
87
107
  # A root with no versions in range has nothing to report, which is an empty
88
- # result rather than a failed request.
108
+ # result rather than a failed request -- unless an activity view closes it
109
+ # on current state, where its descendants may still have moved.
89
110
  #: (untyped, RootVersionPlan) -> Analysis
90
111
  def analysis_for(record, plan)
91
- return Analysis.empty if plan.empty?
112
+ return Analysis.empty if plan.empty? && !plan.closing_record
92
113
 
93
114
  @analyzer.call(record, plan)
94
115
  end
@@ -4,6 +4,17 @@
4
4
  module PaperTrailDiff
5
5
  # Resolves and validates ActiveRecord reflections for a bounded association tree.
6
6
  class AssociationTraversal
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.
9
+ #: (untyped, untyped) -> untyped
10
+ def self.belongs_to_target(reflection, record)
11
+ return reflection.klass unless reflection.polymorphic?
12
+
13
+ Object.const_get(record.public_send(reflection.foreign_type).to_s)
14
+ rescue NameError
15
+ nil
16
+ end
17
+
7
18
  #: (AssociationTree) -> void
8
19
  def initialize(tree)
9
20
  @tree = tree
@@ -37,6 +48,18 @@ module PaperTrailDiff
37
48
  end.freeze
38
49
  end
39
50
 
51
+ # HABTM join rows have no versions of their own, so membership changes
52
+ # cannot be split into events once a range runs past the last root version
53
+ # into live state.
54
+ #: (untyped) -> void
55
+ def reject_live_habtm_activity!(model_class)
56
+ paths = habtm_paths(model_class)
57
+ return if paths.empty?
58
+
59
+ message = "live-ended activity cannot reconstruct HABTM event boundaries: #{paths.join(', ')}"
60
+ raise UnsupportedLiveActivityError, message
61
+ end
62
+
40
63
  #: (untyped, AssociationTree, path: String) -> Array[untyped]
41
64
  def reflections_for(model_class, tree, path:)
42
65
  key = [model_class.name.to_s, path, tree.object_id]
@@ -2,11 +2,11 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # Resolves `:first` and `:last` endpoints for a whole batch in two queries per
5
+ # Resolves `:first` endpoints for a whole batch in a fixed number of queries per
6
6
  # model class. Left to the caller this is a per-root lookup, which reintroduces
7
7
  # exactly the queries a batched comparison exists to remove.
8
8
  class BatchBoundaryResolver
9
- BOUNDARIES = %i[first last].freeze
9
+ BOUNDARIES = %i[first].freeze
10
10
 
11
11
  class << self
12
12
  #: (untyped) -> bool
@@ -45,16 +45,17 @@ module PaperTrailDiff
45
45
  #: (untyped, untyped, Hash[Array[String], Hash[Symbol, untyped]]) -> untyped
46
46
  def resolve(endpoint, other, index)
47
47
  return endpoint unless symbolic?(endpoint)
48
+ raise ConfigurationError, TimelineRange::LAST_REMOVED if endpoint == :last
48
49
 
49
50
  unless BOUNDARIES.include?(endpoint)
50
51
  raise ConfigurationError,
51
- "unsupported boundary: #{endpoint.inspect}; use :first, :last, a version, or a record"
52
+ "unsupported boundary: #{endpoint.inspect}; use :first, a version, or a record"
52
53
  end
53
54
 
54
55
  identity = anchor_identity(other)
55
56
  unless identity
56
57
  raise ConfigurationError,
57
- 'a :first or :last endpoint needs the other endpoint to name a record'
58
+ 'a :first endpoint needs the other endpoint to name a record'
58
59
  end
59
60
 
60
61
  index.dig(identity, endpoint) || endpoint
@@ -100,33 +101,24 @@ module PaperTrailDiff
100
101
 
101
102
  #: (Hash[Array[String], Hash[Symbol, untyped]], untyped, Array[untyped]) -> void
102
103
  def add_model_boundaries(index, model_class, ids)
103
- first_ids, last_ids, versions = boundary_versions(model_class, ids)
104
+ firsts = first_versions(model_class, ids)
104
105
  ids.each do |id|
105
- index[identity_key(model_class, id)] = {
106
- first: versions[boundary_key(first_ids, id)],
107
- last: versions[boundary_key(last_ids, id)]
108
- }
106
+ index[identity_key(model_class, id)] = { first: firsts[id.to_s] }
109
107
  end
110
108
  end
111
109
 
112
- # Two grouped queries name each root's outermost versions, and one more
113
- # loads them, whatever the size of the batch.
114
- #: (untyped, Array[untyped]) -> [Hash[untyped, untyped], Hash[untyped, untyped], Hash[untyped, untyped]]
115
- def boundary_versions(model_class, ids)
110
+ # Each root's earliest version by time, in one query whatever the size of
111
+ # the batch. The lowest id is not the earliest version when ids are not
112
+ # sequential, as with UUIDs, so time decides and the usual ordering breaks
113
+ # a tie -- refusing one that ids cannot order.
114
+ #: (untyped, Array[untyped]) -> Hash[String, untyped]
115
+ def first_versions(model_class, ids)
116
116
  version_class = model_class.paper_trail.version_class
117
- scope = version_class.where(item_type: model_class.base_class.name.to_s, item_id: ids)
118
- first_ids = scope.group(:item_id).minimum(:id)
119
- last_ids = scope.group(:item_id).maximum(:id)
120
- loaded = version_class.where(id: (first_ids.values + last_ids.values).uniq).index_by(&:id)
121
- [first_ids, last_ids, loaded]
122
- end
123
-
124
- # Grouped keys come back with whatever type the column uses, so match on the
125
- # string form rather than assuming integers.
126
- #: (Hash[untyped, untyped], untyped) -> untyped
127
- def boundary_key(grouped, id)
128
- key = grouped.keys.find { |candidate| candidate.to_s == id.to_s }
129
- grouped[key]
117
+ table = version_class.arel_table
118
+ candidates = version_class.where(item_type: model_class.base_class.name.to_s, item_id: ids)
119
+ .where(VersionQuery.no_earlier_version(table)).to_a
120
+ candidates.group_by { |version| version.item_id.to_s }
121
+ .transform_values { |versions| Support.chronological_sort(versions).first }
130
122
  end
131
123
 
132
124
  #: (untyped, untyped) -> Array[String]
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Refuses known per-record association loading before a batch reconstructs it.
6
+ class BatchSafety
7
+ #: (AssociationTraversal, allow_expensive: bool) -> void
8
+ def initialize(traversal, allow_expensive:)
9
+ unless [true, false].include?(allow_expensive)
10
+ raise ConfigurationError, 'allow_expensive: must be true or false'
11
+ end
12
+
13
+ @traversal = traversal
14
+ @allow_expensive = allow_expensive
15
+ end
16
+
17
+ #: (untyped, ?historical: bool) -> void
18
+ def call(model, historical: true)
19
+ return if @allow_expensive
20
+
21
+ @traversal.selected_reflections(model).each do |path, reflection|
22
+ check(reflection, path, historical)
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ # @rbs @traversal: AssociationTraversal
29
+ # @rbs @allow_expensive: bool
30
+
31
+ #: (untyped, String, bool) -> void
32
+ def check(reflection, path, historical)
33
+ reason = reason_for(reflection, historical)
34
+ if reason
35
+ raise UnsupportedBatchPathError,
36
+ "association #{path} requires per-record reconstruction (#{reason}); " \
37
+ 'remove this path or explicitly pass allow_expensive: true'
38
+ end
39
+ return unless reflection.options[:through]
40
+
41
+ check(reflection.through_reflection, path, historical)
42
+ check(reflection.source_reflection, path, historical)
43
+ end
44
+
45
+ #: (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?
51
+
52
+ 'through source is not belongs_to'
53
+ end
54
+ end
55
+ end
@@ -5,13 +5,16 @@ module PaperTrailDiff
5
5
  # Builds one root's Analysis inside a batch, from versions the batch already
6
6
  # selected and history it already prepared.
7
7
  class BatchedRootAnalyzer
8
- #: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool) -> void
9
- def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:)
8
+ #: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool, ?within: untyped, ?snapshots: bool, ?group: Symbol?) -> void
9
+ def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:, within: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
10
10
  @tree = tree
11
11
  @timeline_snapshotter = timeline_snapshotter
12
12
  @activity_snapshotter = activity_snapshotter
13
13
  @preparer = preparer
14
14
  @activity = activity
15
+ @within = within
16
+ @snapshots = snapshots
17
+ @group = group
15
18
  end
16
19
 
17
20
  #: (untyped, RootVersionPlan) -> Analysis
@@ -22,7 +25,7 @@ module PaperTrailDiff
22
25
  TimelineBuilder.new(
23
26
  record,
24
27
  from: plan.versions.first,
25
- to: plan.versions.last,
28
+ to: plan.closing_record || plan.versions.last,
26
29
  within: nil,
27
30
  plan: plan,
28
31
  snapshotter: @timeline_snapshotter
@@ -36,14 +39,20 @@ module PaperTrailDiff
36
39
  # @rbs @activity_snapshotter: untyped
37
40
  # @rbs @preparer: untyped
38
41
  # @rbs @activity: bool
42
+ # @rbs @within: untyped
43
+ # @rbs @snapshots: bool
44
+ # @rbs @group: Symbol?
39
45
 
40
46
  #: (untyped, RootVersionPlan) -> Analysis
41
47
  def activity_analysis(record, plan)
42
48
  range = TimelineRange.new(
43
- record, from: plan.versions.first, to: plan.versions.last, within: nil, plan: plan
49
+ record, from: (@within ? nil : plan.versions.first),
50
+ to: (@within ? nil : plan.closing_record || plan.versions.last),
51
+ within: @within, plan: plan
44
52
  )
45
53
  ActivityTimelineBuilder.new(
46
- record, range: range, tree: @tree, snapshotter: @activity_snapshotter
54
+ record, range: range, tree: @tree, snapshotter: @activity_snapshotter,
55
+ snapshots: @snapshots, group: @group
47
56
  ).analyze
48
57
  end
49
58
  end
@@ -6,9 +6,13 @@ 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]?) -> void
10
- def initialize(records, time_range:, version_scope: nil, live_endpoints: nil)
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)
11
12
  @records = records
13
+ # An activity view needs a root to reconstruct from even in a window with
14
+ # no root version, exactly as a single-record activity timeline does.
15
+ @context_required = context_required
12
16
  @time_range = time_range
13
17
  @version_scope = version_scope
14
18
  @live_endpoints = live_endpoints
@@ -32,6 +36,7 @@ module PaperTrailDiff
32
36
  # @rbs @time_range: TimeRange?
33
37
  # @rbs @version_scope: untyped
34
38
  # @rbs @live_endpoints: Hash[Array[String], untyped]?
39
+ # @rbs @context_required: bool
35
40
 
36
41
  #: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
37
42
  def select_model(model_class, records, selected)
@@ -64,6 +69,7 @@ module PaperTrailDiff
64
69
  selected: chosen ? in_range.select { |version| chosen.include?(version.id) } : in_range,
65
70
  after_range: after_range,
66
71
  windowed: !@time_range.nil?,
72
+ context_required: @context_required,
67
73
  filtered: !@version_scope.nil?,
68
74
  live_endpoint: live_endpoint
69
75
  ).call
@@ -104,43 +110,9 @@ module PaperTrailDiff
104
110
  range ? range.scope(scope) : scope
105
111
  end
106
112
 
107
- # One row per root: the earliest version after the window, found without a
108
- # window function so the query stays portable.
109
113
  #: (untyped, Array[untyped], TimeRange) -> Hash[String, untyped]
110
114
  def trailing_versions(model_class, ids, range)
111
- table = model_class.paper_trail.version_class.arel_table
112
- relation = range.trailing_scope(base_scope(model_class, ids))
113
- ordered(relation.where(no_earlier_trailing(table, range)))
114
- .to_h { |version| [version.item_id.to_s, version] }
115
- end
116
-
117
- #: (untyped, TimeRange) -> untyped
118
- def no_earlier_trailing(table, range)
119
- arel = Object.const_get(:Arel) #: untyped
120
- later = table.alias('paper_trail_diff_later_roots')
121
- arel.const_get(:SelectManager).new
122
- .from(later)
123
- .project(arel.sql('1'))
124
- .where(preceding(table, later, range))
125
- .exists
126
- .not
127
- end
128
-
129
- # Mirrors the window's own end handling, so a version sitting exactly on an
130
- # inclusive boundary stays inside the window instead of masking the trailing
131
- # version that follows it.
132
- #: (untyped, untyped, TimeRange) -> untyped
133
- def preceding(table, later, range)
134
- same_item = later[:item_type].eq(table[:item_type])
135
- .and(later[:item_id].eq(table[:item_id]))
136
- same_item.and(after_window(later, range)).and(later[:created_at].lt(table[:created_at]))
137
- end
138
-
139
- #: (untyped, TimeRange) -> untyped
140
- def after_window(later, range)
141
- return later[:created_at].gteq(range.end_time) if range.exclude_end?
142
-
143
- later[:created_at].gt(range.end_time)
115
+ range.first_trailing_versions(base_scope(model_class, ids))
144
116
  end
145
117
 
146
118
  #: (untyped, Array[untyped]) -> untyped
@@ -153,7 +125,7 @@ module PaperTrailDiff
153
125
 
154
126
  #: (untyped) -> Array[untyped]
155
127
  def ordered(relation)
156
- relation.reorder(created_at: :asc, id: :asc).to_a
128
+ VersionQuery.chronological(relation).to_a
157
129
  end
158
130
 
159
131
  #: (untyped, untyped) -> Array[String]