paper_trail_diff 0.2.0 → 0.3.1

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/QUICKSTART.md +44 -2
  4. data/README.md +188 -14
  5. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  6. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
  7. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  8. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  9. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  10. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  11. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  12. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  13. data/lib/paper_trail_diff/activity_event_route_finder.rb +112 -0
  14. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +59 -437
  15. data/lib/paper_trail_diff/activity_history.rb +136 -0
  16. data/lib/paper_trail_diff/activity_range.rb +28 -12
  17. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  18. data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
  19. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
  20. data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
  21. data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
  22. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -3
  23. data/lib/paper_trail_diff/collection_comparator.rb +155 -0
  24. data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
  25. data/lib/paper_trail_diff/collection_transition.rb +25 -0
  26. data/lib/paper_trail_diff/comparison_batch.rb +151 -0
  27. data/lib/paper_trail_diff/engine.rb +5 -45
  28. data/lib/paper_trail_diff/errors.rb +9 -0
  29. data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
  30. data/lib/paper_trail_diff/instrumentation.rb +30 -0
  31. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
  32. data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
  33. data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
  34. data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
  35. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
  36. data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
  37. data/lib/paper_trail_diff/prepared_history.rb +14 -4
  38. data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
  39. data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
  40. data/lib/paper_trail_diff/snapshot.rb +66 -3
  41. data/lib/paper_trail_diff/support.rb +18 -0
  42. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
  43. data/lib/paper_trail_diff/time_range.rb +85 -0
  44. data/lib/paper_trail_diff/time_version_range.rb +53 -0
  45. data/lib/paper_trail_diff/timeline_builder.rb +13 -7
  46. data/lib/paper_trail_diff/timeline_range.rb +72 -0
  47. data/lib/paper_trail_diff/version.rb +1 -1
  48. data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
  49. data/lib/paper_trail_diff.rb +83 -12
  50. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  51. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
  52. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  53. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  54. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  55. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  56. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  57. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  58. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +54 -0
  59. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +20 -71
  60. data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
  61. data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
  62. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  63. data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
  64. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  65. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
  66. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
  67. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  68. data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
  69. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
  70. data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
  71. data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
  72. data/sig/generated/paper_trail_diff/engine.rbs +0 -12
  73. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  74. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
  75. data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
  76. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
  77. data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
  78. data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
  79. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
  80. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
  81. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
  82. data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
  83. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
  84. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
  85. data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
  86. data/sig/generated/paper_trail_diff/support.rbs +6 -0
  87. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
  88. data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
  89. data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
  90. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
  91. data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
  92. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
  93. data/sig/generated/paper_trail_diff.rbs +16 -8
  94. metadata +56 -4
@@ -4,17 +4,19 @@
4
4
  module PaperTrailDiff
5
5
  # PaperTrail/ActiveRecord boundary that produces plain record snapshots.
6
6
  class PaperTrailAdapter
7
- #: (associations: Array[String | Symbol], ignore: ignore_option) -> void
8
- def initialize(associations:, ignore:)
7
+ #: (associations: Array[String | Symbol], ignore: ignore_option, ?reload_live_endpoints: bool) -> void
8
+ def initialize(associations:, ignore:, reload_live_endpoints: true)
9
9
  @association_tree = AssociationTree.build(associations)
10
10
  @ignore_policy = IgnorePolicy.build(ignore, association_paths: @association_tree.paths)
11
11
  @traversal = AssociationTraversal.new(@association_tree)
12
+ @live_endpoints = LiveEndpointProvider.new(
13
+ tree: @association_tree, traversal: @traversal, reload: reload_live_endpoints
14
+ )
15
+ @instrumentation_payload = @live_endpoints.comparison_payload
12
16
  @snapshot_pool = SnapshotPool.new
13
17
  @normalizer = SnapshotNormalizer.new(
14
- tree: @association_tree,
15
- ignore_policy: @ignore_policy,
16
- traversal: @traversal,
17
- pool: @snapshot_pool
18
+ tree: @association_tree, ignore_policy: @ignore_policy,
19
+ traversal: @traversal, pool: @snapshot_pool
18
20
  )
19
21
  @historical_store = build_historical_store
20
22
  @timeline_snapshotter = TimelineSnapshotProvider.new(@historical_store)
