paper_trail_diff 0.11.0 → 0.13.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 +144 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1367
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +146 -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 +9 -37
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -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 +181 -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 +59 -24
- 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 +25 -2
- 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 +9 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
- 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 +22 -2
- data/lib/paper_trail_diff/time_version_range.rb +1 -15
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +25 -43
- 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/value_objects.rb +66 -0
- 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 +33 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +81 -43
- 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/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 +31 -18
- 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 +15 -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 +13 -0
- 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 +10 -14
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +37 -27
- metadata +31 -5
|
@@ -4,17 +4,21 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Batched record states and relationship candidates for one activity range.
|
|
6
6
|
class PreparedHistory
|
|
7
|
+
attr_reader :activity_index #: PreparedActivityIndex?
|
|
8
|
+
|
|
7
9
|
DIRECT_RESOLVERS = {
|
|
8
10
|
belongs_to: :resolve_belongs_to,
|
|
9
11
|
has_one: :resolve_children,
|
|
10
12
|
has_many: :resolve_children,
|
|
11
|
-
has_and_belongs_to_many: :
|
|
13
|
+
has_and_belongs_to_many: :habtm_records
|
|
12
14
|
}.freeze
|
|
13
15
|
private_constant :DIRECT_RESOLVERS
|
|
14
16
|
|
|
15
|
-
#: (PreparedRecordIndex) -> void
|
|
16
|
-
def initialize(records)
|
|
17
|
+
#: (PreparedRecordIndex, ?activity_index: PreparedActivityIndex?) -> void
|
|
18
|
+
def initialize(records, activity_index: nil)
|
|
17
19
|
@records = records
|
|
20
|
+
@state_reader = PreparedStateAssociationReader.new(records, method(:edge))
|
|
21
|
+
@activity_index = activity_index
|
|
18
22
|
@edges = {} #: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
|
|
19
23
|
@unsupported = {} #: Hash[Array[String], bool]
|
|
20
24
|
@habtm = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
|
|
@@ -24,7 +28,7 @@ module PaperTrailDiff
|
|
|
24
28
|
def merge_edge(owner_class, reflection, groups)
|
|
25
29
|
key = edge_key(owner_class, reflection)
|
|
26
30
|
empty = {} #: Hash[String, Hash[Symbol, untyped]]
|
|
27
|
-
@edges[key] =
|
|
31
|
+
@edges[key] = Support.merge_record_groups(@edges.fetch(key, empty), groups)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
#: (untyped, untyped) -> Hash[String, Hash[Symbol, untyped]]
|
|
@@ -48,6 +52,16 @@ module PaperTrailDiff
|
|
|
48
52
|
@records.transition(model_class, id, version)
|
|
49
53
|
end
|
|
50
54
|
|
|
55
|
+
#: (untyped, untyped, untyped, ?direct: bool) -> [bool, untyped]
|
|
56
|
+
def record_after(model_class, id, version, direct: false)
|
|
57
|
+
@records.record_after(model_class, id, version, direct: direct)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
|
|
61
|
+
def snapshot_states(owner, reflection, boundary)
|
|
62
|
+
@state_reader.call(owner, reflection, boundary)
|
|
63
|
+
end
|
|
64
|
+
|
|
51
65
|
#: (untyped, untyped, untyped, habtm_boundary: untyped) -> [bool, Array[untyped]]
|
|
52
66
|
def resolve(record, reflection, boundary, habtm_boundary:)
|
|
53
67
|
return [false, []] unless supported?(record.class, reflection)
|
|
@@ -63,6 +77,7 @@ module PaperTrailDiff
|
|
|
63
77
|
private
|
|
64
78
|
|
|
65
79
|
# @rbs @records: PreparedRecordIndex
|
|
80
|
+
# @rbs @state_reader: PreparedStateAssociationReader
|
|
66
81
|
# @rbs @edges: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
|
|
67
82
|
# @rbs @unsupported: Hash[Array[String], bool]
|
|
68
83
|
# @rbs @habtm: Hash[Array[String], Hash[String, Array[untyped]]]
|
|
@@ -70,12 +85,7 @@ module PaperTrailDiff
|
|
|
70
85
|
#: (untyped, untyped) -> bool
|
|
71
86
|
def supported?(owner_class, reflection)
|
|
72
87
|
!@unsupported[edge_key(owner_class, reflection)] &&
|
|
73
|
-
reflection.scope.nil? && !
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
#: (untyped) -> bool
|
|
77
|
-
def default_scoped?(model_class)
|
|
78
|
-
model_class.default_scopes?
|
|
88
|
+
reflection.scope.nil? && !reflection.klass.default_scopes?
|
|
79
89
|
end
|
|
80
90
|
|
|
81
91
|
#: (untyped, untyped, untyped, untyped) -> Array[untyped]?
|
|
@@ -94,11 +104,6 @@ module PaperTrailDiff
|
|
|
94
104
|
child_records(record, reflection, boundary)
|
|
95
105
|
end
|
|
96
106
|
|
|
97
|
-
#: (untyped, untyped, untyped, untyped) -> Array[untyped]?
|
|
98
|
-
def resolve_habtm(record, reflection, boundary, habtm_boundary)
|
|
99
|
-
habtm_records(record, reflection, boundary, habtm_boundary)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
107
|
#: (untyped, untyped, untyped) -> Array[untyped]?
|
|
103
108
|
def belongs_to_records(record, reflection, boundary)
|
|
104
109
|
foreign_key = scalar_foreign_key(reflection)
|
|
@@ -107,7 +112,7 @@ module PaperTrailDiff
|
|
|
107
112
|
id = record.public_send(foreign_key)
|
|
108
113
|
return [] if id.nil?
|
|
109
114
|
|
|
110
|
-
model_class =
|
|
115
|
+
model_class = AssociationTraversal.belongs_to_target(reflection, record)
|
|
111
116
|
return unless model_class && @records.loaded?(model_class, id)
|
|
112
117
|
|
|
113
118
|
Array(@records.record_before(model_class, id, boundary)).compact
|
|
@@ -189,15 +194,6 @@ module PaperTrailDiff
|
|
|
189
194
|
key.to_s unless key.is_a?(Array)
|
|
190
195
|
end
|
|
191
196
|
|
|
192
|
-
#: (untyped, untyped) -> untyped
|
|
193
|
-
def belongs_to_class(record, reflection)
|
|
194
|
-
return reflection.klass unless reflection.polymorphic?
|
|
195
|
-
|
|
196
|
-
Object.const_get(record.public_send(reflection.foreign_type).to_s)
|
|
197
|
-
rescue NameError
|
|
198
|
-
nil
|
|
199
|
-
end
|
|
200
|
-
|
|
201
197
|
#: (untyped, untyped) -> Array[String]
|
|
202
198
|
def owner_types(reflection, owner)
|
|
203
199
|
types = [owner.class.name, owner.class.base_class.name, reflection.active_record.name]
|
|
@@ -218,10 +214,5 @@ module PaperTrailDiff
|
|
|
218
214
|
def edge_key(owner_class, reflection)
|
|
219
215
|
[owner_class.base_class.name.to_s, reflection.name.to_s]
|
|
220
216
|
end
|
|
221
|
-
|
|
222
|
-
#: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
|
|
223
|
-
def merge_groups(existing, incoming)
|
|
224
|
-
Support.merge_record_groups(existing, incoming)
|
|
225
|
-
end
|
|
226
217
|
end
|
|
227
218
|
end
|
|
@@ -17,16 +17,21 @@ module PaperTrailDiff
|
|
|
17
17
|
)
|
|
18
18
|
@record = record
|
|
19
19
|
@root_ids = root_ids
|
|
20
|
+
@root_versions = root_versions
|
|
20
21
|
@tree = tree
|
|
21
22
|
@traversal = traversal
|
|
22
23
|
@records = PreparedRecordIndex.new(start_at, end_at: end_at, live_records: live_records)
|
|
23
|
-
|
|
24
|
+
activity = PreparedActivityIndex.new(
|
|
25
|
+
@records, root_versions, start_at: start_at, end_at: end_at || root_versions.last.created_at
|
|
26
|
+
)
|
|
27
|
+
@history = PreparedHistory.new(@records, activity_index: activity)
|
|
24
28
|
@edges = PreparedEdgeLoader.new(@records, root_versions, start_at: start_at)
|
|
25
29
|
@prepared = {} #: Hash[Array[String], Array[String]]
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
#: () -> PreparedHistory
|
|
29
33
|
def call
|
|
34
|
+
@records.seed(@record.class, @root_ids, @root_versions)
|
|
30
35
|
load_node(@record.class, @root_ids, @tree, path: '')
|
|
31
36
|
@history
|
|
32
37
|
end
|
|
@@ -35,6 +40,7 @@ module PaperTrailDiff
|
|
|
35
40
|
|
|
36
41
|
# @rbs @record: untyped
|
|
37
42
|
# @rbs @root_ids: Array[untyped]
|
|
43
|
+
# @rbs @root_versions: Array[untyped]
|
|
38
44
|
# @rbs @tree: AssociationTree
|
|
39
45
|
# @rbs @traversal: AssociationTraversal
|
|
40
46
|
# @rbs @records: PreparedRecordIndex
|
|
@@ -44,6 +50,7 @@ module PaperTrailDiff
|
|
|
44
50
|
|
|
45
51
|
#: (untyped, Array[untyped], AssociationTree, path: String) -> void
|
|
46
52
|
def load_node(model_class, ids, tree, path:)
|
|
53
|
+
@history.activity_index&.register(model_class, ids)
|
|
47
54
|
@traversal.reflections_for(model_class, tree, path: path).each do |reflection|
|
|
48
55
|
subtree = tree.child(reflection.name)
|
|
49
56
|
next unless subtree
|
|
@@ -119,7 +126,12 @@ module PaperTrailDiff
|
|
|
119
126
|
|
|
120
127
|
#: (Hash[String, Hash[Symbol, untyped]]) -> void
|
|
121
128
|
def load_groups(groups)
|
|
122
|
-
groups.each_value
|
|
129
|
+
groups.each_value do |group|
|
|
130
|
+
model = group.fetch(:model)
|
|
131
|
+
ids = group.fetch(:ids)
|
|
132
|
+
@history.activity_index&.register(model, ids)
|
|
133
|
+
load_records(model, ids)
|
|
134
|
+
end
|
|
123
135
|
end
|
|
124
136
|
|
|
125
137
|
#: (untyped, Array[untyped]) -> void
|
|
@@ -10,7 +10,7 @@ module PaperTrailDiff
|
|
|
10
10
|
#: (untyped) -> void
|
|
11
11
|
def initialize(record)
|
|
12
12
|
@model_class = record.class
|
|
13
|
-
@attributes =
|
|
13
|
+
@attributes = stored_attributes(record.class, record.attributes)
|
|
14
14
|
freeze
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -36,9 +36,18 @@ module PaperTrailDiff
|
|
|
36
36
|
#: (untyped, Hash[untyped, untyped]) -> void
|
|
37
37
|
def initialize_attributes(model_class, attributes)
|
|
38
38
|
@model_class = model_class
|
|
39
|
-
@attributes =
|
|
39
|
+
@attributes = stored_attributes(model_class, attributes)
|
|
40
40
|
freeze
|
|
41
41
|
end
|
|
42
|
+
|
|
43
|
+
# A live fallback and a version decoded from history must agree on what
|
|
44
|
+
# the stored history could represent, or the gap between them reads as a
|
|
45
|
+
# change.
|
|
46
|
+
#: (untyped, Hash[untyped, untyped]) -> Hash[untyped, untyped]
|
|
47
|
+
def stored_attributes(model_class, attributes)
|
|
48
|
+
digits = StoredTimePrecision.digits(model_class)
|
|
49
|
+
Support.immutable_copy(StoredTimePrecision.apply(attributes, digits))
|
|
50
|
+
end
|
|
42
51
|
end
|
|
43
52
|
|
|
44
53
|
# Extracts a version's scalar state without constructing a disposable AR object.
|
|
@@ -121,17 +130,37 @@ module PaperTrailDiff
|
|
|
121
130
|
|
|
122
131
|
#: (untyped) -> untyped
|
|
123
132
|
def record_before(boundary)
|
|
133
|
+
state_before(boundary)&.instantiate
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
#: (untyped) -> PreparedRecordState?
|
|
137
|
+
def state_before(boundary)
|
|
124
138
|
index = boundary_index(boundary)
|
|
125
|
-
return state_for(versions.fetch(index))
|
|
139
|
+
return state_for(versions.fetch(index)) if index
|
|
140
|
+
|
|
141
|
+
@live
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A handled nil is known absence, not permission to query the current row.
|
|
145
|
+
#: (untyped, ?direct: bool) -> [bool, untyped]
|
|
146
|
+
def record_after(version, direct: false)
|
|
147
|
+
index = @version_positions[version.id.to_s]
|
|
148
|
+
return [false, nil] unless index
|
|
126
149
|
|
|
127
|
-
|
|
150
|
+
state = state_after(index)
|
|
151
|
+
[true, direct ? state : state&.instantiate]
|
|
128
152
|
end
|
|
129
153
|
|
|
130
154
|
#: () -> Array[untyped]
|
|
131
155
|
def records
|
|
132
|
-
|
|
156
|
+
states.map(&:instantiate)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
#: () -> Array[PreparedRecordState]
|
|
160
|
+
def states
|
|
161
|
+
recorded = versions.filter_map { |version| state_for(version) }
|
|
133
162
|
live = @live
|
|
134
|
-
live ? [*
|
|
163
|
+
live ? [*recorded, live] : recorded
|
|
135
164
|
end
|
|
136
165
|
|
|
137
166
|
#: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
|
|
@@ -164,11 +193,15 @@ module PaperTrailDiff
|
|
|
164
193
|
# boundary's transaction. The first is monotonic over the chronological
|
|
165
194
|
# list and the second is indexed, so neither rescans a long history once
|
|
166
195
|
# per boundary.
|
|
196
|
+
#
|
|
197
|
+
# "At or after" is decided by the version order rather than the timestamp
|
|
198
|
+
# alone. A version written in the same instant as the boundary but before
|
|
199
|
+
# it already took effect there; reading its pre-change state would put the
|
|
200
|
+
# boundary on the wrong side of a change it follows.
|
|
167
201
|
#: (untyped) -> Integer?
|
|
168
202
|
def boundary_index(boundary)
|
|
169
|
-
created_at = boundary.created_at
|
|
170
203
|
chronological = versions.bsearch_index do |candidate|
|
|
171
|
-
candidate
|
|
204
|
+
VersionBoundary.at_or_after?(candidate, boundary)
|
|
172
205
|
end
|
|
173
206
|
transactional = transaction_index(boundary)
|
|
174
207
|
return chronological unless transactional
|
|
@@ -200,6 +233,7 @@ module PaperTrailDiff
|
|
|
200
233
|
#: (untyped) -> PreparedRecordState?
|
|
201
234
|
def state_for(version)
|
|
202
235
|
return @states[version.id] if @states.key?(version.id)
|
|
236
|
+
return @states[version.id] = nil unless version.object
|
|
203
237
|
|
|
204
238
|
prepared = @state_loader.call(version)
|
|
205
239
|
return @states[version.id] = prepared if prepared
|
|
@@ -226,6 +260,8 @@ module PaperTrailDiff
|
|
|
226
260
|
end
|
|
227
261
|
@state_loader = PreparedVersionStateLoader.new
|
|
228
262
|
@series = {} #: Hash[Array[String], PreparedRecordSeries]
|
|
263
|
+
@direct_loader = DirectRecordStateLoader.new
|
|
264
|
+
@direct_states = {} #: Hash[PreparedRecordState, PreparedRecordState?]
|
|
229
265
|
end
|
|
230
266
|
|
|
231
267
|
#: (untyped, Array[untyped]) -> void
|
|
@@ -240,21 +276,67 @@ module PaperTrailDiff
|
|
|
240
276
|
missing.each { |id| add_series(model_class, id, versions, live) }
|
|
241
277
|
end
|
|
242
278
|
|
|
279
|
+
#: (untyped, Array[untyped], Array[untyped]) -> void
|
|
280
|
+
def seed(model_class, ids, versions)
|
|
281
|
+
grouped = versions.group_by { |version| version.item_id.to_s }
|
|
282
|
+
live = available_live_records(model_class, ids).to_h { |record| [record.id.to_s, record] }
|
|
283
|
+
ids.each { |id| add_series(model_class, id, grouped, live) }
|
|
284
|
+
end
|
|
285
|
+
|
|
243
286
|
#: (untyped, untyped, untyped) -> untyped
|
|
244
287
|
def record_before(model_class, id, boundary)
|
|
245
288
|
@series.fetch(identity(model_class, id)).record_before(boundary)
|
|
246
289
|
end
|
|
247
290
|
|
|
291
|
+
#: (untyped, untyped, untyped) -> [bool, PreparedRecordState?]
|
|
292
|
+
def snapshot_state_before(model_class, id, boundary)
|
|
293
|
+
series = @series[identity(model_class, id)]
|
|
294
|
+
return [false, nil] unless series
|
|
295
|
+
|
|
296
|
+
state = series.state_before(boundary)
|
|
297
|
+
return [true, nil] unless state
|
|
298
|
+
|
|
299
|
+
@direct_states[state] = @direct_loader.complete(state) unless @direct_states.key?(state)
|
|
300
|
+
prepared = @direct_states[state]
|
|
301
|
+
[!prepared.nil?, prepared]
|
|
302
|
+
end
|
|
303
|
+
|
|
248
304
|
#: (untyped, untyped, untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
|
|
249
305
|
def transition(model_class, id, version)
|
|
250
306
|
@series[identity(model_class, id)]&.transition(version)
|
|
251
307
|
end
|
|
252
308
|
|
|
309
|
+
#: (untyped, untyped, untyped, ?direct: bool) -> [bool, untyped]
|
|
310
|
+
def record_after(model_class, id, version, direct: false)
|
|
311
|
+
handled, state = @series[identity(model_class, id)]&.record_after(version, direct: direct)
|
|
312
|
+
return [false, nil] unless handled
|
|
313
|
+
return [true, state] unless direct && state
|
|
314
|
+
|
|
315
|
+
prepared = @direct_loader.complete(state)
|
|
316
|
+
[!prepared.nil?, prepared]
|
|
317
|
+
end
|
|
318
|
+
|
|
253
319
|
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
254
320
|
def records_for(model_class, ids)
|
|
255
321
|
ids.flat_map { |id| @series.fetch(identity(model_class, id)).records }
|
|
256
322
|
end
|
|
257
323
|
|
|
324
|
+
#: (untyped, Array[untyped], String) -> Array[untyped]
|
|
325
|
+
def attribute_values_for(model_class, ids, name)
|
|
326
|
+
plain_reader = generated_reader?(model_class, name)
|
|
327
|
+
ids.flat_map do |id|
|
|
328
|
+
@series.fetch(identity(model_class, id)).states.map do |state|
|
|
329
|
+
direct = @direct_loader.complete(state) if plain_reader
|
|
330
|
+
direct ? direct.attributes[name] : state.instantiate.public_send(name)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
#: (untyped, untyped) -> Array[untyped]
|
|
336
|
+
def versions_for_id(model_class, id)
|
|
337
|
+
@series.fetch(identity(model_class, id)).versions
|
|
338
|
+
end
|
|
339
|
+
|
|
258
340
|
#: (untyped, untyped) -> bool
|
|
259
341
|
def loaded?(model_class, id)
|
|
260
342
|
@series.key?(identity(model_class, id))
|
|
@@ -262,11 +344,19 @@ module PaperTrailDiff
|
|
|
262
344
|
|
|
263
345
|
private
|
|
264
346
|
|
|
347
|
+
#: (untyped, String) -> bool
|
|
348
|
+
def generated_reader?(model, name)
|
|
349
|
+
model.define_attribute_methods
|
|
350
|
+
model.instance_method(name).owner.name.to_s.end_with?('GeneratedAttributeMethods')
|
|
351
|
+
end
|
|
352
|
+
|
|
265
353
|
# @rbs @start_time: untyped
|
|
266
354
|
# @rbs @end_time: untyped
|
|
267
355
|
# @rbs @seeded_live_records: Hash[Array[String], untyped]
|
|
268
356
|
# @rbs @state_loader: PreparedVersionStateLoader
|
|
269
357
|
# @rbs @series: Hash[Array[String], PreparedRecordSeries]
|
|
358
|
+
# @rbs @direct_loader: DirectRecordStateLoader
|
|
359
|
+
# @rbs @direct_states: Hash[PreparedRecordState, PreparedRecordState?]
|
|
270
360
|
|
|
271
361
|
# Callers bound the range with either a version or a bare timestamp.
|
|
272
362
|
#: (untyped) -> untyped
|
|
@@ -314,27 +404,10 @@ module PaperTrailDiff
|
|
|
314
404
|
#: (untyped) -> untyped
|
|
315
405
|
def window_condition(version_class)
|
|
316
406
|
table = version_class.arel_table
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def first_after_range(table)
|
|
322
|
-
arel = Object.const_get(:Arel) #: untyped
|
|
323
|
-
later = table.alias('paper_trail_diff_later_versions')
|
|
324
|
-
arel.const_get(:SelectManager).new
|
|
325
|
-
.from(later)
|
|
326
|
-
.project(arel.sql('1'))
|
|
327
|
-
.where(preceding_trailing_version(table, later))
|
|
328
|
-
.exists
|
|
329
|
-
.not
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
#: (untyped, untyped) -> untyped
|
|
333
|
-
def preceding_trailing_version(table, later)
|
|
334
|
-
later[:item_type].eq(table[:item_type])
|
|
335
|
-
.and(later[:item_id].eq(table[:item_id]))
|
|
336
|
-
.and(later[:created_at].gt(@end_time))
|
|
337
|
-
.and(later[:created_at].lt(table[:created_at]))
|
|
407
|
+
first_after = VersionQuery.no_earlier_version(table) do |earlier|
|
|
408
|
+
earlier[:created_at].gt(@end_time)
|
|
409
|
+
end
|
|
410
|
+
table[:created_at].lteq(@end_time).or(first_after)
|
|
338
411
|
end
|
|
339
412
|
|
|
340
413
|
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Normalizes supported recorded graphs without historical ActiveRecord instances.
|
|
6
|
+
class PreparedSnapshotBuilder
|
|
7
|
+
#: (AssociationTraversal) -> void
|
|
8
|
+
def initialize(traversal)
|
|
9
|
+
@traversal = traversal
|
|
10
|
+
@loader = DirectRecordStateLoader.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, history: PreparedHistory?) -> [bool, RecordSnapshot?]
|
|
14
|
+
def call(version, boundary, tree:, normalizer:, history:)
|
|
15
|
+
return [true, nil] unless version.object
|
|
16
|
+
|
|
17
|
+
state = @loader.call(version)
|
|
18
|
+
return [false, nil] unless state
|
|
19
|
+
|
|
20
|
+
build(state, boundary, tree, normalizer, history, path: '')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# @rbs @traversal: AssociationTraversal
|
|
26
|
+
# @rbs @loader: DirectRecordStateLoader
|
|
27
|
+
|
|
28
|
+
#: (PreparedRecordState, untyped, AssociationTree, SnapshotNormalizer, PreparedHistory?, path: String, ?incoming: untyped) -> [bool, RecordSnapshot?]
|
|
29
|
+
def build(state, boundary, tree, normalizer, history, path:, incoming: nil) # rubocop:disable Metrics/ParameterLists
|
|
30
|
+
associations = {} #: snapshot_associations
|
|
31
|
+
@traversal.reflections_for(state.model_class, tree, path: path).each do |reflection|
|
|
32
|
+
return [false, nil] unless history
|
|
33
|
+
|
|
34
|
+
handled, snapshot = association(state, reflection, boundary, tree, normalizer, history,
|
|
35
|
+
path)
|
|
36
|
+
return [false, nil] unless handled && snapshot
|
|
37
|
+
|
|
38
|
+
associations[reflection.name.to_s] = snapshot
|
|
39
|
+
end
|
|
40
|
+
[true, normalizer.from_state(state, tree: tree, path: path,
|
|
41
|
+
incoming: incoming, associations: associations)]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
#: (PreparedRecordState, untyped, untyped, AssociationTree, SnapshotNormalizer, PreparedHistory, String) -> [bool, AssociationSnapshot?]
|
|
45
|
+
def association(owner, reflection, boundary, tree, normalizer, history, path) # rubocop:disable Metrics/ParameterLists
|
|
46
|
+
handled, states = history.snapshot_states(owner, reflection, boundary)
|
|
47
|
+
return [false, nil] unless handled
|
|
48
|
+
|
|
49
|
+
subtree = tree.child(reflection.name)
|
|
50
|
+
return [false, nil] unless subtree
|
|
51
|
+
|
|
52
|
+
child_path = Support.association_path(path, reflection.name.to_s)
|
|
53
|
+
records = [] #: Array[RecordSnapshot]
|
|
54
|
+
states.each do |state|
|
|
55
|
+
supported, snapshot = build(state, boundary, subtree, normalizer, history,
|
|
56
|
+
path: child_path, incoming: reflection)
|
|
57
|
+
return [false, nil] unless supported && snapshot
|
|
58
|
+
|
|
59
|
+
records << snapshot
|
|
60
|
+
end
|
|
61
|
+
[true, AssociationSnapshot.new(kind: reflection.macro, records: records)]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -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,14 +49,24 @@ 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
|
|
@@ -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
|
|
@@ -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]
|