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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Immutable positions for one collection membership and ordering.
6
+ class CollectionIdentityIndex
7
+ #: (Array[untyped]) -> void
8
+ def initialize(records)
9
+ @positions = {} #: Hash[Array[untyped], Integer]
10
+ @serialized_positions = {} #: Hash[Array[String], Integer]
11
+ @id_positions = {} #: Hash[String, Integer?]
12
+ records.each_with_index { |record, index| register(record, index) }
13
+ @positions.freeze
14
+ @serialized_positions.freeze
15
+ @id_positions.freeze
16
+ freeze
17
+ end
18
+
19
+ #: (untyped, untyped) -> Integer?
20
+ def position(type, id)
21
+ @serialized_positions[[type.to_s, id.to_s]]
22
+ end
23
+
24
+ #: (untyped) -> Integer?
25
+ def position_for_id(id)
26
+ @id_positions[id.to_s]
27
+ end
28
+
29
+ private
30
+
31
+ # @rbs @positions: Hash[Array[untyped], Integer]
32
+ # @rbs @serialized_positions: Hash[Array[String], Integer]
33
+ # @rbs @id_positions: Hash[String, Integer?]
34
+
35
+ #: (untyped, Integer) -> void
36
+ def register(record, index)
37
+ identity = record.identity
38
+ if @positions.key?(identity)
39
+ raise ArgumentError, "duplicate record identity: #{identity.inspect}"
40
+ end
41
+
42
+ @positions[identity] = index
43
+ @serialized_positions[[record.type.to_s, record.id.to_s]] = index
44
+ register_id(record.id, index)
45
+ end
46
+
47
+ #: (untyped, Integer) -> void
48
+ def register_id(id, index)
49
+ key = id.to_s
50
+ @id_positions[key] = @id_positions.key?(key) ? nil : index
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # One collection mutation between structurally adjacent snapshots.
6
+ class CollectionTransition
7
+ attr_reader :before #: RecordSnapshot?
8
+ attr_reader :after #: RecordSnapshot?
9
+
10
+ #: (from: AssociationSnapshot, before: RecordSnapshot?, after: RecordSnapshot?) -> void
11
+ def initialize(from:, before:, after:)
12
+ @from_object_id = from.object_id
13
+ @before = before
14
+ @after = after
15
+ freeze
16
+ end
17
+
18
+ #: (AssociationSnapshot) -> bool
19
+ def from?(association)
20
+ @from_object_id == association.object_id
21
+ end
22
+
23
+ # @rbs @from_object_id: Integer
24
+ end
25
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Validates and executes independent comparisons with shared live endpoint loading.
6
+ class ComparisonBatch
7
+ #: (Array[comparison_input], live_loader: untyped, preparer: untyped, history_preparer: untyped, historical_snapshotter: untyped, live_normalizer: untyped) -> void
8
+ def initialize( # rubocop:disable Metrics/ParameterLists
9
+ comparisons,
10
+ live_loader:,
11
+ preparer:,
12
+ history_preparer:,
13
+ historical_snapshotter:,
14
+ live_normalizer:
15
+ )
16
+ @comparisons = comparisons
17
+ @live_loader = live_loader
18
+ @preparer = preparer
19
+ @history_preparer = history_preparer
20
+ @historical_snapshotter = historical_snapshotter
21
+ @live_normalizer = live_normalizer
22
+ end
23
+
24
+ #: () -> comparison_results
25
+ def call
26
+ pairs = comparison_pairs
27
+ pairs.each { |from, to| Endpoint.validate_pair!(from, to) }
28
+ ensure_unique_identities!(pairs)
29
+ prepare_endpoint_classes!(pairs)
30
+ live_records = load_live_records(pairs.flatten)
31
+ prepare_historical_batches!(pairs, live_records)
32
+ live_snapshots = normalize_live_records(live_records)
33
+
34
+ pairs.to_h do |from, to|
35
+ identity = Support.immutable_copy(Endpoint.identity(from))
36
+ [identity, compare(from, to, live_snapshots)]
37
+ end.freeze
38
+ end
39
+
40
+ private
41
+
42
+ # @rbs @comparisons: Array[comparison_input]
43
+ # @rbs @live_loader: untyped
44
+ # @rbs @preparer: untyped
45
+ # @rbs @history_preparer: untyped
46
+ # @rbs @historical_snapshotter: untyped
47
+ # @rbs @live_normalizer: untyped
48
+
49
+ #: (untyped, untyped, Hash[identity, RecordSnapshot]) -> Diff
50
+ def compare(from, to, live_snapshots)
51
+ Engine.compare(snapshot(from, live_snapshots), snapshot(to, live_snapshots))
52
+ end
53
+
54
+ #: (untyped, Hash[identity, RecordSnapshot]) -> RecordSnapshot?
55
+ def snapshot(endpoint, live_snapshots)
56
+ return @historical_snapshotter.call(endpoint) if Endpoint.version?(endpoint)
57
+
58
+ live_snapshots.fetch(Endpoint.identity(endpoint))
59
+ end
60
+
61
+ #: (Array[untyped]) -> Hash[identity, untyped]
62
+ def load_live_records(endpoints)
63
+ records = endpoints.reject { |endpoint| Endpoint.version?(endpoint) }.uniq do |record|
64
+ Endpoint.identity(record)
65
+ end
66
+ @live_loader.call(records)
67
+ end
68
+
69
+ #: (Hash[identity, untyped]) -> Hash[identity, RecordSnapshot]
70
+ def normalize_live_records(records)
71
+ records.transform_values { |record| @live_normalizer.call(record) }
72
+ end
73
+
74
+ #: (Array[[untyped, untyped]], Hash[identity, untyped]) -> void
75
+ def prepare_historical_batches!(pairs, live_records)
76
+ grouped = historical_versions(pairs, live_records).group_by do |version|
77
+ Endpoint.model_class(version)
78
+ end
79
+ grouped.each_value do |versions|
80
+ records = versions.map { |version| live_records.fetch(Endpoint.identity(version)) }.uniq
81
+ @history_preparer.call(records, versions)
82
+ end
83
+ end
84
+
85
+ #: (Array[[untyped, untyped]], Hash[identity, untyped]) -> Array[untyped]
86
+ def historical_versions(pairs, live_records)
87
+ selected = pairs.flatten.select do |endpoint|
88
+ Endpoint.version?(endpoint) && live_records.key?(Endpoint.identity(endpoint))
89
+ end
90
+ selected.uniq { |version| [version.class.name, version.id] }
91
+ end
92
+
93
+ #: () -> Array[[untyped, untyped]]
94
+ def comparison_pairs
95
+ unless @comparisons.is_a?(Array)
96
+ raise ConfigurationError, 'comparisons: must be an array of from:/to: hashes'
97
+ end
98
+
99
+ @comparisons.map.with_index do |comparison, index|
100
+ comparison_pair(comparison, index)
101
+ end
102
+ end
103
+
104
+ #: (untyped, Integer) -> [untyped, untyped]
105
+ def comparison_pair(comparison, index)
106
+ valid_comparison!(comparison, index)
107
+ [endpoint(comparison, :from), endpoint(comparison, :to)]
108
+ end
109
+
110
+ #: (untyped, Integer) -> void
111
+ def valid_comparison!(comparison, index)
112
+ if comparison.is_a?(Hash)
113
+ keys = comparison.keys.map(&:to_s)
114
+ return if keys.sort == %w[from to] && keys.length == 2
115
+ end
116
+
117
+ message = "comparisons[#{index}]: must contain exactly from: and to:"
118
+ raise ConfigurationError, message
119
+ end
120
+
121
+ #: (Hash[untyped, untyped], Symbol) -> untyped
122
+ def endpoint(comparison, name)
123
+ comparison.key?(name) ? comparison.fetch(name) : comparison.fetch(name.to_s)
124
+ end
125
+
126
+ #: (Array[[untyped, untyped]]) -> void
127
+ def ensure_unique_identities!(pairs)
128
+ identities = pairs.map { |from, _to| Endpoint.identity(from) }
129
+ return if identities.uniq.length == identities.length
130
+
131
+ raise ConfigurationError, 'comparisons: root identities must be unique'
132
+ end
133
+
134
+ #: (Array[[untyped, untyped]]) -> void
135
+ def prepare_endpoint_classes!(pairs)
136
+ endpoint_requirements(pairs).each do |model_class, historical|
137
+ @preparer.call(model_class, historical: historical)
138
+ end
139
+ end
140
+
141
+ #: (Array[[untyped, untyped]]) -> Hash[untyped, bool]
142
+ def endpoint_requirements(pairs)
143
+ requirements = {} #: Hash[untyped, bool]
144
+ pairs.flatten.each_with_object(requirements) do |endpoint, collected|
145
+ historical = Endpoint.version?(endpoint)
146
+ model_class = historical ? Endpoint.model_class(endpoint) : endpoint.class
147
+ collected[model_class] = collected.fetch(model_class, false) || historical
148
+ end
149
+ end
150
+ end
151
+ end
@@ -124,51 +124,11 @@ module PaperTrailDiff
124
124
 
