paper_trail_diff 0.3.0 → 0.4.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +269 -30
  4. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  5. data/lib/paper_trail_diff/activity_boundary.rb +21 -0
  6. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  7. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  8. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  9. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  10. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  11. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  12. data/lib/paper_trail_diff/activity_event_route_finder.rb +92 -0
  13. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +56 -650
  14. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  15. data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
  16. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
  17. data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
  18. data/lib/paper_trail_diff/analysis.rb +9 -0
  19. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +2 -0
  20. data/lib/paper_trail_diff/collection_comparator.rb +14 -1
  21. data/lib/paper_trail_diff/collection_transition.rb +6 -3
  22. data/lib/paper_trail_diff/endpoint.rb +27 -2
  23. data/lib/paper_trail_diff/errors.rb +3 -0
  24. data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
  25. data/lib/paper_trail_diff/historical_snapshot_store.rb +9 -2
  26. data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
  27. data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
  28. data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
  29. data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
  30. data/lib/paper_trail_diff/snapshot.rb +26 -0
  31. data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
  32. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
  33. data/lib/paper_trail_diff/time_version_range.rb +11 -0
  34. data/lib/paper_trail_diff/timeline_range.rb +57 -4
  35. data/lib/paper_trail_diff/version.rb +1 -1
  36. data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
  37. data/lib/paper_trail_diff.rb +10 -0
  38. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  39. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
  40. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  41. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  42. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  43. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  44. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  45. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  46. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +47 -0
  47. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +18 -110
  48. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  49. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
  50. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  51. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
  52. data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
  53. data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
  54. data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
  55. data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
  56. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  57. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
  58. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -2
  59. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
  60. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
  61. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
  62. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
  63. data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
  64. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
  65. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
  66. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
  67. data/sig/generated/paper_trail_diff/timeline_range.rbs +29 -0
  68. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
  69. metadata +24 -4
@@ -21,9 +21,14 @@ module PaperTrailDiff
21
21
  state
22
22
  end
23
23
 
24
+ # Mirrors PaperTrail's reifier, which writes attributes directly. Mass
25
+ # assignment would route reconstructed state through application-defined
26
+ # attribute writers and reify a different record than the version stored.
24
27
  #: () -> untyped
25
28
  def instantiate
26
- @model_class.new(@attributes)
29
+ record = @model_class.new
30
+ @attributes.each { |name, value| record[name] = value }
31
+ record
27
32
  end
28
33
 
29
34
  private
@@ -108,6 +113,7 @@ module PaperTrailDiff
108
113
  @version_positions = @versions.each_with_index.to_h do |version, index|
109
114
  [version.id.to_s, index]
110
115
  end.freeze
116
+ @transaction_positions = build_transaction_positions
111
117
  @live = live
112
118
  @state_loader = state_loader
113
119
  @states = {} #: Hash[untyped, PreparedRecordState?]
@@ -115,8 +121,8 @@ module PaperTrailDiff
115
121
 
116
122
  #: (untyped) -> untyped
117
123
  def record_before(boundary)
118
- version = versions.find { |candidate| eligible?(candidate, boundary) }
119
- return state_for(version)&.instantiate if version
124
+ index = boundary_index(boundary)
125
+ return state_for(versions.fetch(index))&.instantiate if index
120
126
 
121
127
  @live&.instantiate
122
128
  end
@@ -146,6 +152,7 @@ module PaperTrailDiff
146
152
  # @rbs @state_loader: PreparedVersionStateLoader
147
153
  # @rbs @states: Hash[untyped, PreparedRecordState?]
148
154
  # @rbs @version_positions: Hash[String, Integer]
155
+ # @rbs @transaction_positions: Hash[untyped, Integer]
149
156
 
150
157
  #: (Integer) -> PreparedRecordState?
151
158
  def state_after(index)
@@ -153,16 +160,41 @@ module PaperTrailDiff
153
160
  successor ? state_for(successor) : @live
154
161
  end
155
162
 
156
- #: (untyped, untyped) -> bool
157
- def eligible?(version, boundary)
158
- version.created_at >= boundary.created_at || same_transaction?(version, boundary)
163
+ # A version is eligible when it is at or after the boundary, or shares the
164
+ # boundary's transaction. The first is monotonic over the chronological
165
+ # list and the second is indexed, so neither rescans a long history once
166
+ # per boundary.
167
+ #: (untyped) -> Integer?
168
+ def boundary_index(boundary)
169
+ created_at = boundary.created_at
170
+ chronological = versions.bsearch_index do |candidate|
171
+ candidate.created_at >= created_at
172
+ end
173
+ transactional = transaction_index(boundary)
174
+ return chronological unless transactional
175
+ return transactional unless chronological
176
+
177
+ [chronological, transactional].min
159
178
  end
