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,144 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Reconstructs and caches immutable historical snapshots for one adapter configuration.
6
+ class HistoricalSnapshotStore
7
+ #: (tree: AssociationTree, traversal: AssociationTraversal, normalizer: SnapshotNormalizer, preparer: untyped) -> void
8
+ def initialize(tree:, traversal:, normalizer:, preparer:)
9
+ @tree = tree
10
+ @traversal = traversal
11
+ @normalizer = normalizer
12
+ @preparer = preparer
13
+ @snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
14
+ @prepared_history = nil #: PreparedHistory?
15
+ end
16
+
17
+ #: (untyped, Array[untyped]) -> void
18
+ def prepare(record, root_versions)
19
+ return if @tree.empty?
20
+
21
+ @prepared_history = PreparedHistoryLoader.new(
22
+ record,
23
+ root_versions: root_versions,
24
+ tree: @tree,
25
+ traversal: @traversal
26
+ ).call
27
+ end
28
+
29
+ #: (untyped, untyped) -> RecordSnapshot?
30
+ def call(root_endpoint, context_endpoint)
31
+ key = snapshot_key(root_endpoint, context_endpoint)
32
+ return @snapshots[key] if @snapshots.key?(key)
33
+
34
+ @snapshots[key] = custom(
35
+ root_endpoint,
36
+ context_endpoint,
37
+ tree: @tree,
38
+ normalizer: @normalizer
39
+ )
40
+ end
41
+
42
+ #: (untyped, untyped) -> RecordSnapshot?
43
+ def uncached(root_endpoint, context_endpoint)
44
+ custom(
45
+ root_endpoint,
46
+ context_endpoint,
47
+ tree: @tree,
48
+ normalizer: @normalizer
49
+ )
50
+ end
51
+
52
+ #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
53
+ def custom(root_endpoint, context_endpoint, tree:, normalizer:)
54
+ if Endpoint.version?(root_endpoint)
55
+ return snapshot_from_version(
56
+ root_endpoint,
57
+ context_endpoint,
58
+ tree: tree,
59
+ normalizer: normalizer
60
+ )
61
+ end
62
+
63
+ snapshot_from_live_root(
64
+ root_endpoint,
65
+ context_endpoint,
66
+ tree: tree,
67
+ normalizer: normalizer
68
+ )
69
+ end
70
+
71
+ #: (untyped, habtm_version: untyped) -> untyped
72
+ def association_reader(context_version, habtm_version:)
73
+ fallback = HistoricalAssociationReifier.new(
74
+ context_version,
75
+ habtm_version: habtm_version
76
+ )
77
+ history = @prepared_history
78
+ return fallback unless history
79
+
80
+ PreparedAssociationReifier.new(
81
+ history,
82
+ context_version,
83
+ habtm_boundary: habtm_version,
84
+ fallback: fallback
85
+ )
86
+ end
87
+
88
+ private
89
+
90
+ # @rbs @tree: AssociationTree
91
+ # @rbs @traversal: AssociationTraversal
92
+ # @rbs @normalizer: SnapshotNormalizer
93
+ # @rbs @preparer: untyped
94
+ # @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
95
+ # @rbs @prepared_history: PreparedHistory?
96
+
97
+ #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
98
+ def snapshot_from_version(root_version, context_version, tree:, normalizer:)
99
+ model_class = Endpoint.model_class(root_version)
100
+ @preparer.call(model_class, historical: true)
101
+ @traversal.ensure_habtm_history!(model_class, root_version)
102
+ record = root_version.reify(dup: true)
103
+ normalize(
104
+ record,
105
+ context_version,
106
+ tree: tree,
107
+ normalizer: normalizer,
108
+ habtm_version: root_version
109
+ )
110
+ end
111
+
112
+ #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
113
+ def snapshot_from_live_root(root_record, context_version, tree:, normalizer:)
114
+ record = Endpoint.reload_record(root_record)
115
+ @preparer.call(record.class, historical: true)
116
+ normalize(
117
+ record,
118
+ context_version,
119
+ tree: tree,
120
+ normalizer: normalizer,
121
+ habtm_version: context_version
122
+ )
123
+ end
124
+
125
+ #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, habtm_version: untyped) -> RecordSnapshot?
126
+ def normalize(record, context_version, tree:, normalizer:, habtm_version:)
127
+ reifier = association_reader(context_version, habtm_version: habtm_version)
128
+ reflections = [] #: Array[untyped]
129
+ reflections = @traversal.reflections_for(record.class, tree, path: '') if record
130
+ reifier.reify(record, reflections) if record && !reflections.empty?
131
+ normalizer.call(record, reifier: reifier)
132
+ end
133
+
134
+ #: (untyped, untyped) -> Array[untyped]
135
+ def snapshot_key(root_endpoint, context_endpoint)
136
+ [
137
+ root_endpoint.class.name,
138
+ root_endpoint.id,
139
+ context_endpoint.class.name,
140
+ context_endpoint.id
141
+ ]
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Leaves live ActiveRecord associations untouched while sharing snapshot normalization.
6
+ class LiveAssociationReader
7
+ #: (untyped, Array[untyped]) -> void
8
+ def reify(_record, _reflections); end
9
+ end
10
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # PaperTrail/ActiveRecord boundary that produces plain record snapshots.
6
+ class PaperTrailAdapter
7
+ #: (associations: Array[String | Symbol], ignore: ignore_option) -> void
8
+ def initialize(associations:, ignore:)
9
+ @association_tree = AssociationTree.build(associations)
10
+ @ignore_policy = IgnorePolicy.build(ignore, association_paths: @association_tree.paths)
11
+ @traversal = AssociationTraversal.new(@association_tree)
12
+ @snapshot_pool = SnapshotPool.new
13
+ @normalizer = SnapshotNormalizer.new(
14
+ tree: @association_tree,
15
+ ignore_policy: @ignore_policy,
16
+ traversal: @traversal,
17
+ pool: @snapshot_pool
18
+ )
19
+ @historical_store = build_historical_store
20
+ @timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
21
+ @activity_snapshotter = build_activity_snapshotter
22
+ end
23
+
24
+ #: (untyped, untyped) -> Diff
25
+ def compare(from_endpoint, to_endpoint)
26
+ Endpoint.validate_pair!(from_endpoint, to_endpoint)
27
+ Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
28
+ end
29
+
30
+ #: (untyped, from: untyped, to: untyped) -> Array[Step]
31
+ def timeline(record, from:, to:)
32
+ prepare_traversal!(record.class, historical: true)
33
+ builder = TimelineBuilder.new(
34
+ record,
35
+ from: from,
36
+ to: to,
37
+ snapshotter: @timeline_snapshotter
38
+ )
39
+ builder.build
40
+ end
41
+
42
+ #: (untyped, from: untyped, to: untyped) -> Array[ActivityStep]
43
+ def activity_timeline(record, from:, to:)
44
+ prepare_traversal!(record.class, historical: true)
45
+ reject_live_habtm_activity!(record.class) if Endpoint.record?(to)
46
+ ActivityTimelineBuilder.new(
47
+ record,
48
+ from: from,
49
+ to: to,
50
+ tree: @association_tree,
51
+ snapshotter: @activity_snapshotter
52
+ ).build
53
+ end
54
+
55
+ #: (untyped, from: untyped, to: untyped, ?activity: bool) -> Analysis
56
+ def analyze(record, from:, to:, activity: false)
57
+ if activity
58
+ prepare_traversal!(record.class, historical: true)
59
+ return activity_builder(record, from: from, to: to).analyze
60
+ end
61
+
62
+ prepare_traversal!(record.class, historical: true)
63
+ TimelineBuilder.new(
64
+ record,
65
+ from: from,
66
+ to: to,
67
+ snapshotter: @timeline_snapshotter
68
+ ).analyze
69
+ end
70
+
71
+ private
72
+
73
+ # @rbs @association_tree: AssociationTree
74
+ # @rbs @ignore_policy: IgnorePolicy
75
+ # @rbs @traversal: AssociationTraversal
76
+ # @rbs @snapshot_pool: SnapshotPool
77
+ # @rbs @normalizer: SnapshotNormalizer
78
+ # @rbs @historical_store: HistoricalSnapshotStore
79
+ # @rbs @timeline_snapshotter: TimelineSnapshotProvider
80
+ # @rbs @activity_snapshotter: ActivitySnapshotProvider
81
+
82
+ #: () -> HistoricalSnapshotStore
83
+ def build_historical_store
84
+ HistoricalSnapshotStore.new(
85
+ tree: @association_tree,
86
+ traversal: @traversal,
87
+ normalizer: @normalizer,
88
+ preparer: method(:prepare_traversal!)
89
+ )
90
+ end
91
+
92
+ #: () -> ActivitySnapshotProvider
93
+ def build_activity_snapshotter
94
+ refresher = BranchSnapshotRefresher.new(
95
+ tree: @association_tree,
96
+ ignore_policy: @ignore_policy,
97
+ traversal: @traversal,
98
+ pool: @snapshot_pool,
99
+ normalizer: @normalizer,
100
+ full_snapshotter: method(:snapshot_at),
101
+ partial_snapshotter: @historical_store.method(:custom),
102
+ association_reader: @historical_store.method(:association_reader)
103
+ )
104
+ ActivitySnapshotProvider.new(
105
+ snapshotter: method(:snapshot_at),
106
+ refresher: refresher,
107
+ preparer: @historical_store.method(:prepare)
108
+ )
109
+ end
110
+
111
+ #: (untyped, from: untyped, to: untyped) -> ActivityTimelineBuilder
112
+ def activity_builder(record, from:, to:)
113
+ ActivityTimelineBuilder.new(
114
+ record,
115
+ from: from,
116
+ to: to,
117
+ tree: @association_tree,
118
+ snapshotter: @activity_snapshotter
119
+ )
120
+ end
121
+
122
+ #: (untyped) -> void
123
+ def reject_live_habtm_activity!(model_class)
124
+ paths = @traversal.habtm_paths(model_class)
125
+ return if paths.empty?
126
+
127
+ message = "live-ended activity cannot reconstruct HABTM event boundaries: #{paths.join(', ')}"
128
+ raise UnsupportedLiveActivityError, message
129
+ end
130
+
131
+ #: (untyped) -> RecordSnapshot?
132
+ def snapshot_for_endpoint(endpoint)
133
+ Endpoint.version?(endpoint) ? historical_snapshot(endpoint) : live_snapshot(endpoint)
134
+ end
135
+
136
+ #: (untyped) -> RecordSnapshot?
137
+ def historical_snapshot(version)
138
+ snapshot_at(version, version)
139
+ end
140
+
141
+ #: (untyped) -> RecordSnapshot?
142
+ def uncached_historical_snapshot(version)
143
+ @historical_store.uncached(version, version)
144
+ end
145
+
146
+ #: (untyped, untyped) -> RecordSnapshot?
147
+ def snapshot_at(root_endpoint, context_endpoint)
148
+ return live_snapshot(root_endpoint) if Endpoint.record?(context_endpoint)
149
+
150
+ @historical_store.call(root_endpoint, context_endpoint)
151
+ end
152
+
153
+ #: (untyped) -> RecordSnapshot
154
+ def live_snapshot(record)
155
+ current = Endpoint.reload_record(record)
156
+ prepare_traversal!(current.class, historical: false)
157
+ @normalizer.call(current, reifier: LiveAssociationReader.new) ||
158
+ raise(InvalidEndpointError, 'current record endpoint could not be normalized')
159
+ end
160
+
161
+ #: (untyped, historical: bool) -> void
162
+ def prepare_traversal!(model_class, historical:)
163
+ return if @association_tree.empty?
164
+
165
+ ensure_association_tracking! if historical
166
+ @traversal.validate!(model_class)
167
+ end
168
+
169
+ #: () -> void
170
+ def ensure_association_tracking!
171
+ paper_trail = Object.const_get(:PaperTrail) #: untyped
172
+ config = paper_trail.config #: untyped
173
+ available = defined?(::PaperTrailAssociationTracking) &&
174
+ config.respond_to?(:track_associations?) &&
175
+ config.track_associations?
176
+ return if available
177
+
178
+ message = 'association tracking must be loaded and enabled to compare historical associations'
179
+ raise AssociationTrackingUnavailableError, message
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Resolves indexed associations and delegates unsupported reflections to PT-AT.
6
+ class PreparedAssociationReifier
7
+ #: (PreparedHistory, untyped, habtm_boundary: untyped, fallback: HistoricalAssociationReifier) -> void
8
+ def initialize(history, boundary, habtm_boundary:, fallback:)
9
+ @history = history
10
+ @boundary = boundary
11
+ @habtm_boundary = habtm_boundary
12
+ @fallback = fallback
13
+ @reified = {} #: Hash[Array[untyped], bool]
14
+ end
15
+
16
+ #: (untyped, Array[untyped]) -> void
17
+ def reify(record, reflections)
18
+ reflections.each { |reflection| reify_association(record, reflection) }
19
+ end
20
+
21
+ private
22
+
23
+ # @rbs @history: PreparedHistory
24
+ # @rbs @boundary: untyped
25
+ # @rbs @habtm_boundary: untyped
26
+ # @rbs @fallback: HistoricalAssociationReifier
27
+ # @rbs @reified: Hash[Array[untyped], bool]
28
+
29
+ #: (untyped, untyped) -> void
30
+ def reify_association(record, reflection)
31
+ key = [record.object_id, reflection.name]
32
+ return if @reified[key]
33
+
34
+ @reified[key] = true
35
+ handled, records = @history.resolve(
36
+ record,
37
+ reflection,
38
+ @boundary,
39
+ habtm_boundary: @habtm_boundary
40
+ )
41
+ return @fallback.reify(record, [reflection]) unless handled
42
+
43
+ association = record.association(reflection.name)
44
+ association.target = reflection.collection? ? records : records.first
45
+ association.loaded!
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Loads every historical identity that can participate in one association edge.
6
+ class PreparedEdgeLoader
7
+ EDGE_LOADERS = {
8
+ belongs_to: :belongs_to_edge,
9
+ has_one: :child_edge,
10
+ has_many: :child_edge,
11
+ has_and_belongs_to_many: :habtm_edge
12
+ }.freeze
13
+ private_constant :EDGE_LOADERS
14
+
15
+ #: (PreparedRecordIndex, Array[untyped]) -> void
16
+ def initialize(records, root_versions)
17
+ @records = records
18
+ @transaction_ids = root_versions.filter_map(&:transaction_id).uniq.freeze
19
+ end
20
+
21
+ #: (untyped, Array[untyped], untyped) -> [Hash[String, Hash[Symbol, untyped]], Hash[String, Array[untyped]]?]
22
+ def call(owner_class, owner_ids, reflection)
23
+ loader = EDGE_LOADERS.fetch(reflection.macro)
24
+ send(loader, owner_class, owner_ids, reflection)
25
+ end
26
+
27
+ private
28
+
29
+ # @rbs @records: PreparedRecordIndex
30
+ # @rbs @transaction_ids: Array[untyped]
31
+
32
+ #: (untyped, Array[untyped], untyped) -> [Hash[String, Hash[Symbol, untyped]], nil]
33
+ def belongs_to_edge(owner_class, owner_ids, reflection)
34
+ [belongs_to_groups(owner_class, owner_ids, reflection), nil]
35
+ end
36
+
37
+ #: (untyped, Array[untyped], untyped) -> [Hash[String, Hash[Symbol, untyped]], nil]
38
+ def child_edge(owner_class, owner_ids, reflection)
39
+ [child_groups(owner_class, owner_ids, reflection), nil]
40
+ end
41
+
42
+ #: (untyped, Array[untyped], untyped) -> [Hash[String, Hash[Symbol, untyped]], Hash[String, Array[untyped]]]
43
+ def habtm_edge(owner_class, _owner_ids, reflection)
44
+ habtm_groups(owner_class, reflection)
45
+ end
46
+
47
+ #: (untyped, Array[untyped], untyped) -> Hash[String, Hash[Symbol, untyped]]
48
+ def belongs_to_groups(owner_class, owner_ids, reflection)
49
+ key = scalar_foreign_key(reflection)
50
+ return {} unless key
51
+
52
+ grouped = {} #: Hash[String, Hash[Symbol, untyped]]
53
+ @records.records_for(owner_class, owner_ids).each do |owner|
54
+ id = owner.public_send(key)
55
+ model_class = target_class(reflection, owner)
56
+ add_identity(grouped, model_class, id) if model_class && id
57
+ end
58
+ grouped
59
+ end
60
+
61
+ #: (untyped, Array[untyped], untyped) -> Hash[String, Hash[Symbol, untyped]]
62
+ def child_groups(owner_class, owner_ids, reflection)
63
+ key = scalar_foreign_key(reflection)
64
+ return {} unless key
65
+
66
+ ids = historical_child_ids(owner_class, owner_ids, reflection, key)
67
+ ids.concat(live_child_ids(owner_class, owner_ids, reflection, key))
68
+ group_for(reflection.klass, ids)
69
+ end
70
+
71
+ #: (untyped, untyped) -> [Hash[String, Hash[Symbol, untyped]], Hash[String, Array[untyped]]]
72
+ def habtm_groups(owner_class, reflection)
73
+ rows = habtm_rows(owner_class, reflection)
74
+ memberships = @transaction_ids.to_h do |transaction_id|
75
+ [transaction_id.to_s, membership_ids(rows, transaction_id)]
76
+ end
77
+ [group_for(reflection.klass, rows.filter_map(&:foreign_key_id)), memberships]
78
+ end
79
+
80
+ #: (untyped, untyped) -> Array[untyped]
81
+ def habtm_rows(owner_class, reflection)
82
+ owner_class.paper_trail.version_association_class.where(
83
+ version_id: @transaction_ids,
84
+ foreign_key_name: reflection.name.to_s
85
+ ).to_a
86
+ end
87
+
88
+ #: (Array[untyped], untyped) -> Array[untyped]
89
+ def membership_ids(rows, transaction_id)
90
+ rows.filter_map do |row|
91
+ row.foreign_key_id if row.version_id.to_s == transaction_id.to_s
92
+ end.uniq
93
+ end
94
+
95
+ #: (untyped, Array[untyped], untyped, String) -> Array[untyped]
96
+ def historical_child_ids( # rubocop:disable Metrics/AbcSize
97
+ owner_class,
98
+ owner_ids,
99
+ reflection,
100
+ foreign_key
101
+ )
102
+ version_class = owner_class.paper_trail.version_class
103
+ relation = owner_class.paper_trail.version_association_class.joins(:version).where(
104
+ foreign_key_name: foreign_key,
105
+ foreign_key_id: owner_ids
106
+ ).where(foreign_type: parent_types(owner_class)).where(
107
+ version_class.table_name => { item_type: reflection.klass.base_class.name }
108
+ )
109
+ relation.distinct.pluck(version_class.arel_table[:item_id])
110
+ end
111
+
112
+ #: (untyped, Array[untyped], untyped, String) -> Array[untyped]
113
+ def live_child_ids(owner_class, owner_ids, reflection, foreign_key)
114
+ relation = reflection.klass.base_class.unscoped.where(foreign_key => owner_ids)
115
+ if reflection.options[:as]
116
+ relation = relation.where(reflection.type => parent_types(owner_class))
117
+ end
118
+ relation.pluck(reflection.klass.primary_key)
119
+ end
120
+
121
+ #: (untyped, untyped) -> untyped
122
+ def target_class(reflection, owner)
123
+ return reflection.klass unless reflection.polymorphic?
124
+
125
+ Object.const_get(owner.public_send(reflection.foreign_type).to_s)
126
+ rescue NameError
127
+ nil
128
+ end
129
+
130
+ #: (Hash[String, Hash[Symbol, untyped]], untyped, untyped) -> void
131
+ def add_identity(groups, model_class, id)
132
+ group = groups[model_class.name.to_s] ||= { model: model_class, ids: [] }
133
+ group.fetch(:ids) << id unless group.fetch(:ids).include?(id)
134
+ end
135
+
136
+ #: (untyped, Array[untyped]) -> Hash[String, Hash[Symbol, untyped]]
137
+ def group_for(model_class, ids)
138
+ unique = ids.compact.uniq
139
+ return {} if unique.empty?
140
+
141
+ { model_class.name.to_s => { model: model_class, ids: unique } }
142
+ end
143
+
144
+ #: (untyped) -> String?
145
+ def scalar_foreign_key(reflection)
146
+ key = reflection.foreign_key
147
+ key.to_s unless key.is_a?(Array)
148
+ end
149
+
150
+ #: (untyped) -> Array[String?]
151
+ def parent_types(owner_class)
152
+ [nil, '', owner_class.name.to_s, owner_class.base_class.name.to_s].uniq
153
+ end
154
+ end
155
+ end