paper_trail_diff 0.2.0 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/QUICKSTART.md +44 -2
- data/README.md +179 -14
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +261 -45
- data/lib/paper_trail_diff/activity_history.rb +136 -0
- data/lib/paper_trail_diff/activity_range.rb +28 -12
- data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
- data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
- data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +5 -3
- data/lib/paper_trail_diff/collection_comparator.rb +155 -0
- data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
- data/lib/paper_trail_diff/collection_transition.rb +25 -0
- data/lib/paper_trail_diff/comparison_batch.rb +151 -0
- data/lib/paper_trail_diff/engine.rb +5 -45
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
- data/lib/paper_trail_diff/instrumentation.rb +30 -0
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
- data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
- data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
- data/lib/paper_trail_diff/prepared_history.rb +14 -4
- data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
- data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
- data/lib/paper_trail_diff/snapshot.rb +66 -3
- data/lib/paper_trail_diff/support.rb +18 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
- data/lib/paper_trail_diff/time_range.rb +85 -0
- data/lib/paper_trail_diff/time_version_range.rb +53 -0
- data/lib/paper_trail_diff/timeline_builder.rb +13 -7
- data/lib/paper_trail_diff/timeline_range.rb +72 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
- data/lib/paper_trail_diff.rb +74 -12
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +49 -8
- data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
- data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
- data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
- data/sig/generated/paper_trail_diff/engine.rbs +0 -12
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
- data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
- data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
- data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
- data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
- data/sig/generated/paper_trail_diff/support.rbs +6 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
- data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
- data/sig/generated/paper_trail_diff.rbs +16 -8
- metadata +38 -4
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|