@@ -23,40 +25,59 @@ module PaperTrailDiff
23
25
 
24
26
  #: (untyped, untyped) -> Diff
25
27
  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
+ payload = @instrumentation_payload.merge(comparison_count: 1)
29
+ Instrumentation.instrument('compare', payload) do
30
+ Endpoint.validate_pair!(from_endpoint, to_endpoint)
31
+ Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
32
+ end
33
+ end
34
+
35
+ # Compares independent pairs and returns immutable results keyed by item identity.
36
+ #: (Array[comparison_input]) -> comparison_results
37
+ def compare_many(comparisons)
38
+ count = comparisons.is_a?(Array) ? comparisons.length : 0
39
+ payload = @instrumentation_payload.merge(comparison_count: count)
40
+ Instrumentation.instrument('compare_many', payload) do
41
+ ComparisonBatch.new(
42
+ comparisons,
43
+ live_loader: @live_endpoints.method(:call), preparer: method(:prepare_traversal!),
44
+ history_preparer: @historical_store.method(:prepare_batch),
45
+ historical_snapshotter: method(:historical_snapshot),
46
+ live_normalizer: method(:normalize_live_snapshot)
47
+ ).call
48
+ end
28
49
  end
29
50
 
30
- #: (untyped, from: untyped, to: untyped) -> Array[Step]
31
- def timeline(record, from:, to:)
51
+ #: (untyped, from: untyped, to: untyped, within: untyped) -> Array[Step]
52
+ def timeline(record, from:, to:, within:)
32
53
  prepare_traversal!(record.class, historical: true)
33
54
  builder = TimelineBuilder.new(
34
55
  record,
35
56
  from: from,
36
57
  to: to,
58
+ within: within,
37
59
  snapshotter: @timeline_snapshotter
38
60
  )
39
61
  builder.build
40
62
  end
41
63
 
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
64
+ #: (untyped, from: untyped, to: untyped, within: untyped) -> Array[ActivityStep]
65
+ def activity_timeline(record, from:, to:, within:)
66
+ payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
67
+ Instrumentation.instrument('activity_timeline', payload) do
68
+ prepare_traversal!(record.class, historical: true)
69
+ reject_live_habtm_activity!(record.class) if Endpoint.record?(to)
70
+ steps = activity_builder(record, from: from, to: to, within: within).build
71
+ payload[:step_count] = steps.length
72
+ steps
73
+ end
53
74
  end
54
75
 
55
- #: (untyped, from: untyped, to: untyped, ?activity: bool) -> Analysis
56
- def analyze(record, from:, to:, activity: false)
76
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool) -> Analysis
77
+ def analyze(record, from:, to:, within:, activity: false)
57
78
  if activity
58
79
  prepare_traversal!(record.class, historical: true)
59
- return activity_builder(record, from: from, to: to).analyze
80
+ return activity_builder(record, from: from, to: to, within: within).analyze
60
81
  end
61
82
 
62
83
  prepare_traversal!(record.class, historical: true)
@@ -64,6 +85,7 @@ module PaperTrailDiff
64
85
  record,
65
86
  from: from,
66
87
  to: to,
88
+ within: within,
67
89
  snapshotter: @timeline_snapshotter
68
90
  ).analyze
69
91
  end
@@ -71,6 +93,8 @@ module PaperTrailDiff
71
93
  private
72
94
 
73
95
  # @rbs @association_tree: AssociationTree
96
+ # @rbs @live_endpoints: LiveEndpointProvider
97
+ # @rbs @instrumentation_payload: Hash[Symbol, untyped]
74
98
  # @rbs @ignore_policy: IgnorePolicy
75
99
  # @rbs @traversal: AssociationTraversal
76
100
  # @rbs @snapshot_pool: SnapshotPool
@@ -92,14 +116,13 @@ module PaperTrailDiff
92
116
  #: () -> ActivitySnapshotProvider
93
117
  def build_activity_snapshotter