125
125
  #: (AssociationSnapshot, AssociationSnapshot) -> CollectionAssociationDiff
126
126
  def compare_collection(from_association, to_association)
127
- from_records = index_records(from_association.records)
128
- to_records = index_records(to_association.records)
129
-
130
- CollectionAssociationDiff.new(
131
- kind: from_association.kind,
132
- added: records_missing_from(to_records, from_records),
133
- removed: records_missing_from(from_records, to_records),
134
- changed: changed_records(from_records, to_records)
135
- )
136
- end
137
-
138
- #: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordSnapshot]
139
- def records_missing_from(records, other_records)
140
- identities = sorted_identities(records.keys - other_records.keys)
141
- identities.map { |identity| records.fetch(identity) }
142
- end
143
-
144
- #: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordChange]
145
- def changed_records(from_records, to_records)
146
- sorted_identities(from_records.keys & to_records.keys).filter_map do |identity|
147
- from_record = from_records.fetch(identity)
148
- to_record = to_records.fetch(identity)
149
- next if from_record.equal?(to_record)
150
-
151
- compare_record(from_record, to_record)
152
- end
153
- end
154
-
155
- #: (Array[RecordSnapshot]) -> Hash[Array[untyped], RecordSnapshot]
156
- def index_records(records)
157
- index = {} #: Hash[identity, RecordSnapshot]
158
- records.each do |record|
159
- identity = record.identity
160
- if index.key?(identity)
161
- raise ArgumentError, "duplicate record identity: #{identity.inspect}"
162
- end
163
-
164
- index[identity] = record
165
- end
166
- index
167
- end
168
-
169
- #: (Array[Array[untyped]]) -> Array[Array[untyped]]
170
- def sorted_identities(identities)
171
- identities.sort_by { |type, id| [type, id.inspect] }
127
+ CollectionComparator.new(
128
+ from_association,
129
+ to_association,
130
+ record_comparer: method(:compare_record)
131
+ ).call
172
132
  end
