paper_trail_diff 0.3.1 → 0.5.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -0
  3. data/README.md +316 -32
  4. data/lib/paper_trail_diff/activity_boundary.rb +21 -0
  5. data/lib/paper_trail_diff/activity_event_route_finder.rb +24 -44
  6. data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
  7. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
  8. data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
  9. data/lib/paper_trail_diff/analysis.rb +9 -0
  10. data/lib/paper_trail_diff/analysis_batch.rb +74 -0
  11. data/lib/paper_trail_diff/batch_boundary_resolver.rb +137 -0
  12. data/lib/paper_trail_diff/batched_root_analyzer.rb +50 -0
  13. data/lib/paper_trail_diff/batched_root_versions.rb +131 -0
  14. data/lib/paper_trail_diff/collection_comparator.rb +14 -1
  15. data/lib/paper_trail_diff/collection_transition.rb +6 -3
  16. data/lib/paper_trail_diff/comparison_batch.rb +37 -13
  17. data/lib/paper_trail_diff/endpoint.rb +27 -2
  18. data/lib/paper_trail_diff/errors.rb +3 -0
  19. data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
  20. data/lib/paper_trail_diff/historical_snapshot_store.rb +12 -9
  21. data/lib/paper_trail_diff/paper_trail_adapter.rb +49 -33
  22. data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
  23. data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
  24. data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
  25. data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
  26. data/lib/paper_trail_diff/snapshot.rb +26 -0
  27. data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
  28. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
  29. data/lib/paper_trail_diff/time_version_range.rb +11 -0
  30. data/lib/paper_trail_diff/timeline_builder.rb +3 -3
  31. data/lib/paper_trail_diff/timeline_range.rb +66 -6
  32. data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
  33. data/lib/paper_trail_diff/version.rb +1 -1
  34. data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
  35. data/lib/paper_trail_diff.rb +37 -5
  36. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
  37. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +10 -17
  38. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
  39. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  40. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
  41. data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
  42. data/sig/generated/paper_trail_diff/analysis_batch.rbs +42 -0
  43. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +62 -0
  44. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +28 -0
  45. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +62 -0
  46. data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
  47. data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
  48. data/sig/generated/paper_trail_diff/comparison_batch.rbs +15 -0
  49. data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
  50. data/sig/generated/paper_trail_diff/errors.rbs +4 -0
  51. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
  52. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -3
  53. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +16 -7
  54. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
  55. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
  56. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
  57. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
  58. data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
  59. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
  60. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
  61. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
  62. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  63. data/sig/generated/paper_trail_diff/timeline_range.rbs +35 -2
  64. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
  65. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
  66. data/sig/generated/paper_trail_diff.rbs +13 -3
  67. metadata +16 -4
@@ -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,10 +4,10 @@
4
4
  module PaperTrailDiff
5
5
  # Selects and compares a chronological slice of a record's version history.
6
6
  class TimelineBuilder
7
- #: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped) -> void
8
- def initialize(record, from:, to:, snapshotter:, within: nil)
7
+ #: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?versions: Array[untyped]?) -> void
8
+ def initialize(record, from:, to:, snapshotter:, within: nil, versions: nil) # rubocop:disable Metrics/ParameterLists
9
9
  @record = record
10
- @range = TimelineRange.new(record, from: from, to: to, within: within)
10
+ @range = TimelineRange.new(record, from: from, to: to, within: within, versions: versions)
11
11
  @snapshotter = snapshotter
12
12
  end
13
13
 
@@ -4,28 +4,46 @@
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?
10
12
 
11
- #: (untyped, from: untyped, to: untyped, within: untyped) -> void
12
- def initialize(record, from:, to:, within:)
13
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?) -> void
14
+ def initialize(record, from:, to:, within:, versions: nil)
13
15
  @record = record
14
- @from = from
15
- @to = to
16
+ @versions = versions&.freeze
17
+ @requested_from = from
18
+ @requested_to = to
16
19
  @time_range = build_time_range(within)
17
20
  validate_mode!
21
+ @from = resolve(from)
22
+ @to = resolve(to)
18
23
  freeze
19
24
  end
20
25
 
