paper_trail_diff 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +158 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1388
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +169 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +10 -38
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/collection_comparator.rb +18 -20
- data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +176 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +39 -15
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +35 -3
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +14 -1
- data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +21 -2
- data/lib/paper_trail_diff/time_version_range.rb +2 -16
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +29 -46
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +41 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +78 -42
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +31 -25
- metadata +31 -5
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Reads plain direct edges without creating AR records for their members.
|
|
6
|
+
class PreparedStateAssociationReader
|
|
7
|
+
#: (PreparedRecordIndex, untyped) -> void
|
|
8
|
+
def initialize(records, edges)
|
|
9
|
+
@records = records
|
|
10
|
+
@edges = edges
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
#: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
|
|
14
|
+
def call(owner, reflection, boundary)
|
|
15
|
+
return [false, []] unless supported?(reflection)
|
|
16
|
+
|
|
17
|
+
if reflection.belongs_to?
|
|
18
|
+
id = owner.attributes[reflection.foreign_key.to_s]
|
|
19
|
+
return [true, []] if id.nil?
|
|
20
|
+
|
|
21
|
+
handled, state = @records.snapshot_state_before(reflection.klass, id, boundary)
|
|
22
|
+
return [handled, state ? [state] : []]
|
|
23
|
+
end
|
|
24
|
+
children(owner, reflection, boundary)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# @rbs @records: PreparedRecordIndex
|
|
30
|
+
# @rbs @edges: untyped
|
|
31
|
+
|
|
32
|
+
#: (untyped) -> bool
|
|
33
|
+
def supported?(reflection)
|
|
34
|
+
!reflection.options[:through] && !reflection.polymorphic? && reflection.scope.nil? &&
|
|
35
|
+
!reflection.klass.default_scopes? && !reflection.foreign_key.is_a?(Array) &&
|
|
36
|
+
%i[belongs_to has_one has_many].include?(reflection.macro)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
#: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
|
|
40
|
+
def children(owner, reflection, boundary)
|
|
41
|
+
selected = [] #: Array[PreparedRecordState]
|
|
42
|
+
@edges.call(owner.model_class, reflection).each_value do |group|
|
|
43
|
+
ids_for(group, owner).each do |id|
|
|
44
|
+
handled, child = @records.snapshot_state_before(group.fetch(:model), id, boundary)
|
|
45
|
+
return [false, []] unless handled
|
|
46
|
+
|
|
47
|
+
selected << child if child && member?(child, owner, reflection)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
[true, reflection.collection? ? selected : selected.first(1)]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
#: (untyped, PreparedRecordState) -> Array[untyped]
|
|
54
|
+
def ids_for(group, owner)
|
|
55
|
+
owners = group[:owners]
|
|
56
|
+
id = owner.attributes[owner.model_class.primary_key].to_s
|
|
57
|
+
empty = [] #: Array[untyped]
|
|
58
|
+
owners ? owners.fetch(id, empty) : group.fetch(:ids)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
#: (PreparedRecordState, PreparedRecordState, untyped) -> bool
|
|
62
|
+
def member?(child, owner, reflection)
|
|
63
|
+
owner_id = owner.attributes[owner.model_class.primary_key].to_s
|
|
64
|
+
return false unless child.attributes[reflection.foreign_key.to_s].to_s == owner_id
|
|
65
|
+
return true unless reflection.options[:as]
|
|
66
|
+
|
|
67
|
+
matching_type?(child, owner.model_class, reflection)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: (PreparedRecordState, untyped, untyped) -> bool
|
|
71
|
+
def matching_type?(child, model, reflection)
|
|
72
|
+
types = [model.name, model.base_class.name,
|
|
73
|
+
reflection.active_record.name]
|
|
74
|
+
types.include?(child.attributes[reflection.type.to_s].to_s)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -27,6 +27,10 @@ module PaperTrailDiff
|
|
|
27
27
|
# The root versions this plan reports as mutations, which excludes any
|
|
28
28
|
# version present only to reveal what the last of them produced.
|
|
29
29
|
attr_reader :mutations #: Array[untyped]
|
|
30
|
+
# Whether a wall-clock window closes on the root's own destruction. Its
|
|
31
|
+
# final state is then the absence the destroy left, just as a window
|
|
32
|
+
# closing on current state ends at the live record.
|
|
33
|
+
attr_reader :closes_on_destroy #: bool
|
|
30
34
|
|
|
31
35
|
class << self
|
|
32
36
|
# Adjacent boundaries, which is what an unfiltered range reports.
|
|
@@ -45,20 +49,30 @@ module PaperTrailDiff
|
|
|
45
49
|
steps = [] #: Array[[untyped, untyped]]
|
|
46
50
|
new(versions: versions, steps: steps)
|
|
47
51
|
end
|
|
52
|
+
|
|
53
|
+
# No root version to report or replay: only descendants moved, and the
|
|
54
|
+
# live record is the root's state throughout.
|
|
55
|
+
#: (untyped) -> RootVersionPlan
|
|
56
|
+
def closing_on(record)
|
|
57
|
+
versions = [] #: Array[untyped]
|
|
58
|
+
steps = [] #: Array[[untyped, untyped]]
|
|
59
|
+
new(versions: versions, steps: steps, closing_record: record)
|
|
60
|
+
end
|
|
48
61
|
end
|
|
49
62
|
|
|
50
|
-
#: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped) -> void
|
|
63
|
+
#: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped, ?closes_on_destroy: bool) -> void
|
|
51
64
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
52
65
|
versions:, steps:, context_version: nil, reconstruction_versions: nil, mutations: nil,
|
|
53
|
-
closing_record: nil
|
|
66
|
+
closing_record: nil, closes_on_destroy: false
|
|
54
67
|
)
|
|
55
68
|
@closing_record = closing_record
|
|
69
|
+
@closes_on_destroy = closes_on_destroy
|
|
56
70
|
@versions = versions.freeze
|
|
57
71
|
@reconstruction_versions = (reconstruction_versions || versions).freeze
|
|
58
72
|
@steps = steps.freeze
|
|
59
73
|
@context_version = context_version
|
|
60
74
|
@mutations = (mutations || default_mutations).freeze
|
|
61
|
-
@mutation_keys =
|
|
75
|
+
@mutation_keys = key_set(@mutations)
|
|
62
76
|
freeze
|
|
63
77
|
end
|
|
64
78
|
|
|
@@ -67,6 +81,15 @@ module PaperTrailDiff
|
|
|
67
81
|
versions.empty?
|
|
68
82
|
end
|
|
69
83
|
|
|
84
|
+
#: () -> RootVersionPlan
|
|
85
|
+
def closing_on_destroy
|
|
86
|
+
self.class.new(
|
|
87
|
+
versions: versions, steps: steps, context_version: context_version,
|
|
88
|
+
reconstruction_versions: reconstruction_versions, mutations: mutations,
|
|
89
|
+
closing_record: closing_record, closes_on_destroy: true
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
70
93
|
# What the range's final state is read from, which is the live record when
|
|
71
94
|
# the window closes on current state and the last version otherwise.
|
|
72
95
|
#: () -> untyped
|
|
@@ -91,6 +114,15 @@ module PaperTrailDiff
|
|
|
91
114
|
@versions.reject { |version| key(version) == context }
|
|
92
115
|
end
|
|
93
116
|
|
|
117
|
+
# A Set only makes the lookup in `mutation?` fast; an Array would answer the
|
|
118
|
+
# same, which is why mutant leaves this alone.
|
|
119
|
+
# mutant:disable
|
|
120
|
+
#: (Array[untyped]) -> Set[Array[untyped]]
|
|
121
|
+
def key_set(versions)
|
|
122
|
+
Set.new(versions.map { |version| key(version) })
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# By class name, so a version still matches after its class is reloaded.
|
|
94
126
|
#: (untyped) -> Array[untyped]
|
|
95
127
|
def key(version)
|
|
96
128
|
[version.class.name, version.id]
|
|
@@ -37,10 +37,8 @@ module PaperTrailDiff
|
|
|
37
37
|
return without_selection if @selected.empty?
|
|
38
38
|
|
|
39
39
|
closing = revealing_version || @live_endpoint
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
contiguous_plan(closing)
|
|
40
|
+
plan = @filtered ? filtered_plan(closing) : contiguous_plan(closing)
|
|
41
|
+
closes_on_destroy?(closing) ? plan.closing_on_destroy : plan
|
|
44
42
|
end
|
|
45
43
|
|
|
46
44
|
private
|
|
@@ -69,13 +67,16 @@ module PaperTrailDiff
|
|
|
69
67
|
end
|
|
70
68
|
|
|
71
69
|
# An activity view still needs a root to reconstruct from even when no root
|
|
72
|
-
# version falls inside the window, because descendants may have moved.
|
|
70
|
+
# version falls inside the window, because descendants may have moved. With
|
|
71
|
+
# no later version, a window closing on current state reads the root from
|
|
72
|
+
# the live record, which is then its state throughout the window.
|
|
73
73
|
#: () -> RootVersionPlan
|
|
74
74
|
def without_selection
|
|
75
75
|
return RootVersionPlan.empty unless @context_required
|
|
76
76
|
if @after_range
|
|
77
77
|
return RootVersionPlan.contiguous([@after_range], context_version: @after_range)
|
|
78
78
|
end
|
|
79
|
+
return RootVersionPlan.closing_on(@live_endpoint) if @live_endpoint && @windowed
|
|
79
80
|
|
|
80
81
|
raise IncompleteTimeRangeError, INCOMPLETE
|
|
81
82
|
end
|
|
@@ -149,6 +150,16 @@ module PaperTrailDiff
|
|
|
149
150
|
immediate_successor(@selected.last) || @after_range
|
|
150
151
|
end
|
|
151
152
|
|
|
153
|
+
# A window with nothing after its last selected mutation can only close on
|
|
154
|
+
# that mutation being the record's destruction.
|
|
155
|
+
#: (untyped) -> bool
|
|
156
|
+
def closes_on_destroy?(closing)
|
|
157
|
+
return false if closing || !@windowed
|
|
158
|
+
raise IncompleteTimeRangeError, INCOMPLETE unless terminal_destroy?
|
|
159
|
+
|
|
160
|
+
true
|
|
161
|
+
end
|
|
162
|
+
|
|
152
163
|
# A range closing on the record's own destruction needs no later version:
|
|
153
164
|
# nothing can follow it, so demanding one would reject the range forever.
|
|
154
165
|
#: () -> bool
|
|
@@ -15,14 +15,29 @@ module PaperTrailDiff
|
|
|
15
15
|
class ScopedAnalysis
|
|
16
16
|
attr_reader :analyses #: Hash[Array[String], Analysis]
|
|
17
17
|
attr_reader :unreachable #: Array[Array[String]]
|
|
18
|
+
# The live records the selection loaded, in the order it loaded them. The
|
|
19
|
+
# gem already has them; withholding them only makes a caller query the same
|
|
20
|
+
# rows again to attach its own presentation data. A destroyed root has no
|
|
21
|
+
# record to hand back, so `roots` and `unreachable` stay complementary.
|
|
22
|
+
attr_reader :roots #: Array[untyped]
|
|
23
|
+
# Requested missing-root analyses, kept separate because live SQL conditions
|
|
24
|
+
# cannot prove which deleted rows matched. nil means no destroy history.
|
|
25
|
+
attr_reader :deleted #: Hash[Array[String], Analysis?]
|
|
18
26
|
|
|
19
|
-
#: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
|
|
20
|
-
def initialize(analyses:, unreachable:)
|
|
27
|
+
#: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]], ?roots: Array[untyped], ?deleted: Hash[Array[String], Analysis?]) -> void
|
|
28
|
+
def initialize(analyses:, unreachable:, roots: [], deleted: {})
|
|
21
29
|
@analyses = analyses
|
|
22
30
|
@unreachable = Support.immutable_copy(unreachable)
|
|
31
|
+
# Not deep-copied: these are the caller's own model instances, and
|
|
32
|
+
# freezing them would make an ordinary `record.title` on a lazy attribute
|
|
33
|
+
# fail. The array itself is frozen so the collection cannot be reshaped.
|
|
34
|
+
@roots = roots.dup.freeze
|
|
35
|
+
@deleted = Support.immutable_copy(deleted)
|
|
23
36
|
freeze
|
|
24
37
|
end
|
|
25
38
|
|
|
39
|
+
# Two elements, not three: `roots` is reached by name so that existing
|
|
40
|
+
# destructuring keeps working.
|
|
26
41
|
#: () -> [Hash[Array[String], Analysis], Array[Array[String]]]
|
|
27
42
|
def to_ary
|
|
28
43
|
[analyses, unreachable]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Selects both populations and checks their shared ceiling before analysis.
|
|
6
|
+
class ScopedAnalysisBuilder
|
|
7
|
+
#: (untyped, limit: Integer?, within: untyped, historical_filter: untyped, max_filter_versions: Integer, include_deleted: bool, analyzer: untyped, deleted_analyzer: untyped) -> void
|
|
8
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
|
+
scope, limit:, within:, historical_filter:, max_filter_versions:, include_deleted:,
|
|
10
|
+
analyzer:, deleted_analyzer:
|
|
11
|
+
)
|
|
12
|
+
raise ConfigurationError, 'limit: is required when selecting roots by scope' if limit.nil?
|
|
13
|
+
unless [true, false].include?(include_deleted)
|
|
14
|
+
raise ConfigurationError, 'include_deleted: must be true or false'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@selection = ScopedRootSelection.new(
|
|
18
|
+
scope, limit: limit, time_range: within.nil? ? nil : TimeRange.new(within),
|
|
19
|
+
historical_filter: historical_filter, max_filter_versions: max_filter_versions
|
|
20
|
+
)
|
|
21
|
+
@limit = limit
|
|
22
|
+
@include_deleted = include_deleted
|
|
23
|
+
@analyzer = analyzer
|
|
24
|
+
@deleted_analyzer = deleted_analyzer
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#: () -> ScopedAnalysis
|
|
28
|
+
def call
|
|
29
|
+
selection = @selection.call
|
|
30
|
+
if @include_deleted && selection.records.length + selection.unreachable.length > @limit
|
|
31
|
+
raise BatchLimitExceededError, 'include_deleted: live and missing roots exceed limit:'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
ScopedAnalysis.new(
|
|
35
|
+
analyses: @analyzer.call(selection.records), roots: selection.records,
|
|
36
|
+
unreachable: selection.unreachable,
|
|
37
|
+
deleted: @include_deleted ? @deleted_analyzer.call(selection.unreachable) : {}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @rbs @selection: ScopedRootSelection
|
|
42
|
+
# @rbs @limit: Integer
|
|
43
|
+
# @rbs @include_deleted: bool
|
|
44
|
+
# @rbs @analyzer: untyped
|
|
45
|
+
# @rbs @deleted_analyzer: untyped
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -26,6 +26,9 @@ module PaperTrailDiff
|
|
|
26
26
|
# window. `where(status: 'published')` selects what is published now, which is
|
|
27
27
|
# not the same set as what was published while the window was open.
|
|
28
28
|
class ScopedRootSelection
|
|
29
|
+
PAGE_SIZE = 250
|
|
30
|
+
private_constant :PAGE_SIZE
|
|
31
|
+
|
|
29
32
|
# A plain class rather than Data.define, which arrived in Ruby 3.2 while this
|
|
30
33
|
# gem supports 3.1.
|
|
31
34
|
class Result
|
|
@@ -40,16 +43,25 @@ module PaperTrailDiff
|
|
|
40
43
|
end
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
#: (untyped, time_range: TimeRange?, limit: Integer) -> void
|
|
44
|
-
def initialize(scope, time_range:, limit:
|
|
46
|
+
#: (untyped, time_range: TimeRange?, limit: Integer, ?historical_filter: untyped, ?max_filter_versions: Integer) -> void
|
|
47
|
+
def initialize(scope, time_range:, limit:, historical_filter: nil,
|
|
48
|
+
max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT)
|
|
45
49
|
@scope = normalize_scope(scope)
|
|
46
50
|
@time_range = time_range
|
|
47
51
|
@limit = validate_limit(limit)
|
|
52
|
+
@historical_filter = unless historical_filter.nil?
|
|
53
|
+
HistoricalFilter.new(base_class, predicate: historical_filter,
|
|
54
|
+
time_range: time_range, limit: @limit,
|
|
55
|
+
max_versions: max_filter_versions)
|
|
56
|
+
end
|
|
48
57
|
end
|
|
49
58
|
|
|
50
59
|
#: () -> Result
|
|
51
60
|
def call
|
|
61
|
+
return database_selection if @historical_filter.nil? && comparable_keys?
|
|
62
|
+
|
|
52
63
|
candidates = versioned_ids
|
|
64
|
+
candidates = @historical_filter.call(candidates) if @historical_filter && candidates.any?
|
|
53
65
|
return Result.new(records: [], unreachable: []) if candidates.empty?
|
|
54
66
|
|
|
55
67
|
live = live_ids(candidates)
|
|
@@ -64,6 +76,47 @@ module PaperTrailDiff
|
|
|
64
76
|
# @rbs @scope: untyped
|
|
65
77
|
# @rbs @time_range: TimeRange?
|
|
66
78
|
# @rbs @limit: Integer
|
|
79
|
+
# @rbs @historical_filter: untyped
|
|
80
|
+
|
|
81
|
+
# Leaves the candidate set in the database. Plucking every root changed in
|
|
82
|
+
# the window would cost memory and a statement proportional to all activity
|
|
83
|
+
# in the window -- not to the relation or `limit:` -- so a report on one
|
|
84
|
+
# record could read every id in a busy table. A subquery keeps the work
|
|
85
|
+
# bounded by what the relation and the deletions actually contain.
|
|
86
|
+
#: () -> Result
|
|
87
|
+
def database_selection
|
|
88
|
+
candidates = window_versions.select(:item_id)
|
|
89
|
+
Result.new(
|
|
90
|
+
records: selected_records(candidates),
|
|
91
|
+
unreachable: missing_ids.map { |id| identity(id) }.freeze
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Comparing the two columns in SQL needs them to share a type: PostgreSQL
|
|
96
|
+
# rejects an integer key against a string item_id outright.
|
|
97
|
+
#: () -> bool
|
|
98
|
+
def comparable_keys?
|
|
99
|
+
return false unless primary_key.is_a?(String)
|
|
100
|
+
|
|
101
|
+
item_id = version_class.columns_hash['item_id']&.type
|
|
102
|
+
key = base_class.columns_hash[primary_key]&.type
|
|
103
|
+
!item_id.nil? && item_id == key
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Changed in the window but without a row, found with an anti-join instead
|
|
107
|
+
# of comparing two plucked lists.
|
|
108
|
+
#: () -> Array[String]
|
|
109
|
+
def missing_ids
|
|
110
|
+
live = base_class.unscoped.select(primary_key)
|
|
111
|
+
window_versions.where.not(item_id: live).distinct.pluck(:item_id).map(&:to_s).uniq.sort
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
#: () -> untyped
|
|
115
|
+
def window_versions
|
|
116
|
+
relation = version_class.where(item_type: item_type)
|
|
117
|
+
range = @time_range
|
|
118
|
+
range ? range.scope(relation) : relation
|
|
119
|
+
end
|
|
67
120
|
|
|
68
121
|
# Accepts a model class as readily as a relation: `Article` and
|
|
69
122
|
# `Article.where(...)` both name a population, and `all` is what makes them
|
|
@@ -93,27 +146,67 @@ module PaperTrailDiff
|
|
|
93
146
|
# version table alone so that it does not depend on rows still existing.
|
|
94
147
|
#: () -> Array[String]
|
|
95
148
|
def versioned_ids
|
|
96
|
-
|
|
97
|
-
range = @time_range
|
|
98
|
-
relation = range.scope(relation) if range
|
|
99
|
-
relation.distinct.pluck(:item_id).map(&:to_s).uniq
|
|
149
|
+
window_versions.distinct.pluck(:item_id).map(&:to_s).uniq
|
|
100
150
|
end
|
|
101
151
|
|
|
102
152
|
# Which of those candidates still have a row, asked without the relation's
|
|
103
153
|
# conditions so that "filtered out" and "no longer exists" stay separable.
|
|
104
154
|
#: (Array[String]) -> Array[String]
|
|
105
155
|
def live_ids(candidates)
|
|
106
|
-
|
|
156
|
+
candidates.each_slice(PAGE_SIZE).flat_map do |slice|
|
|
157
|
+
base_class.unscoped.where(primary_key => slice).pluck(primary_key).map(&:to_s)
|
|
158
|
+
end
|
|
107
159
|
end
|
|
108
160
|
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
|
|
161
|
+
# Deduplicate records rather than adding SQL DISTINCT: an otherwise valid
|
|
162
|
+
# relation may order by joined columns or select a PostgreSQL json column.
|
|
163
|
+
# Read bounded pages until the relation ends or too many unique roots appear.
|
|
164
|
+
#: (untyped) -> Array[untyped]
|
|
112
165
|
def selected_records(live)
|
|
113
|
-
return [] if live.empty?
|
|
166
|
+
return [] if live.is_a?(Array) && live.empty?
|
|
167
|
+
|
|
168
|
+
records = {} #: Hash[untyped, untyped]
|
|
169
|
+
consumed = 0
|
|
170
|
+
loop do
|
|
171
|
+
page = record_page(live, consumed)
|
|
172
|
+
page.each { |record| records[record.id] ||= record }
|
|
173
|
+
reject_oversized!(records.length)
|
|
174
|
+
break if page.length < PAGE_SIZE
|
|
175
|
+
|
|
176
|
+
consumed += page.length
|
|
177
|
+
end
|
|
178
|
+
records.values.freeze
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
#: (untyped, Integer) -> Array[untyped]
|
|
182
|
+
def record_page(live, consumed)
|
|
183
|
+
count = page_limit(consumed)
|
|
184
|
+
return [] if count.zero?
|
|
185
|
+
|
|
186
|
+
# An id list or a candidate subquery; both read the same way in SQL.
|
|
187
|
+
conditions = { primary_key => live } #: Hash[untyped, untyped]
|
|
188
|
+
ordered_scope.where(conditions)
|
|
189
|
+
.offset((@scope.offset_value || 0) + consumed).limit(count).to_a
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Preserve explicit ordering: SQL Server rejects duplicate ORDER BY columns,
|
|
193
|
+
# even when the extra ordering would only have served as a tie-breaker.
|
|
194
|
+
#: () -> untyped
|
|
195
|
+
def ordered_scope
|
|
196
|
+
return @scope unless @scope.order_values.empty?
|
|
197
|
+
|
|
198
|
+
@scope.order(primary_key => :asc)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
#: (Integer) -> Integer
|
|
202
|
+
def page_limit(consumed)
|
|
203
|
+
remaining = @scope.limit_value
|
|
204
|
+
[remaining ? remaining - consumed : PAGE_SIZE, PAGE_SIZE].min
|
|
205
|
+
end
|
|
114
206
|
|
|
115
|
-
|
|
116
|
-
|
|
207
|
+
#: (Integer) -> void
|
|
208
|
+
def reject_oversized!(count)
|
|
209
|
+
return unless count > @limit
|
|
117
210
|
|
|
118
211
|
raise BatchLimitExceededError,
|
|
119
212
|
"scope: selected more than #{@limit} roots; narrow the window or the " \
|
|
@@ -171,7 +171,11 @@ module PaperTrailDiff
|
|
|
171
171
|
|
|
172
172
|
#: (type: String, id: untyped, attributes: snapshot_attributes, ?associations: snapshot_associations) -> void
|
|
173
173
|
def initialize(type:, id:, attributes:, associations: {})
|
|
174
|
-
|
|
174
|
+
unless type.is_a?(String)
|
|
175
|
+
raise ArgumentError, "record type must be a String, not #{type.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@type = Support.immutable_copy(type)
|
|
175
179
|
@id = Support.immutable_copy(id)
|
|
176
180
|
@identity = [@type, @id].freeze
|
|
177
181
|
@attributes = normalize_hash(attributes)
|
|
@@ -184,6 +188,15 @@ module PaperTrailDiff
|
|
|
184
188
|
RecordReference.new(type: type, id: id)
|
|
185
189
|
end
|
|
186
190
|
|
|
191
|
+
# A copy holding a different state for one association.
|
|
192
|
+
#: (String, AssociationSnapshot) -> RecordSnapshot
|
|
193
|
+
def with_association(name, association)
|
|
194
|
+
self.class.new(
|
|
195
|
+
type: type, id: id, attributes: attributes,
|
|
196
|
+
associations: associations.merge(name => association)
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
187
200
|
#: () -> Hash[Symbol, untyped]
|
|
188
201
|
def to_h
|
|
189
202
|
value = {
|