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,128 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Internal activity boundary plus the selected root branches affected by its event.
6
+ class ActivityEvent
7
+ attr_reader :version #: untyped
8
+ attr_reader :branches #: Array[String]?
9
+
10
+ #: (version: untyped, branches: Array[String]?) -> void
11
+ def initialize(version:, branches:)
12
+ @version = version
13
+ @branches = branches ? branches.map(&:to_s).uniq.sort.freeze : nil
14
+ freeze
15
+ end
16
+
17
+ #: () -> bool
18
+ def root?
19
+ branches.nil?
20
+ end
21
+ end
22
+
23
+ # Accumulates each version once while retaining every selected root branch.
24
+ class ActivityEventRegistry
25
+ def initialize
26
+ @events = {} #: Hash[Array[untyped], Hash[Symbol, untyped]]
27
+ end
28
+
29
+ #: (Array[untyped], ?branch: String?) -> void
30
+ def add(versions, branch: nil)
31
+ versions.each do |version|
32
+ key = [version.class.name, version.id]
33
+ existing = @events[key]
34
+ @events[key] = {
35
+ version: version,
36
+ branches: merged_branches(existing, branch)
37
+ }
38
+ end
39
+ end
40
+
41
+ #: () -> Array[ActivityEvent]
42
+ def to_a
43
+ @events.values.map do |event|
44
+ ActivityEvent.new(
45
+ version: event.fetch(:version),
46
+ branches: event.fetch(:branches)
47
+ )
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # @rbs @events: Hash[Array[untyped], Hash[Symbol, untyped]]
54
+
55
+ #: (Hash[Symbol, untyped]?, String?) -> Array[String]?
56
+ def merged_branches(existing, branch)
57
+ return nil if branch.nil?
58
+ return [branch] unless existing
59
+ return nil if existing.fetch(:branches).nil?
60
+
61
+ (existing.fetch(:branches) | [branch])
62
+ end
63
+ end
64
+
65
+ # Resolves the branch union that PT-AT exposes atomically for one transaction.
66
+ class ActivityTransactionGroups
67
+ #: (Array[ActivityEvent]) -> void
68
+ def initialize(events)
69
+ groups = grouped_events(events)
70
+ @branches = build_groups(groups)
71
+ @group_sizes = groups.transform_values(&:length)
72
+ end
73
+
74
+ #: (ActivityEvent) -> Array[String]?
75
+ def branches_for(event)
76
+ key = transaction_key(event)
77
+ return event.branches unless key && @branches.key?(key)
78
+
79
+ @branches[key]
80
+ end
81
+
82
+ #: (ActivityEvent) -> bool
83
+ def isolated?(event)
84
+ key = transaction_key(event)
85
+ !key || @group_sizes.fetch(key, 0) == 1
86
+ end
87
+
88
+ private
89
+
90
+ # @rbs @branches: Hash[Array[untyped], Array[String]?]
91
+ # @rbs @group_sizes: Hash[Array[untyped], Integer]
92
+
93
+ #: (Hash[Array[untyped], Array[ActivityEvent]]) -> Hash[Array[untyped], Array[String]?]
94
+ def build_groups(groups)
95
+ groups.to_h do |key, selected_events|
96
+ [key, group_branches(selected_events)]
97
+ end
98
+ end
99
+
100
+ #: (Array[ActivityEvent]) -> Hash[Array[untyped], Array[ActivityEvent]]
101
+ def grouped_events(events)
102
+ groups = {} #: Hash[Array[untyped], Array[ActivityEvent]]
103
+ events.each do |event|
104
+ key = transaction_key(event)
105
+ next unless key
106
+
107
+ groups[key] ||= [] #: Array[ActivityEvent]
108
+ groups[key] << event
109
+ end
110
+ groups
111
+ end
112
+
113
+ #: (Array[ActivityEvent]) -> Array[String]?
114
+ def group_branches(events)
115
+ return if events.any?(&:root?)
116
+
117
+ events.flat_map { |event| event.branches || [] }.uniq.sort.freeze
118
+ end
119
+
120
+ #: (ActivityEvent) -> Array[untyped]?
121
+ def transaction_key(event)
122
+ version = event.version
123
+ return unless version.respond_to?(:transaction_id) && version.transaction_id
124
+
125
+ [version.class.name, version.transaction_id]
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,493 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Applies safe descendant event deltas without rebuilding an entire selected branch.
6
+ # rubocop:disable Metrics/ClassLength
7
+ class ActivityEventSnapshotRefresher
8
+ RECORD_AFTER_HANDLERS = {
9
+ 'create' => :created_record_after,
10
+ 'update' => :updated_record_after
11
+ }.freeze
12
+ private_constant :RECORD_AFTER_HANDLERS
13
+
14
+ #: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped) -> void
15
+ def initialize(traversal:, pool:, components:, association_reader: nil)
16
+ @traversal = traversal
17
+ @pool = pool
18
+ @components = components
19
+ @association_reader = association_reader || method(:historical_reader)
20
+ end
21
+
22
+ #: (untyped, untyped, RecordSnapshot, Array[String], ActivityEvent?) -> [bool, RecordSnapshot?]
23
+ def call( # rubocop:disable Metrics/MethodLength
24
+ root_endpoint,
25
+ context_endpoint,
26
+ previous,
27
+ branches,
28
+ event
29
+ )
30
+ return [false, nil] unless event && branches.one?
31
+
32
+ tree, normalizer = @components.call(branches)
33
+ collection_routes = direct_collection_routes(
34
+ endpoint_model_class(root_endpoint),
35
+ tree,
36
+ event.version.item_type.to_s
37
+ )
38
+ unless collection_routes.empty?
39
+ return [false, nil] if unsafe_through_membership_event?(collection_routes, event.version)
40
+
41
+ return collection_target_snapshot(
42
+ root_endpoint,
43
+ context_endpoint,
44
+ previous,
45
+ normalizer,
46
+ collection_routes,
47
+ event.version
48
+ )
49
+ end
50
+
51
+ belongs_to_target_snapshot(
52
+ root_endpoint,
53
+ context_endpoint,
54
+ previous,
55
+ tree,
56
+ normalizer,
57
+ event.version
58
+ )
59
+ end
60
+
61
+ private
62
+
63
+ # @rbs @traversal: AssociationTraversal
64
+ # @rbs @pool: SnapshotPool
65
+ # @rbs @components: untyped
66
+ # @rbs @association_reader: untyped
67
+
68
+ #: (Array[Array[untyped]], untyped) -> bool
69
+ def unsafe_through_membership_event?(routes, version)
70
+ return false if version.event.to_s == 'update'
71
+
72
+ routes.any? do |route|
73
+ route.first(route.length - 1).any? do |entry|
74
+ reflection = entry.fetch(1)
75
+ reflection.options[:through]
76
+ end
77
+ end
78
+ end
79
+
80
+ #: (untyped, untyped, RecordSnapshot, SnapshotNormalizer, Array[Array[untyped]], untyped) -> [bool, RecordSnapshot]
81
+ def collection_target_snapshot( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
82
+ root_endpoint,
83
+ context_endpoint,
84
+ previous,
85
+ normalizer,
86
+ routes,
87
+ version
88
+ )
89
+ record = record_after(version)
90
+ snapshot = previous
91
+ routes.each do |route|
92
+ _name, reflection, subtree, path = route.last
93
+ replacement = if record
94
+ normalize_event_record(
95
+ record,
96
+ reflection,
97
+ subtree,
98
+ path,
99
+ normalizer,
100
+ root_endpoint,
101
+ context_endpoint
102
+ )
103
+ end
104
+ snapshot, = replace_collection_route(snapshot, route, version, record, replacement)
105
+ end
106
+ [true, snapshot]
107
+ end
108
+
109
+ #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
110
+ def direct_collection_routes( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
111
+ model_class,
112
+ tree,
113
+ target_type,
114
+ path: ''
115
+ )
116
+ @traversal.reflections_for(model_class, tree, path: path).flat_map do |reflection|
117
+ name = reflection.name.to_s
118
+ subtree = tree.child(name)
119
+ next [] unless subtree
120
+
121
+ child_path = Support.association_path(path, name)
122
+ route_entry = [name, reflection, subtree, child_path]
123
+ routes = [] #: Array[Array[untyped]]
124
+ if reflection.macro == :has_many && !reflection.options[:through] &&
125
+ reflection.klass.base_class.name.to_s == target_type
126
+ routes << [route_entry]
127
+ end
128
+ unless subtree.empty? || reflection.polymorphic?
129
+ nested = direct_collection_routes(
130
+ reflection.klass,
131
+ subtree,
132
+ target_type,
133
+ path: child_path
134
+ )
135
+ routes.concat(nested.map { |route| [route_entry, *route] })
136
+ end
137
+ routes
138
+ end
139
+ end
140
+
141
+ #: (RecordSnapshot, Array[untyped], untyped, untyped, RecordSnapshot?, ?depth: Integer) -> [RecordSnapshot, bool]
142
+ def replace_collection_route( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
143
+ snapshot,
144
+ route,
145
+ version,
146
+ record,
147
+ replacement,
148
+ depth: 0
149
+ )
150
+ name, reflection, _subtree, path = route.fetch(depth)
151
+ association = snapshot.associations.fetch(name)
152
+ changed = false
153
+ updated_records = if depth == route.length - 1
154
+ child = if record && member_of_owner?(record, reflection, snapshot)
155
+ replacement
156
+ end
157
+ replace_collection_record(
158
+ association.records,
159
+ version,
160
+ child
161
+ ).tap { |value| changed = !value.equal?(association.records) }
162
+ else
163
+ association.records.map do |child_snapshot|
164
+ updated, child_changed = replace_collection_route(
165
+ child_snapshot,
166
+ route,
167
+ version,
168
+ record,
169
+ replacement,
170
+ depth: depth + 1
171
+ )
172
+ changed ||= child_changed
173
+ updated
174
+ end
175
+ end
176
+ return [snapshot, false] unless changed
177
+
178
+ updated_association = @pool.association(
179
+ path,
180
+ AssociationSnapshot.new(kind: association.kind, records: updated_records)
181
+ )
182
+ updated_snapshot = RecordSnapshot.new(
183
+ type: snapshot.type,
184
+ id: snapshot.id,
185
+ attributes: snapshot.attributes,
186
+ associations: snapshot.associations.merge(name => updated_association)
187
+ )
188
+ parent_path = depth.zero? ? '' : route.fetch(depth - 1).fetch(3)
189
+ updated_snapshot = @pool.record(parent_path, updated_snapshot) unless parent_path.empty?
190
+ [updated_snapshot, true]
191
+ end
192
+
193
+ #: (untyped, untyped, RecordSnapshot, AssociationTree, SnapshotNormalizer, untyped) -> [bool, RecordSnapshot?]
194
+ def belongs_to_target_snapshot( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
195
+ root_endpoint,
196
+ context_endpoint,
197
+ previous,
198
+ tree,
199
+ normalizer,
200
+ version
201
+ )
202
+ return [false, nil] unless %w[update destroy].include?(version.event.to_s)
203
+
204
+ root_class = endpoint_model_class(root_endpoint)
205
+ routes = belongs_to_routes(root_class, tree, version.item_type.to_s)
206
+ return [false, nil] if routes.empty?
207
+
208
+ snapshot = previous
209
+ routes.each do |route|
210
+ replacement = normalized_route_record(
211
+ route,
212
+ version,
213
+ normalizer,
214
+ root_endpoint,
215
+ context_endpoint
216
+ )
217
+ snapshot, = replace_route(snapshot, route, version, replacement)
218
+ end
219
+ [true, snapshot]
220
+ end
221
+
222
+ #: (untyped, AssociationTree, String, ?path: String) -> Array[Array[untyped]]
223
+ def belongs_to_routes( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
224
+ model_class,
225
+ tree,
226
+ target_type,
227
+ path: ''
228
+ )
229
+ @traversal.reflections_for(model_class, tree, path: path).flat_map do |reflection|
230
+ name = reflection.name.to_s
231
+ subtree = tree.child(name)
232
+ next [] unless subtree
233
+
234
+ child_path = Support.association_path(path, name)
235
+ route_entry = [name, reflection, subtree, child_path]
236
+ routes = [] #: Array[Array[untyped]]
237
+ if reflection.macro == :belongs_to && !reflection.polymorphic? &&
238
+ reflection.klass.base_class.name.to_s == target_type
239
+ routes << [route_entry]
240
+ end
241
+ unless subtree.empty? || reflection.polymorphic?
242
+ nested = belongs_to_routes(reflection.klass, subtree, target_type, path: child_path)
243
+ routes.concat(nested.map { |route| [route_entry, *route] })
244
+ end
245
+ routes
246
+ end
247
+ end
248
+
249
+ #: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
250
+ def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
251
+ record = record_after(version)
252
+ return unless record
253
+
254
+ _name, reflection, subtree, path = route.last
255
+ normalize_event_record(
256
+ record,
257
+ reflection,
258
+ subtree,
259
+ path,
260
+ normalizer,
261
+ root_endpoint,
262
+ context_endpoint
263
+ )
264
+ end
265
+
266
+ #: (RecordSnapshot, Array[untyped], untyped, RecordSnapshot?, ?depth: Integer) -> [RecordSnapshot, bool]
267
+ def replace_route( # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
268
+ snapshot,
269
+ route,
270
+ version,
271
+ replacement,
272
+ depth: 0
273
+ )
274
+ name, _reflection, _subtree, = route.fetch(depth)
275
+ association = snapshot.associations.fetch(name)
276
+
277
+ records = association.records
278
+ changed = false
279
+ updated_records = if depth == route.length - 1
280
+ replace_existing_target(records, version, replacement).tap do |value|
281
+ changed = !value.equal?(records)
282
+ end
283
+ else
284
+ records.map do |record|
285
+ updated, record_changed = replace_route(
286
+ record,
287
+ route,
288
+ version,
289
+ replacement,
290
+ depth: depth + 1
291
+ )
292
+ changed ||= record_changed
293
+ updated
294
+ end
295
+ end
296
+ return [snapshot, false] unless changed
297
+
298
+ updated_association = AssociationSnapshot.new(
299
+ kind: association.kind,
300
+ records: updated_records
301
+ )
302
+ updated_snapshot = RecordSnapshot.new(
303
+ type: snapshot.type,
304
+ id: snapshot.id,
305
+ attributes: snapshot.attributes,
306
+ associations: snapshot.associations.merge(name => updated_association)
307
+ )
308
+ [updated_snapshot, true]
309
+ end
310
+
311
+ #: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> Array[RecordSnapshot]
312
+ def replace_existing_target(records, version, replacement)
313
+ index = records.index do |record|
314
+ record.id.to_s == version.item_id.to_s &&
315
+ record.type.to_s == replacement_type(version, replacement)
316
+ end
317
+ return records unless index
318
+
319
+ updated = records.dup
320
+ replacement ? updated[index] = replacement : updated.delete_at(index)
321
+ updated
322
+ end
323
+
324
+ #: (untyped) -> untyped
325
+ def record_after(version)
326
+ return if version.event.to_s == 'destroy'
327
+
328
+ changed_record = changed_record_after(version)
329
+ return changed_record if changed_record
330
+
331
+ successor = version.next
332
+ record = successor&.reify(dup: true)
333
+ return record if record
334
+
335
+ model_class = Endpoint.model_class(version)
336
+ criteria = { model_class.primary_key => version.item_id } #: Hash[untyped, untyped]
337
+ model_class.unscoped.find_by(criteria)
338
+ end
339
+
340
+ # PaperTrail create/update versions contain deserialized change pairs, so
341
+ # their post-event records need no successor query.
342
+ #: (untyped) -> untyped
343
+ def changed_record_after(version)
344
+ handler = RECORD_AFTER_HANDLERS[version.event.to_s]
345
+ send(handler, version) if handler
346
+ end
347
+
348
+ #: (untyped) -> untyped
349
+ def created_record_after(version)
350
+ model_class = Endpoint.model_class(version)
351
+ changes = deserialized_changeset(version, model_class)
352
+ return unless changes.respond_to?(:each) && !changes.empty?
353
+
354
+ attributes = after_attributes(changes, model_class)
355
+ model_class.new(attributes)
356
+ end
357
+
358
+ #: (untyped) -> untyped
359
+ def updated_record_after(version)
360
+ record = version.reify(dup: true)
361
+ changes = deserialized_changeset(version, record&.class)
362
+ apply_changes(record, changes)
363
+ end
364
+
365
+ #: (untyped, untyped) -> Hash[untyped, untyped]
366
+ def after_attributes(changes, model_class)
367
+ names = model_class.attribute_names
368
+ attributes = {} #: Hash[untyped, untyped]
369
+ changes.each do |name, values|
370
+ next unless names.include?(name.to_s)
371
+
372
+ attributes[name] = values.last
373
+ end
374
+ attributes
375
+ end
376
+
377
+ #: (untyped, untyped) -> untyped
378
+ def apply_changes(record, changes)
379
+ return unless record && changes.respond_to?(:each) && !changes.empty?
380
+
381
+ changes.each do |name, values|
382
+ next unless record.has_attribute?(name) && values.respond_to?(:last)
383
+
384
+ record[name] = values.last
385
+ end
386
+ record
387
+ end
388
+
389
+ # Mirrors PaperTrail's changeset deserialization without resolving
390
+ # +version.item+, which would issue one live-record query per event.
391
+ #: (untyped, untyped) -> untyped
392
+ def deserialized_changeset(version, model_class)
393
+ return unless model_class && version.class.column_names.include?('object_changes')
394
+
395
+ paper_trail = Object.const_get(:PaperTrail) #: untyped
396
+ adapter = paper_trail.config.object_changes_adapter
397
+ return adapter.load_changeset(version) if adapter.respond_to?(:load_changeset)
398
+
399
+ standard_changeset(paper_trail, version, model_class)
400
+ end
401
+
402
+ #: (untyped, untyped, untyped) -> untyped
403
+ def standard_changeset(paper_trail, version, model_class)
404
+ raw_changes = version.send(:object_changes_deserialized)
405
+ active_support = Object.const_get(:ActiveSupport) #: untyped
406
+ changes = active_support.const_get(:HashWithIndifferentAccess).new(raw_changes)
407
+ serializers = paper_trail.const_get(:AttributeSerializers)
408
+ serializers.const_get(:ObjectChangesAttribute).new(model_class).deserialize(changes)
409
+ changes
410
+ end
411
+
412
+ #: (untyped, untyped, RecordSnapshot) -> bool
413
+ def member_of_owner?(record, reflection, owner) # rubocop:disable Metrics/AbcSize
414
+ foreign_keys = Array(reflection.foreign_key)
415
+ actual_ids = foreign_keys.map { |key| record.public_send(key) }
416
+ expected_ids = Array(owner.id)
417
+ # @type var expected_ids: Array[untyped]
418
+ # Explicit blocks keep the inline RBS checker from inferring an unusable Proc type.
419
+ actual = actual_ids.map { |id| id.to_s } # rubocop:disable Style/SymbolProc
420
+ expected = expected_ids.map { |id| id.to_s } # rubocop:disable Style/SymbolProc
421
+ return false unless actual == expected
422
+ return true unless reflection.options[:as]
423
+
424
+ type = record.public_send(reflection.type).to_s
425
+ owner_types = [
426
+ owner.type.to_s,
427
+ reflection.active_record.name.to_s,
428
+ reflection.active_record.base_class.name.to_s
429
+ ]
430
+ owner_types.include?(type)
431
+ end
432
+
433
+ #: (untyped, untyped, AssociationTree, String, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
434
+ def normalize_event_record( # rubocop:disable Metrics/ParameterLists
435
+ record,
436
+ reflection,
437
+ subtree,
438
+ path,
439
+ normalizer,
440
+ root_endpoint,
441
+ context_endpoint
442
+ )
443
+ habtm_boundary = Endpoint.version?(root_endpoint) ? root_endpoint : context_endpoint
444
+ reifier = @association_reader.call(
445
+ context_endpoint,
446
+ habtm_version: habtm_boundary
447
+ )
448
+ normalizer.call_child(
449
+ record,
450
+ tree: subtree,
451
+ path: path,
452
+ incoming: reflection,
453
+ reifier: reifier
454
+ )
455
+ end
456
+
457
+ #: (untyped, habtm_version: untyped) -> HistoricalAssociationReifier
458
+ def historical_reader(context_endpoint, habtm_version:)
459
+ HistoricalAssociationReifier.new(context_endpoint, habtm_version: habtm_version)
460
+ end
461
+
462
+ #: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> Array[RecordSnapshot]
463
+ def replace_collection_record(records, version, replacement)
464
+ index = records.index do |record|
465
+ record.id.to_s == version.item_id.to_s &&
466
+ record.type.to_s == replacement_type(version, replacement)
467
+ end
468
+ return records unless replacement || index
469
+
470
+ updated = records.dup
471
+ if replacement
472
+ index ? updated[index] = replacement : updated << replacement
473
+ else
474
+ position = index || raise(ConfigurationError, 'missing collection event identity')
475
+ updated.delete_at(position)
476
+ end
477
+ updated
478
+ end
479
+
480
+ #: (untyped, RecordSnapshot?) -> String
481
+ def replacement_type(version, replacement)
482
+ return replacement.type.to_s if replacement
483
+
484
+ Endpoint.model_class(version).name.to_s
485
+ end
486
+
487
+ #: (untyped) -> untyped
488
+ def endpoint_model_class(endpoint)
489
+ Endpoint.version?(endpoint) ? Endpoint.model_class(endpoint) : endpoint.class
490
+ end
491
+ end
492
+ # rubocop:enable Metrics/ClassLength
493
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Tests activity versions against a historical-version or wall-clock range end.
6
+ class ActivityRange
7
+ #: (untyped, untyped) -> void
8
+ def initialize(start_version, end_boundary)
9
+ @start_version = start_version
10
+ @end_boundary = end_boundary
11
+ @start_key = Support.chronological_version_key(start_version)
12
+ @end_key = if Endpoint.version?(end_boundary)
13
+ Support.chronological_version_key(end_boundary)
14
+ end
15
+ end
16
+
17
+ #: (untyped) -> bool
18
+ def include?(version)
19
+ key = Support.chronological_version_key(version)
20
+ key_before_or_equal?(@start_key, key) && before_end?(version, key)
21
+ end
22
+
23
+ # Narrows an Active Record version relation by timestamp. Exact tie-breaking
24
+ # remains in #include? so this works for integer, UUID, and custom version IDs.
25
+ #: (untyped) -> untyped
26
+ def scope(relation)
27
+ relation.where(created_at: @start_version.created_at..end_time)
28
+ end
29
+
30
+ private
31
+
32
+ # @rbs @start_version: untyped
33
+ # @rbs @end_boundary: untyped
34
+ # @rbs @start_key: Array[untyped]
35
+ # @rbs @end_key: Array[untyped]?
36
+
37
+ #: (untyped, Array[untyped]) -> bool
38
+ def before_end?(version, key)
39
+ end_key = @end_key
40
+ return key_before_or_equal?(key, end_key) if end_key
41
+
42
+ version.created_at <= @end_boundary
43
+ end
44
+
45
+ #: () -> untyped
46
+ def end_time
47
+ Endpoint.version?(@end_boundary) ? @end_boundary.created_at : @end_boundary
48
+ end
49
+
50
+ #: (Array[untyped], Array[untyped]) -> bool
51
+ def key_before_or_equal?(left, right)
52
+ comparison = left <=> right
53
+ raise ConfigurationError, 'versions have incomparable timestamps' unless comparison
54
+
55
+ comparison <= 0
56
+ end
57
+ end
58
+ end