173
133
 
174
134
  #: (RecordSnapshot?, RecordSnapshot?) -> bool
@@ -14,9 +14,18 @@ module PaperTrailDiff
14
14
  # Raised when a live endpoint is not a clean, persisted ActiveRecord record.
15
15
  class InvalidEndpointError < Error; end
16
16
 
17
+ # Raised when reload opt-out encounters an association that was not preloaded.
18
+ class UnloadedAssociationError < InvalidEndpointError; end
19
+
17
20
  # Raised when a timeline boundary is absent or the requested range is reversed.
18
21
  class InvalidTimelineRangeError < Error; end
19
22
 
23
+ # Raised when `within` is not a finite, chronological range of time-like values.
24
+ class InvalidTimeRangeError < InvalidTimelineRangeError; end
25
+
26
+ # Raised when an in-range mutation has no later root boundary for reconstruction.
27
+ class IncompleteTimeRangeError < InvalidTimelineRangeError; end
28
+
20
29
  # Raised when a requested ActiveRecord association does not exist.
21
30
  class UnknownAssociationError < Error; end
22
31
 
@@ -10,22 +10,35 @@ module PaperTrailDiff
10
10
  @traversal = traversal
11
11
  @normalizer = normalizer
12
12
  @preparer = preparer
13
+ @live_graph_collector = LiveGraphCollector.new(tree: tree, traversal: traversal)
13
14
  @snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
14
15
  @prepared_history = nil #: PreparedHistory?
16
+ @prepared_histories = {} #: Hash[Array[untyped], PreparedHistory]
15
17
  end
16
18
 
17
- #: (untyped, Array[untyped]) -> void
18
- def prepare(record, root_versions)
19
+ #: (untyped, Array[untyped], ?start_at: untyped) -> void
20
+ def prepare(record, root_versions, start_at: root_versions.first.created_at)
19
21
  return if @tree.empty?