160
179
 
161
- #: (untyped, untyped) -> bool
162
- def same_transaction?(version, boundary)
180
+ #: (untyped) -> Integer?
181
+ def transaction_index(boundary)
163
182
  transaction_id = boundary.transaction_id if boundary.respond_to?(:transaction_id)
164
- transaction_id && version.respond_to?(:transaction_id) &&
165
- version.transaction_id == transaction_id
183
+ return unless transaction_id
184
+
185
+ @transaction_positions[transaction_id]
186
+ end
187
+
188
+ #: () -> Hash[untyped, Integer]
189
+ def build_transaction_positions
190
+ positions = {} #: Hash[untyped, Integer]
191
+ @versions.each_with_index do |version, index|
192
+ next unless version.respond_to?(:transaction_id)
193
+
194
+ transaction_id = version.transaction_id
195
+ positions[transaction_id] ||= index if transaction_id
196
+ end
197
+ positions.freeze
166
198
  end
167
199
 
168
200
  #: (untyped) -> PreparedRecordState?
@@ -185,9 +217,10 @@ module PaperTrailDiff
185
217
 
186
218
  # Request-scoped scalar history loaded once per model identity.
187
219
  class PreparedRecordIndex
188
- #: (untyped, ?live_records: Array[untyped]) -> void
189
- def initialize(start_at, live_records: [])
190
- @start_time = start_at
220
+ #: (untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
221
+ def initialize(start_at, end_at: nil, live_records: [])
222
+ @start_time = boundary_time(start_at)
223
+ @end_time = boundary_time(end_at)
191
224
  @seeded_live_records = live_records.to_h do |record|
192
225
  [identity(record.class, record.id), record]
193
226
  end
@@ -230,10 +263,17 @@ module PaperTrailDiff
230
263
  private
231
264
 
232
265
  # @rbs @start_time: untyped
266
+ # @rbs @end_time: untyped
233
267
  # @rbs @seeded_live_records: Hash[Array[String], untyped]
234
268
  # @rbs @state_loader: PreparedVersionStateLoader
235
269
  # @rbs @series: Hash[Array[String], PreparedRecordSeries]
236
270
 
271
+ # Callers bound the range with either a version or a bare timestamp.
272
+ #: (untyped) -> untyped
273
+ def boundary_time(value)
274
+ value.respond_to?(:created_at) ? value.created_at : value
275
+ end
276
+
237
277
  #: (untyped, untyped) -> Array[String]
238
278
  def identity(model_class, id)
239
279
  [model_class.base_class.name.to_s, id.to_s]
@@ -254,12 +294,47 @@ module PaperTrailDiff
254
294
  )
255
295
  end
256
296
 
297
+ # A PaperTrail version is a pre-change snapshot, so the state at the last
298
+ # selected boundary can only live in the next version after it. One
299
+ # trailing version per identity is retained, rather than every version
300
+ # recorded between the requested range and the present.
257
301
  #: (untyped, Array[untyped]) -> Array[untyped]
258
302
  def versions_for(model_class, ids)
259
- model_class.paper_trail.version_class.where(
303
+ version_class = model_class.paper_trail.version_class
304
+ scope = version_class.where(
260
305
  item_type: model_class.base_class.name,
261
306
  item_id: ids
262
- ).where('created_at >= ?', @start_time).order(:id).to_a
307
+ ).where(created_at: @start_time..)
308
+ scope = scope.where(window_condition(version_class)) if @end_time
309
+ scope.order(:id).to_a
310
+ end
311
+
312
+ # In range, or the earliest version after it, resolved in the same query
313
+ # rather than with a window function or one query per identity.
314
+ #: (untyped) -> untyped
315
+ def window_condition(version_class)
316
+ table = version_class.arel_table
317
+ table[:created_at].lteq(@end_time).or(first_after_range(table))
318
+ end
319
+
320
+ #: (untyped) -> untyped
321
+ def first_after_range(table)
322
+ arel = Object.const_get(:Arel) #: untyped
323
+ later = table.alias('paper_trail_diff_later_versions')
324
+ arel.const_get(:SelectManager).new
325
+ .from(later)
326
+ .project(arel.sql('1'))
327
+ .where(preceding_trailing_version(table, later))
328
+ .exists
329
+ .not
330
+ end
331
+
332
+ #: (untyped, untyped) -> untyped
333
+ def preceding_trailing_version(table, later)
334
+ later[:item_type].eq(table[:item_type])
335
+ .and(later[:item_id].eq(table[:item_id]))
336
+ .and(later[:created_at].gt(@end_time))
337
+ .and(later[:created_at].lt(table[:created_at]))
263
338
  end
