paper_trail_diff 0.1.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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +45 -0
  3. data/LICENSE +22 -0
  4. data/README.md +473 -0
  5. data/lib/paper_trail_diff/activity_boundary.rb +82 -0
  6. data/lib/paper_trail_diff/activity_event.rb +128 -0
  7. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +493 -0
  8. data/lib/paper_trail_diff/activity_range.rb +58 -0
  9. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +156 -0
  10. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +161 -0
  11. data/lib/paper_trail_diff/activity_timeline_builder.rb +177 -0
  12. data/lib/paper_trail_diff/activity_version_collector.rb +196 -0
  13. data/lib/paper_trail_diff/analysis.rb +26 -0
  14. data/lib/paper_trail_diff/association_discovery.rb +132 -0
  15. data/lib/paper_trail_diff/association_traversal.rb +131 -0
  16. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +191 -0
  17. data/lib/paper_trail_diff/configuration.rb +170 -0
  18. data/lib/paper_trail_diff/diagnostics.rb +242 -0
  19. data/lib/paper_trail_diff/endpoint.rb +87 -0
  20. data/lib/paper_trail_diff/engine.rb +180 -0
  21. data/lib/paper_trail_diff/errors.rb +34 -0
  22. data/lib/paper_trail_diff/historical_association_reifier.rb +103 -0
  23. data/lib/paper_trail_diff/historical_snapshot_store.rb +144 -0
  24. data/lib/paper_trail_diff/live_association_reader.rb +10 -0
  25. data/lib/paper_trail_diff/paper_trail_adapter.rb +182 -0
  26. data/lib/paper_trail_diff/prepared_association_reifier.rb +48 -0
  27. data/lib/paper_trail_diff/prepared_edge_loader.rb +155 -0
  28. data/lib/paper_trail_diff/prepared_history.rb +217 -0
  29. data/lib/paper_trail_diff/prepared_history_loader.rb +136 -0
  30. data/lib/paper_trail_diff/prepared_record_index.rb +154 -0
  31. data/lib/paper_trail_diff/snapshot.rb +120 -0
  32. data/lib/paper_trail_diff/snapshot_normalizer.rb +182 -0
  33. data/lib/paper_trail_diff/step.rb +28 -0
  34. data/lib/paper_trail_diff/support.rb +80 -0
  35. data/lib/paper_trail_diff/timeline_builder.rb +60 -0
  36. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -0
  37. data/lib/paper_trail_diff/value_objects.rb +134 -0
  38. data/lib/paper_trail_diff/version.rb +6 -0
  39. data/lib/paper_trail_diff/version_range.rb +81 -0
  40. data/lib/paper_trail_diff.rb +131 -0
  41. data/sig/generated/paper_trail_diff/activity_boundary.rbs +43 -0
  42. data/sig/generated/paper_trail_diff/activity_event.rbs +64 -0
  43. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +98 -0
  44. data/sig/generated/paper_trail_diff/activity_range.rbs +36 -0
  45. data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +54 -0
  46. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +71 -0
  47. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +61 -0
  48. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +71 -0
  49. data/sig/generated/paper_trail_diff/analysis.rbs +18 -0
  50. data/sig/generated/paper_trail_diff/association_discovery.rbs +63 -0
  51. data/sig/generated/paper_trail_diff/association_traversal.rbs +54 -0
  52. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +56 -0
  53. data/sig/generated/paper_trail_diff/configuration.rbs +67 -0
  54. data/sig/generated/paper_trail_diff/diagnostics.rbs +109 -0
  55. data/sig/generated/paper_trail_diff/endpoint.rbs +30 -0
  56. data/sig/generated/paper_trail_diff/engine.rbs +54 -0
  57. data/sig/generated/paper_trail_diff/errors.rbs +43 -0
  58. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +37 -0
  59. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +50 -0
  60. data/sig/generated/paper_trail_diff/live_association_reader.rbs +9 -0
  61. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +72 -0
  62. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +27 -0
  63. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +65 -0
  64. data/sig/generated/paper_trail_diff/prepared_history.rbs +93 -0
  65. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +67 -0
  66. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +84 -0
  67. data/sig/generated/paper_trail_diff/snapshot.rbs +69 -0
  68. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +76 -0
  69. data/sig/generated/paper_trail_diff/step.rbs +18 -0
  70. data/sig/generated/paper_trail_diff/support.rbs +30 -0
  71. data/sig/generated/paper_trail_diff/timeline_builder.rbs +31 -0
  72. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -0
  73. data/sig/generated/paper_trail_diff/value_objects.rbs +87 -0
  74. data/sig/generated/paper_trail_diff/version.rbs +5 -0
  75. data/sig/generated/paper_trail_diff/version_range.rbs +35 -0
  76. data/sig/generated/paper_trail_diff.rbs +56 -0
  77. metadata +144 -0
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Advances scalar-only root events while retaining already reconstructed associations.
6
+ class ActivityRootSnapshotRefresher
7
+ #: (tree: AssociationTree, traversal: AssociationTraversal, normalizer: SnapshotNormalizer, record_after: untyped, changeset: untyped, partial_snapshotter: untyped) -> void
8
+ def initialize( # rubocop:disable Metrics/ParameterLists
9
+ tree:,
10
+ traversal:,
11
+ normalizer:,
12
+ record_after:,
13
+ changeset:,
14
+ partial_snapshotter:
15
+ )
16
+ @tree = tree
17
+ @traversal = traversal
18
+ @normalizer = normalizer
19
+ @record_after = record_after
20
+ @changeset = changeset
21
+ @partial_snapshotter = partial_snapshotter
22
+ @unsupported_branches = {} #: Hash[String, Array[String]]
23
+ @relationship_reflections = {} #: Hash[String, Array[untyped]]
24
+ end
25
+
26
+ #: (untyped, untyped, RecordSnapshot?, ActivityEvent) -> [bool, RecordSnapshot?]
27
+ def call( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
28
+ root_endpoint,
29
+ context_endpoint,
30
+ previous,
31
+ event
32
+ )
33
+ return [false, nil] unless previous && event.root?
34
+
35
+ version = event.version
36
+ model_class = Endpoint.model_class(version)
37
+ return [false, nil] unless same_record?(previous, version)
38
+
39
+ changes = @changeset.call(version, model_class)
40
+ return [false, nil] unless changes.respond_to?(:each)
41
+
42
+ branches = branches_to_refresh(model_class, changes)
43
+ partial = nil #: RecordSnapshot?
44
+ unless branches.empty?
45
+ partial = @partial_snapshotter.call(root_endpoint, context_endpoint, branches)
46
+ end
47
+ return [false, nil] if !branches.empty? && !partial
48
+
49
+ attributes = advanced_attributes(previous, version, changes)
50
+ return [false, nil] unless attributes
51
+
52
+ incoming_associations = {} #: snapshot_associations
53
+ incoming_associations = partial.associations if partial
54
+ associations = previous.associations.merge(incoming_associations)
55
+ return [true, previous] if attributes == previous.attributes &&
56
+ associations == previous.associations
57
+
58
+ [
59
+ true,
60
+ RecordSnapshot.new(
61
+ type: previous.type,
62
+ id: previous.id,
63
+ attributes: attributes,
64
+ associations: associations
65
+ )
66
+ ]
67
+ end
68
+
69
+ private
70
+
71
+ # @rbs @tree: AssociationTree
72
+ # @rbs @traversal: AssociationTraversal
73
+ # @rbs @normalizer: SnapshotNormalizer
74
+ # @rbs @record_after: untyped
75
+ # @rbs @changeset: untyped
76
+ # @rbs @partial_snapshotter: untyped
77
+ # @rbs @unsupported_branches: Hash[String, Array[String]]
78
+ # @rbs @relationship_reflections: Hash[String, Array[untyped]]
79
+
80
+ #: (RecordSnapshot, untyped, untyped) -> snapshot_attributes?
81
+ def advanced_attributes(previous, version, changes)
82
+ return previous.attributes if changes.empty?
83
+
84
+ record = @record_after.call(version)
85
+ return unless record
86
+
87
+ @normalizer.attributes_for(record, tree: @tree, path: '')
88
+ end
89
+
90
+ #: (untyped, untyped) -> Array[String]
91
+ def branches_to_refresh(model_class, changes)
92
+ (unsupported_branches(model_class) + changed_relationship_branches(model_class, changes))
93
+ .uniq
94
+ .sort
95
+ .freeze
96
+ end
97
+
98
+ #: (untyped) -> Array[String]
99
+ def unsupported_branches(model_class)
100
+ key = model_class.name.to_s
101
+ return @unsupported_branches[key] if @unsupported_branches.key?(key)
102
+
103
+ @unsupported_branches[key] = @traversal.selected_reflections(
104
+ model_class
105
+ ).filter_map do |path, reflection|
106
+ path.split('.').first unless direct_versioned_reflection?(reflection)
107
+ end.uniq.sort.freeze
108
+ end
109
+
110
+ #: (untyped) -> bool
111
+ def direct_versioned_reflection?(reflection)
112
+ return false if reflection.polymorphic? || reflection.options[:through]
113
+ return false if reflection.macro == :has_and_belongs_to_many
114
+
115
+ paper_trail = Object.const_get(:PaperTrail) #: untyped
116
+ paper_trail.request.enabled_for_model?(reflection.klass)
117
+ rescue NameError
118
+ false
119
+ end
120
+
121
+ #: (RecordSnapshot, untyped) -> bool
122
+ def same_record?(snapshot, version)
123
+ identity = Endpoint.identity(version)
124
+ snapshot.type.to_s == identity.fetch(0) && snapshot.id.to_s == identity.fetch(1)
125
+ end
126
+
127
+ #: (untyped, untyped) -> Array[String]
128
+ def changed_relationship_branches(model_class, changes)
129
+ changed = changes.each_key.map(&:to_s)
130
+ root_relationship_reflections(model_class).filter_map do |reflection|
131
+ reflection.name.to_s if changed.intersect?(relationship_columns(reflection))
132
+ end
133
+ end
134
+
135
+ #: (untyped) -> Array[untyped]
136
+ def root_relationship_reflections(model_class)
137
+ key = model_class.name.to_s
138
+ @relationship_reflections[key] ||= @traversal.reflections_for(
139
+ model_class,
140
+ @tree,
141
+ path: ''
142
+ ).select { |reflection| reflection.macro == :belongs_to }.freeze
143
+ end
144
+
145
+ #: (untyped) -> Array[String]
146
+ def relationship_columns(reflection)
147
+ foreign_keys = Array(reflection.foreign_key)
148
+ # @type var foreign_keys: Array[untyped]
149
+ columns = foreign_keys.map do |name| # rubocop:disable Style/SymbolProc
150
+ name.to_s
151
+ end
152
+ columns << reflection.foreign_type.to_s if reflection.polymorphic?
153
+ columns
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Optional full/partial snapshot operations consumed by the activity sequence.
6
+ class ActivitySnapshotProvider
7
+ #: (snapshotter: untyped, refresher: untyped, preparer: untyped) -> void
8
+ def initialize(snapshotter:, refresher:, preparer:)
9
+ @snapshotter = snapshotter
10
+ @refresher = refresher
11
+ @preparer = preparer
12
+ end
13
+
14
+ #: (untyped, Array[untyped]) -> void
15
+ def prepare(record, root_versions)
16
+ @preparer.call(record, root_versions)
17
+ end
18
+
19
+ #: (untyped, untyped) -> RecordSnapshot?
20
+ def call(root_endpoint, context_endpoint)
21
+ @snapshotter.call(root_endpoint, context_endpoint)
22
+ end
23
+
24
+ #: (untyped, untyped, RecordSnapshot?, Array[String], event: ActivityEvent, isolated: bool) -> RecordSnapshot?
25
+ def refresh( # rubocop:disable Metrics/ParameterLists
26
+ root_endpoint,
27
+ context_endpoint,
28
+ previous_snapshot,
29
+ branches,
30
+ event:,
31
+ isolated:
32
+ )
33
+ @refresher.call(
34
+ root_endpoint,
35
+ context_endpoint,
36
+ previous_snapshot,
37
+ branches,
38
+ event: event,
39
+ isolated: isolated
40
+ )
41
+ end
42
+
43
+ #: (untyped, untyped, RecordSnapshot?, event: ActivityEvent) -> RecordSnapshot?
44
+ def advance_root(root_endpoint, context_endpoint, previous_snapshot, event:)
45
+ @refresher.advance_root(
46
+ root_endpoint,
47
+ context_endpoint,
48
+ previous_snapshot,
49
+ event: event
50
+ )
51
+ end
52
+
53
+ # @rbs @snapshotter: untyped
54
+ # @rbs @refresher: untyped
55
+ # @rbs @preparer: untyped
56
+ end
57
+
58
+ # Builds chronological snapshots, refreshing only branches affected by prior events.
59
+ class ActivitySnapshotSequence
60
+ #: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped) -> void
61
+ def initialize(root_versions, events, snapshotter, current: nil)
62
+ @root_versions = root_versions
63
+ @events = events
64
+ @snapshotter = snapshotter
65
+ @current = current
66
+ @transaction_groups = ActivityTransactionGroups.new(events)
67
+ @root_endpoints = build_root_endpoints
68
+ end
69
+
70
+ #: () -> Array[RecordSnapshot?]
71
+ def call
72
+ snapshots = [] #: Array[RecordSnapshot?]
73
+ each { |_event, snapshot| snapshots << snapshot }
74
+ snapshots
75
+ end
76
+
77
+ #: () { (ActivityEvent, RecordSnapshot?) -> void } -> void
78
+ def each
79
+ first = @events.first
80
+ return unless first
81
+
82
+ previous_snapshot = full_snapshot(first)
83
+ yield first, previous_snapshot
84
+ @events.each_cons(2) do |pair|
85
+ previous_event = pair.fetch(0)
86
+ event = pair.fetch(1)
87
+ previous_snapshot = transition_snapshot(previous_event, event, previous_snapshot)
88
+ yield event, previous_snapshot
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ # @rbs @root_versions: Array[untyped]
95
+ # @rbs @events: Array[ActivityEvent]
96
+ # @rbs @snapshotter: untyped
97
+ # @rbs @current: untyped
98
+ # @rbs @transaction_groups: ActivityTransactionGroups
99
+ # @rbs @root_endpoints: Hash[untyped, untyped]
100
+
101
+ #: (ActivityEvent, ActivityEvent, RecordSnapshot?) -> RecordSnapshot?
102
+ def transition_snapshot(previous_event, event, previous_snapshot)
103
+ return advance_root(previous_event, event, previous_snapshot) if
104
+ incremental_root?(previous_event)
105
+
106
+ branches = @transaction_groups.branches_for(previous_event)
107
+ return full_snapshot(event) unless branches && @snapshotter.respond_to?(:refresh)
108
+
109
+ @snapshotter.refresh(
110
+ root_endpoint(event.version),
111
+ event.version,
112
+ previous_snapshot,
113
+ branches,
114
+ event: previous_event,
115
+ isolated: @transaction_groups.isolated?(previous_event)
116
+ )
117
+ end
118
+
119
+ #: (ActivityEvent) -> bool
120
+ def incremental_root?(event)
121
+ event.root? && @transaction_groups.isolated?(event) &&
122
+ @snapshotter.respond_to?(:advance_root)
123
+ end
124
+
125
+ #: (ActivityEvent, ActivityEvent, RecordSnapshot?) -> RecordSnapshot?
126
+ def advance_root(previous_event, event, previous_snapshot)
127
+ @snapshotter.advance_root(
128
+ root_endpoint(event.version),
129
+ event.version,
130
+ previous_snapshot,
131
+ event: previous_event
132
+ )
133
+ end
134
+
135
+ #: (ActivityEvent) -> RecordSnapshot?
136
+ def full_snapshot(event)
137
+ @snapshotter.call(root_endpoint(event.version), event.version)
138
+ end
139
+
140
+ #: (untyped) -> untyped
141
+ def root_endpoint(activity_version)
142
+ @root_endpoints.fetch(activity_version)
143
+ end
144
+
145
+ #: () -> Hash[untyped, untyped]
146
+ def build_root_endpoints
147
+ root_index = 0
148
+ endpoints = {} #: Hash[untyped, untyped]
149
+ endpoints.compare_by_identity
150
+ @events.each do |event|
151
+ while @root_versions[root_index] &&
152
+ Support.compare_versions(@root_versions[root_index], event.version).negative?
153
+ root_index += 1
154
+ end
155
+ endpoint = @root_versions[root_index] || @current || @root_versions.last
156
+ endpoints[event.version] = endpoint
157
+ end
158
+ endpoints
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Compares adjacent root and selected-descendant activity boundaries.
6
+ class ActivityTimelineBuilder
7
+ #: (untyped, from: untyped, to: untyped, tree: AssociationTree, snapshotter: untyped) -> void
8
+ def initialize(record, from:, to:, tree:, snapshotter:)
9
+ @record = record
10
+ @from = from
11
+ @to = to
12
+ @tree = tree
13
+ @snapshotter = snapshotter
14
+ end
15
+
16
+ #: () -> Array[ActivityStep]
17
+ def build
18
+ return build_to_current if Endpoint.record?(@to)
19
+
20
+ Endpoint.validate!(@to)
21
+ build_between_versions
22
+ end
23
+
24
+ # Builds all three version-bounded views from one activity snapshot pass.
25
+ #: () -> Analysis
26
+ def analyze
27
+ unless Endpoint.version?(@to)
28
+ raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
29
+ end
30
+
31
+ root_versions = VersionRange.new(@record, from: @from, to: @to).select
32
+ prepare_history(root_versions)
33
+ events = collect_events(root_versions)
34
+ activity_steps, root_snapshots, = event_history(root_versions, events)
35
+ build_analysis(root_versions, root_snapshots, activity_steps)
36
+ end
37
+
38
+ private
39
+
40
+ # @rbs @record: untyped
41
+ # @rbs @from: untyped
42
+ # @rbs @to: untyped
43
+ # @rbs @tree: AssociationTree
44
+ # @rbs @snapshotter: untyped
45
+
46
+ #: () -> Array[ActivityStep]
47
+ def build_between_versions
48
+ root_versions = VersionRange.new(@record, from: @from, to: @to).select
49
+ prepare_history(root_versions)
50
+ events = collect_events(root_versions)
51
+ build_event_steps(root_versions, events)
52
+ end
53
+
54
+ #: () -> Array[ActivityStep]
55
+ def build_to_current
56
+ validate_current_range!
57
+ current_snapshot, captured_at = capture_current
58
+ root_versions = VersionRange.new(@record, from: @from, to: @from).select_through_latest
59
+ prepare_history(root_versions)
60
+ events = collect_events(root_versions, range_end: captured_at)
61
+ build_event_steps(
62
+ root_versions,
63
+ events,
64
+ current: @to,
65
+ final_boundary: ActivityBoundary.current(@to, captured_at: captured_at),
66
+ final_snapshot: current_snapshot
67
+ )
68
+ end
69
+
70
+ #: () -> [RecordSnapshot?, untyped]
71
+ def capture_current
72
+ [@snapshotter.call(@to, @to), Time.now.utc]
73
+ end
74
+
75
+ #: () -> void
76
+ def validate_current_range!
77
+ unless Endpoint.version?(@from)
78
+ raise InvalidTimelineRangeError, '`from` must be a root PaperTrail version'
79
+ end
80
+
81
+ Endpoint.validate_pair!(@from, @to)
82
+ Endpoint.validate_pair!(@record, @to)
83
+ end
84
+
85
+ #: (Array[untyped], ?range_end: untyped) -> Array[ActivityEvent]
86
+ def collect_events(root_versions, range_end: root_versions.last)
87
+ ActivityVersionCollector.new(
88
+ @record,
89
+ root_versions: root_versions,
90
+ tree: @tree,
91
+ traversal: AssociationTraversal.new(@tree),
92
+ range_end: range_end
93
+ ).call
94
+ end
95
+
96
+ #: (Array[untyped]) -> void
97
+ def prepare_history(root_versions)
98
+ @snapshotter.prepare(@record, root_versions) if @snapshotter.respond_to?(:prepare)
99
+ end
100
+
101
+ #: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
102
+ def build_event_steps(
103
+ root_versions,
104
+ events,
105
+ current: nil,
106
+ final_boundary: nil,
107
+ final_snapshot: nil
108
+ )
109
+ steps, _root_snapshots, previous_snapshot = event_history(
110
+ root_versions,
111
+ events,
112
+ current: current
113
+ )
114
+ previous_event = events.last
115
+ previous_boundary = ActivityBoundary.from_version(previous_event.version) if previous_event
116
+ if final_boundary && previous_boundary
117
+ steps << ActivityStep.new(
118
+ from_boundary: previous_boundary,
119
+ to_boundary: final_boundary,
120
+ diff: Engine.compare(previous_snapshot, final_snapshot)
121
+ )
122
+ end
123
+ steps.freeze
124
+ end
125
+
126
+ #: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> [Array[ActivityStep], Hash[Array[untyped], RecordSnapshot?], RecordSnapshot?]
127
+ def event_history(root_versions, events, current: nil) # rubocop:disable Metrics/MethodLength
128
+ sequence = ActivitySnapshotSequence.new(root_versions, events, @snapshotter, current: current)
129
+ steps = [] #: Array[ActivityStep]
130
+ root_snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
131
+ previous_boundary = nil #: ActivityBoundary?
132
+ previous_snapshot = nil #: RecordSnapshot?
133
+ sequence.each do |event, snapshot|
134
+ boundary = ActivityBoundary.from_version(event.version)
135
+ root_snapshots[version_key(event.version)] = snapshot if event.root?
136
+ if previous_boundary
137
+ steps << ActivityStep.new(
138
+ from_boundary: previous_boundary,
139
+ to_boundary: boundary,
140
+ diff: Engine.compare(previous_snapshot, snapshot)
141
+ )
142
+ end
143
+ previous_boundary = boundary
144
+ previous_snapshot = snapshot
145
+ end
146
+ [steps, root_snapshots, previous_snapshot]
147
+ end
148
+
149
+ #: (Array[untyped], Hash[Array[untyped], RecordSnapshot?], Array[ActivityStep]) -> Analysis
150
+ def build_analysis(root_versions, root_snapshots, activity_steps)
151
+ snapshots = root_versions.map do |version|
152
+ root_snapshots.fetch(version_key(version))
153
+ end
154
+ Analysis.new(
155
+ diff: Engine.compare(snapshots.first, snapshots.last),
156
+ timeline: build_root_steps(root_versions, snapshots),
157
+ activity_timeline: activity_steps.freeze
158
+ )
159
+ end
160
+
161
+ #: (Array[untyped], Array[RecordSnapshot?]) -> Array[Step]
162
+ def build_root_steps(root_versions, snapshots)
163
+ root_versions.each_cons(2).with_index.map do |versions, index|
164
+ Step.new(
165
+ from_version: versions.fetch(0),
166
+ to_version: versions.fetch(1),
167
+ diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
168
+ )
169
+ end.freeze
170
+ end
171
+
172
+ #: (untyped) -> Array[untyped]
173
+ def version_key(version)
174
+ [version.class.name, version.id]
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Collects root and selected-descendant versions relevant to one root history range.
6
+ class ActivityVersionCollector
7
+ #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_end: untyped) -> void
8
+ def initialize(record, root_versions:, tree:, traversal:, range_end: root_versions.last)
9
+ @record = record
10
+ @root_versions = root_versions
11
+ @tree = tree
12
+ @traversal = traversal
13
+ @range = ActivityRange.new(root_versions.first, range_end)
14
+ @registry = ActivityEventRegistry.new
15
+ end
16
+
17
+ #: () -> Array[ActivityEvent]
18
+ def call
19
+ @registry.add(@root_versions)
20
+ collect_tree(@record.class, [@record.id], @tree, path: '', branch: nil) unless @tree.empty?
21
+ @registry.to_a.sort_by do |event|
22
+ Support.chronological_version_key(event.version)
23
+ end.freeze
24
+ end
25
+
26
+ private
27
+
28
+ # @rbs @record: untyped
29
+ # @rbs @root_versions: Array[untyped]
30
+ # @rbs @tree: AssociationTree
31
+ # @rbs @traversal: AssociationTraversal
32
+ # @rbs @range: ActivityRange
33
+ # @rbs @registry: ActivityEventRegistry
34
+
35
+ #: (untyped, Array[untyped], AssociationTree, path: String, branch: String?) -> void
36
+ def collect_tree(parent_class, parent_ids, tree, path:, branch:)
37
+ parent = group(parent_class, parent_ids)
38
+ @registry.add(parent.fetch(:versions), branch: branch) unless path.empty?
39
+ @traversal.reflections_for(parent_class, tree, path: path).each do |reflection|
40
+ subtree = tree.child(reflection.name)
41
+ next unless subtree
42
+
43
+ selected_branch = branch || reflection.name.to_s
44
+ collect_reflection(parent, reflection, subtree, path, selected_branch)
45
+ end
46
+ end
47
+
48
+ #: (Hash[Symbol, untyped], untyped, AssociationTree, String, String) -> void
49
+ def collect_reflection(parent, reflection, subtree, path, branch)
50
+ groups = edge_groups(parent, reflection, branch)
51
+ groups.each_value do |group|
52
+ @registry.add(group.fetch(:versions), branch: branch)
53
+ next if subtree.empty?
54
+
55
+ child_path = join_path(path, reflection.name.to_s)
56
+ collect_tree(group.fetch(:model), group.fetch(:ids), subtree,
57
+ path: child_path, branch: branch)
58
+ end
59
+ end
60
+
61
+ #: (Hash[Symbol, untyped], untyped, String) -> Hash[String, untyped]
62
+ def edge_groups(parent, reflection, branch)
63
+ return through_groups(parent, reflection, branch) if reflection.options[:through]
64
+
65
+ case reflection.macro
66
+ when :belongs_to
67
+ belongs_to_groups(parent.fetch(:model), parent.fetch(:versions), reflection)
68
+ when :has_one, :has_many
69
+ direct_child_groups(parent.fetch(:model), parent.fetch(:ids), reflection)
70
+ when :has_and_belongs_to_many
71
+ habtm_groups(parent.fetch(:model), parent.fetch(:versions), reflection)
72
+ else
73
+ {}
74
+ end
75
+ end
76
+
77
+ #: (Hash[Symbol, untyped], untyped, String) -> Hash[String, untyped]
78
+ def through_groups(parent, reflection, branch)
79
+ through = reflection.through_reflection
80
+ intermediate = edge_groups(parent, through, branch)
81
+ result = {} #: Hash[String, untyped]
82
+ intermediate.each_value do |group|
83
+ @registry.add(group.fetch(:versions), branch: branch)
84
+ source = reflection.source_reflection
85
+ children = edge_groups(group, source, branch)
86
+ merge_groups!(result, children)
87
+ end
88
+ result
89
+ end
90
+
91
+ #: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
92
+ def belongs_to_groups(parent_class, parent_versions, reflection)
93
+ rows = parent_class.paper_trail.version_association_class.where(
94
+ version_id: parent_versions.map(&:id),
95
+ foreign_key_name: reflection.foreign_key.to_s
96
+ ).to_a
97
+ identity_groups(rows, reflection)
98
+ end
99
+
100
+ #: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
101
+ def direct_child_groups(parent_class, parent_ids, reflection) # rubocop:disable Metrics/AbcSize
102
+ version_class = parent_class.paper_trail.version_class
103
+ relation = parent_class.paper_trail.version_association_class.joins(:version).where(
104
+ foreign_key_name: reflection.foreign_key.to_s,
105
+ foreign_key_id: parent_ids
106
+ ).where(foreign_type: related_parent_types(parent_class))
107
+ item_ids = relation.where(
108
+ version_class.table_name => { item_type: reflection.klass.base_class.name }
109
+ ).distinct.pluck(version_class.arel_table[:item_id])
110
+ return {} if item_ids.empty?
111
+
112
+ { reflection.klass.name => group(reflection.klass, item_ids) }
113
+ end
114
+
115
+ #: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
116
+ def habtm_groups(parent_class, parent_versions, reflection)
117
+ transaction_ids = parent_versions.filter_map do |version|
118
+ version.transaction_id if version.respond_to?(:transaction_id)
119
+ end
120
+ rows = parent_class.paper_trail.version_association_class.where(
121
+ version_id: transaction_ids,
122
+ foreign_key_name: reflection.name.to_s
123
+ ).to_a
124
+ identity_groups(rows, reflection)
125
+ end
126
+
127
+ #: (Array[untyped], untyped) -> Hash[String, untyped]
128
+ def identity_groups(rows, reflection)
129
+ identities = rows.group_by { |row| target_class(reflection, row) }
130
+ groups = {} #: Hash[String, untyped]
131
+ identities.each do |model_class, class_rows|
132
+ next unless model_class
133
+
134
+ ids = class_rows.map(&:foreign_key_id).compact.uniq
135
+ groups[model_class.name] = group(model_class, ids)
136
+ end
137
+ groups
138
+ end
139
+
140
+ #: (untyped, Array[untyped]) -> Hash[Symbol, untyped]
141
+ def group(model_class, ids)
142
+ { model: model_class, ids: ids, versions: versions_for(model_class, ids) }
143
+ end
144
+
145
+ #: (Hash[String, untyped], Hash[String, untyped]) -> void
146
+ def merge_groups!(destination, source)
147
+ source.each do |name, incoming|
148
+ existing = destination[name]
149
+ unless existing
150
+ destination[name] = incoming
151
+ next
152
+ end
153
+
154
+ ids = (existing.fetch(:ids) | incoming.fetch(:ids))
155
+ destination[name] = group(existing.fetch(:model), ids)
156
+ end
157
+ end
158
+
159
+ #: (untyped, Array[untyped]) -> Array[untyped]
160
+ def versions_for(model_class, ids)
161
+ return [] if ids.empty?
162
+
163
+ version_class = model_class.paper_trail.version_class
164
+ @range.scope(
165
+ version_class.where(
166
+ item_type: model_class.base_class.name,
167
+ item_id: ids
168
+ )
169
+ ).to_a.select { |version| in_range?(version) }
170
+ end
171
+
172
+ #: (untyped, untyped) -> untyped
173
+ def target_class(reflection, row)
174
+ return reflection.klass unless reflection.polymorphic?
175
+
176
+ Object.const_get(row.foreign_type.to_s) unless row.foreign_type.to_s.empty?
177
+ rescue NameError
178
+ nil
179
+ end
180
+
181
+ #: (untyped) -> Array[String?]
182
+ def related_parent_types(parent_class)
183
+ [nil, '', parent_class.name.to_s, parent_class.base_class.name.to_s].uniq
184
+ end
185
+
186
+ #: (untyped) -> bool
187
+ def in_range?(version)
188
+ @range.include?(version)
189
+ end
190
+
191
+ #: (String, String) -> String
192
+ def join_path(parent, name)
193
+ parent.empty? ? name : "#{parent}.#{name}"
194
+ end
195
+ end
196
+ end