20
22
 
21
23
  @prepared_history = PreparedHistoryLoader.new(
22
24
  record,
23
25
  root_versions: root_versions,
26
+ start_at: start_at,
24
27
  tree: @tree,
25
28
  traversal: @traversal
26
29
  ).call
27
30
  end
28
31
 
32
+ # Prepares selected history for several roots of the same model class.
33
+ #: (Array[untyped], Array[untyped]) -> void
34
+ def prepare_batch(records, root_versions)
35
+ return if @tree.empty? || records.empty? || root_versions.empty?
36
+
37
+ Instrumentation.instrument('prepare_history', batch_payload(records, root_versions)) do
38
+ register_batch(records, root_versions)
39
+ end
40
+ end
41
+
29
42
  #: (untyped, untyped) -> RecordSnapshot?
30
43
  def call(root_endpoint, context_endpoint)
31
44
  key = snapshot_key(root_endpoint, context_endpoint)
@@ -74,7 +87,7 @@ module PaperTrailDiff
74
87
  context_version,
75
88
  habtm_version: habtm_version
76
89
  )
77
- history = @prepared_history
90
+ history = @prepared_histories[context_key(context_version)] || @prepared_history
78
91
  return fallback unless history
79
92
 
80
93
  PreparedAssociationReifier.new(
@@ -85,14 +98,53 @@ module PaperTrailDiff
85
98
  )
86
99
  end
87
100
 
101
+ #: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
102
+ def record_transition(version)
103
+ history = @prepared_history
104
+ return unless history
105
+
106
+ history.record_transition(Endpoint.model_class(version), version.item_id, version)
107
+ end
108
+
88
109
  private
89
110
 
90
111
  # @rbs @tree: AssociationTree
91
112
  # @rbs @traversal: AssociationTraversal
92
113
  # @rbs @normalizer: SnapshotNormalizer
93
114
  # @rbs @preparer: untyped
115
+ # @rbs @live_graph_collector: LiveGraphCollector
94
116
  # @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
95
117
  # @rbs @prepared_history: PreparedHistory?
118
+ # @rbs @prepared_histories: Hash[Array[untyped], PreparedHistory]
119
+
120
+ #: (untyped) -> Array[untyped]
121
+ def context_key(endpoint)
122
+ [endpoint.class.name, endpoint.id]
123
+ end
124
+
125
+ #: (Array[untyped], Array[untyped]) -> Hash[Symbol, untyped]
126
+ def batch_payload(records, root_versions)
127
+ {
128
+ root_count: records.length,
129
+ version_count: root_versions.length,
130
+ model_type: records.first.class.base_class.name.to_s,
131
+ batched: true
132
+ }
133
+ end
134
+
135
+ #: (Array[untyped], Array[untyped]) -> void
136
+ def register_batch(records, root_versions)
137
+ history = PreparedHistoryLoader.new(
138
+ records.first,
139
+ root_ids: records.map(&:id),
140
+ root_versions: root_versions,
141
+ start_at: root_versions.map(&:created_at).min,
142
+ tree: @tree,
143
+ traversal: @traversal,
144
+ live_records: @live_graph_collector.call(records)
145
+ ).call
146
+ root_versions.each { |version| @prepared_histories[context_key(version)] = history }
147
+ end
96
148
 
97
149
  #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
98
150
  def snapshot_from_version(root_version, context_version, tree:, normalizer:)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require 'active_support/notifications'
