paper_trail_diff 0.14.0 → 0.15.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 +343 -0
- data/QUICKSTART.md +21 -4
- data/README.md +58 -55
- data/docs/architecture.md +32 -11
- data/docs/performance.md +116 -391
- data/docs/reference.md +345 -106
- data/docs/testing.md +63 -7
- data/lib/paper_trail_diff/activity_boundary.rb +83 -26
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
- data/lib/paper_trail_diff/activity_event.rb +41 -10
- data/lib/paper_trail_diff/activity_history.rb +34 -9
- data/lib/paper_trail_diff/activity_range.rb +18 -3
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
- data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
- data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
- data/lib/paper_trail_diff/activity_view.rb +69 -0
- data/lib/paper_trail_diff/analysis.rb +13 -1
- data/lib/paper_trail_diff/analysis_batch.rb +24 -30
- data/lib/paper_trail_diff/approximate_paths.rb +48 -0
- data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
- data/lib/paper_trail_diff/association_discovery.rb +9 -1
- data/lib/paper_trail_diff/association_key.rb +56 -0
- data/lib/paper_trail_diff/association_traversal.rb +13 -4
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
- data/lib/paper_trail_diff/batch_preload.rb +53 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
- data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
- data/lib/paper_trail_diff/change_row.rb +133 -0
- data/lib/paper_trail_diff/collection_comparator.rb +5 -32
- data/lib/paper_trail_diff/configuration.rb +35 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
- data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
- data/lib/paper_trail_diff/deprecation.rb +61 -0
- data/lib/paper_trail_diff/diagnostics.rb +84 -99
- data/lib/paper_trail_diff/endpoint.rb +26 -3
- data/lib/paper_trail_diff/errors.rb +3 -1
- data/lib/paper_trail_diff/fallback_paths.rb +38 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
- data/lib/paper_trail_diff/historical_filter.rb +19 -10
- data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
- data/lib/paper_trail_diff/nested_comparator.rb +19 -18
- data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
- data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
- data/lib/paper_trail_diff/prepared_history.rb +9 -1
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
- data/lib/paper_trail_diff/root_version_selection.rb +4 -2
- data/lib/paper_trail_diff/scope_condition.rb +76 -21
- data/lib/paper_trail_diff/scope_ranking.rb +12 -2
- data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
- data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
- data/lib/paper_trail_diff/snapshot.rb +16 -1
- data/lib/paper_trail_diff/support.rb +29 -2
- data/lib/paper_trail_diff/time_range.rb +32 -10
- data/lib/paper_trail_diff/timeline_builder.rb +7 -7
- data/lib/paper_trail_diff/timeline_range.rb +9 -1
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
- data/lib/paper_trail_diff/transaction_cut.rb +181 -0
- data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +37 -8
- data/lib/paper_trail_diff/version_query.rb +34 -10
- data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
- data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
- data/lib/paper_trail_diff/version_table.rb +60 -0
- data/lib/paper_trail_diff.rb +98 -56
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
- data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
- data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
- data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
- data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
- data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
- data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
- data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
- data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
- data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
- data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
- data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
- data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
- data/sig/generated/paper_trail_diff/errors.rbs +3 -1
- data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
- data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
- data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
- data/sig/generated/paper_trail_diff/support.rbs +15 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
- data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
- data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
- data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
- data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
- data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
- data/sig/generated/paper_trail_diff.rbs +29 -26
- metadata +46 -10
- data/lib/paper_trail_diff/activity_grouping.rb +0 -38
- data/lib/paper_trail_diff/batch_safety.rb +0 -63
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
- data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +0 -92
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Leaves out the events of records that were outside the root's tree.
|
|
6
|
+
#
|
|
7
|
+
# A record found in the tree at one time in a range brings every version it
|
|
8
|
+
# saved in the range, including those it saved while it belonged elsewhere:
|
|
9
|
+
# a comment edited on another article before it moved to this one, or after
|
|
10
|
+
# it left a scope. Such a version changes nothing the root shows. As a
|
|
11
|
+
# boundary it would open a step of its own, and inside a transaction, where
|
|
12
|
+
# a change surfaces one event late, that step would take the credit for the
|
|
13
|
+
# root's own change.
|
|
14
|
+
#
|
|
15
|
+
# PT-AT reads associations as they were before a transaction, so a record
|
|
16
|
+
# is judged by two snapshots: the one at its transaction's first event, and
|
|
17
|
+
# the one after its last, which is the next event's or what the range
|
|
18
|
+
# closes on. A record in neither was outside the tree throughout.
|
|
19
|
+
#
|
|
20
|
+
# The root's own events are always kept, and so are the versions of a
|
|
21
|
+
# through's join rows, which a snapshot does not hold, and an event whose
|
|
22
|
+
# step is not reported, which only bounds one that is.
|
|
23
|
+
class ActivityTreeFilter
|
|
24
|
+
# `closing` is the snapshot after the last event: the current record's,
|
|
25
|
+
# or nil when nothing follows, as after the root's destruction.
|
|
26
|
+
#: (Array[ActivityEvent], reported: untyped, closing: RecordSnapshot?) -> void
|
|
27
|
+
def initialize(events, reported:, closing:)
|
|
28
|
+
@reported = reported
|
|
29
|
+
@closing = closing
|
|
30
|
+
@starts, @ends = spans(events)
|
|
31
|
+
@types = {} #: Hash[String, Array[String?]]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Yields each event of the sequence with its snapshot, and whether to keep
|
|
35
|
+
# it, in order.
|
|
36
|
+
#: (untyped) { (ActivityEvent, RecordSnapshot?, bool) -> void } -> void
|
|
37
|
+
def call(sequence)
|
|
38
|
+
pairs = sequence.to_enum.to_a #: Array[[ActivityEvent, RecordSnapshot?]]
|
|
39
|
+
pairs.each_with_index do |(event, snapshot), index|
|
|
40
|
+
yield event, snapshot, keep?(event, index, pairs)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# @rbs @reported: untyped
|
|
47
|
+
# @rbs @closing: RecordSnapshot?
|
|
48
|
+
# @rbs @starts: Array[Integer]
|
|
49
|
+
# @rbs @ends: Array[Integer]
|
|
50
|
+
# @rbs @types: Hash[String, Array[String?]]
|
|
51
|
+
|
|
52
|
+
#: (ActivityEvent, Integer, Array[[ActivityEvent, RecordSnapshot?]]) -> bool
|
|
53
|
+
def keep?(event, index, pairs)
|
|
54
|
+
return true if event.root? || event.join? || !reported?(event)
|
|
55
|
+
|
|
56
|
+
position = @ends.fetch(index)
|
|
57
|
+
after = position < pairs.length ? pairs.fetch(position).last : @closing
|
|
58
|
+
holds?(pairs.fetch(@starts.fetch(index)).last, event) || holds?(after, event)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
#: (ActivityEvent) -> bool
|
|
62
|
+
def reported?(event)
|
|
63
|
+
!@reported || @reported.call(event)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Where each event's transaction starts, and the position just past its
|
|
67
|
+
# last event. Two transactions can interleave, so a transaction's events
|
|
68
|
+
# need not be adjacent.
|
|
69
|
+
#: (Array[ActivityEvent]) -> [Array[Integer], Array[Integer]]
|
|
70
|
+
def spans(events)
|
|
71
|
+
keys = transaction_keys(events)
|
|
72
|
+
first = {} #: Hash[Array[untyped] | Integer, Integer]
|
|
73
|
+
last = {} #: Hash[Array[untyped] | Integer, Integer]
|
|
74
|
+
keys.each_with_index do |key, index|
|
|
75
|
+
first[key] ||= index
|
|
76
|
+
last[key] = index
|
|
77
|
+
end
|
|
78
|
+
[keys.map { |key| first.fetch(key) }, keys.map { |key| last.fetch(key) + 1 }]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A version saved outside a transaction is a transaction of its own,
|
|
82
|
+
# keyed by its position.
|
|
83
|
+
#: (Array[ActivityEvent]) -> Array[Array[untyped] | Integer]
|
|
84
|
+
def transaction_keys(events)
|
|
85
|
+
events.each_with_index.map do |event, index|
|
|
86
|
+
ActivityTransactionGroups.transaction_key(event.version) || index
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
#: (RecordSnapshot?, ActivityEvent) -> bool
|
|
91
|
+
def holds?(snapshot, event)
|
|
92
|
+
return false unless snapshot
|
|
93
|
+
|
|
94
|
+
version = event.version
|
|
95
|
+
types = types(version.item_type)
|
|
96
|
+
branches = event.branches #: Array[String]
|
|
97
|
+
branches.any? { |name| contains?(snapshot.associations.fetch(name), types, version.item_id) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
#: (AssociationSnapshot, Array[String?], untyped) -> bool
|
|
101
|
+
def contains?(association, types, id)
|
|
102
|
+
types.any? { |type| association.position(type, id) } || association.records.any? do |record|
|
|
103
|
+
record.associations.each_value.any? { |nested| contains?(nested, types, id) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A version names its record's base class, and a snapshot the record's
|
|
108
|
+
# own class, which may be any subclass of it.
|
|
109
|
+
#: (String) -> Array[String?]
|
|
110
|
+
def types(base_name)
|
|
111
|
+
@types[base_name] ||= [base_name, *Object.const_get(base_name).descendants.map(&:name)]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -22,13 +22,15 @@ module PaperTrailDiff
|
|
|
22
22
|
@registry = ActivityEventRegistry.new
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# A tie between version tables, or between ids that do not count up,
|
|
26
|
+
# raises rather than building a timeline in an arbitrary order.
|
|
25
27
|
#: () -> Array[ActivityEvent]
|
|
26
28
|
def call
|
|
27
29
|
@registry.add(@root_versions)
|
|
28
30
|
collect_tree(@record.class, [@record.id], @tree, path: '', branch: nil) unless @tree.empty?
|
|
29
|
-
@registry.to_a.sort_by
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
events = @registry.to_a.sort_by { |event| Support.chronological_version_key(event.version) }
|
|
32
|
+
pair = Support.ambiguous_pair(events.map(&:version))
|
|
33
|
+
pair ? raise(AmbiguousVersionOrderError, Support.ambiguous_message(pair)) : events.freeze
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
private
|
|
@@ -92,7 +94,7 @@ module PaperTrailDiff
|
|
|
92
94
|
# A through is always read with prepared history, which knows the
|
|
93
95
|
# join row's state either side of each version.
|
|
94
96
|
@registry.add(@prepared&.join_versions(group, reflection) || group.fetch(:versions),
|
|
95
|
-
branch: branch)
|
|
97
|
+
branch: branch, join: true)
|
|
96
98
|
source = reflection.source_reflection
|
|
97
99
|
children = edge_groups(group, source, branch)
|
|
98
100
|
merge_groups!(result, children)
|
|
@@ -141,7 +143,7 @@ module PaperTrailDiff
|
|
|
141
143
|
return identity_groups(rows, reflection)
|
|
142
144
|
end
|
|
143
145
|
transaction_ids = parent_versions.filter_map do |version|
|
|
144
|
-
|
|
146
|
+
VersionBoundary.transaction_id(version)
|
|
145
147
|
end
|
|
146
148
|
rows = parent_class.paper_trail.version_association_class.where(
|
|
147
149
|
version_id: transaction_ids,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# How a request presents its activity steps: whether they keep their
|
|
6
|
+
# snapshots, whether a transaction's steps merge into one, and which of
|
|
7
|
+
# their versions' recorded changes they carry. Built and checked once per
|
|
8
|
+
# request, and read the same way by both activity builders, or the same
|
|
9
|
+
# history would read differently depending on how it was asked for.
|
|
10
|
+
class ActivityView
|
|
11
|
+
# Options only an activity view uses are refused without one.
|
|
12
|
+
#: (activity: bool, snapshots: bool, group: Symbol?, recorded: IgnorePolicy?) -> ActivityView
|
|
13
|
+
def self.build(activity:, snapshots:, group:, recorded:)
|
|
14
|
+
view = new(snapshots: snapshots, group: group, recorded: recorded)
|
|
15
|
+
return view if activity
|
|
16
|
+
|
|
17
|
+
{ snapshots: snapshots, recorded_changes: !recorded.nil?, group: !group.nil? }
|
|
18
|
+
.each do |name, given|
|
|
19
|
+
raise ConfigurationError, "#{name}: requires activity: true" if given
|
|
20
|
+
end
|
|
21
|
+
view
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
#: (?snapshots: bool, ?group: Symbol?, ?recorded: IgnorePolicy?) -> void
|
|
25
|
+
def initialize(snapshots: false, group: nil, recorded: nil)
|
|
26
|
+
unless group.nil? || group == :transaction
|
|
27
|
+
raise ConfigurationError, 'group: must be :transaction or nil'
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@snapshots = snapshots
|
|
31
|
+
@group = group
|
|
32
|
+
@recorded = recorded
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Merging a group compares its outer states, so the snapshots must survive
|
|
37
|
+
# the build even when the caller did not ask to keep them.
|
|
38
|
+
#: () -> bool
|
|
39
|
+
def retain?
|
|
40
|
+
@snapshots || grouping?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The attributes a boundary's version recorded, when they were asked for.
|
|
44
|
+
#: (root: bool) -> Array[String]?
|
|
45
|
+
def recorded_attributes(root:)
|
|
46
|
+
@recorded&.recorded_attributes(root: root)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Applied last, to finished steps, so grouping sees the same timeline the
|
|
50
|
+
# caller would otherwise have received.
|
|
51
|
+
#: (Array[ActivityStep]) -> Array[ActivityStep]
|
|
52
|
+
def present(steps)
|
|
53
|
+
return steps unless grouping?
|
|
54
|
+
|
|
55
|
+
ActivityTransactionGrouper.new(steps, retain: @snapshots).call
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# @rbs @snapshots: bool
|
|
61
|
+
# @rbs @group: Symbol?
|
|
62
|
+
# @rbs @recorded: IgnorePolicy?
|
|
63
|
+
|
|
64
|
+
#: () -> bool
|
|
65
|
+
def grouping?
|
|
66
|
+
@group == :transaction
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -11,7 +11,9 @@ module PaperTrailDiff
|
|
|
11
11
|
attr_reader :to_snapshot #: RecordSnapshot?
|
|
12
12
|
|
|
13
13
|
# The result for a record whose requested history contains no versions,
|
|
14
|
-
# which is an empty history rather than a failed request.
|
|
14
|
+
# which is an empty history rather than a failed request. Its snapshots
|
|
15
|
+
# are nil because no state was read, not because the record was absent:
|
|
16
|
+
# reading one would rebuild the whole graph, per record in a batch.
|
|
15
17
|
#: () -> Analysis
|
|
16
18
|
def self.empty
|
|
17
19
|
timeline = [] #: Array[Step]
|
|
@@ -31,6 +33,16 @@ module PaperTrailDiff
|
|
|
31
33
|
freeze
|
|
32
34
|
end
|
|
33
35
|
|
|
36
|
+
# Every activity step's rows, in time order. Only the activity timeline says
|
|
37
|
+
# who made each change, so an analysis built without it has no rows to give.
|
|
38
|
+
#: () -> Array[ChangeRow]
|
|
39
|
+
def change_rows
|
|
40
|
+
steps = activity_timeline
|
|
41
|
+
raise ConfigurationError, 'change_rows requires activity: true' unless steps
|
|
42
|
+
|
|
43
|
+
steps.flat_map(&:change_rows).freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
34
46
|
# Reconstructed endpoint states are opt-in, because they carry the whole
|
|
35
47
|
# selected graph whether or not anything changed, which dwarfs the rest of
|
|
36
48
|
# the payload for a wide graph.
|
|
@@ -6,16 +6,16 @@ module PaperTrailDiff
|
|
|
6
6
|
# versions and preparing their association history once for the whole batch
|
|
7
7
|
# rather than once per root.
|
|
8
8
|
class AnalysisBatch
|
|
9
|
-
#: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?
|
|
9
|
+
#: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?context_required: bool) -> void
|
|
10
10
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
11
11
|
records, time_range:, live_loader:, history_preparer:, analyzer:, version_scope: nil,
|
|
12
|
-
|
|
12
|
+
context_required: false
|
|
13
13
|
)
|
|
14
14
|
@records = records
|
|
15
15
|
@context_required = context_required
|
|
16
16
|
@time_range = time_range
|
|
17
|
-
|
|
18
|
-
@
|
|
17
|
+
# Checked up front rather than failing partway through a batch.
|
|
18
|
+
@version_scope = VersionScopeFilter.validated(version_scope)
|
|
19
19
|
@live_loader = live_loader
|
|
20
20
|
@history_preparer = history_preparer
|
|
21
21
|
@analyzer = analyzer
|
|
@@ -25,16 +25,17 @@ module PaperTrailDiff
|
|
|
25
25
|
def call
|
|
26
26
|
records = validated_records
|
|
27
27
|
loaded = @live_loader.call(records)
|
|
28
|
+
windows = TransactionCut.batch_windows(records, @time_range)
|
|
28
29
|
selected = BatchedRootVersions.new(
|
|
29
30
|
records, time_range: @time_range, version_scope: @version_scope,
|
|
30
|
-
live_endpoints:
|
|
31
|
-
context_required: @context_required
|
|
31
|
+
live_endpoints: loaded,
|
|
32
|
+
context_required: @context_required, windows: windows
|
|
32
33
|
).call
|
|
33
|
-
prepare(records, selected, loaded)
|
|
34
|
+
prepare(records, selected, loaded, windows)
|
|
34
35
|
records.to_h do |record|
|
|
35
36
|
key = Endpoint.identity(record)
|
|
36
37
|
plan = selected.fetch(key, RootVersionPlan.empty)
|
|
37
|
-
[Support.immutable_copy(key), analysis_for(record, plan)]
|
|
38
|
+
[Support.immutable_copy(key), analysis_for(record, plan, windows.fetch(key, @time_range))]
|
|
38
39
|
end.freeze
|
|
39
40
|
end
|
|
40
41
|
|
|
@@ -43,26 +44,19 @@ module PaperTrailDiff
|
|
|
43
44
|
# @rbs @records: Array[untyped]
|
|
44
45
|
# @rbs @time_range: TimeRange?
|
|
45
46
|
# @rbs @version_scope: untyped
|
|
46
|
-
# @rbs @close_on_current: bool
|
|
47
47
|
# @rbs @context_required: bool
|
|
48
48
|
# @rbs @live_loader: untyped
|
|
49
49
|
# @rbs @history_preparer: untyped
|
|
50
50
|
# @rbs @analyzer: untyped
|
|
51
51
|
|
|
52
|
-
# A filter is a callable that narrows the version relation, so it is checked
|
|
53
|
-
# up front rather than failing partway through a batch.
|
|
54
|
-
#: (untyped) -> untyped
|
|
55
|
-
def validated_scope(scope)
|
|
56
|
-
return scope if scope.nil? || scope.respond_to?(:call)
|
|
57
|
-
|
|
58
|
-
raise ConfigurationError, 'version_scope: must respond to call'
|
|
59
|
-
end
|
|
60
|
-
|
|
61
52
|
#: () -> Array[untyped]
|
|
62
53
|
def validated_records
|
|
63
54
|
raise ConfigurationError, 'records: must be an array' unless @records.is_a?(Array)
|
|
64
55
|
|
|
65
|
-
@records.each
|
|
56
|
+
@records.each do |record|
|
|
57
|
+
Endpoint.validate_root!(record)
|
|
58
|
+
Endpoint.validate!(record)
|
|
59
|
+
end
|
|
66
60
|
identities = @records.map { |record| Endpoint.identity(record) }
|
|
67
61
|
return @records if identities.uniq.length == identities.length
|
|
68
62
|
|
|
@@ -74,8 +68,8 @@ module PaperTrailDiff
|
|
|
74
68
|
# The roots are preloaded first, because preparation reads their current
|
|
75
69
|
# association state as a fallback and would otherwise walk it one root at a
|
|
76
70
|
# time.
|
|
77
|
-
#: (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], untyped]) -> void
|
|
78
|
-
def prepare(records, selected, loaded)
|
|
71
|
+
#: (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], untyped], Hash[Array[String], TimeRange]) -> void
|
|
72
|
+
def prepare(records, selected, loaded, windows)
|
|
79
73
|
records.group_by(&:class).each_value do |grouped|
|
|
80
74
|
versions = grouped.flat_map do |record|
|
|
81
75
|
selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).reconstruction_versions
|
|
@@ -84,7 +78,7 @@ module PaperTrailDiff
|
|
|
84
78
|
|
|
85
79
|
roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
|
|
86
80
|
live = closes_live?(grouped, selected)
|
|
87
|
-
@history_preparer.call(roots, versions, **preparation_range(versions, live: live))
|
|
81
|
+
@history_preparer.call(roots, versions, **preparation_range(versions, windows, live: live))
|
|
88
82
|
end
|
|
89
83
|
end
|
|
90
84
|
|
|
@@ -97,21 +91,21 @@ module PaperTrailDiff
|
|
|
97
91
|
|
|
98
92
|
# A plan closing on the live record reads history up to now; the others
|
|
99
93
|
# stop at the window, so a past window does not load everything since.
|
|
100
|
-
#: (Array[untyped],
|
|
101
|
-
def preparation_range(versions, live:
|
|
102
|
-
|
|
103
|
-
end_at = [
|
|
104
|
-
{ start_at: @time_range
|
|
94
|
+
#: (Array[untyped], Hash[Array[String], TimeRange], live: bool) -> Hash[Symbol, untyped]
|
|
95
|
+
def preparation_range(versions, windows, live:)
|
|
96
|
+
times = versions.map(&:created_at)
|
|
97
|
+
end_at = [times.max, @time_range&.end_time, (Time.now.utc if live)].compact.max
|
|
98
|
+
{ start_at: TransactionCut.earliest_start(@time_range, windows) || times.min, end_at: end_at }
|
|
105
99
|
end
|
|
106
100
|
|
|
107
101
|
# A root with no versions in range has nothing to report, which is an empty
|
|
108
102
|
# result rather than a failed request -- unless an activity view closes it
|
|
109
103
|
# on current state, where its descendants may still have moved.
|
|
110
|
-
#: (untyped, RootVersionPlan) -> Analysis
|
|
111
|
-
def analysis_for(record, plan)
|
|
104
|
+
#: (untyped, RootVersionPlan, TimeRange?) -> Analysis
|
|
105
|
+
def analysis_for(record, plan, window)
|
|
112
106
|
return Analysis.empty if plan.empty? && !plan.closing_record
|
|
113
107
|
|
|
114
|
-
@analyzer.call(record, plan)
|
|
108
|
+
@analyzer.call(record, plan, window)
|
|
115
109
|
end
|
|
116
110
|
end
|
|
117
111
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# The requested paths prepared history cannot read exactly, each with why.
|
|
6
|
+
# PT-AT reads such a path instead, one record at a time, when the caller
|
|
7
|
+
# passes `allow_approximate: true`.
|
|
8
|
+
module ApproximatePaths
|
|
9
|
+
#: (AssociationTraversal, untyped) -> Array[[String, String]]
|
|
10
|
+
def self.call(traversal, model)
|
|
11
|
+
traversal.selected_reflections(model).filter_map do |path, reflection|
|
|
12
|
+
reason = fallback_reason(reflection, ranked: true)
|
|
13
|
+
[path, reason] if reason
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A has_many through reads its parts' conditions only, at any depth. A
|
|
18
|
+
# has_one through over a ranked part is refused whole, so its parts, too,
|
|
19
|
+
# are checked for conditions alone.
|
|
20
|
+
#: (untyped, ranked: bool) -> String?
|
|
21
|
+
def self.fallback_reason(reflection, ranked:)
|
|
22
|
+
reason = reason_for(reflection, ranked)
|
|
23
|
+
return reason if reason || !reflection.through_reflection?
|
|
24
|
+
|
|
25
|
+
[reflection.through_reflection, reflection.source_reflection].each do |part|
|
|
26
|
+
part_reason = fallback_reason(part, ranked: false)
|
|
27
|
+
return part_reason if part_reason
|
|
28
|
+
end
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
private_class_method :fallback_reason
|
|
32
|
+
|
|
33
|
+
# A scope prepared history can read as conditions on stored attributes
|
|
34
|
+
# is batched like any other path. A polymorphic target's class, and so
|
|
35
|
+
# its scope, is known only per record.
|
|
36
|
+
#: (untyped, bool) -> String?
|
|
37
|
+
def self.reason_for(reflection, ranked)
|
|
38
|
+
return ('association scope' if reflection.scope) if reflection.polymorphic?
|
|
39
|
+
if ranked && ScopeCondition.ranked_through?(reflection)
|
|
40
|
+
return 'limit, offset or has_one order inside a has_one through'
|
|
41
|
+
end
|
|
42
|
+
return if ScopeCondition.build(reflection, ranked: ranked)
|
|
43
|
+
|
|
44
|
+
reflection.scope ? 'association scope' : 'default-scoped target'
|
|
45
|
+
end
|
|
46
|
+
private_class_method :reason_for
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -74,10 +74,10 @@ module PaperTrailDiff
|
|
|
74
74
|
#: (untyped) -> untyped
|
|
75
75
|
def relation(filter)
|
|
76
76
|
versions = { item_type: @reflection.klass.base_class.name, item_id: filter }
|
|
77
|
-
relation = @associations
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
relation = VersionTable.joined(@associations, @versions)
|
|
78
|
+
.where(foreign_key_name: @reflection.foreign_key,
|
|
79
|
+
foreign_type: owner_types)
|
|
80
|
+
.where(@versions.table_name => versions)
|
|
81
81
|
candidates(@versions, relation)
|
|
82
82
|
end
|
|
83
83
|
|
|
@@ -136,8 +136,8 @@ module PaperTrailDiff
|
|
|
136
136
|
|
|
137
137
|
#: (Hash[Symbol, untyped]) -> untyped
|
|
138
138
|
def owner_rows(conditions)
|
|
139
|
-
@associations
|
|
140
|
-
|
|
139
|
+
VersionTable.joined(@associations, @versions).where(conditions)
|
|
140
|
+
.where(@versions.table_name => { item_type: @owner.base_class.name })
|
|
141
141
|
end
|
|
142
142
|
end
|
|
143
143
|
|
|
@@ -102,11 +102,19 @@ module PaperTrailDiff
|
|
|
102
102
|
#: (untyped, root: bool) -> Array[untyped]
|
|
103
103
|
def supported_reflections(model_class, root:)
|
|
104
104
|
reflections = model_class.reflect_on_all_associations.reject do |reflection|
|
|
105
|
-
AssociationTraversal.unsupported_reason(model_class, reflection, root: root)
|
|
105
|
+
AssociationTraversal.unsupported_reason(model_class, reflection, root: root) ||
|
|
106
|
+
unversioned?(reflection)
|
|
106
107
|
end
|
|
107
108
|
reflections.sort_by { |reflection| reflection.name.to_s }
|
|
108
109
|
end
|
|
109
110
|
|
|
111
|
+
# Every request that reads history refuses a path whose target, or a
|
|
112
|
+
# model its has_many :through goes through, has no versions.
|
|
113
|
+
#: (untyped) -> bool
|
|
114
|
+
def unversioned?(reflection)
|
|
115
|
+
TraversalPreparer.unversioned_models(reflection).any?
|
|
116
|
+
end
|
|
117
|
+
|
|
110
118
|
#: (untyped) -> untyped
|
|
111
119
|
def reflection_target(reflection)
|
|
112
120
|
reflection.klass unless reflection.polymorphic?
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# PT-AT records which record a child pointed at by that record's id, and
|
|
6
|
+
# history matches children to owners and targets by id. An association
|
|
7
|
+
# keyed on another column (`primary_key:`) would read other records'
|
|
8
|
+
# history, so it is refused.
|
|
9
|
+
module AssociationKey
|
|
10
|
+
# Why the association, or a part of a through, cannot be read by id.
|
|
11
|
+
#: (untyped) -> String?
|
|
12
|
+
def self.unsupported_reason(reflection)
|
|
13
|
+
part = keyed_part(reflection)
|
|
14
|
+
return unless part
|
|
15
|
+
|
|
16
|
+
"#{part.active_record.name}##{part.name} matches records by " \
|
|
17
|
+
"#{part.options[:primary_key]} rather than the primary key, and association " \
|
|
18
|
+
'history is recorded by primary key'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The reflection, or the first part of a through, keyed on a column other
|
|
22
|
+
# than its owner's or target's primary key.
|
|
23
|
+
#: (untyped) -> untyped
|
|
24
|
+
def self.keyed_part(reflection)
|
|
25
|
+
return reflection if other_key?(reflection)
|
|
26
|
+
return unless reflection.through_reflection?
|
|
27
|
+
|
|
28
|
+
[reflection.through_reflection, reflection.source_reflection].filter_map do |part|
|
|
29
|
+
keyed_part(part)
|
|
30
|
+
end.first
|
|
31
|
+
end
|
|
32
|
+
private_class_method :keyed_part
|
|
33
|
+
|
|
34
|
+
# A belongs_to names a key of its target; any other macro, one of its
|
|
35
|
+
# owner. A polymorphic target has no single primary key to compare with.
|
|
36
|
+
#: (untyped) -> bool
|
|
37
|
+
def self.other_key?(reflection)
|
|
38
|
+
key = reflection.options[:primary_key]
|
|
39
|
+
return false unless key
|
|
40
|
+
return true if reflection.polymorphic?
|
|
41
|
+
|
|
42
|
+
model = reflection.belongs_to? ? reflection.klass : reflection.active_record
|
|
43
|
+
!same_column?(key, model.primary_key)
|
|
44
|
+
end
|
|
45
|
+
private_class_method :other_key?
|
|
46
|
+
|
|
47
|
+
# Column names are strings once the key is, so the one mutation left,
|
|
48
|
+
# `eql?` for `==`, answers the same.
|
|
49
|
+
# mutant:disable
|
|
50
|
+
#: (untyped, untyped) -> bool
|
|
51
|
+
def self.same_column?(key, primary_key)
|
|
52
|
+
key.to_s == primary_key
|
|
53
|
+
end
|
|
54
|
+
private_class_method :same_column?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Resolves and validates ActiveRecord reflections for a bounded association tree.
|
|
6
|
-
class AssociationTraversal
|
|
6
|
+
class AssociationTraversal # rubocop:disable Metrics/ClassLength
|
|
7
7
|
# The class a `belongs_to` points at for this record, reading a polymorphic
|
|
8
8
|
# type column when there is one. Nil when the record points at nothing.
|
|
9
9
|
#: (untyped, untyped) -> untyped
|
|
@@ -66,15 +66,24 @@ module PaperTrailDiff
|
|
|
66
66
|
def self.unsupported_reason(model_class, reflection, root:)
|
|
67
67
|
return 'PaperTrail versions' if paper_trail_versions?(model_class, reflection)
|
|
68
68
|
return reflection.macro.to_s unless SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro)
|
|
69
|
-
|
|
69
|
+
if reflection.macro == :has_and_belongs_to_many
|
|
70
|
+
return habtm_reason(reflection, root) || VersionTable.unsupported_reason(reflection)
|
|
71
|
+
end
|
|
70
72
|
|
|
71
73
|
habtm = habtm_in_chain(reflection)
|
|
72
|
-
return unless habtm
|
|
74
|
+
return storage_reason(reflection) unless habtm
|
|
73
75
|
|
|
74
76
|
"it goes through #{habtm.active_record.name}##{habtm.name}, a has_and_belongs_to_many, " \
|
|
75
77
|
'whose hidden join model has no history to compare'
|
|
76
78
|
end
|
|
77
79
|
|
|
80
|
+
# History matches records by primary key, within one set of version tables.
|
|
81
|
+
#: (untyped) -> String?
|
|
82
|
+
def self.storage_reason(reflection)
|
|
83
|
+
AssociationKey.unsupported_reason(reflection) || VersionTable.unsupported_reason(reflection)
|
|
84
|
+
end
|
|
85
|
+
private_class_method :storage_reason
|
|
86
|
+
|
|
78
87
|
# PT-AT records a HABTM's membership only when its owner saves a version,
|
|
79
88
|
# in rows keyed by transaction and association name, not by owner. A root
|
|
80
89
|
# checkpoint so holds the root's membership and no one else's: another
|
|
@@ -166,7 +175,7 @@ module PaperTrailDiff
|
|
|
166
175
|
def ensure_habtm_history!(model_class, version)
|
|
167
176
|
paths = habtm_paths(model_class)
|
|
168
177
|
return if paths.empty?
|
|
169
|
-
return if
|
|
178
|
+
return if VersionBoundary.transaction_id(version)
|
|
170
179
|
|
|
171
180
|
message = "HABTM history is incomplete at version #{version.id}: #{paths.join(', ')}"
|
|
172
181
|
raise IncompleteAssociationHistoryError, message
|
|
@@ -113,10 +113,9 @@ module PaperTrailDiff
|
|
|
113
113
|
# a tie -- refusing one that ids cannot order.
|
|
114
114
|
#: (untyped, Array[untyped]) -> Hash[String, untyped]
|
|
115
115
|
def first_versions(model_class, ids)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
.where(VersionQuery.no_earlier_version(table)).to_a
|
|
116
|
+
table = model_class.paper_trail.version_class.arel_table
|
|
117
|
+
candidates = VersionQuery.for_items(model_class, ids)
|
|
118
|
+
.where(VersionQuery.no_earlier_version(table)).to_a
|
|
120
119
|
candidates.group_by { |version| version.item_id.to_s }
|
|
121
120
|
.transform_values { |versions| Support.chronological_sort(versions).first }
|
|
122
121
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Which associations Active Record's preloader loads correctly for many
|
|
6
|
+
# owners at once.
|
|
7
|
+
module BatchPreload
|
|
8
|
+
# The preloader applies a limit or offset to all owners' records at once,
|
|
9
|
+
# not to each owner's, so such an association is loaded per record. A
|
|
10
|
+
# has_one is no exception. Through a part with a limit, an offset or a
|
|
11
|
+
# has_one cut, the preloader keeps what the part alone would load, while
|
|
12
|
+
# the through's own query keeps the part's conditions only.
|
|
13
|
+
#
|
|
14
|
+
# The declared scope is read, not a record's association scope: Active
|
|
15
|
+
# Record adds `limit(1)` to every has_one's, which would load every
|
|
16
|
+
# has_one per record.
|
|
17
|
+
#: (untyped) -> bool
|
|
18
|
+
def self.safe?(reflection)
|
|
19
|
+
scope = reflection.scope
|
|
20
|
+
return false if scope && !scope.arity.zero?
|
|
21
|
+
return true unless reflection.collection? || reflection.has_one?
|
|
22
|
+
return false if cut_part?(reflection)
|
|
23
|
+
|
|
24
|
+
relation = declared_relation(reflection)
|
|
25
|
+
relation.limit_value.nil? && relation.offset_value.nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: (untyped) -> untyped
|
|
29
|
+
def self.declared_relation(reflection)
|
|
30
|
+
relation = reflection.klass.default_scoped
|
|
31
|
+
reflection.scope ? reflection.scope_for(relation) : relation
|
|
32
|
+
end
|
|
33
|
+
private_class_method :declared_relation
|
|
34
|
+
|
|
35
|
+
#: (untyped) -> bool
|
|
36
|
+
def self.cut_part?(reflection)
|
|
37
|
+
reflection.collection? &&
|
|
38
|
+
(ScopeCondition.ranked_parts?(reflection) || through_parts(reflection).any?(&:has_one?))
|
|
39
|
+
end
|
|
40
|
+
private_class_method :cut_part?
|
|
41
|
+
|
|
42
|
+
# Every part a through passes, at any depth.
|
|
43
|
+
#: (untyped) -> Array[untyped]
|
|
44
|
+
def self.through_parts(reflection)
|
|
45
|
+
return [] unless reflection.through_reflection?
|
|
46
|
+
|
|
47
|
+
[reflection.through_reflection, reflection.source_reflection].flat_map do |part|
|
|
48
|
+
[part, *through_parts(part)]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
private_class_method :through_parts
|
|
52
|
+
end
|
|
53
|
+
end
|