264
339
 
265
340
  #: (untyped, Array[untyped]) -> Array[untyped]
@@ -57,6 +57,30 @@ module PaperTrailDiff
57
57
  end
58
58
  private_constant :IdentityIndexCache
59
59
 
60
+ # Identifies one snapshot for internal carry-forward. A transition cannot
61
+ # hold its origin without retaining every earlier snapshot at that path,
62
+ # and `object_id` is only guaranteed unique among live objects, so Ruby may
63
+ # hand a collected snapshot's id to a later one.
64
+ class SerialSequence
65
+ #: () -> void
66
+ def initialize
67
+ @mutex = Mutex.new
68
+ @counter = 0
69
+ end
70
+
71
+ #: () -> Integer
72
+ def next_serial
73
+ @mutex.synchronize { @counter += 1 }
74
+ end
75
+
76
+ # @rbs @mutex: Thread::Mutex
77
+ # @rbs @counter: Integer
78
+ end
79
+ private_constant :SerialSequence
80
+
81
+ SERIALS = SerialSequence.new
82
+ private_constant :SERIALS
83
+
60
84
  class << self
61
85
  #: (Symbol) -> bool
62
86
  def collection_kind?(kind)
@@ -66,6 +90,7 @@ module PaperTrailDiff
66
90
 
67
91
  attr_reader :kind #: Symbol
68
92
  attr_reader :records #: Array[RecordSnapshot]
93
+ attr_reader :serial #: Integer
69
94
 
70
95
  #: (kind: Symbol, records: Array[RecordSnapshot], ?identity_index_cache: untyped, ?transition: CollectionTransition?) -> void
71
96
  def initialize(kind:, records:, identity_index_cache: nil, transition: nil)
@@ -80,6 +105,7 @@ module PaperTrailDiff
80
105
  @records = records.frozen? ? records : records.dup.freeze
81
106
  @identity_index_cache = identity_index_cache || IdentityIndexCache.new
82
107
  @transition = transition
108
+ @serial = SERIALS.next_serial
83
109
  freeze
84
110
  end
85
111
 
@@ -53,6 +53,7 @@ module PaperTrailDiff
53
53
  @traversal = traversal
54
54
  @pool = pool
55
55
  @structural_columns = {} #: Hash[String, Array[String]]
56
+ @excluded_attributes = {} #: Hash[Array[untyped], Array[String]]
56
57
  end
57
58
 
58
59
  #: (untyped, reifier: untyped) -> RecordSnapshot?
@@ -86,6 +87,7 @@ module PaperTrailDiff
86
87
  # @rbs @traversal: AssociationTraversal
87
88
  # @rbs @pool: SnapshotPool
88
89
  # @rbs @structural_columns: Hash[String, Array[String]]
90
+ # @rbs @excluded_attributes: Hash[Array[untyped], Array[String]]
89
91
 
90
92
  #: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot?
91
93
  def normalize_record(record, tree, path, reifier, reflections)
@@ -107,15 +109,25 @@ module PaperTrailDiff
107
109
  attributes
108
110
  end
109
111
 
112
+ # Every input is fixed for one model class at one selected path, and the
113
+ # path's structural columns are recorded before its records normalize, so
114
+ # this is resolved once instead of per record per boundary.
110
115
  #: (untyped, Array[untyped], String) -> Array[String]
111
116
  def excluded_attributes(record, reflections, path)
112
- primary_key = record.class.primary_key #: untyped
117
+ model_class = record.class
118
+ @excluded_attributes[[model_class, path]] ||=
119
+ build_excluded_attributes(model_class, reflections, path)
120
+ end
121
+
122
+ #: (untyped, Array[untyped], String) -> Array[String]
123
+ def build_excluded_attributes(model_class, reflections, path)
124
+ primary_key = model_class.primary_key #: untyped
113
125
  primary_keys = primary_key.is_a?(Array) ? primary_key : [primary_key]