5
+
6
+ module PaperTrailDiff
7
+ # Emits quiet, namespaced runtime events for application-owned diagnostics.
8
+ module Instrumentation
9
+ module_function
10
+
11
+ #: (String | Symbol, Hash[Symbol, untyped]) { () -> untyped } -> untyped
12
+ def instrument(event, payload, &)
13
+ active_support = Object.const_get(:ActiveSupport) #: untyped
14
+ notifications = active_support.const_get(:Notifications) #: untyped
15
+ notifications.instrument(
16
+ "#{event}.paper_trail_diff",
17
+ payload,
18
+ &
19
+ )
20
+ end
21
+
22
+ #: (association_paths: Array[String], reload_live_endpoints: bool) -> Hash[Symbol, untyped]
23
+ def comparison_payload(association_paths:, reload_live_endpoints:)
24
+ {
25
+ association_paths: association_paths,
26
+ reload_live_endpoints: reload_live_endpoints
27
+ }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Reloads current-record endpoints and their explicitly selected associations in batches.
6
+ class LiveEndpointBatchLoader
7
+ #: (tree: AssociationTree) -> void
8
+ def initialize(tree:)
9
+ @tree = tree
10
+ end
11
+
12
+ #: (Array[untyped]) -> Hash[identity, untyped]
13
+ def call(records)
14
+ loaded = {} #: Hash[identity, untyped]
15
+ records.group_by(&:class).each do |model_class, grouped|
16
+ load_group(model_class, grouped).each do |record|
17
+ loaded[Endpoint.identity(record)] = record
18
+ end
19
+ end
20
+ ensure_all_loaded!(records, loaded)
21
+ loaded
22
+ end
23
+
24
+ private
25
+
26
+ # @rbs @tree: AssociationTree
27
+
28
+ #: (untyped, Array[untyped]) -> Array[untyped]
29
+ def load_group(model_class, records)
30
+ relation = model_class.unscoped.where(model_class.primary_key => records.map(&:id).uniq)
31
+ loaded = relation.to_a
32
+ preload_tree(loaded, @tree)
33
+ loaded
34
+ end
35
+
36
+ #: (Array[untyped], AssociationTree) -> void
37
+ def preload_tree(records, tree)
38
+ return if records.empty? || tree.empty?
39
+
40
+ records.group_by(&:class).each do |model_class, grouped_records|
41
+ preload_model_tree(model_class, grouped_records, tree)
42
+ end
43
+ end
44
+
45
+ #: (untyped, Array[untyped], AssociationTree) -> void
46
+ def preload_model_tree(model_class, records, tree)
47
+ batch, individual = selected_reflections(model_class, tree).partition do |reflection, _|
48
+ batch_safe?(records.fetch(0), reflection)
49
+ end
50
+ preload_batch(records, batch.map { |reflection, _| reflection.name })
51
+ preload_subtrees(records, batch, individually: false)
52
+ preload_subtrees(records, individual, individually: true)
53
+ end
54
+
55
+ #: (untyped, AssociationTree) -> Array[[untyped, AssociationTree]]
56
+ def selected_reflections(model_class, tree)
57
+ tree.children.map do |name, subtree|
58
+ reflection = model_class.reflect_on_association(name)
59
+ raise ConfigurationError, "unknown association: #{name}" unless reflection
60
+
61
+ [reflection, subtree]
62
+ end
63
+ end
64
+
65
+ #: (Array[untyped], Array[[untyped, AssociationTree]], individually: bool) -> void
66
+ def preload_subtrees(records, reflections, individually:)
67
+ reflections.each do |reflection, subtree|
68
+ children = if individually
69
+ load_individually(records, reflection)
70
+ else
71
+ targets(records, reflection)
72
+ end
73
+ preload_tree(children, subtree)
74
+ end
75
+ end
76
+
77
+ #: (Array[untyped], Array[Symbol]) -> void
78
+ def preload_batch(records, associations)
79
+ return if associations.empty?
80
+
81
+ active_record_preloader.new(
82
+ records: records,
83
+ associations: associations
84
+ ).call
85
+ end
86
+
87
+ #: () -> untyped
88
+ def active_record_preloader
89
+ active_record = Object.const_get(:ActiveRecord)
90
+ active_record.const_get(:Associations).const_get(:Preloader)
91
+ end
92
+
93
+ #: (untyped, untyped) -> bool
94
+ def batch_safe?(record, reflection)
95
+ scope = reflection.scope
96
+ return false if scope && !scope.arity.zero?
97
+ return true unless reflection.collection?
98
+
99
+ relation = record.association(reflection.name).scope
100
+ relation.limit_value.nil? && relation.offset_value.nil?
101
+ end
102
+
103
+ #: (Array[untyped], untyped) -> Array[untyped]
104
+ def load_individually(records, reflection)
105
+ unique_records(records.flat_map do |record|
106
+ Array(record.association(reflection.name).load_target)
107
+ end)
108
+ end
109
+
110
+ #: (Array[untyped], untyped) -> Array[untyped]
111
+ def targets(records, reflection)
112
+ unique_records(records.flat_map do |record|
113
+ Array(record.association(reflection.name).target)
114
+ end)
115
+ end
116
+
117
+ #: (Array[untyped]) -> Array[untyped]
118
+ def unique_records(records)
119
+ records.compact.uniq do |record|
120
+ [record.class.base_class.name.to_s, record.id.to_s]
121
+ end
122
+ end
123
+
124
+ #: (Array[untyped], Hash[identity, untyped]) -> void
125
+ def ensure_all_loaded!(records, loaded)
126
+ missing = records.map { |record| Endpoint.identity(record) }.uniq - loaded.keys
127
+ return if missing.empty?
128
+
129
+ message = 'current record endpoint could not be reloaded from the database'
130
+ raise InvalidEndpointError, message
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Selects safe reload or validated caller-owned loading for current endpoints.
6
+ class LiveEndpointProvider
7
+ attr_reader :reload #: bool
8
+ attr_reader :comparison_payload #: Hash[Symbol, untyped]
9
+
10
+ #: (tree: AssociationTree, traversal: AssociationTraversal, reload: bool) -> void
11
+ def initialize(tree:, traversal:, reload:)
12
+ unless [true, false].include?(reload)
13
+ raise ConfigurationError, 'reload_live_endpoints: must be true or false'
14
+ end
15
+
16
+ @reload = reload
17
+ @comparison_payload = Instrumentation.comparison_payload(
18
+ association_paths: tree.paths,
19
+ reload_live_endpoints: reload
20
+ ).freeze
21
+ @loader = if reload
22
+ LiveEndpointBatchLoader.new(tree: tree)
23
+ else
24
+ PreloadedEndpointBatchLoader.new(tree: tree, traversal: traversal)
25
+ end
26
+ end
27
+
28
+ #: (Array[untyped]) -> Hash[identity, untyped]
29
+ def call(records)
30
+ Instrumentation.instrument('load_live_endpoints', payload(records)) do
31
+ @loader.call(records)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # @rbs @loader: untyped
38
+
39
+ #: (Array[untyped]) -> Hash[Symbol, untyped]
40
+ def payload(records)
41
+ {
42
+ endpoint_count: records.length,
43
+ model_types: records.map { |record| record.class.base_class.name.to_s }.uniq.sort.freeze,
44
+ reload_live_endpoints: reload
45
+ }
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Flattens an already-preloaded, explicitly bounded live association graph.
6
+ class LiveGraphCollector
7
+ #: (tree: AssociationTree, traversal: AssociationTraversal) -> void
8
+ def initialize(tree:, traversal:)
9
+ @tree = tree
10
+ @traversal = traversal
11
+ end
12
+
13
+ #: (Array[untyped]) -> Array[untyped]
14
+ def call(records)
15
+ collected = {} #: Hash[Array[untyped], untyped]
16
+ collect(records, @tree, path: '', into: collected)
17
+ collected.values
18
+ end
19
+
20
+ private
21
+
22
+ # @rbs @tree: AssociationTree
23
+ # @rbs @traversal: AssociationTraversal
24
+
25
+ #: (Array[untyped], AssociationTree, path: String, into: Hash[Array[untyped], untyped]) -> void
26
+ def collect(records, tree, path:, into:)
27
+ records.each { |record| into[Endpoint.identity(record)] = record }
28
+ records.group_by(&:class).each do |model_class, grouped|
29
+ collect_group(model_class, grouped, tree, path: path, into: into)
30
+ end
31
+ end
32
+
33
+ #: (untyped, Array[untyped], AssociationTree, path: String, into: Hash[Array[untyped], untyped]) -> void
34
+ def collect_group(model_class, records, tree, path:, into:)
35
+ @traversal.reflections_for(model_class, tree, path: path).each do |reflection|
36
+ subtree = tree.child(reflection.name)
37
+ next unless subtree
38
+
39
+ child_path = Support.association_path(path, reflection.name.to_s)
40
+ collect(children_for(records, reflection), subtree, path: child_path, into: into)
41
+ end
42
+ end
43
+
44
+ #: (Array[untyped], untyped) -> Array[untyped]
45
+ def children_for(records, reflection)
46
+ records.flat_map do |record|
47
+ associated = record.public_send(reflection.name)
48
+ reflection.collection? ? associated.to_a : Array(associated).compact
49
+ end
50
+ end
51
+ end
52
+ end