94
118
  refresher = BranchSnapshotRefresher.new(
95
- tree: @association_tree,
96
- ignore_policy: @ignore_policy,
97
- traversal: @traversal,
98
- pool: @snapshot_pool,
119
+ tree: @association_tree, ignore_policy: @ignore_policy,
120
+ traversal: @traversal, pool: @snapshot_pool,
99
121
  normalizer: @normalizer,
100
122
  full_snapshotter: method(:snapshot_at),
101
123
  partial_snapshotter: @historical_store.method(:custom),
102
- association_reader: @historical_store.method(:association_reader)
124
+ association_reader: @historical_store.method(:association_reader),
125
+ record_transition: @historical_store.method(:record_transition)
103
126
  )
104
127
  ActivitySnapshotProvider.new(
105
128
  snapshotter: method(:snapshot_at),
@@ -108,12 +131,11 @@ module PaperTrailDiff
108
131
  )
109
132
  end
110
133
 
111
- #: (untyped, from: untyped, to: untyped) -> ActivityTimelineBuilder
112
- def activity_builder(record, from:, to:)
134
+ #: (untyped, from: untyped, to: untyped, within: untyped) -> ActivityTimelineBuilder
135
+ def activity_builder(record, from:, to:, within:)
113
136
  ActivityTimelineBuilder.new(
114
137
  record,
115
- from: from,
116
- to: to,
138
+ range: TimelineRange.new(record, from: from, to: to, within: within),
117
139
  tree: @association_tree,
118
140
  snapshotter: @activity_snapshotter
119
141
  )
@@ -152,7 +174,12 @@ module PaperTrailDiff
152
174
 
153
175
  #: (untyped) -> RecordSnapshot
154
176
  def live_snapshot(record)
155
- current = Endpoint.reload_record(record)
177
+ current = @live_endpoints.call([record]).fetch(Endpoint.identity(record))
178
+ normalize_live_snapshot(current)
179
+ end
180
+
181
+ #: (untyped) -> RecordSnapshot
182
+ def normalize_live_snapshot(current)
156
183
  prepare_traversal!(current.class, historical: false)
157
184
  @normalizer.call(current, reifier: LiveAssociationReader.new) ||
158
185
  raise(InvalidEndpointError, 'current record endpoint could not be normalized')
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Validates and reuses caller-owned live records without issuing association queries.
6
+ class PreloadedEndpointBatchLoader
7
+ #: (tree: AssociationTree, traversal: AssociationTraversal) -> void
8
+ def initialize(tree:, traversal:)
9
+ @tree = tree
10
+ @traversal = traversal
11
+ end
12
+
13
+ #: (Array[untyped]) -> Hash[identity, untyped]
14
+ def call(records)
15
+ records.each { |record| validate_record(record, @tree, path: '') }
16
+ records.to_h { |record| [Endpoint.identity(record), record] }
17
+ end
18
+
19
+ private
20
+
21
+ # @rbs @tree: AssociationTree
22
+ # @rbs @traversal: AssociationTraversal
23
+
24
+ #: (untyped, AssociationTree, path: String) -> void
25
+ def validate_record(record, tree, path:)
26
+ @traversal.reflections_for(record.class, tree, path: path).each do |reflection|
27
+ validate_association(record, tree, path, reflection)
28
+ end
29
+ end
30
+
31
+ #: (untyped, AssociationTree, String, untyped) -> void
32
+ def validate_association(record, tree, path, reflection)
33
+ association_path = Support.association_path(path, reflection.name.to_s)
34
+ association = record.association(reflection.name)
35
+ unless association.loaded?
36
+ message = "current endpoint association is not preloaded: #{association_path}"
37
+ raise UnloadedAssociationError, message
38
+ end
39
+
40
+ subtree = tree.child(reflection.name)
41
+ return unless subtree
42
+
43
+ Array(association.target).compact.each do |child|
44
+ validate_record(child, subtree, path: association_path)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -12,10 +12,11 @@ module PaperTrailDiff
12
12
  }.freeze
13
13
  private_constant :EDGE_LOADERS
14
14
 
15
- #: (PreparedRecordIndex, Array[untyped]) -> void
16
- def initialize(records, root_versions)
15
+ #: (PreparedRecordIndex, Array[untyped], ?start_at: untyped) -> void
16
+ def initialize(records, root_versions, start_at: root_versions.first&.created_at)
17
17
  @records = records
18
18
  @transaction_ids = root_versions.filter_map(&:transaction_id).uniq.freeze
19
+ @start_at = start_at
19
20
  end
