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,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Advances scalar-only root events while retaining already reconstructed associations.
|
|
6
|
+
class ActivityRootSnapshotRefresher
|
|
7
|
+
#: (tree: AssociationTree, traversal: AssociationTraversal, normalizer: SnapshotNormalizer, record_after: untyped, changeset: untyped, partial_snapshotter: untyped) -> void
|
|
8
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
|
+
tree:,
|
|
10
|
+
traversal:,
|
|
11
|
+
normalizer:,
|
|
12
|
+
record_after:,
|
|
13
|
+
changeset:,
|
|
14
|
+
partial_snapshotter:
|
|
15
|
+
)
|
|
16
|
+
@tree = tree
|
|
17
|
+
@traversal = traversal
|
|
18
|
+
@normalizer = normalizer
|
|
19
|
+
@record_after = record_after
|
|
20
|
+
@changeset = changeset
|
|
21
|
+
@partial_snapshotter = partial_snapshotter
|
|
22
|
+
@unsupported_branches = {} #: Hash[String, Array[String]]
|
|
23
|
+
@relationship_reflections = {} #: Hash[String, Array[untyped]]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
#: (untyped, untyped, RecordSnapshot?, ActivityEvent) -> [bool, RecordSnapshot?]
|
|
27
|
+
def call( # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
28
|
+
root_endpoint,
|
|
29
|
+
context_endpoint,
|
|
30
|
+
previous,
|
|
31
|
+
event
|
|
32
|
+
)
|
|
33
|
+
return [false, nil] unless previous && event.root?
|
|
34
|
+
|
|
35
|
+
version = event.version
|
|
36
|
+
model_class = Endpoint.model_class(version)
|
|
37
|
+
return [false, nil] unless same_record?(previous, version)
|
|
38
|
+
|
|
39
|
+
changes = @changeset.call(version, model_class)
|
|
40
|
+
return [false, nil] unless changes.respond_to?(:each)
|
|
41
|
+
|
|
42
|
+
branches = branches_to_refresh(model_class, changes)
|
|
43
|
+
partial = nil #: RecordSnapshot?
|
|
44
|
+
unless branches.empty?
|
|
45
|
+
partial = @partial_snapshotter.call(root_endpoint, context_endpoint, branches)
|
|
46
|
+
end
|
|
47
|
+
return [false, nil] if !branches.empty? && !partial
|
|
48
|
+
|
|
49
|
+
attributes = advanced_attributes(previous, version, changes)
|
|
50
|
+
return [false, nil] unless attributes
|
|
51
|
+
|
|
52
|
+
incoming_associations = {} #: snapshot_associations
|
|
53
|
+
incoming_associations = partial.associations if partial
|
|
54
|
+
associations = previous.associations.merge(incoming_associations)
|
|
55
|
+
return [true, previous] if attributes == previous.attributes &&
|
|
56
|
+
associations == previous.associations
|
|
57
|
+
|
|
58
|
+
[
|
|
59
|
+
true,
|
|
60
|
+
RecordSnapshot.new(
|
|
61
|
+
type: previous.type,
|
|
62
|
+
id: previous.id,
|
|
63
|
+
attributes: attributes,
|
|
64
|
+
associations: associations
|
|
65
|
+
)
|
|
66
|
+
]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# @rbs @tree: AssociationTree
|
|
72
|
+
# @rbs @traversal: AssociationTraversal
|
|
73
|
+
# @rbs @normalizer: SnapshotNormalizer
|
|
74
|
+
# @rbs @record_after: untyped
|
|
75
|
+
# @rbs @changeset: untyped
|
|
76
|
+
# @rbs @partial_snapshotter: untyped
|
|
77
|
+
# @rbs @unsupported_branches: Hash[String, Array[String]]
|
|
78
|
+
# @rbs @relationship_reflections: Hash[String, Array[untyped]]
|
|
79
|
+
|
|
80
|
+
#: (RecordSnapshot, untyped, untyped) -> snapshot_attributes?
|
|
81
|
+
def advanced_attributes(previous, version, changes)
|
|
82
|
+
return previous.attributes if changes.empty?
|
|
83
|
+
|
|
84
|
+
record = @record_after.call(version)
|
|
85
|
+
return unless record
|
|
86
|
+
|
|
87
|
+
@normalizer.attributes_for(record, tree: @tree, path: '')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
#: (untyped, untyped) -> Array[String]
|
|
91
|
+
def branches_to_refresh(model_class, changes)
|
|
92
|
+
(unsupported_branches(model_class) + changed_relationship_branches(model_class, changes))
|
|
93
|
+
.uniq
|
|
94
|
+
.sort
|
|
95
|
+
.freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
#: (untyped) -> Array[String]
|
|
99
|
+
def unsupported_branches(model_class)
|
|
100
|
+
key = model_class.name.to_s
|
|
101
|
+
return @unsupported_branches[key] if @unsupported_branches.key?(key)
|
|
102
|
+
|
|
103
|
+
@unsupported_branches[key] = @traversal.selected_reflections(
|
|
104
|
+
model_class
|
|
105
|
+
).filter_map do |path, reflection|
|
|
106
|
+
path.split('.').first unless direct_versioned_reflection?(reflection)
|
|
107
|
+
end.uniq.sort.freeze
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
#: (untyped) -> bool
|
|
111
|
+
def direct_versioned_reflection?(reflection)
|
|
112
|
+
return false if reflection.polymorphic? || reflection.options[:through]
|
|
113
|
+
return false if reflection.macro == :has_and_belongs_to_many
|
|
114
|
+
|
|
115
|
+
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
116
|
+
paper_trail.request.enabled_for_model?(reflection.klass)
|
|
117
|
+
rescue NameError
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: (RecordSnapshot, untyped) -> bool
|
|
122
|
+
def same_record?(snapshot, version)
|
|
123
|
+
identity = Endpoint.identity(version)
|
|
124
|
+
snapshot.type.to_s == identity.fetch(0) && snapshot.id.to_s == identity.fetch(1)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
#: (untyped, untyped) -> Array[String]
|
|
128
|
+
def changed_relationship_branches(model_class, changes)
|
|
129
|
+
changed = changes.each_key.map(&:to_s)
|
|
130
|
+
root_relationship_reflections(model_class).filter_map do |reflection|
|
|
131
|
+
reflection.name.to_s if changed.intersect?(relationship_columns(reflection))
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
#: (untyped) -> Array[untyped]
|
|
136
|
+
def root_relationship_reflections(model_class)
|
|
137
|
+
key = model_class.name.to_s
|
|
138
|
+
@relationship_reflections[key] ||= @traversal.reflections_for(
|
|
139
|
+
model_class,
|
|
140
|
+
@tree,
|
|
141
|
+
path: ''
|
|
142
|
+
).select { |reflection| reflection.macro == :belongs_to }.freeze
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
#: (untyped) -> Array[String]
|
|
146
|
+
def relationship_columns(reflection)
|
|
147
|
+
foreign_keys = Array(reflection.foreign_key)
|
|
148
|
+
# @type var foreign_keys: Array[untyped]
|
|
149
|
+
columns = foreign_keys.map do |name| # rubocop:disable Style/SymbolProc
|
|
150
|
+
name.to_s
|
|
151
|
+
end
|
|
152
|
+
columns << reflection.foreign_type.to_s if reflection.polymorphic?
|
|
153
|
+
columns
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Optional full/partial snapshot operations consumed by the activity sequence.
|
|
6
|
+
class ActivitySnapshotProvider
|
|
7
|
+
#: (snapshotter: untyped, refresher: untyped, preparer: untyped) -> void
|
|
8
|
+
def initialize(snapshotter:, refresher:, preparer:)
|
|
9
|
+
@snapshotter = snapshotter
|
|
10
|
+
@refresher = refresher
|
|
11
|
+
@preparer = preparer
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
#: (untyped, Array[untyped]) -> void
|
|
15
|
+
def prepare(record, root_versions)
|
|
16
|
+
@preparer.call(record, root_versions)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#: (untyped, untyped) -> RecordSnapshot?
|
|
20
|
+
def call(root_endpoint, context_endpoint)
|
|
21
|
+
@snapshotter.call(root_endpoint, context_endpoint)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
#: (untyped, untyped, RecordSnapshot?, Array[String], event: ActivityEvent, isolated: bool) -> RecordSnapshot?
|
|
25
|
+
def refresh( # rubocop:disable Metrics/ParameterLists
|
|
26
|
+
root_endpoint,
|
|
27
|
+
context_endpoint,
|
|
28
|
+
previous_snapshot,
|
|
29
|
+
branches,
|
|
30
|
+
event:,
|
|
31
|
+
isolated:
|
|
32
|
+
)
|
|
33
|
+
@refresher.call(
|
|
34
|
+
root_endpoint,
|
|
35
|
+
context_endpoint,
|
|
36
|
+
previous_snapshot,
|
|
37
|
+
branches,
|
|
38
|
+
event: event,
|
|
39
|
+
isolated: isolated
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped, untyped, RecordSnapshot?, event: ActivityEvent) -> RecordSnapshot?
|
|
44
|
+
def advance_root(root_endpoint, context_endpoint, previous_snapshot, event:)
|
|
45
|
+
@refresher.advance_root(
|
|
46
|
+
root_endpoint,
|
|
47
|
+
context_endpoint,
|
|
48
|
+
previous_snapshot,
|
|
49
|
+
event: event
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @rbs @snapshotter: untyped
|
|
54
|
+
# @rbs @refresher: untyped
|
|
55
|
+
# @rbs @preparer: untyped
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Builds chronological snapshots, refreshing only branches affected by prior events.
|
|
59
|
+
class ActivitySnapshotSequence
|
|
60
|
+
#: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped) -> void
|
|
61
|
+
def initialize(root_versions, events, snapshotter, current: nil)
|
|
62
|
+
@root_versions = root_versions
|
|
63
|
+
@events = events
|
|
64
|
+
@snapshotter = snapshotter
|
|
65
|
+
@current = current
|
|
66
|
+
@transaction_groups = ActivityTransactionGroups.new(events)
|
|
67
|
+
@root_endpoints = build_root_endpoints
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: () -> Array[RecordSnapshot?]
|
|
71
|
+
def call
|
|
72
|
+
snapshots = [] #: Array[RecordSnapshot?]
|
|
73
|
+
each { |_event, snapshot| snapshots << snapshot }
|
|
74
|
+
snapshots
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#: () { (ActivityEvent, RecordSnapshot?) -> void } -> void
|
|
78
|
+
def each
|
|
79
|
+
first = @events.first
|
|
80
|
+
return unless first
|
|
81
|
+
|
|
82
|
+
previous_snapshot = full_snapshot(first)
|
|
83
|
+
yield first, previous_snapshot
|
|
84
|
+
@events.each_cons(2) do |pair|
|
|
85
|
+
previous_event = pair.fetch(0)
|
|
86
|
+
event = pair.fetch(1)
|
|
87
|
+
previous_snapshot = transition_snapshot(previous_event, event, previous_snapshot)
|
|
88
|
+
yield event, previous_snapshot
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# @rbs @root_versions: Array[untyped]
|
|
95
|
+
# @rbs @events: Array[ActivityEvent]
|
|
96
|
+
# @rbs @snapshotter: untyped
|
|
97
|
+
# @rbs @current: untyped
|
|
98
|
+
# @rbs @transaction_groups: ActivityTransactionGroups
|
|
99
|
+
# @rbs @root_endpoints: Hash[untyped, untyped]
|
|
100
|
+
|
|
101
|
+
#: (ActivityEvent, ActivityEvent, RecordSnapshot?) -> RecordSnapshot?
|
|
102
|
+
def transition_snapshot(previous_event, event, previous_snapshot)
|
|
103
|
+
return advance_root(previous_event, event, previous_snapshot) if
|
|
104
|
+
incremental_root?(previous_event)
|
|
105
|
+
|
|
106
|
+
branches = @transaction_groups.branches_for(previous_event)
|
|
107
|
+
return full_snapshot(event) unless branches && @snapshotter.respond_to?(:refresh)
|
|
108
|
+
|
|
109
|
+
@snapshotter.refresh(
|
|
110
|
+
root_endpoint(event.version),
|
|
111
|
+
event.version,
|
|
112
|
+
previous_snapshot,
|
|
113
|
+
branches,
|
|
114
|
+
event: previous_event,
|
|
115
|
+
isolated: @transaction_groups.isolated?(previous_event)
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
#: (ActivityEvent) -> bool
|
|
120
|
+
def incremental_root?(event)
|
|
121
|
+
event.root? && @transaction_groups.isolated?(event) &&
|
|
122
|
+
@snapshotter.respond_to?(:advance_root)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
#: (ActivityEvent, ActivityEvent, RecordSnapshot?) -> RecordSnapshot?
|
|
126
|
+
def advance_root(previous_event, event, previous_snapshot)
|
|
127
|
+
@snapshotter.advance_root(
|
|
128
|
+
root_endpoint(event.version),
|
|
129
|
+
event.version,
|
|
130
|
+
previous_snapshot,
|
|
131
|
+
event: previous_event
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
#: (ActivityEvent) -> RecordSnapshot?
|
|
136
|
+
def full_snapshot(event)
|
|
137
|
+
@snapshotter.call(root_endpoint(event.version), event.version)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
#: (untyped) -> untyped
|
|
141
|
+
def root_endpoint(activity_version)
|
|
142
|
+
@root_endpoints.fetch(activity_version)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
#: () -> Hash[untyped, untyped]
|
|
146
|
+
def build_root_endpoints
|
|
147
|
+
root_index = 0
|
|
148
|
+
endpoints = {} #: Hash[untyped, untyped]
|
|
149
|
+
endpoints.compare_by_identity
|
|
150
|
+
@events.each do |event|
|
|
151
|
+
while @root_versions[root_index] &&
|
|
152
|
+
Support.compare_versions(@root_versions[root_index], event.version).negative?
|
|
153
|
+
root_index += 1
|
|
154
|
+
end
|
|
155
|
+
endpoint = @root_versions[root_index] || @current || @root_versions.last
|
|
156
|
+
endpoints[event.version] = endpoint
|
|
157
|
+
end
|
|
158
|
+
endpoints
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Compares adjacent root and selected-descendant activity boundaries.
|
|
6
|
+
class ActivityTimelineBuilder
|
|
7
|
+
#: (untyped, from: untyped, to: untyped, tree: AssociationTree, snapshotter: untyped) -> void
|
|
8
|
+
def initialize(record, from:, to:, tree:, snapshotter:)
|
|
9
|
+
@record = record
|
|
10
|
+
@from = from
|
|
11
|
+
@to = to
|
|
12
|
+
@tree = tree
|
|
13
|
+
@snapshotter = snapshotter
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
#: () -> Array[ActivityStep]
|
|
17
|
+
def build
|
|
18
|
+
return build_to_current if Endpoint.record?(@to)
|
|
19
|
+
|
|
20
|
+
Endpoint.validate!(@to)
|
|
21
|
+
build_between_versions
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Builds all three version-bounded views from one activity snapshot pass.
|
|
25
|
+
#: () -> Analysis
|
|
26
|
+
def analyze
|
|
27
|
+
unless Endpoint.version?(@to)
|
|
28
|
+
raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
root_versions = VersionRange.new(@record, from: @from, to: @to).select
|
|
32
|
+
prepare_history(root_versions)
|
|
33
|
+
events = collect_events(root_versions)
|
|
34
|
+
activity_steps, root_snapshots, = event_history(root_versions, events)
|
|
35
|
+
build_analysis(root_versions, root_snapshots, activity_steps)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# @rbs @record: untyped
|
|
41
|
+
# @rbs @from: untyped
|
|
42
|
+
# @rbs @to: untyped
|
|
43
|
+
# @rbs @tree: AssociationTree
|
|
44
|
+
# @rbs @snapshotter: untyped
|
|
45
|
+
|
|
46
|
+
#: () -> Array[ActivityStep]
|
|
47
|
+
def build_between_versions
|
|
48
|
+
root_versions = VersionRange.new(@record, from: @from, to: @to).select
|
|
49
|
+
prepare_history(root_versions)
|
|
50
|
+
events = collect_events(root_versions)
|
|
51
|
+
build_event_steps(root_versions, events)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#: () -> Array[ActivityStep]
|
|
55
|
+
def build_to_current
|
|
56
|
+
validate_current_range!
|
|
57
|
+
current_snapshot, captured_at = capture_current
|
|
58
|
+
root_versions = VersionRange.new(@record, from: @from, to: @from).select_through_latest
|
|
59
|
+
prepare_history(root_versions)
|
|
60
|
+
events = collect_events(root_versions, range_end: captured_at)
|
|
61
|
+
build_event_steps(
|
|
62
|
+
root_versions,
|
|
63
|
+
events,
|
|
64
|
+
current: @to,
|
|
65
|
+
final_boundary: ActivityBoundary.current(@to, captured_at: captured_at),
|
|
66
|
+
final_snapshot: current_snapshot
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: () -> [RecordSnapshot?, untyped]
|
|
71
|
+
def capture_current
|
|
72
|
+
[@snapshotter.call(@to, @to), Time.now.utc]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
#: () -> void
|
|
76
|
+
def validate_current_range!
|
|
77
|
+
unless Endpoint.version?(@from)
|
|
78
|
+
raise InvalidTimelineRangeError, '`from` must be a root PaperTrail version'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Endpoint.validate_pair!(@from, @to)
|
|
82
|
+
Endpoint.validate_pair!(@record, @to)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#: (Array[untyped], ?range_end: untyped) -> Array[ActivityEvent]
|
|
86
|
+
def collect_events(root_versions, range_end: root_versions.last)
|
|
87
|
+
ActivityVersionCollector.new(
|
|
88
|
+
@record,
|
|
89
|
+
root_versions: root_versions,
|
|
90
|
+
tree: @tree,
|
|
91
|
+
traversal: AssociationTraversal.new(@tree),
|
|
92
|
+
range_end: range_end
|
|
93
|
+
).call
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
#: (Array[untyped]) -> void
|
|
97
|
+
def prepare_history(root_versions)
|
|
98
|
+
@snapshotter.prepare(@record, root_versions) if @snapshotter.respond_to?(:prepare)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (Array[untyped], Array[ActivityEvent], ?current: untyped, ?final_boundary: ActivityBoundary?, ?final_snapshot: RecordSnapshot?) -> Array[ActivityStep]
|
|
102
|
+
def build_event_steps(
|
|
103
|
+
root_versions,
|
|
104
|
+
events,
|
|
105
|
+
current: nil,
|
|
106
|
+
final_boundary: nil,
|
|
107
|
+
final_snapshot: nil
|
|
108
|
+
)
|
|
109
|
+
steps, _root_snapshots, previous_snapshot = event_history(
|
|
110
|
+
root_versions,
|
|
111
|
+
events,
|
|
112
|
+
current: current
|
|
113
|
+
)
|
|
114
|
+
previous_event = events.last
|
|
115
|
+
previous_boundary = ActivityBoundary.from_version(previous_event.version) if previous_event
|
|
116
|
+
if final_boundary && previous_boundary
|
|
117
|
+
steps << ActivityStep.new(
|
|
118
|
+
from_boundary: previous_boundary,
|
|
119
|
+
to_boundary: final_boundary,
|
|
120
|
+
diff: Engine.compare(previous_snapshot, final_snapshot)
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
steps.freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
#: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> [Array[ActivityStep], Hash[Array[untyped], RecordSnapshot?], RecordSnapshot?]
|
|
127
|
+
def event_history(root_versions, events, current: nil) # rubocop:disable Metrics/MethodLength
|
|
128
|
+
sequence = ActivitySnapshotSequence.new(root_versions, events, @snapshotter, current: current)
|
|
129
|
+
steps = [] #: Array[ActivityStep]
|
|
130
|
+
root_snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
|
|
131
|
+
previous_boundary = nil #: ActivityBoundary?
|
|
132
|
+
previous_snapshot = nil #: RecordSnapshot?
|
|
133
|
+
sequence.each do |event, snapshot|
|
|
134
|
+
boundary = ActivityBoundary.from_version(event.version)
|
|
135
|
+
root_snapshots[version_key(event.version)] = snapshot if event.root?
|
|
136
|
+
if previous_boundary
|
|
137
|
+
steps << ActivityStep.new(
|
|
138
|
+
from_boundary: previous_boundary,
|
|
139
|
+
to_boundary: boundary,
|
|
140
|
+
diff: Engine.compare(previous_snapshot, snapshot)
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
previous_boundary = boundary
|
|
144
|
+
previous_snapshot = snapshot
|
|
145
|
+
end
|
|
146
|
+
[steps, root_snapshots, previous_snapshot]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
#: (Array[untyped], Hash[Array[untyped], RecordSnapshot?], Array[ActivityStep]) -> Analysis
|
|
150
|
+
def build_analysis(root_versions, root_snapshots, activity_steps)
|
|
151
|
+
snapshots = root_versions.map do |version|
|
|
152
|
+
root_snapshots.fetch(version_key(version))
|
|
153
|
+
end
|
|
154
|
+
Analysis.new(
|
|
155
|
+
diff: Engine.compare(snapshots.first, snapshots.last),
|
|
156
|
+
timeline: build_root_steps(root_versions, snapshots),
|
|
157
|
+
activity_timeline: activity_steps.freeze
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
#: (Array[untyped], Array[RecordSnapshot?]) -> Array[Step]
|
|
162
|
+
def build_root_steps(root_versions, snapshots)
|
|
163
|
+
root_versions.each_cons(2).with_index.map do |versions, index|
|
|
164
|
+
Step.new(
|
|
165
|
+
from_version: versions.fetch(0),
|
|
166
|
+
to_version: versions.fetch(1),
|
|
167
|
+
diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
|
|
168
|
+
)
|
|
169
|
+
end.freeze
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
#: (untyped) -> Array[untyped]
|
|
173
|
+
def version_key(version)
|
|
174
|
+
[version.class.name, version.id]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Collects root and selected-descendant versions relevant to one root history range.
|
|
6
|
+
class ActivityVersionCollector
|
|
7
|
+
#: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_end: untyped) -> void
|
|
8
|
+
def initialize(record, root_versions:, tree:, traversal:, range_end: root_versions.last)
|
|
9
|
+
@record = record
|
|
10
|
+
@root_versions = root_versions
|
|
11
|
+
@tree = tree
|
|
12
|
+
@traversal = traversal
|
|
13
|
+
@range = ActivityRange.new(root_versions.first, range_end)
|
|
14
|
+
@registry = ActivityEventRegistry.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: () -> Array[ActivityEvent]
|
|
18
|
+
def call
|
|
19
|
+
@registry.add(@root_versions)
|
|
20
|
+
collect_tree(@record.class, [@record.id], @tree, path: '', branch: nil) unless @tree.empty?
|
|
21
|
+
@registry.to_a.sort_by do |event|
|
|
22
|
+
Support.chronological_version_key(event.version)
|
|
23
|
+
end.freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# @rbs @record: untyped
|
|
29
|
+
# @rbs @root_versions: Array[untyped]
|
|
30
|
+
# @rbs @tree: AssociationTree
|
|
31
|
+
# @rbs @traversal: AssociationTraversal
|
|
32
|
+
# @rbs @range: ActivityRange
|
|
33
|
+
# @rbs @registry: ActivityEventRegistry
|
|
34
|
+
|
|
35
|
+
#: (untyped, Array[untyped], AssociationTree, path: String, branch: String?) -> void
|
|
36
|
+
def collect_tree(parent_class, parent_ids, tree, path:, branch:)
|
|
37
|
+
parent = group(parent_class, parent_ids)
|
|
38
|
+
@registry.add(parent.fetch(:versions), branch: branch) unless path.empty?
|
|
39
|
+
@traversal.reflections_for(parent_class, tree, path: path).each do |reflection|
|
|
40
|
+
subtree = tree.child(reflection.name)
|
|
41
|
+
next unless subtree
|
|
42
|
+
|
|
43
|
+
selected_branch = branch || reflection.name.to_s
|
|
44
|
+
collect_reflection(parent, reflection, subtree, path, selected_branch)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#: (Hash[Symbol, untyped], untyped, AssociationTree, String, String) -> void
|
|
49
|
+
def collect_reflection(parent, reflection, subtree, path, branch)
|
|
50
|
+
groups = edge_groups(parent, reflection, branch)
|
|
51
|
+
groups.each_value do |group|
|
|
52
|
+
@registry.add(group.fetch(:versions), branch: branch)
|
|
53
|
+
next if subtree.empty?
|
|
54
|
+
|
|
55
|
+
child_path = join_path(path, reflection.name.to_s)
|
|
56
|
+
collect_tree(group.fetch(:model), group.fetch(:ids), subtree,
|
|
57
|
+
path: child_path, branch: branch)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
#: (Hash[Symbol, untyped], untyped, String) -> Hash[String, untyped]
|
|
62
|
+
def edge_groups(parent, reflection, branch)
|
|
63
|
+
return through_groups(parent, reflection, branch) if reflection.options[:through]
|
|
64
|
+
|
|
65
|
+
case reflection.macro
|
|
66
|
+
when :belongs_to
|
|
67
|
+
belongs_to_groups(parent.fetch(:model), parent.fetch(:versions), reflection)
|
|
68
|
+
when :has_one, :has_many
|
|
69
|
+
direct_child_groups(parent.fetch(:model), parent.fetch(:ids), reflection)
|
|
70
|
+
when :has_and_belongs_to_many
|
|
71
|
+
habtm_groups(parent.fetch(:model), parent.fetch(:versions), reflection)
|
|
72
|
+
else
|
|
73
|
+
{}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#: (Hash[Symbol, untyped], untyped, String) -> Hash[String, untyped]
|
|
78
|
+
def through_groups(parent, reflection, branch)
|
|
79
|
+
through = reflection.through_reflection
|
|
80
|
+
intermediate = edge_groups(parent, through, branch)
|
|
81
|
+
result = {} #: Hash[String, untyped]
|
|
82
|
+
intermediate.each_value do |group|
|
|
83
|
+
@registry.add(group.fetch(:versions), branch: branch)
|
|
84
|
+
source = reflection.source_reflection
|
|
85
|
+
children = edge_groups(group, source, branch)
|
|
86
|
+
merge_groups!(result, children)
|
|
87
|
+
end
|
|
88
|
+
result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
#: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
|
|
92
|
+
def belongs_to_groups(parent_class, parent_versions, reflection)
|
|
93
|
+
rows = parent_class.paper_trail.version_association_class.where(
|
|
94
|
+
version_id: parent_versions.map(&:id),
|
|
95
|
+
foreign_key_name: reflection.foreign_key.to_s
|
|
96
|
+
).to_a
|
|
97
|
+
identity_groups(rows, reflection)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
#: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
|
|
101
|
+
def direct_child_groups(parent_class, parent_ids, reflection) # rubocop:disable Metrics/AbcSize
|
|
102
|
+
version_class = parent_class.paper_trail.version_class
|
|
103
|
+
relation = parent_class.paper_trail.version_association_class.joins(:version).where(
|
|
104
|
+
foreign_key_name: reflection.foreign_key.to_s,
|
|
105
|
+
foreign_key_id: parent_ids
|
|
106
|
+
).where(foreign_type: related_parent_types(parent_class))
|
|
107
|
+
item_ids = relation.where(
|
|
108
|
+
version_class.table_name => { item_type: reflection.klass.base_class.name }
|
|
109
|
+
).distinct.pluck(version_class.arel_table[:item_id])
|
|
110
|
+
return {} if item_ids.empty?
|
|
111
|
+
|
|
112
|
+
{ reflection.klass.name => group(reflection.klass, item_ids) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
#: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
|
|
116
|
+
def habtm_groups(parent_class, parent_versions, reflection)
|
|
117
|
+
transaction_ids = parent_versions.filter_map do |version|
|
|
118
|
+
version.transaction_id if version.respond_to?(:transaction_id)
|
|
119
|
+
end
|
|
120
|
+
rows = parent_class.paper_trail.version_association_class.where(
|
|
121
|
+
version_id: transaction_ids,
|
|
122
|
+
foreign_key_name: reflection.name.to_s
|
|
123
|
+
).to_a
|
|
124
|
+
identity_groups(rows, reflection)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
#: (Array[untyped], untyped) -> Hash[String, untyped]
|
|
128
|
+
def identity_groups(rows, reflection)
|
|
129
|
+
identities = rows.group_by { |row| target_class(reflection, row) }
|
|
130
|
+
groups = {} #: Hash[String, untyped]
|
|
131
|
+
identities.each do |model_class, class_rows|
|
|
132
|
+
next unless model_class
|
|
133
|
+
|
|
134
|
+
ids = class_rows.map(&:foreign_key_id).compact.uniq
|
|
135
|
+
groups[model_class.name] = group(model_class, ids)
|
|
136
|
+
end
|
|
137
|
+
groups
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
#: (untyped, Array[untyped]) -> Hash[Symbol, untyped]
|
|
141
|
+
def group(model_class, ids)
|
|
142
|
+
{ model: model_class, ids: ids, versions: versions_for(model_class, ids) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
#: (Hash[String, untyped], Hash[String, untyped]) -> void
|
|
146
|
+
def merge_groups!(destination, source)
|
|
147
|
+
source.each do |name, incoming|
|
|
148
|
+
existing = destination[name]
|
|
149
|
+
unless existing
|
|
150
|
+
destination[name] = incoming
|
|
151
|
+
next
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
ids = (existing.fetch(:ids) | incoming.fetch(:ids))
|
|
155
|
+
destination[name] = group(existing.fetch(:model), ids)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
160
|
+
def versions_for(model_class, ids)
|
|
161
|
+
return [] if ids.empty?
|
|
162
|
+
|
|
163
|
+
version_class = model_class.paper_trail.version_class
|
|
164
|
+
@range.scope(
|
|
165
|
+
version_class.where(
|
|
166
|
+
item_type: model_class.base_class.name,
|
|
167
|
+
item_id: ids
|
|
168
|
+
)
|
|
169
|
+
).to_a.select { |version| in_range?(version) }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
#: (untyped, untyped) -> untyped
|
|
173
|
+
def target_class(reflection, row)
|
|
174
|
+
return reflection.klass unless reflection.polymorphic?
|
|
175
|
+
|
|
176
|
+
Object.const_get(row.foreign_type.to_s) unless row.foreign_type.to_s.empty?
|
|
177
|
+
rescue NameError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
#: (untyped) -> Array[String?]
|
|
182
|
+
def related_parent_types(parent_class)
|
|
183
|
+
[nil, '', parent_class.name.to_s, parent_class.base_class.name.to_s].uniq
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
#: (untyped) -> bool
|
|
187
|
+
def in_range?(version)
|
|
188
|
+
@range.include?(version)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
#: (String, String) -> String
|
|
192
|
+
def join_path(parent, name)
|
|
193
|
+
parent.empty? ? name : "#{parent}.#{name}"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|