paper_trail_diff 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +287 -0
- data/QUICKSTART.md +9 -6
- data/README.md +84 -9
- data/docs/architecture.md +32 -9
- data/docs/performance.md +106 -13
- data/docs/reference.md +242 -29
- data/docs/testing.md +34 -11
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +19 -9
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +11 -1
- data/lib/paper_trail_diff/activity_collection_record_updater.rb +16 -2
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +20 -1
- data/lib/paper_trail_diff/activity_event_route_finder.rb +2 -1
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +1 -1
- data/lib/paper_trail_diff/activity_range.rb +14 -2
- data/lib/paper_trail_diff/activity_relationship.rb +12 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +5 -2
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +131 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +30 -24
- data/lib/paper_trail_diff/activity_version_collector.rb +31 -20
- data/lib/paper_trail_diff/association_activity_edge.rb +236 -0
- data/lib/paper_trail_diff/association_activity_selection.rb +197 -0
- data/lib/paper_trail_diff/association_discovery.rb +8 -13
- data/lib/paper_trail_diff/association_traversal.rb +169 -45
- data/lib/paper_trail_diff/batch_safety.rb +23 -15
- data/lib/paper_trail_diff/batched_root_versions.rb +21 -39
- data/lib/paper_trail_diff/comparison_batch.rb +37 -10
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +34 -16
- data/lib/paper_trail_diff/diagnostics.rb +26 -8
- data/lib/paper_trail_diff/direct_record_state_loader.rb +10 -1
- data/lib/paper_trail_diff/errors.rb +2 -1
- data/lib/paper_trail_diff/habtm_transaction_owners.rb +68 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +32 -2
- data/lib/paper_trail_diff/historical_snapshot_store.rb +6 -9
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +35 -2
- data/lib/paper_trail_diff/live_graph_collector.rb +1 -1
- data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -6
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +1 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +110 -36
- data/lib/paper_trail_diff/prepared_edge_loader.rb +29 -45
- data/lib/paper_trail_diff/prepared_history.rb +63 -54
- data/lib/paper_trail_diff/prepared_history_loader.rb +33 -43
- data/lib/paper_trail_diff/prepared_record_index.rb +26 -0
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +1 -1
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +119 -29
- data/lib/paper_trail_diff/recorded_changes.rb +83 -0
- data/lib/paper_trail_diff/recorded_class.rb +53 -0
- data/lib/paper_trail_diff/scope_condition.rb +331 -0
- data/lib/paper_trail_diff/scope_ranking.rb +196 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +4 -3
- data/lib/paper_trail_diff/scoped_root_selection.rb +93 -43
- data/lib/paper_trail_diff/snapshot_normalizer.rb +10 -3
- data/lib/paper_trail_diff/support.rb +9 -4
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -1
- data/lib/paper_trail_diff/time_range.rb +9 -12
- data/lib/paper_trail_diff/time_version_range.rb +5 -5
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +13 -3
- data/lib/paper_trail_diff/version_range.rb +8 -15
- data/lib/paper_trail_diff/version_scope_filter.rb +11 -3
- data/lib/paper_trail_diff.rb +31 -2
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +10 -2
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +5 -0
- data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +5 -0
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +7 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_relationship.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_target_candidate_loader.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +11 -5
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +10 -4
- data/sig/generated/paper_trail_diff/association_activity_edge.rbs +148 -0
- data/sig/generated/paper_trail_diff/association_activity_selection.rbs +112 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +3 -5
- data/sig/generated/paper_trail_diff/association_traversal.rbs +84 -9
- data/sig/generated/paper_trail_diff/batch_safety.rbs +10 -4
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +2 -8
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +22 -4
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +16 -4
- data/sig/generated/paper_trail_diff/diagnostics.rbs +7 -2
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +6 -0
- data/sig/generated/paper_trail_diff/errors.rbs +2 -1
- data/sig/generated/paper_trail_diff/habtm_transaction_owners.rbs +40 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +13 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +3 -2
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +17 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +52 -10
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +4 -12
- data/sig/generated/paper_trail_diff/prepared_history.rbs +33 -22
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +15 -13
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +15 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +56 -11
- data/sig/generated/paper_trail_diff/recorded_changes.rbs +47 -0
- data/sig/generated/paper_trail_diff/recorded_class.rbs +22 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +185 -0
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +117 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +41 -7
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +6 -0
- data/sig/generated/paper_trail_diff/support.rbs +5 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +4 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -4
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +7 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +4 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -0
- data/sig/generated/paper_trail_diff.rbs +21 -2
- metadata +20 -4
|
@@ -25,7 +25,7 @@ module PaperTrailDiff
|
|
|
25
25
|
routes = @route_finder.belongs_to_routes(
|
|
26
26
|
endpoint_model_class(root_endpoint), tree, version.item_type.to_s
|
|
27
27
|
)
|
|
28
|
-
return [false, nil] if routes.empty?
|
|
28
|
+
return [false, nil] if routes.empty? || scoped_route?(routes)
|
|
29
29
|
|
|
30
30
|
snapshot = previous
|
|
31
31
|
routes.each do |route|
|
|
@@ -43,6 +43,15 @@ module PaperTrailDiff
|
|
|
43
43
|
# @rbs @record_resolver: ActivityEventRecordResolver
|
|
44
44
|
# @rbs @record_normalizer: ActivityEventRecordNormalizer
|
|
45
45
|
|
|
46
|
+
# An edit can move a target into or out of a scope, which a replacement
|
|
47
|
+
# in place cannot show, so a scoped route's branch is read again instead.
|
|
48
|
+
#: (Array[Array[untyped]]) -> bool
|
|
49
|
+
def scoped_route?(routes)
|
|
50
|
+
routes.any? do |route|
|
|
51
|
+
route.any? { |entry| ScopeCondition.scoped_path?(entry.fetch(1)) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
46
55
|
#: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
|
|
47
56
|
def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
|
|
48
57
|
record = @record_resolver.record_after(version, direct: route.last.fetch(2).empty?)
|
|
@@ -91,8 +100,7 @@ module PaperTrailDiff
|
|
|
91
100
|
#: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> [Array[RecordSnapshot], bool]
|
|
92
101
|
def replace_existing_target(records, version, replacement)
|
|
93
102
|
index = records.index do |record|
|
|
94
|
-
record.id.to_s == version.item_id.to_s &&
|
|
95
|
-
record.type.to_s == replacement_type(version, replacement)
|
|
103
|
+
record.id.to_s == version.item_id.to_s && same_table?(record, version)
|
|
96
104
|
end
|
|
97
105
|
return [records, false] unless index
|
|
98
106
|
|
|
@@ -101,16 +109,18 @@ module PaperTrailDiff
|
|
|
101
109
|
[updated.freeze, true]
|
|
102
110
|
end
|
|
103
111
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
# The target is matched by id within its table rather than by type name.
|
|
113
|
+
# Without `item_subtype`, a version names only the STI base class, and a
|
|
114
|
+
# row whose type changes is replaced by a record of another class.
|
|
115
|
+
#: (RecordSnapshot, untyped) -> bool
|
|
116
|
+
def same_table?(record, version)
|
|
117
|
+
model = AssociationTraversal.named_class(record.type.to_s)
|
|
118
|
+
!model.nil? && model.base_class == Endpoint.model_class(version).base_class
|
|
109
119
|
end
|
|
110
120
|
|
|
111
121
|
#: (untyped) -> untyped
|
|
112
122
|
def endpoint_model_class(endpoint)
|
|
113
|
-
Endpoint.version?(endpoint) ?
|
|
123
|
+
Endpoint.version?(endpoint) ? RecordedClass.for(endpoint) : endpoint.class
|
|
114
124
|
end
|
|
115
125
|
end
|
|
116
126
|
end
|
|
@@ -14,7 +14,8 @@ module PaperTrailDiff
|
|
|
14
14
|
|
|
15
15
|
#: (Array[Array[untyped]], untyped, ActivitySnapshotDelta?) -> bool
|
|
16
16
|
def safe?(routes, version, delta)
|
|
17
|
-
!
|
|
17
|
+
!scoped_route?(routes) &&
|
|
18
|
+
!unsafe_through_membership_event?(routes, version) &&
|
|
18
19
|
!unsafe_nested_membership_delta?(routes, delta)
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -62,6 +63,15 @@ module PaperTrailDiff
|
|
|
62
63
|
@route_updater.call(snapshot, change).fetch(0)
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
# Any edit can move a record into or out of a scope, so a scoped route's
|
|
67
|
+
# branch is read again from history instead.
|
|
68
|
+
#: (Array[Array[untyped]]) -> bool
|
|
69
|
+
def scoped_route?(routes)
|
|
70
|
+
routes.any? do |route|
|
|
71
|
+
route.any? { |entry| ScopeCondition.scoped_path?(entry.fetch(1)) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
65
75
|
#: (Array[Array[untyped]], untyped) -> bool
|
|
66
76
|
def unsafe_through_membership_event?(routes, version)
|
|
67
77
|
return false if version.event.to_s == 'update'
|
|
@@ -33,12 +33,13 @@ module PaperTrailDiff
|
|
|
33
33
|
#: (AssociationSnapshot, untyped, RecordSnapshot?) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]
|
|
34
34
|
def call(association, version, replacement)
|
|
35
35
|
records = association.records
|
|
36
|
-
position = association.position(replacement_type(version, replacement), version.item_id)
|
|
36
|
+
position = association.position(replacement_type(version, replacement), version.item_id) ||
|
|
37
|
+
retyped_position(association, version)
|
|
37
38
|
return [records, nil, nil, true] unless replacement || position
|
|
38
39
|
|
|
39
40
|
before = records.fetch(position) if position
|
|
40
41
|
updated = replacement_records(records, position, replacement)
|
|
41
|
-
[updated, before, replacement,
|
|
42
|
+
[updated, before, replacement, !!before && before.identity == replacement&.identity]
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
#: (untyped, RecordSnapshot?) -> String
|
|
@@ -52,6 +53,19 @@ module PaperTrailDiff
|
|
|
52
53
|
|
|
53
54
|
# @rbs @relationship: ActivityRelationship
|
|
54
55
|
|
|
56
|
+
# A row whose STI type changed is the same row under a new identity, so it
|
|
57
|
+
# is found by id among the records of its table.
|
|
58
|
+
#: (AssociationSnapshot, untyped) -> Integer?
|
|
59
|
+
def retyped_position(association, version)
|
|
60
|
+
position = association.position_for_id(version.item_id)
|
|
61
|
+
return unless position
|
|
62
|
+
|
|
63
|
+
type = association.records.fetch(position).type
|
|
64
|
+
position if Object.const_get(type).base_class == Endpoint.model_class(version).base_class
|
|
65
|
+
rescue NameError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
55
69
|
#: (Array[RecordSnapshot], Integer?, RecordSnapshot?) -> Array[RecordSnapshot]
|
|
56
70
|
def replacement_records(records, position, replacement)
|
|
57
71
|
updated = records.dup
|
|
@@ -129,7 +129,26 @@ module PaperTrailDiff
|
|
|
129
129
|
return unless before
|
|
130
130
|
|
|
131
131
|
attributes = before.attributes.merge(after_attributes(changes, model))
|
|
132
|
-
|
|
132
|
+
model_after = class_after(model, attributes)
|
|
133
|
+
return unless model_after
|
|
134
|
+
|
|
135
|
+
@direct_loader.complete(PreparedRecordState.from_attributes(model_after, attributes))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Without `item_subtype` a version names only the STI base class, and a
|
|
139
|
+
# change can move the row to another class, so the type the row holds
|
|
140
|
+
# after the change decides. A type that names no class has no direct
|
|
141
|
+
# state; the caller falls back to reifying the record.
|
|
142
|
+
#: (untyped, Hash[untyped, untyped]) -> untyped
|
|
143
|
+
def class_after(model, attributes)
|
|
144
|
+
column = model.inheritance_column.to_s
|
|
145
|
+
return model unless model.columns_hash.key?(column)
|
|
146
|
+
|
|
147
|
+
type = attributes[column].to_s
|
|
148
|
+
type.empty? ? model.base_class : model.base_class.sti_class_for(type)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
active_record = Object.const_get(:ActiveRecord) #: untyped
|
|
151
|
+
raise unless e.is_a?(active_record.const_get(:SubclassNotFound))
|
|
133
152
|
end
|
|
134
153
|
|
|
135
154
|
#: (untyped) -> untyped
|
|
@@ -70,7 +70,8 @@ module PaperTrailDiff
|
|
|
70
70
|
entry = [name, reflection, subtree, child_path]
|
|
71
71
|
direct = [] #: Array[Array[untyped]]
|
|
72
72
|
direct << [entry] if direct_route?(reflection, target_type, macro)
|
|
73
|
-
|
|
73
|
+
# Validation rejects a path that continues past a polymorphic target.
|
|
74
|
+
return direct if subtree.empty?
|
|
74
75
|
|
|
75
76
|
nested = routes(reflection.klass, subtree, target_type, macro, path: child_path)
|
|
76
77
|
direct.concat(nested.map { |route| [entry, *route] })
|
|
@@ -111,7 +111,7 @@ module PaperTrailDiff
|
|
|
111
111
|
|
|
112
112
|
#: (untyped) -> untyped
|
|
113
113
|
def endpoint_model_class(endpoint)
|
|
114
|
-
Endpoint.version?(endpoint) ?
|
|
114
|
+
Endpoint.version?(endpoint) ? RecordedClass.for(endpoint) : endpoint.class
|
|
115
115
|
end
|
|
116
116
|
end
|
|
117
117
|
end
|
|
@@ -9,7 +9,6 @@ module PaperTrailDiff
|
|
|
9
9
|
|
|
10
10
|
#: (untyped, untyped) -> void
|
|
11
11
|
def initialize(start_boundary, end_boundary)
|
|
12
|
-
@start_boundary = start_boundary
|
|
13
12
|
@end_boundary = end_boundary
|
|
14
13
|
@start_key = if Endpoint.version?(start_boundary)
|
|
15
14
|
Support.chronological_version_key(start_boundary)
|
|
@@ -39,9 +38,22 @@ module PaperTrailDiff
|
|
|
39
38
|
!Endpoint.version?(@end_boundary)
|
|
40
39
|
end
|
|
41
40
|
|
|
41
|
+
# Whether the state a version recorded after its change held inside the
|
|
42
|
+
# range. A range ending at a version ends at the state before it, so the
|
|
43
|
+
# change that version records is past the end, unlike #include?.
|
|
44
|
+
#: (untyped, untyped) -> bool
|
|
45
|
+
def recorded_before_end?(created_at, id)
|
|
46
|
+
end_key = @end_key
|
|
47
|
+
return created_at <= @end_boundary unless end_key
|
|
48
|
+
|
|
49
|
+
comparison = Support.version_key(created_at, id) <=> end_key
|
|
50
|
+
raise ConfigurationError, 'versions have incomparable timestamps' unless comparison
|
|
51
|
+
|
|
52
|
+
comparison.negative?
|
|
53
|
+
end
|
|
54
|
+
|
|
42
55
|
private
|
|
43
56
|
|
|
44
|
-
# @rbs @start_boundary: untyped
|
|
45
57
|
# @rbs @end_boundary: untyped
|
|
46
58
|
# @rbs @start_key: Array[untyped]?
|
|
47
59
|
# @rbs @start_time: untyped
|
|
@@ -11,6 +11,7 @@ module PaperTrailDiff
|
|
|
11
11
|
# @type var expected_ids: Array[untyped]
|
|
12
12
|
expected = expected_ids.map { |id| id.to_s } # rubocop:disable Style/SymbolProc
|
|
13
13
|
return false unless actual == expected
|
|
14
|
+
return false unless sti_member?(record, reflection, state)
|
|
14
15
|
return true unless reflection.options[:as]
|
|
15
16
|
|
|
16
17
|
type = event_attribute(record, reflection.type, state: state).to_s
|
|
@@ -27,6 +28,17 @@ module PaperTrailDiff
|
|
|
27
28
|
|
|
28
29
|
private
|
|
29
30
|
|
|
31
|
+
#: (untyped, untyped, Symbol) -> bool
|
|
32
|
+
def sti_member?(record, reflection, state)
|
|
33
|
+
model_class = reflection.klass
|
|
34
|
+
return true if model_class.descends_from_active_record?
|
|
35
|
+
|
|
36
|
+
column = model_class.inheritance_column
|
|
37
|
+
type = event_attribute(record, column, state: state)
|
|
38
|
+
attributes = { column => type } #: Hash[untyped, untyped]
|
|
39
|
+
AssociationTraversal.sti_member?(model_class, attributes)
|
|
40
|
+
end
|
|
41
|
+
|
|
30
42
|
#: (untyped, untyped, state: Symbol) -> Array[String]
|
|
31
43
|
def relationship_owner_values(record, reflection, state:)
|
|
32
44
|
Array(reflection.foreign_key).map do |key|
|
|
@@ -33,7 +33,7 @@ module PaperTrailDiff
|
|
|
33
33
|
return [false, nil] unless previous && event.root?
|
|
34
34
|
|
|
35
35
|
version = event.version
|
|
36
|
-
model_class =
|
|
36
|
+
model_class = RecordedClass.for(version)
|
|
37
37
|
return [false, nil] unless same_record?(previous, version)
|
|
38
38
|
|
|
39
39
|
changes = @changeset.call(version, model_class)
|
|
@@ -108,9 +108,12 @@ module PaperTrailDiff
|
|
|
108
108
|
end.uniq.sort.freeze
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
# A polymorphic target's class is known only per record, so it cannot be
|
|
112
|
+
# checked here; a target that is not versioned raises when it is read.
|
|
111
113
|
#: (untyped) -> bool
|
|
112
114
|
def direct_versioned_reflection?(reflection)
|
|
113
|
-
return
|
|
115
|
+
return true if reflection.polymorphic?
|
|
116
|
+
return false if reflection.options[:through]
|
|
114
117
|
return false if reflection.macro == :has_and_belongs_to_many
|
|
115
118
|
|
|
116
119
|
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Loads the `belongs_to` targets owners pointed at during one activity range:
|
|
6
|
+
# at its start and after each change inside it.
|
|
7
|
+
#
|
|
8
|
+
# PT-AT records a version's foreign keys as they stood after its change, so
|
|
9
|
+
# the target at the start comes from the latest version before it, and the
|
|
10
|
+
# version a range ends at names a target only after the range. Reading only
|
|
11
|
+
# the owners' versions inside the range misses a target whose owner did not
|
|
12
|
+
# change there -- an author renamed under an article nobody edited.
|
|
13
|
+
class ActivityTargetCandidateLoader
|
|
14
|
+
# A plain class rather than Data.define, which arrived in Ruby 3.2 while this
|
|
15
|
+
# gem supports 3.1.
|
|
16
|
+
class Row
|
|
17
|
+
attr_reader :owner_id #: untyped
|
|
18
|
+
attr_reader :foreign_key_id #: untyped
|
|
19
|
+
attr_reader :foreign_type #: String?
|
|
20
|
+
attr_reader :created_at #: untyped
|
|
21
|
+
attr_reader :event #: String?
|
|
22
|
+
attr_reader :version_id #: untyped
|
|
23
|
+
|
|
24
|
+
#: (untyped, untyped, String?, ?untyped, ?String?, ?untyped) -> void
|
|
25
|
+
def initialize(owner_id, foreign_key_id, foreign_type, created_at = nil, event = nil, # rubocop:disable Metrics/ParameterLists
|
|
26
|
+
version_id = nil)
|
|
27
|
+
@owner_id = owner_id
|
|
28
|
+
@foreign_key_id = foreign_key_id
|
|
29
|
+
@foreign_type = foreign_type
|
|
30
|
+
@created_at = created_at
|
|
31
|
+
@event = event
|
|
32
|
+
@version_id = version_id
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#: (untyped, Array[untyped], untyped, ActivityRange) -> void
|
|
38
|
+
def initialize(owner_class, owner_ids, reflection, range)
|
|
39
|
+
@owner_class = owner_class
|
|
40
|
+
@owner_ids = owner_ids
|
|
41
|
+
@reflection = reflection
|
|
42
|
+
@range = range
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#: () -> Array[Row]
|
|
46
|
+
def call
|
|
47
|
+
rows = historical_rows.select do |row|
|
|
48
|
+
@range.recorded_before_end?(row.created_at, row.version_id)
|
|
49
|
+
end
|
|
50
|
+
@range.current_end? ? rows + current_rows : rows
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One shared query keeps the owner and timestamp needed to partition a
|
|
54
|
+
# batch's candidates back into each root's exact activity range.
|
|
55
|
+
#: () -> Array[Row]
|
|
56
|
+
def historical_rows
|
|
57
|
+
return [] if composite_foreign_key?
|
|
58
|
+
|
|
59
|
+
version_class = @owner_class.paper_trail.version_class
|
|
60
|
+
relation = candidate_scope(version_class).call(historical_relation(version_class))
|
|
61
|
+
historical_columns(relation, version_class).map do |row|
|
|
62
|
+
owner_id, id, type, created_at, event, version_id = row
|
|
63
|
+
Row.new(Support.record_id(@owner_class, owner_id), id, type, created_at, event, version_id)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The live row holds the state at a range that ends at the current record.
|
|
68
|
+
#: () -> Array[Row]
|
|
69
|
+
def current_rows
|
|
70
|
+
return [] if composite_foreign_key? || @owner_class.composite_primary_key?
|
|
71
|
+
|
|
72
|
+
live_rows.filter_map do |owner_id, id, type|
|
|
73
|
+
Row.new(owner_id, id, type) unless id.nil?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# @rbs @owner_class: untyped
|
|
80
|
+
# @rbs @owner_ids: Array[untyped]
|
|
81
|
+
# @rbs @reflection: untyped
|
|
82
|
+
# @rbs @range: ActivityRange
|
|
83
|
+
|
|
84
|
+
#: (untyped) -> untyped
|
|
85
|
+
def historical_relation(version_class)
|
|
86
|
+
association_class.joins(:version)
|
|
87
|
+
.where(foreign_key_name: @reflection.foreign_key)
|
|
88
|
+
.where(version_class.table_name => {
|
|
89
|
+
item_type: @owner_class.base_class.name, item_id: @owner_ids
|
|
90
|
+
})
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
#: (untyped, untyped) -> Array[Array[untyped]]
|
|
94
|
+
def historical_columns(relation, version_class)
|
|
95
|
+
versions = version_class.arel_table
|
|
96
|
+
associations = association_class.arel_table
|
|
97
|
+
relation.pluck(versions[:item_id], associations[:foreign_key_id],
|
|
98
|
+
associations[:foreign_type], versions[:created_at], versions[:event],
|
|
99
|
+
versions[:id])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A polymorphic key names its target in a type column; any other names it
|
|
103
|
+
# in the reflection, so no type is read. Like the history, the live read
|
|
104
|
+
# takes owners of every class under the base class: a batch can ask for
|
|
105
|
+
# the owners of an association through one subclass.
|
|
106
|
+
#: () -> Array[Array[untyped]]
|
|
107
|
+
def live_rows
|
|
108
|
+
relation = @owner_class.base_class.unscoped.where(@owner_class.primary_key => @owner_ids)
|
|
109
|
+
key = @reflection.foreign_key
|
|
110
|
+
return relation.pluck(@owner_class.primary_key, key) unless @reflection.polymorphic?
|
|
111
|
+
|
|
112
|
+
relation.pluck(@owner_class.primary_key, key, @reflection.foreign_type)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
#: (untyped) -> VersionAssociationCandidateScope
|
|
116
|
+
def candidate_scope(version_class)
|
|
117
|
+
VersionAssociationCandidateScope.new(version_class, @range.start_time,
|
|
118
|
+
end_at: @range.end_time)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: () -> untyped
|
|
122
|
+
def association_class
|
|
123
|
+
@owner_class.paper_trail.version_association_class
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
#: () -> bool
|
|
127
|
+
def composite_foreign_key?
|
|
128
|
+
@reflection.foreign_key.is_a?(Array)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -6,8 +6,8 @@ module PaperTrailDiff
|
|
|
6
6
|
class ActivityTimelineBuilder
|
|
7
7
|
include ActivityGrouping
|
|
8
8
|
|
|
9
|
-
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped,
|
|
10
|
-
def initialize(record, range:, tree:, snapshotter:, snapshots
|
|
9
|
+
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, snapshots: bool, group: Symbol?) -> void
|
|
10
|
+
def initialize(record, range:, tree:, snapshotter:, snapshots:, group:) # rubocop:disable Metrics/ParameterLists
|
|
11
11
|
ActivityGrouping.validate!(group)
|
|
12
12
|
@snapshots = snapshots
|
|
13
13
|
@group = group
|
|
@@ -15,7 +15,6 @@ module PaperTrailDiff
|
|
|
15
15
|
# the build even when the caller did not ask to keep them.
|
|
16
16
|
@retain = snapshots || grouping?
|
|
17
17
|
@record = record
|
|
18
|
-
@from = range.from
|
|
19
18
|
@to = range.to
|
|
20
19
|
@range = range
|
|
21
20
|
@tree = tree
|
|
@@ -40,6 +39,8 @@ module PaperTrailDiff
|
|
|
40
39
|
return analyze_current if Endpoint.record?(@to)
|
|
41
40
|
|
|
42
41
|
plan = @range.select_plan
|
|
42
|
+
return Analysis.empty if plan.empty?
|
|
43
|
+
|
|
43
44
|
root_versions = plan.reconstruction_versions
|
|
44
45
|
prepare_history(root_versions)
|
|
45
46
|
events = collect_events(root_versions)
|
|
@@ -49,7 +50,6 @@ module PaperTrailDiff
|
|
|
49
50
|
private
|
|
50
51
|
|
|
51
52
|
# @rbs @record: untyped
|
|
52
|
-
# @rbs @from: untyped
|
|
53
53
|
# @rbs @to: untyped
|
|
54
54
|
# @rbs @range: TimelineRange
|
|
55
55
|
# @rbs @tree: AssociationTree
|
|
@@ -65,10 +65,14 @@ module PaperTrailDiff
|
|
|
65
65
|
# Every boundary in the span, filtered out or not. A filter narrows where the
|
|
66
66
|
# span starts and ends, but the sequence inside it stays complete: dropping
|
|
67
67
|
# boundaries would fold the changes they carried into a neighbouring step and
|
|
68
|
-
# credit them to whoever made that one.
|
|
68
|
+
# credit them to whoever made that one. A filter that keeps no version
|
|
69
|
+
# leaves no span, as it does for a checkpoint timeline.
|
|
69
70
|
#: () -> Array[ActivityStep]
|
|
70
71
|
def build_between_versions
|
|
71
|
-
|
|
72
|
+
plan = @range.select_plan
|
|
73
|
+
return no_steps if plan.empty?
|
|
74
|
+
|
|
75
|
+
root_versions = plan.reconstruction_versions
|
|
72
76
|
prepare_history(root_versions)
|
|
73
77
|
events = collect_events(root_versions)
|
|
74
78
|
history = event_history(root_versions, events)
|
|
@@ -102,7 +106,7 @@ module PaperTrailDiff
|
|
|
102
106
|
def current_history
|
|
103
107
|
validate_current_range!
|
|
104
108
|
plan = @range.select_plan
|
|
105
|
-
return
|
|
109
|
+
return empty_history(plan) if plan.empty?
|
|
106
110
|
|
|
107
111
|
snapshot, captured_at = capture_current if plan.closing_record
|
|
108
112
|
versions = plan.reconstruction_versions
|
|
@@ -113,6 +117,14 @@ module PaperTrailDiff
|
|
|
113
117
|
[plan, events, history, snapshot, boundary]
|
|
114
118
|
end
|
|
115
119
|
|
|
120
|
+
# Without a closing boundary nothing reads the events or the closing pair,
|
|
121
|
+
# so their mutations cannot change an answer.
|
|
122
|
+
# mutant:disable
|
|
123
|
+
#: (RootVersionPlan) -> [RootVersionPlan, Array[ActivityEvent], ActivityHistory, RecordSnapshot?, ActivityBoundary?]
|
|
124
|
+
def empty_history(plan)
|
|
125
|
+
[plan, [], ActivityHistory.empty, nil, nil]
|
|
126
|
+
end
|
|
127
|
+
|
|
116
128
|
#: () -> [RecordSnapshot?, untyped]
|
|
117
129
|
def capture_current
|
|
118
130
|
[@snapshotter.call(@to, @to), Time.now.utc]
|
|
@@ -120,12 +132,11 @@ module PaperTrailDiff
|
|
|
120
132
|
|
|
121
133
|
#: () -> void
|
|
122
134
|
def validate_current_range!
|
|
123
|
-
unless Endpoint.version?(@from)
|
|
135
|
+
unless Endpoint.version?(@range.from)
|
|
124
136
|
raise InvalidTimelineRangeError, '`from` must be a root PaperTrail version'
|
|
125
137
|
end
|
|
126
138
|
|
|
127
|
-
Endpoint.validate_pair!(@from, @to)
|
|
128
|
-
Endpoint.validate_pair!(@record, @to)
|
|
139
|
+
Endpoint.validate_pair!(@range.from, @to)
|
|
129
140
|
AssociationTraversal.new(@tree).reject_live_habtm_activity!(@record.class)
|
|
130
141
|
end
|
|
131
142
|
|
|
@@ -139,7 +150,6 @@ module PaperTrailDiff
|
|
|
139
150
|
@record,
|
|
140
151
|
root_versions: root_versions,
|
|
141
152
|
tree: @tree,
|
|
142
|
-
traversal: AssociationTraversal.new(@tree),
|
|
143
153
|
prepared: (@snapshotter.activity_index if @snapshotter.respond_to?(:activity_index)),
|
|
144
154
|
range_start: range_start,
|
|
145
155
|
range_end: range_end
|
|
@@ -156,14 +166,15 @@ module PaperTrailDiff
|
|
|
156
166
|
|
|
157
167
|
# Appends the transition into an explicit closing boundary, which is either
|
|
158
168
|
# a requested current record or the absence a final root destroy leaves.
|
|
169
|
+
# Either follows the root versions, which are themselves events.
|
|
159
170
|
#: (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
|
|
160
171
|
def activity_steps(history, events, final_boundary, final_snapshot)
|
|
161
172
|
steps = history.steps.dup
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if final_boundary && previous_boundary
|
|
173
|
+
if final_boundary
|
|
174
|
+
last_event = events.last #: ActivityEvent
|
|
165
175
|
steps << ActivityStep.between(
|
|
166
|
-
from_boundary:
|
|
176
|
+
from_boundary: ActivityBoundary.from_version(last_event.version),
|
|
177
|
+
to_boundary: final_boundary,
|
|
167
178
|
from_snapshot: history.last_snapshot, to_snapshot: final_snapshot,
|
|
168
179
|
retain: @retain
|
|
169
180
|
)
|
|
@@ -176,7 +187,7 @@ module PaperTrailDiff
|
|
|
176
187
|
#: (Array[untyped]) -> ActivityBoundary?
|
|
177
188
|
def destroyed_boundary(root_versions)
|
|
178
189
|
version = root_versions.last
|
|
179
|
-
return unless version
|
|
190
|
+
return unless version.event == 'destroy'
|
|
180
191
|
|
|
181
192
|
ActivityBoundary.destroyed(version)
|
|
182
193
|
end
|
|
@@ -210,14 +221,9 @@ module PaperTrailDiff
|
|
|
210
221
|
|
|
211
222
|
#: () -> TimeActivityTimelineBuilder
|
|
212
223
|
def time_builder
|
|
213
|
-
TimeActivityTimelineBuilder.new(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
tree: @tree,
|
|
217
|
-
snapshotter: @snapshotter,
|
|
218
|
-
snapshots: @snapshots,
|
|
219
|
-
group: @group
|
|
220
|
-
)
|
|
224
|
+
TimeActivityTimelineBuilder.new(@record, range: @range, tree: @tree,
|
|
225
|
+
snapshotter: @snapshotter, snapshots: @snapshots,
|
|
226
|
+
group: @group)
|
|
221
227
|
end
|
|
222
228
|
end
|
|
223
229
|
end
|
|
@@ -4,12 +4,11 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Collects root and selected-descendant versions relevant to one root history range.
|
|
6
6
|
class ActivityVersionCollector
|
|
7
|
-
#: (untyped, root_versions: Array[untyped], tree: AssociationTree,
|
|
7
|
+
#: (untyped, root_versions: Array[untyped], tree: AssociationTree, ?range_start: untyped, ?range_end: untyped, ?prepared: PreparedActivityIndex?) -> void
|
|
8
8
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
9
|
record,
|
|
10
10
|
root_versions:,
|
|
11
11
|
tree:,
|
|
12
|
-
traversal:,
|
|
13
12
|
range_start: root_versions.first,
|
|
14
13
|
range_end: root_versions.last,
|
|
15
14
|
prepared: nil
|
|
@@ -18,7 +17,7 @@ module PaperTrailDiff
|
|
|
18
17
|
@record = record
|
|
19
18
|
@root_versions = root_versions
|
|
20
19
|
@tree = tree
|
|
21
|
-
@traversal =
|
|
20
|
+
@traversal = AssociationTraversal.new(tree)
|
|
22
21
|
@range = ActivityRange.new(range_start, range_end)
|
|
23
22
|
@registry = ActivityEventRegistry.new
|
|
24
23
|
end
|
|
@@ -62,7 +61,7 @@ module PaperTrailDiff
|
|
|
62
61
|
@registry.add(group.fetch(:versions), branch: branch)
|
|
63
62
|
next if subtree.empty?
|
|
64
63
|
|
|
65
|
-
child_path = Support.association_path(path, reflection.name
|
|
64
|
+
child_path = Support.association_path(path, reflection.name)
|
|
66
65
|
collect_tree(group.fetch(:model), group.fetch(:ids), subtree,
|
|
67
66
|
path: child_path, branch: branch)
|
|
68
67
|
end
|
|
@@ -74,7 +73,7 @@ module PaperTrailDiff
|
|
|
74
73
|
|
|
75
74
|
case reflection.macro
|
|
76
75
|
when :belongs_to
|
|
77
|
-
belongs_to_groups(parent.fetch(:model), parent.fetch(:
|
|
76
|
+
belongs_to_groups(parent.fetch(:model), parent.fetch(:ids), reflection)
|
|
78
77
|
when :has_one, :has_many
|
|
79
78
|
direct_child_groups(parent.fetch(:model), parent.fetch(:ids), reflection)
|
|
80
79
|
when :has_and_belongs_to_many
|
|
@@ -90,24 +89,35 @@ module PaperTrailDiff
|
|
|
90
89
|
intermediate = edge_groups(parent, through, branch)
|
|
91
90
|
result = {} #: Hash[String, untyped]
|
|
92
91
|
intermediate.each_value do |group|
|
|
93
|
-
|
|
92
|
+
# A through is always read with prepared history, which knows the
|
|
93
|
+
# join row's state either side of each version.
|
|
94
|
+
@registry.add(@prepared&.join_versions(group, reflection) || group.fetch(:versions),
|
|
95
|
+
branch: branch)
|
|
94
96
|
source = reflection.source_reflection
|
|
95
97
|
children = edge_groups(group, source, branch)
|
|
96
98
|
merge_groups!(result, children)
|
|
97
99
|
end
|
|
98
|
-
|
|
100
|
+
# Under `source_type:`, only targets of that type are members.
|
|
101
|
+
reflection.options[:source_type]&.then { |type| result.slice(type.to_s) } || result
|
|
99
102
|
end
|
|
100
103
|
|
|
104
|
+
# Every target the owners pointed at in the range, not only those their own
|
|
105
|
+
# changes there recorded: an author renamed under an article nobody edited
|
|
106
|
+
# is still that article's activity.
|
|
101
107
|
#: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
|
|
102
|
-
def belongs_to_groups(parent_class,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
def belongs_to_groups(parent_class, parent_ids, reflection)
|
|
109
|
+
prepared = @prepared
|
|
110
|
+
return unprepared_belongs_to_groups(parent_class, parent_ids, reflection) unless prepared
|
|
111
|
+
|
|
112
|
+
# Prepared history holds every target its snapshots can contain. One it
|
|
113
|
+
# did not load -- a key changed without a version -- has no state to compare.
|
|
114
|
+
rows = prepared.target_rows(parent_class, parent_ids, reflection, @range)
|
|
115
|
+
identity_groups(rows, reflection) { |model_class, id| prepared.loaded?(model_class, id) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
#: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
|
|
119
|
+
def unprepared_belongs_to_groups(parent_class, parent_ids, reflection)
|
|
120
|
+
rows = ActivityTargetCandidateLoader.new(parent_class, parent_ids, reflection, @range).call
|
|
111
121
|
identity_groups(rows, reflection)
|
|
112
122
|
end
|
|
113
123
|
|
|
@@ -127,8 +137,7 @@ module PaperTrailDiff
|
|
|
127
137
|
#: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
|
|
128
138
|
def habtm_groups(parent_class, parent_versions, reflection)
|
|
129
139
|
if (prepared = @prepared)
|
|
130
|
-
rows = prepared.
|
|
131
|
-
transactions: true)
|
|
140
|
+
rows = prepared.habtm_rows(parent_class, parent_versions, reflection)
|
|
132
141
|
return identity_groups(rows, reflection)
|
|
133
142
|
end
|
|
134
143
|
transaction_ids = parent_versions.filter_map do |version|
|
|
@@ -141,7 +150,7 @@ module PaperTrailDiff
|
|
|
141
150
|
identity_groups(rows, reflection)
|
|
142
151
|
end
|
|
143
152
|
|
|
144
|
-
#: (Array[untyped], untyped) -> Hash[String, untyped]
|
|
153
|
+
#: (Array[untyped], untyped) ?{ (untyped, untyped) -> bool } -> Hash[String, untyped]
|
|
145
154
|
def identity_groups(rows, reflection)
|
|
146
155
|
identities = rows.group_by { |row| target_class(reflection, row) }
|
|
147
156
|
groups = {} #: Hash[String, untyped]
|
|
@@ -149,6 +158,7 @@ module PaperTrailDiff
|
|
|
149
158
|
next unless model_class
|
|
150
159
|
|
|
151
160
|
ids = class_rows.filter_map(&:foreign_key_id).uniq
|
|
161
|
+
ids = ids.select { |id| yield(model_class, id) } if block_given?
|
|
152
162
|
groups[model_class.name] = group(model_class, ids)
|
|
153
163
|
end
|
|
154
164
|
groups
|
|
@@ -156,6 +166,7 @@ module PaperTrailDiff
|
|
|
156
166
|
|
|
157
167
|
#: (untyped, Array[untyped]) -> Hash[Symbol, untyped]
|
|
158
168
|
def group(model_class, ids)
|
|
169
|
+
ids = @prepared&.member_ids(model_class, ids) || ids
|
|
159
170
|
{ model: model_class, ids: ids, versions: versions_for(model_class, ids) }
|
|
160
171
|
end
|
|
161
172
|
|
|
@@ -178,7 +189,7 @@ module PaperTrailDiff
|
|
|
178
189
|
return [] if ids.empty?
|
|
179
190
|
|
|
180
191
|
if (prepared = @prepared)
|
|
181
|
-
return prepared.
|
|
192
|
+
return prepared.member_versions(model_class, ids, @range)
|
|
182
193
|
end
|
|
183
194
|
|
|
184
195
|
version_class = model_class.paper_trail.version_class
|