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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/QUICKSTART.md +44 -2
- data/README.md +188 -14
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
- data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
- data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
- data/lib/paper_trail_diff/activity_event_route_finder.rb +112 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +59 -437
- 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_relationship.rb +55 -0
- 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 +7 -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 +83 -12
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
- data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
- data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
- data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
- data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +54 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +20 -71
- 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_relationship.rbs +23 -0
- 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 +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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
record
|
|
48
|
-
from: from,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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(
|
|
85
|
-
@start_time =
|
|
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 =
|
|
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
|
|