114
126
  # @type var primary_keys: Array[untyped]
115
127
  primary_keys = primary_keys.map { |key| key.to_s } # rubocop:disable Style/SymbolProc
116
128
  ignored = @ignore_policy.attributes_for(path)
117
129
  structural = @structural_columns.fetch(path, [])
118
- (primary_keys + ignored + relationship_columns(reflections) + structural).uniq
130
+ (primary_keys + ignored + relationship_columns(reflections) + structural).uniq.freeze
119
131
  end
120
132
 
121
133
  #: (Array[untyped]) -> Array[String]
@@ -14,17 +14,17 @@ module PaperTrailDiff
14
14
 
15
15
  #: () -> Array[ActivityStep]
16
16
  def build
17
- history, = history_and_versions
18
- history.steps
17
+ history, _root_versions, closing = history_and_versions
18
+ activity_steps(history, closing)
19
19
  end
20
20
 
21
21
  #: () -> Analysis
22
22
  def analyze
23
- history, root_versions = history_and_versions
23
+ history, root_versions, closing = history_and_versions
24
24
  Analysis.new(
25
25
  diff: Engine.compare(history.first_snapshot, history.last_snapshot),
26
- timeline: root_steps(root_versions, history.root_snapshots),
27
- activity_timeline: history.steps
26
+ timeline: ActivityRootSteps.call(root_versions, history.root_snapshots),
27
+ activity_timeline: activity_steps(history, closing)
28
28
  )
29
29
  end
30
30
 
@@ -35,22 +35,61 @@ module PaperTrailDiff
35
35
  # @rbs @tree: AssociationTree
36
36
  # @rbs @snapshotter: untyped
37
37
 
38
- #: () -> [ActivityHistory, Array[untyped]]
38
+ #: () -> [ActivityHistory, Array[untyped], ActivityStep?]
39
39
  def history_and_versions
40
40
  root_versions = @range.select(context_required: !@tree.empty?)
41
- return [ActivityHistory.empty, root_versions] if root_versions.empty?
41
+ return [ActivityHistory.empty, root_versions, nil] if root_versions.empty?
42
42
 
43
43
  prepare_history(root_versions)
44
44
  events = collect_events(root_versions)
45
- return [ActivityHistory.empty, root_versions] unless time_events?(events)
45
+ selected = selected_events(events)
46
+ return [ActivityHistory.empty, root_versions, nil] unless time_events?(selected, events)
46
47
 
47
- history = ActivityHistoryBuilder.new(
48
+ history = build_history(root_versions, events)
49
+ [history, root_versions, closing_step(history, selected.last)]
50
+ end
51
+
52
+ #: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
53
+ def build_history(root_versions, events)
54
+ ActivityHistoryBuilder.new(
48
55
  root_versions,
49
56
  events,
50
57
  @snapshotter,
51
58
  include_step: ->(event) { @range.include?(event.version) }
52
59
  ).call
53
- [history, root_versions]
60
+ end
61
+
62
+ #: (ActivityHistory, ActivityStep?) -> Array[ActivityStep]
63
+ def activity_steps(history, closing)
64
+ return history.steps unless closing
65
+
66
+ (history.steps + [closing]).freeze
67
+ end
68
+
69
+ #: (Array[ActivityEvent]) -> Array[ActivityEvent]
70
+ def selected_events(events)
71
+ events.select { |event| @range.include?(event.version) }
72
+ end
73
+
74
+ # The window's last selected mutation is the root's own destruction, so the
75
+ # timeline closes on the absence it leaves rather than on a later boundary.
76
+ #: (ActivityHistory, ActivityEvent?) -> ActivityStep?
77
+ def closing_step(history, event)
78
+ return unless event && terminal_destroy?(event)
79
+
80
+ version = event.version
81
+ ActivityStep.new(
82
+ from_boundary: ActivityBoundary.from_version(version),
83
+ to_boundary: ActivityBoundary.destroyed(version),
84
+ diff: Engine.compare(history.root_snapshots[ActivityRootSteps.version_key(version)], nil)
85
+ )
86
+ end
87
+
88
+ #: (ActivityEvent?) -> bool
89
+ def terminal_destroy?(event)
90
+ return false unless event
91
+
92
+ event.root? && event.version.event.to_s == 'destroy'
54
93
  end
55
94
 
56
95
  #: (Array[untyped]) -> void
@@ -72,11 +111,11 @@ module PaperTrailDiff
72
111
  ).call