26
+ # A record with no versions has no first or last boundary to resolve, which
27
+ # is an empty history rather than a bad request.
28
+ #: () -> bool
29
+ def unresolved?
30
+ (symbolic?(@requested_from) && @from.nil?) || (symbolic?(@requested_to) && @to.nil?)
31
+ end
32
+
33
+ # A batch may have selected these versions already, in which case reselecting
34
+ # them per record would undo the batching.
21
35
  #: (?context_required: bool) -> Array[untyped]
22
36
  def select(context_required: false)
37
+ preselected = @versions
38
+ return preselected if preselected
39
+
23
40
  range = time_range
24
41
  if range
25
42
  return TimeVersionRange.new(@record, time_range: range).select(
26
43
  context_required: context_required
27
44
  )
28
45
  end
46
+ return empty_versions if unresolved?
29
47
 
30
48
  VersionRange.new(@record, from: @from, to: @to).select
31
49
  end
@@ -49,9 +67,51 @@ module PaperTrailDiff
49
67
  private
50
68
 
51
69
  # @rbs @record: untyped
70
+ # @rbs @versions: Array[untyped]?
71
+ # @rbs @requested_from: untyped
72
+ # @rbs @requested_to: untyped
52
73
  # @rbs @from: untyped
53
74
  # @rbs @to: untyped
54
75
 
76
+ #: () -> Array[untyped]
77
+ def empty_versions
78
+ versions = [] #: Array[untyped]
79
+ versions.freeze
80
+ end
81
+
82
+ #: (untyped) -> bool
83
+ def symbolic?(boundary)
84
+ boundary.is_a?(Symbol)
85
+ end
86
+
87
+ # Resolving `:first` and `:last` here keeps callers from depending on the
88
+ # order PaperTrail happens to give its versions association, which a caller
89
+ # is also free to reorder.
90
+ #: (untyped) -> untyped
91
+ def resolve(boundary)
92
+ return boundary unless symbolic?(boundary)
93
+
94
+ unless BOUNDARY_SYMBOLS.include?(boundary)
95
+ raise InvalidTimelineRangeError,
96
+ "unsupported boundary: #{boundary.inspect}; use :first, :last, a version, or a record"
97
+ end
98
+
99
+ boundary == :first ? ordered_versions.first : ordered_versions.last
100
+ end
101
+
102
+ #: () -> untyped
103
+ def ordered_versions
104
+ versions_relation.reorder(created_at: :asc, id: :asc)
105
+ end
106
+
107
+ #: () -> untyped
108
+ def versions_relation
109
+ @record.public_send(@record.class.versions_association_name)
110
+ rescue NoMethodError => e
111
+ message = 'record does not expose a PaperTrail version history'
112
+ raise InvalidTimelineRangeError, message, cause: e
113
+ end
114
+
55
115
  #: (untyped) -> TimeRange?
56
116
  def build_time_range(within)
57
117
  TimeRange.new(within) unless within.nil?
@@ -60,11 +120,11 @@ module PaperTrailDiff
60
120
  #: () -> void
61
121
  def validate_mode!
62
122
  if time?
63
- return if @from.nil? && @to.nil?
123
+ return if @requested_from.nil? && @requested_to.nil?
64
124
 
65
125
  raise InvalidTimelineRangeError, '`within` cannot be combined with `from` or `to`'
66
126
  end
67
- return unless @from.nil? || @to.nil?
127
+ return unless @requested_from.nil? || @requested_to.nil?
68
128
 
69
129
  raise InvalidTimelineRangeError, 'provide both `from` and `to`, or provide `within`'