20
21
 
21
22
  #: (untyped, Array[untyped], untyped) -> [Hash[String, Hash[Symbol, untyped]], Hash[String, Array[untyped]]?]
@@ -28,6 +29,7 @@ module PaperTrailDiff
28
29
 
29
30
  # @rbs @records: PreparedRecordIndex
30
31
  # @rbs @transaction_ids: Array[untyped]
32
+ # @rbs @start_at: untyped
31
33
 
32
34
  #: (untyped, Array[untyped], untyped) -> [Hash[String, Hash[Symbol, untyped]], nil]
33
35
  def belongs_to_edge(owner_class, owner_ids, reflection)
@@ -63,9 +65,10 @@ module PaperTrailDiff
63
65
  key = scalar_foreign_key(reflection)
64
66
  return {} unless key
65
67
 
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)
68
+ historical = historical_child_ids(owner_class, owner_ids, reflection, key)
69
+ live = live_child_ids(owner_class, owner_ids, reflection, key)
70
+ owners = merge_owners(historical, live)
71
+ group_for(reflection.klass, owners.values.flatten, owners: owners)
69
72
  end
70
73
 
71
74
  #: (untyped, untyped) -> [Hash[String, Hash[Symbol, untyped]], Hash[String, Array[untyped]]]
@@ -92,30 +95,48 @@ module PaperTrailDiff
92
95
  end.uniq
93
96
  end
94
97
 