73
112
  end
74
113
 
75
- #: (Array[ActivityEvent]) -> bool
76
- def time_events?(events)
77
- selected = events.select { |event| @range.include?(event.version) }
114
+ #: (Array[ActivityEvent], Array[ActivityEvent]) -> bool
115
+ def time_events?(selected, events)
78
116
  return false if selected.empty?
79
117
  return true if later_event?(selected.last, events)
118
+ return true if terminal_destroy?(selected.last)
80
119
 
81
120
  message = 'time range requires a later activity boundary to reconstruct its final change'
82
121
  raise IncompleteTimeRangeError, message
@@ -88,24 +127,5 @@ module PaperTrailDiff
88
127
  Support.compare_versions(selected.version, event.version).negative?
89
128
  end
90
129
  end
91
-
92
- #: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
93
- def root_steps(root_versions, root_snapshots)
94
- snapshots = root_versions.map do |version|
95
- root_snapshots.fetch(version_key(version))
96
- end
97
- root_versions.each_cons(2).with_index.map do |versions, index|
98
- Step.new(
99
- from_version: versions.fetch(0),
100
- to_version: versions.fetch(1),
101
- diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
102
- )
103
- end.freeze
104
- end
105
-
106
- #: (untyped) -> Array[untyped]
107
- def version_key(version)
108
- [version.class.name, version.id]
109
- end
110
130
  end
111
131
  end
@@ -19,6 +19,8 @@ module PaperTrailDiff
19
19
 
20
20
  trailing = trailing_version(relation)
21
21
  unless trailing
22
+ return selected.freeze if terminal_destroy?(selected)
23
+
22
24
  message = 'time range requires a later root version to reconstruct its final change'
23
25
  raise IncompleteTimeRangeError, message
24
26
  end
@@ -40,6 +42,15 @@ module PaperTrailDiff
40
42
  raise InvalidTimelineRangeError, message, cause: e
41
43
  end
42
44
 
45
+ # A window closing on the record's own destruction needs no later version:
46
+ # the destroy reveals the preceding mutation and nothing can follow it, so
47
+ # demanding a checkpoint that can never be written would reject the range
48
+ # permanently.
49
+ #: (Array[untyped]) -> bool
50
+ def terminal_destroy?(versions)
51
+ versions.last&.event.to_s == 'destroy'
52
+ end
53
+
43
54
  #: (untyped) -> untyped
44
55
  def trailing_version(relation)
45
56
  @time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
@@ -4,6 +4,8 @@
4
4
  module PaperTrailDiff
5
5
  # Chooses explicit-version or wall-clock selection for one timeline request.
6
6
  class TimelineRange
7
+ BOUNDARY_SYMBOLS = %i[first last].freeze
8
+
7
9
  attr_reader :from #: untyped
8
10
  attr_reader :to #: untyped
9
11
  attr_reader :time_range #: TimeRange?
@@ -11,13 +13,22 @@ module PaperTrailDiff
11
13
  #: (untyped, from: untyped, to: untyped, within: untyped) -> void
12
14
  def initialize(record, from:, to:, within:)
13
15
  @record = record
14
- @from = from
15
- @to = to
16
+ @requested_from = from
17
+ @requested_to = to
16
18
  @time_range = build_time_range(within)
17
19
  validate_mode!
20
+ @from = resolve(from)
21
+ @to = resolve(to)
18
22
  freeze
19
23
  end
20
24
 
25
+ # A record with no versions has no first or last boundary to resolve, which
26
+ # is an empty history rather than a bad request.
27
+ #: () -> bool
28
+ def unresolved?
29
+ (symbolic?(@requested_from) && @from.nil?) || (symbolic?(@requested_to) && @to.nil?)
30
+ end
31
+
21
32
  #: (?context_required: bool) -> Array[untyped]
22
33
  def select(context_required: false)
23
34
  range = time_range
@@ -26,6 +37,7 @@ module PaperTrailDiff
26
37
  context_required: context_required
27
38
  )
28
39
  end
40
+ return empty_versions if unresolved?
29
41
 
30
42
  VersionRange.new(@record, from: @from, to: @to).select
31
43
  end
@@ -49,9 +61,50 @@ module PaperTrailDiff
49
61
  private
50
62
 