70
130
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Checks that a model can be traversed before any reconstruction starts, and
6
+ # that association tracking is actually available when history is involved.
7
+ class TraversalPreparer
8
+ #: (tree: AssociationTree, traversal: AssociationTraversal) -> void
9
+ def initialize(tree:, traversal:)
10
+ @tree = tree
11
+ @traversal = traversal
12
+ end
13
+
14
+ #: (untyped, historical: bool) -> void
15
+ def call(model_class, historical:)
16
+ return if @tree.empty?
17
+
18
+ ensure_association_tracking! if historical
19
+ @traversal.validate!(model_class)
20
+ end
21
+
22
+ private
23
+
24
+ # @rbs @tree: AssociationTree
25
+ # @rbs @traversal: AssociationTraversal
26
+
27
+ #: () -> void
28
+ def ensure_association_tracking!
29
+ paper_trail = Object.const_get(:PaperTrail) #: untyped
30
+ config = paper_trail.config #: untyped
31
+ available = defined?(::PaperTrailAssociationTracking) &&
32
+ config.respond_to?(:track_associations?) &&
33
+ config.track_associations?
34
+ return if available
35
+
36
+ message = 'association tracking must be loaded and enabled to compare historical associations'
37
+ raise AssociationTrackingUnavailableError, message
38
+ end
39
+ end
40
+ end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.3.1'
5
+ VERSION = '0.5.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
@@ -10,6 +10,7 @@ require_relative 'paper_trail_diff/errors'
10
10
  require_relative 'paper_trail_diff/configuration'
11
11
  require_relative 'paper_trail_diff/endpoint'
12
12
  require_relative 'paper_trail_diff/association_traversal'
13
+ require_relative 'paper_trail_diff/traversal_preparer'
13
14
  require_relative 'paper_trail_diff/association_discovery'
14
15
  require_relative 'paper_trail_diff/diagnostics'
15
16
  require_relative 'paper_trail_diff/collection_identity_index'
@@ -36,7 +37,9 @@ require_relative 'paper_trail_diff/live_endpoint_batch_loader'
36
37
  require_relative 'paper_trail_diff/preloaded_endpoint_batch_loader'
37
38
  require_relative 'paper_trail_diff/live_endpoint_provider'
38
39
  require_relative 'paper_trail_diff/live_graph_collector'
40
+ require_relative 'paper_trail_diff/batch_boundary_resolver'
39
41
  require_relative 'paper_trail_diff/comparison_batch'
42
+ require_relative 'paper_trail_diff/batched_root_versions'
40
43
  require_relative 'paper_trail_diff/snapshot_normalizer'
41
44
  require_relative 'paper_trail_diff/historical_snapshot_store'
42
45
  require_relative 'paper_trail_diff/timeline_snapshot_provider'
@@ -55,6 +58,9 @@ require_relative 'paper_trail_diff/branch_snapshot_refresher'
55
58
  require_relative 'paper_trail_diff/activity_boundary'
56
59
  require_relative 'paper_trail_diff/step'
57
60
  require_relative 'paper_trail_diff/analysis'
61
+ require_relative 'paper_trail_diff/activity_root_steps'
62
+ require_relative 'paper_trail_diff/analysis_batch'
63
+ require_relative 'paper_trail_diff/batched_root_analyzer'
58
64
  require_relative 'paper_trail_diff/version_range'
59
65
  require_relative 'paper_trail_diff/time_range'
60
66
  require_relative 'paper_trail_diff/time_version_range'
@@ -89,8 +95,9 @@ require_relative 'paper_trail_diff/paper_trail_adapter'
89
95
  # type traversal_record_path = Array[RecordReference]
90
96
  # end
91
97
 
92
- # Structured version comparison for PaperTrail.
93
- module PaperTrailDiff
98
+ # Structured version comparison for PaperTrail. Each method is a thin,
99
+ # documented entry point, so this reads as an API listing rather than logic.
100
+ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
94
101
  DEFAULT_IGNORED_ATTRIBUTES = ['updated_at'].freeze
95
102
  SUPPORTED_ASSOCIATION_MACROS = %i[
96
103
  belongs_to
@@ -153,16 +160,23 @@ module PaperTrailDiff
153
160
  end
154
161
 
155
162
  # Compares adjacent root and selected-descendant activity boundaries.
156
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option) -> Array[ActivityStep]
163
+ # `reload_live_endpoints:` applies only when `to:` is a current record; the
164
+ # other range forms never read live state.
165
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> Array[ActivityStep]
157
166
  def activity_timeline( # rubocop:disable Metrics/ParameterLists
158
167
  record,
159
168
  from: nil,
160
169
  to: nil,
161
170
  within: nil,
162
171
  associations: [],
163
- ignore: DEFAULT_IGNORED_ATTRIBUTES
172
+ ignore: DEFAULT_IGNORED_ATTRIBUTES,
173
+ reload_live_endpoints: true
164
174
  )