95
- #: (untyped, Array[untyped], untyped, String) -> Array[untyped]
96
- def historical_child_ids( # rubocop:disable Metrics/AbcSize
98
+ #: (untyped, Array[untyped], untyped, String) -> Hash[String, Array[untyped]]
99
+ def historical_child_ids(
97
100
  owner_class,
98
101
  owner_ids,
99
102
  reflection,
100
103
  foreign_key
101
104
  )
102
105
  version_class = owner_class.paper_trail.version_class
103
- relation = owner_class.paper_trail.version_association_class.joins(:version).where(
106
+ association_class = owner_class.paper_trail.version_association_class
107
+ relation = historical_child_relation(owner_class, owner_ids, reflection, foreign_key)
108
+ pairs = child_candidate_scope(version_class).call(relation).distinct.pluck(
109
+ association_class.arel_table[:foreign_key_id],
110
+ version_class.arel_table[:item_id]
111
+ )
112
+ group_ids_by_owner(pairs)
113
+ end
114
+
115
+ #: (untyped, Array[untyped], untyped, String) -> untyped
116
+ def historical_child_relation(owner_class, owner_ids, reflection, foreign_key)
117
+ version_class = owner_class.paper_trail.version_class
118
+ owner_class.paper_trail.version_association_class.joins(:version).where(
104
119
  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 }
120
+ foreign_key_id: owner_ids,
121
+ foreign_type: parent_types(owner_class)
122
+ ).where(version_class.table_name => { item_type: reflection.klass.base_class.name })
123
+ end
124
+
125
+ #: (untyped) -> VersionAssociationCandidateScope
126
+ def child_candidate_scope(version_class)
127
+ VersionAssociationCandidateScope.new(
128
+ version_class,
129
+ @start_at
108
130
  )
109
- relation.distinct.pluck(version_class.arel_table[:item_id])
110
131
  end
111
132
 
112
- #: (untyped, Array[untyped], untyped, String) -> Array[untyped]
133
+ #: (untyped, Array[untyped], untyped, String) -> Hash[String, Array[untyped]]
113
134
  def live_child_ids(owner_class, owner_ids, reflection, foreign_key)
114
135
  relation = reflection.klass.base_class.unscoped.where(foreign_key => owner_ids)
115
136
  if reflection.options[:as]
116
137
  relation = relation.where(reflection.type => parent_types(owner_class))
117
138
  end
118
- relation.pluck(reflection.klass.primary_key)
139
+ group_ids_by_owner(relation.pluck(foreign_key, reflection.klass.primary_key))
119
140
  end
120
141
 
121
142
  #: (untyped, untyped) -> untyped
@@ -133,12 +154,28 @@ module PaperTrailDiff
133
154
  group.fetch(:ids) << id unless group.fetch(:ids).include?(id)
134
155
  end
135
156
 
136
- #: (untyped, Array[untyped]) -> Hash[String, Hash[Symbol, untyped]]
137
- def group_for(model_class, ids)
157
+ #: (untyped, Array[untyped], ?owners: Hash[String, Array[untyped]]?) -> Hash[String, Hash[Symbol, untyped]]
158
+ def group_for(model_class, ids, owners: nil)
138
159
  unique = ids.compact.uniq
139
160
  return {} if unique.empty?
140
161
 
141
- { model_class.name.to_s => { model: model_class, ids: unique } }
162
+ group = { model: model_class, ids: unique } #: Hash[Symbol, untyped]
163
+ group[:owners] = owners if owners
164
+ { model_class.name.to_s => group }
165
+ end
166
+
167
+ #: (Array[Array[untyped]]) -> Hash[String, Array[untyped]]
168
+ def group_ids_by_owner(pairs)
169
+ grouped = {} #: Hash[String, Array[untyped]]
170
+ pairs.each_with_object(grouped) do |(owner_id, child_id), result|
171
+ ids = result[owner_id.to_s] ||= []
172
+ ids << child_id unless ids.include?(child_id)
173
+ end
174
+ end
175
+
176
+ #: (Hash[String, Array[untyped]], Hash[String, Array[untyped]]) -> Hash[String, Array[untyped]]
177
+ def merge_owners(left, right)
178
+ left.merge(right) { |_owner, first, second| first | second }
142
179
  end
143
180
 
144
181
  #: (untyped) -> String?
@@ -43,6 +43,11 @@ module PaperTrailDiff
43
43
  @habtm[edge_key(owner_class, reflection)] = memberships.transform_keys(&:to_s)
44
44
  end
45
45
 
46
+ #: (untyped, untyped, untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
47
+ def record_transition(model_class, id, version)
48
+ @records.transition(model_class, id, version)
49
+ end
50
+
46
51
  #: (untyped, untyped, untyped, habtm_boundary: untyped) -> [bool, Array[untyped]]
47
52
  def resolve(record, reflection, boundary, habtm_boundary:)
48
53
  return [false, []] unless supported?(record.class, reflection)
@@ -112,13 +117,20 @@ module PaperTrailDiff
112
117
  def child_records(record, reflection, boundary)
113
118
  edge(record.class, reflection).values.flat_map do |group|
114
119
  model_class = group.fetch(:model)
115
- group.fetch(:ids).filter_map do |id|
120
+ child_ids_for(group, record).filter_map do |id|
116
121
  child = @records.record_before(model_class, id, boundary)
117
122
  child if child && member_of?(child, reflection, record)
118
123
  end
119
124
  end
120
125
  end
121
126
 
127
+ #: (Hash[Symbol, untyped], untyped) -> Array[untyped]
128
+ def child_ids_for(group, owner)
129
+ owners = group[:owners]
130
+ empty = [] #: Array[untyped]
131
+ owners ? owners.fetch(owner.id.to_s, empty) : group.fetch(:ids)
132
+ end
133
+
122
134
  #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
123
135
  def habtm_records(record, reflection, boundary, habtm_boundary)
124
136
  transaction_id = transaction_id_for(habtm_boundary)
@@ -209,9 +221,7 @@ module PaperTrailDiff
209
221
 
210
222
  #: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
211
223
  def merge_groups(existing, incoming)
212
- existing.merge(incoming) do |_name, left, right|
213
- left.merge(ids: (left.fetch(:ids) | right.fetch(:ids)))
214
- end
224
+ Support.merge_record_groups(existing, incoming)
215
225
  end
216
226
  end
217
227
  end
@@ -4,26 +4,36 @@
4
4
  module PaperTrailDiff
5
5
  # Expands the explicit association tree into a request-scoped PreparedHistory.
6
6
  class PreparedHistoryLoader
7
- #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal) -> void
8
- def initialize(record, root_versions:, tree:, traversal:)
7
+ #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?live_records: Array[untyped]) -> void
8
+ def initialize( # rubocop:disable Metrics/ParameterLists
9
+ record,
10
+ root_versions:,
11
+ tree:,
12
+ traversal:,
13
+ root_ids: [record.id],
14
+ start_at: root_versions.first.created_at,
15
+ live_records: []
16
+ )
9
17
  @record = record
18
+ @root_ids = root_ids
10
19
  @tree = tree
11
20
  @traversal = traversal
12
- @records = PreparedRecordIndex.new(root_versions.first)
21
+ @records = PreparedRecordIndex.new(start_at, live_records: live_records)
13
22
  @history = PreparedHistory.new(@records)
14
- @edges = PreparedEdgeLoader.new(@records, root_versions)
23
+ @edges = PreparedEdgeLoader.new(@records, root_versions, start_at: start_at)
15
24
  @prepared = {} #: Hash[Array[String], Array[String]]
16
25
  end
17
26
 
18
27
  #: () -> PreparedHistory
19
28
  def call
20
- load_node(@record.class, [@record.id], @tree, path: '')
29
+ load_node(@record.class, @root_ids, @tree, path: '')
21
30
  @history
22
31
  end
23
32
 
24
33
  private
25
34
 
26
35
  # @rbs @record: untyped
36
+ # @rbs @root_ids: Array[untyped]
27
37
  # @rbs @tree: AssociationTree
28
38
  # @rbs @traversal: AssociationTraversal
29
39
  # @rbs @records: PreparedRecordIndex
@@ -33,7 +43,6 @@ module PaperTrailDiff
33
43
 
34
44
  #: (untyped, Array[untyped], AssociationTree, path: String) -> void
35
45
  def load_node(model_class, ids, tree, path:)
36
- load_records(model_class, ids)
37
46
  @traversal.reflections_for(model_class, tree, path: path).each do |reflection|
38
47
  subtree = tree.child(reflection.name)
39
48
  next unless subtree
@@ -73,6 +82,7 @@ module PaperTrailDiff
73
82
  missing = unprepared_ids(owner_class, owner_ids, reflection)
74
83
  return @history.edge(owner_class, reflection) if missing.empty?
75
84
 
85
+ load_records(owner_class, missing) if reflection.macro == :belongs_to
76
86
  groups, memberships = @edges.call(owner_class, missing, reflection)
77
87
  @history.merge_edge(owner_class, reflection, groups)
78
88
  @history.register_habtm(owner_class, reflection, memberships) if memberships
@@ -128,9 +138,7 @@ module PaperTrailDiff
128
138
 
129
139
  #: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
130
140
  def merge_groups(left, right)
131
- left.merge(right) do |_name, first, second|
132
- first.merge(ids: (first.fetch(:ids) | second.fetch(:ids)))
133
- end
141
+ Support.merge_record_groups(left, right)
134
142
  end
135
143
  end
136
144
  end
@@ -4,6 +4,9 @@
4
4
  module PaperTrailDiff
5
5
  # Immutable scalar state from a PaperTrail version or live-record fallback.
6
6
  class PreparedRecordState
7
+ attr_reader :model_class #: untyped
8
+ attr_reader :attributes #: Hash[untyped, untyped]
9
+
7
10
  #: (untyped) -> void
8
11
  def initialize(record)
9
12
  @model_class = record.class
@@ -11,23 +14,102 @@ module PaperTrailDiff
11
14
  freeze
12
15
  end
13
16
 
17
+ #: (untyped, Hash[untyped, untyped]) -> PreparedRecordState
18
+ def self.from_attributes(model_class, attributes)
19
+ state = allocate
20
+ state.send(:initialize_attributes, model_class, attributes)
21
+ state
22
+ end
23
+
14
24
  #: () -> untyped
15
25
  def instantiate
16
26
  @model_class.new(@attributes)
17
27
  end
18
28
 
19
- # @rbs @model_class: untyped
20
- # @rbs @attributes: Hash[untyped, untyped]
29
+ private
30
+
31
+ #: (untyped, Hash[untyped, untyped]) -> void
32
+ def initialize_attributes(model_class, attributes)
33
+ @model_class = model_class
34
+ @attributes = Support.immutable_copy(attributes)
35
+ freeze
36
+ end
37
+ end
38
+
39
+ # Extracts a version's scalar state without constructing a disposable AR object.
40
+ class PreparedVersionStateLoader
41
+ #: () -> void
42
+ def initialize
43
+ @serializers = {} #: Hash[untyped, untyped]
44
+ @attribute_names = {} #: Hash[untyped, Array[String]]
45
+ end
46
+
47
+ #: (untyped) -> PreparedRecordState?
48
+ def call(version)
49
+ return unless version.object
50
+
51
+ attributes = version.object_deserialized
52
+ return unless attributes.is_a?(Hash)
53
+
54
+ attributes = stringify_keys(attributes)
55
+ model_class = reification_class(version, attributes)
56
+ return unless direct_attributes?(model_class, attributes)
57
+
58
+ object_attribute_serializer(model_class).deserialize(attributes)
59
+ PreparedRecordState.from_attributes(model_class, attributes)
60
+ rescue StandardError
61
+ nil
62
+ end
63
+
64
+ private
65
+
66
+ # @rbs @serializers: Hash[untyped, untyped]
67
+ # @rbs @attribute_names: Hash[untyped, Array[String]]
68
+
69
+ #: (Hash[untyped, untyped]) -> Hash[String, untyped]
70
+ def stringify_keys(attributes)
71
+ attributes.to_h { |name, value| [name.to_s, value] }
72
+ end
73
+
74
+ #: (untyped, Hash[String, untyped]) -> untyped
75
+ def reification_class(version, attributes)
76
+ model_class = Endpoint.model_class(version)
77
+ inheritance_value = attributes[model_class.inheritance_column]
78
+ return model_class if inheritance_value.nil? || inheritance_value.to_s.empty?
79
+
80
+ model_class.sti_class_for(inheritance_value)
81
+ end
82
+
83
+ #: (untyped, Hash[String, untyped]) -> bool
84
+ def direct_attributes?(model_class, attributes)
85
+ encrypted = model_class.encrypted_attributes if model_class.respond_to?(:encrypted_attributes)
86
+ return false if encrypted&.any?
87
+
88
+ names = @attribute_names[model_class] ||= model_class.attribute_names
89
+ (attributes.keys - names).empty?
90
+ end
91
+
92
+ #: (untyped) -> untyped
93
+ def object_attribute_serializer(model_class)
94
+ paper_trail = Object.const_get(:PaperTrail)
95
+ serializers = paper_trail.const_get(:AttributeSerializers)
96
+ serializer_class = serializers.const_get(:ObjectAttribute)
97
+ @serializers[model_class] ||= serializer_class.new(model_class)
98
+ end
21
99
  end
22
100
 
23
101
  # Versions and live fallback for one model identity.
24
102
  class PreparedRecordSeries
25
103
  attr_reader :versions #: Array[untyped]
26
104
 
27
- #: (versions: Array[untyped], live: PreparedRecordState?) -> void
28
- def initialize(versions:, live:)
29
- @versions = versions.freeze
105
+ #: (versions: Array[untyped], live: PreparedRecordState?, ?state_loader: PreparedVersionStateLoader) -> void
106
+ def initialize(versions:, live:, state_loader: PreparedVersionStateLoader.new)
107
+ @versions = versions.sort_by { |version| Support.chronological_version_key(version) }.freeze
108
+ @version_positions = @versions.each_with_index.to_h do |version, index|
109
+ [version.id.to_s, index]
110
+ end.freeze
30
111
  @live = live
112
+ @state_loader = state_loader
31
113
  @states = {} #: Hash[untyped, PreparedRecordState?]
32
114
  end
33
115
 
@@ -46,10 +128,30 @@ module PaperTrailDiff
46
128
  live ? [*version_records, live.instantiate] : version_records
47
129
  end
48
130
 
131
+ #: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
132
+ def transition(version)
133
+ index = @version_positions[version.id.to_s]
134
+ return unless index
135
+
136
+ before = state_for(versions.fetch(index))
137
+ after = state_after(index)
138
+ return unless before && after && before.model_class == after.model_class
139
+
140
+ [before.attributes, after.attributes]
141
+ end
142
+
49
143
  private
50
144
 
51
145
  # @rbs @live: PreparedRecordState?
146
+ # @rbs @state_loader: PreparedVersionStateLoader
52
147
  # @rbs @states: Hash[untyped, PreparedRecordState?]
148
+ # @rbs @version_positions: Hash[String, Integer]
149
+
150
+ #: (Integer) -> PreparedRecordState?
151
+ def state_after(index)
152
+ successor = versions[index + 1]
153
+ successor ? state_for(successor) : @live
154
+ end
53
155
 
54
156
  #: (untyped, untyped) -> bool
55
157
  def eligible?(version, boundary)
@@ -67,6 +169,9 @@ module PaperTrailDiff
67
169
  def state_for(version)
68
170
  return @states[version.id] if @states.key?(version.id)
69
171
 
172
+ prepared = @state_loader.call(version)
173
+ return @states[version.id] = prepared if prepared
174
+
70
175
  record = version.reify(
71
176
  dup: true,
72
177
  has_many: false,
@@ -80,9 +185,13 @@ module PaperTrailDiff
80
185
 
81
186
  # Request-scoped scalar history loaded once per model identity.
82
187
  class PreparedRecordIndex
83
- #: (untyped) -> void
84
- def initialize(start_version)
85
- @start_time = start_version.created_at
188
+ #: (untyped, ?live_records: Array[untyped]) -> void
189
+ def initialize(start_at, live_records: [])
190
+ @start_time = start_at
191
+ @seeded_live_records = live_records.to_h do |record|
192
+ [identity(record.class, record.id), record]
193
+ end
194
+ @state_loader = PreparedVersionStateLoader.new
86
195
  @series = {} #: Hash[Array[String], PreparedRecordSeries]
87
196
  end
88
197
 
@@ -92,7 +201,9 @@ module PaperTrailDiff
92
201
  return if missing.empty?
93
202
 
94
203
  versions = versions_for(model_class, missing).group_by { |version| version.item_id.to_s }
95
- live = live_records_for(model_class, missing).to_h { |record| [record.id.to_s, record] }
204
+ live = available_live_records(model_class, missing).to_h do |record|
205
+ [record.id.to_s, record]
206
+ end
96
207
  missing.each { |id| add_series(model_class, id, versions, live) }
97
208
  end
98
209
 
@@ -101,6 +212,11 @@ module PaperTrailDiff
101
212
  @series.fetch(identity(model_class, id)).record_before(boundary)
102
213
  end
103
214
 
215
+ #: (untyped, untyped, untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
216
+ def transition(model_class, id, version)
217
+ @series[identity(model_class, id)]&.transition(version)
218
+ end
219
+
104
220
  #: (untyped, Array[untyped]) -> Array[untyped]
105
221
  def records_for(model_class, ids)
106
222
  ids.flat_map { |id| @series.fetch(identity(model_class, id)).records }
@@ -114,6 +230,8 @@ module PaperTrailDiff
114
230
  private
115
231
 
116
232
  # @rbs @start_time: untyped
233
+ # @rbs @seeded_live_records: Hash[Array[String], untyped]
234
+ # @rbs @state_loader: PreparedVersionStateLoader
117
235
  # @rbs @series: Hash[Array[String], PreparedRecordSeries]
118
236
 
119
237
  #: (untyped, untyped) -> Array[String]
@@ -131,7 +249,8 @@ module PaperTrailDiff
131
249
  record = live[id.to_s]
132
250
  @series[identity(model_class, id)] = PreparedRecordSeries.new(
133
251
  versions: versions.fetch(id.to_s, []),
134
- live: record && PreparedRecordState.new(record)
252
+ live: record && PreparedRecordState.new(record),
253
+ state_loader: @state_loader
135
254
  )
136
255
  end
137
256
 
@@ -143,8 +262,17 @@ module PaperTrailDiff
143
262
  ).where('created_at >= ?', @start_time).order(:id).to_a
144
263
  end
145
264
 
265
+ #: (untyped, Array[untyped]) -> Array[untyped]
266
+ def available_live_records(model_class, ids)
267
+ seeded = ids.filter_map { |id| @seeded_live_records[identity(model_class, id)] }
268
+ missing = ids.reject { |id| @seeded_live_records.key?(identity(model_class, id)) }
269
+ seeded.concat(live_records_for(model_class, missing))
270
+ end
271
+
146
272
  #: (untyped, Array[untyped]) -> Array[untyped]
147
273
  def live_records_for(model_class, ids)
274
+ return [] if ids.empty?
275
+
148
276
  primary_key = model_class.primary_key
149
277
  return [] if primary_key.is_a?(Array)
150
278