51
63
  # @rbs @record: untyped
64
+ # @rbs @requested_from: untyped
65
+ # @rbs @requested_to: untyped
52
66
  # @rbs @from: untyped
53
67
  # @rbs @to: untyped
54
68
 
69
+ #: () -> Array[untyped]
70
+ def empty_versions
71
+ versions = [] #: Array[untyped]
72
+ versions.freeze
73
+ end
74
+
75
+ #: (untyped) -> bool
76
+ def symbolic?(boundary)
77
+ boundary.is_a?(Symbol)
78
+ end
79
+
80
+ # Resolving `:first` and `:last` here keeps callers from depending on the
81
+ # order PaperTrail happens to give its versions association, which a caller
82
+ # is also free to reorder.
83
+ #: (untyped) -> untyped
84
+ def resolve(boundary)
85
+ return boundary unless symbolic?(boundary)
86
+
87
+ unless BOUNDARY_SYMBOLS.include?(boundary)
88
+ raise InvalidTimelineRangeError,
89
+ "unsupported boundary: #{boundary.inspect}; use :first, :last, a version, or a record"
90
+ end
91
+
92
+ boundary == :first ? ordered_versions.first : ordered_versions.last
93
+ end
94
+
95
+ #: () -> untyped
96
+ def ordered_versions
97
+ versions_relation.reorder(created_at: :asc, id: :asc)
98
+ end
99
+
100
+ #: () -> untyped
101
+ def versions_relation
102
+ @record.public_send(@record.class.versions_association_name)
103
+ rescue NoMethodError => e
104
+ message = 'record does not expose a PaperTrail version history'
105
+ raise InvalidTimelineRangeError, message, cause: e
106
+ end
107
+
55
108
  #: (untyped) -> TimeRange?
56
109
  def build_time_range(within)
57
110
  TimeRange.new(within) unless within.nil?
@@ -60,11 +113,11 @@ module PaperTrailDiff
60
113
  #: () -> void
61
114
  def validate_mode!
62
115
  if time?
63
- return if @from.nil? && @to.nil?
116
+ return if @requested_from.nil? && @requested_to.nil?
64
117
 
65
118
  raise InvalidTimelineRangeError, '`within` cannot be combined with `from` or `to`'
66
119
  end
67
- return unless @from.nil? || @to.nil?
120
+ return unless @requested_from.nil? || @requested_to.nil?
68
121
 
69
122
  raise InvalidTimelineRangeError, 'provide both `from` and `to`, or provide `within`'
70
123
  end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.0'
6
6
  end
@@ -24,45 +24,72 @@ module PaperTrailDiff
24
24
  # @rbs @start_at: untyped
25
25
  # @rbs @end_at: untyped
26
26
 
27
+ # Only quoting helpers are needed, so the connection is borrowed for the
28
+ # duration of building the condition rather than checked out permanently,
29
+ # which Active Record 7.2 and newer can deprecate. Active Record 7.1 has no
30
+ # `with_connection` and reaches the same connection through the accessor.
31
+ #: () { (untyped) -> String } -> String
32
+ def with_connection(&block)
33
+ return @version_class.with_connection(&block) if @version_class.respond_to?(:with_connection)
34
+
35
+ block.call(@version_class.connection)
36
+ end
37
+
27
38
  #: () -> String
28
- def candidate_condition # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
29
- connection = @version_class.connection
39
+ def candidate_condition
40
+ with_connection { |connection| condition_sql(connection) }
41
+ end
42
+
43
+ #: (untyped) -> String
44
+ def condition_sql(connection) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
30
45
  outer = connection.quote_table_name(@version_class.table_name)
31
46
  later = connection.quote_table_name('paper_trail_diff_later_versions')
32
47
  start_at = connection.quote(@start_at)
33
- destroyed = connection.quote('destroy')
34
- activity = activity_condition(outer, start_at)
48
+ columns = quoted_columns(connection, outer, later)
35
49
  compact_sql(<<~SQL)