165
- PaperTrailAdapter.new(associations: associations, ignore: ignore).activity_timeline(
175
+ PaperTrailAdapter.new(
176
+ associations: associations,
177
+ ignore: ignore,
178
+ reload_live_endpoints: reload_live_endpoints
179
+ ).activity_timeline(
166
180
  record,
167
181
  from: from,
168
182
  to: to,
@@ -190,6 +204,24 @@ module PaperTrailDiff
190
204
  )
191
205
  end
192
206
 
207
+ # Analyzes many roots over one shared time window, preparing their selected
208
+ # history once for the batch instead of once per record. Roots with no
209
+ # versions in the window return an empty `Analysis`.
210
+ #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool) -> Hash[identity, Analysis]
211
+ def analyze_many(
212
+ records,
213
+ within: nil,
214
+ associations: [],
215
+ ignore: DEFAULT_IGNORED_ATTRIBUTES,
216
+ activity: false
217
+ )
218
+ PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_many(
219
+ records,
220
+ within: within,
221
+ activity: activity
222
+ )
223
+ end
224
+
193
225
  # Returns the association macros this release can normalize.
194
226
  #: () -> Array[Symbol]
195
227
  def supported_association_macros
@@ -25,6 +25,12 @@ module PaperTrailDiff
25
25
  # : (untyped, captured_at: untyped) -> ActivityBoundary
26
26
  def self.current: (untyped, captured_at: untyped) -> ActivityBoundary
27
27
 
28
+ # The state a `destroy` version leaves behind. A version records the state
29
+ # before its own event, so the boundary built from a destroy version still
30
+ # holds the record; this one is the absence that follows it.
31
+ # : (untyped) -> ActivityBoundary
32
+ def self.destroyed: (untyped) -> ActivityBoundary
33
+
28
34
  # : (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
29
35
  def initialize: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
30
36
 
@@ -34,6 +40,9 @@ module PaperTrailDiff
34
40
  # : () -> bool
35
41
  def current?: () -> bool
36
42
 
43
+ # : () -> bool
44
+ def destroyed?: () -> bool
45
+
37
46
  # : () -> Hash[Symbol, untyped]
38
47
  def to_h: () -> Hash[Symbol, untyped]
39
48
  end
@@ -14,27 +14,20 @@ module PaperTrailDiff
14
14
 
15
15
  private
16
16
 
17
- @belongs_to_model: untyped
17
+ @belongs_to_routes: Hash[Array[untyped], Array[Array[untyped]]]
18
18
 
19
- @belongs_to_path: String?
19
+ @collection_routes: Hash[Array[untyped], Array[Array[untyped]]]
20
20
 
21
- @belongs_to_routes: Array[Array[untyped]]?
22
-
23
- @belongs_to_tree: AssociationTree?
24
-
25
- @belongs_to_type: String?
26
-
27
- @collection_model: untyped
28
-
29
- @collection_path: String?
30
-
31
- @collection_routes: Array[Array[untyped]]?
32
-
33
- @collection_tree: AssociationTree?
21
+ @traversal: AssociationTraversal
34
22
 
35
- @collection_type: String?
23
+ # Timelines interleave event types, so every requested combination is
24
+ # retained. Model, tree, and path are fixed per adapter, leaving one entry
25
+ # per selected item type.
26
+ # : (Hash[Array[untyped], Array[Array[untyped]]], untyped, AssociationTree, String, String, Symbol) -> Array[Array[untyped]]
27
+ def cached: (Hash[Array[untyped], Array[Array[untyped]]], untyped, AssociationTree, String, String, Symbol) -> Array[Array[untyped]]
36
28
 
37
- @traversal: AssociationTraversal
29
+ # : (Array[untyped]) -> Array[untyped]
30
+ def cache_key: (Array[untyped]) -> Array[untyped]
38
31
 
39
32
  # : (Array[Array[untyped]]) -> Array[Array[untyped]]
40
33
  def freeze_routes: (Array[Array[untyped]]) -> Array[Array[untyped]]