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