36
50
  (
37
- #{activity}
51
+ #{activity_condition(columns, start_at, connection)}
38
52
  OR (
39
- #{column(outer, 'created_at')} < #{start_at}
40
- AND #{column(outer, 'event')} != #{destroyed}
53
+ #{columns.fetch(:outer_created_at)} < #{start_at}
54
+ AND #{columns.fetch(:outer_event)} != #{connection.quote('destroy')}
41
55
  AND NOT EXISTS (
42
56
  SELECT 1
43
57
  FROM #{outer} #{later}
44
- WHERE #{column(later, 'item_type')} = #{column(outer, 'item_type')}
45
- AND #{column(later, 'item_id')} = #{column(outer, 'item_id')}
46
- AND #{column(later, 'created_at')} < #{start_at}
47
- AND #{column(later, 'created_at')} > #{column(outer, 'created_at')}
58
+ WHERE #{columns.fetch(:later_item_type)} = #{columns.fetch(:outer_item_type)}
59
+ AND #{columns.fetch(:later_item_id)} = #{columns.fetch(:outer_item_id)}
60
+ AND #{columns.fetch(:later_created_at)} < #{start_at}
61
+ AND #{columns.fetch(:later_created_at)} > #{columns.fetch(:outer_created_at)}
48
62
  )
49
63
  )
50
64
  )
51
65
  SQL
52
66
  end
53
67
 
54
- #: (String, String) -> String
55
- def activity_condition(table, start_at)
56
- condition = "#{column(table, 'created_at')} >= #{start_at}"
68
+ #: (untyped, String, String) -> Hash[Symbol, String]
69
+ def quoted_columns(connection, outer, later)
70
+ {
71
+ outer_created_at: column(connection, outer, 'created_at'),
72
+ outer_event: column(connection, outer, 'event'),
73
+ outer_item_type: column(connection, outer, 'item_type'),
74
+ outer_item_id: column(connection, outer, 'item_id'),
75
+ later_created_at: column(connection, later, 'created_at'),
76
+ later_item_type: column(connection, later, 'item_type'),
77
+ later_item_id: column(connection, later, 'item_id')
78
+ }
79
+ end
80
+
81
+ #: (Hash[Symbol, String], String, untyped) -> String
82
+ def activity_condition(columns, start_at, connection)
83
+ created_at = columns.fetch(:outer_created_at)
84
+ condition = "#{created_at} >= #{start_at}"
57
85
  return condition unless @end_at
58
86
 
59
- end_at = @version_class.connection.quote(@end_at)
60
- "(#{condition} AND #{column(table, 'created_at')} <= #{end_at})"
87
+ "(#{condition} AND #{created_at} <= #{connection.quote(@end_at)})"
61
88
  end
62
89
 
63
- #: (String, String) -> String
64
- def column(table, name)
65
- "#{table}.#{@version_class.connection.quote_column_name(name)}"
90
+ #: (untyped, String, String) -> String
91
+ def column(connection, table, name)
92
+ "#{table}.#{connection.quote_column_name(name)}"
66
93
  end
67
94
 
68
95
  #: (String) -> String
@@ -40,12 +40,22 @@ require_relative 'paper_trail_diff/comparison_batch'
40
40
  require_relative 'paper_trail_diff/snapshot_normalizer'
41
41
  require_relative 'paper_trail_diff/historical_snapshot_store'
42
42
  require_relative 'paper_trail_diff/timeline_snapshot_provider'
43
+ require_relative 'paper_trail_diff/activity_event_record_resolver'
44
+ require_relative 'paper_trail_diff/activity_event_route_finder'
45
+ require_relative 'paper_trail_diff/activity_relationship'
46
+ require_relative 'paper_trail_diff/activity_collection_route_change'
47
+ require_relative 'paper_trail_diff/activity_collection_record_updater'
48
+ require_relative 'paper_trail_diff/activity_collection_route_updater'
49
+ require_relative 'paper_trail_diff/activity_event_record_normalizer'
50
+ require_relative 'paper_trail_diff/activity_collection_event_applier'
51
+ require_relative 'paper_trail_diff/activity_belongs_to_event_applier'
43
52
  require_relative 'paper_trail_diff/activity_event_snapshot_refresher'
44
53
  require_relative 'paper_trail_diff/activity_root_snapshot_refresher'
45
54
  require_relative 'paper_trail_diff/branch_snapshot_refresher'
46
55
  require_relative 'paper_trail_diff/activity_boundary'
47
56
  require_relative 'paper_trail_diff/step'
48
57
  require_relative 'paper_trail_diff/analysis'
58
+ require_relative 'paper_trail_diff/activity_root_steps'
49
59
  require_relative 'paper_trail_diff/version_range'
50
60
  require_relative 'paper_trail_diff/time_range'
51
61
  require_relative 'paper_trail_diff/time_version_range'