paper_trail_diff 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +287 -0
- data/QUICKSTART.md +9 -6
- data/README.md +84 -9
- data/docs/architecture.md +32 -9
- data/docs/performance.md +106 -13
- data/docs/reference.md +242 -29
- data/docs/testing.md +34 -11
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +19 -9
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +11 -1
- data/lib/paper_trail_diff/activity_collection_record_updater.rb +16 -2
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +20 -1
- data/lib/paper_trail_diff/activity_event_route_finder.rb +2 -1
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +1 -1
- data/lib/paper_trail_diff/activity_range.rb +14 -2
- data/lib/paper_trail_diff/activity_relationship.rb +12 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +5 -2
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +131 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +30 -24
- data/lib/paper_trail_diff/activity_version_collector.rb +31 -20
- data/lib/paper_trail_diff/association_activity_edge.rb +236 -0
- data/lib/paper_trail_diff/association_activity_selection.rb +197 -0
- data/lib/paper_trail_diff/association_discovery.rb +8 -13
- data/lib/paper_trail_diff/association_traversal.rb +169 -45
- data/lib/paper_trail_diff/batch_safety.rb +23 -15
- data/lib/paper_trail_diff/batched_root_versions.rb +21 -39
- data/lib/paper_trail_diff/comparison_batch.rb +37 -10
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +34 -16
- data/lib/paper_trail_diff/diagnostics.rb +26 -8
- data/lib/paper_trail_diff/direct_record_state_loader.rb +10 -1
- data/lib/paper_trail_diff/errors.rb +2 -1
- data/lib/paper_trail_diff/habtm_transaction_owners.rb +68 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +32 -2
- data/lib/paper_trail_diff/historical_snapshot_store.rb +6 -9
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +35 -2
- data/lib/paper_trail_diff/live_graph_collector.rb +1 -1
- data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -6
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +1 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +110 -36
- data/lib/paper_trail_diff/prepared_edge_loader.rb +29 -45
- data/lib/paper_trail_diff/prepared_history.rb +63 -54
- data/lib/paper_trail_diff/prepared_history_loader.rb +33 -43
- data/lib/paper_trail_diff/prepared_record_index.rb +26 -0
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +1 -1
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +119 -29
- data/lib/paper_trail_diff/recorded_changes.rb +83 -0
- data/lib/paper_trail_diff/recorded_class.rb +53 -0
- data/lib/paper_trail_diff/scope_condition.rb +331 -0
- data/lib/paper_trail_diff/scope_ranking.rb +196 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +4 -3
- data/lib/paper_trail_diff/scoped_root_selection.rb +93 -43
- data/lib/paper_trail_diff/snapshot_normalizer.rb +10 -3
- data/lib/paper_trail_diff/support.rb +9 -4
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -1
- data/lib/paper_trail_diff/time_range.rb +9 -12
- data/lib/paper_trail_diff/time_version_range.rb +5 -5
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +13 -3
- data/lib/paper_trail_diff/version_range.rb +8 -15
- data/lib/paper_trail_diff/version_scope_filter.rb +11 -3
- data/lib/paper_trail_diff.rb +31 -2
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +10 -2
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +5 -0
- data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +5 -0
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +7 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_relationship.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_target_candidate_loader.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +11 -5
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +10 -4
- data/sig/generated/paper_trail_diff/association_activity_edge.rbs +148 -0
- data/sig/generated/paper_trail_diff/association_activity_selection.rbs +112 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +3 -5
- data/sig/generated/paper_trail_diff/association_traversal.rbs +84 -9
- data/sig/generated/paper_trail_diff/batch_safety.rbs +10 -4
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +2 -8
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +22 -4
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +16 -4
- data/sig/generated/paper_trail_diff/diagnostics.rbs +7 -2
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +6 -0
- data/sig/generated/paper_trail_diff/errors.rbs +2 -1
- data/sig/generated/paper_trail_diff/habtm_transaction_owners.rbs +40 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +13 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +3 -2
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +17 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +52 -10
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +4 -12
- data/sig/generated/paper_trail_diff/prepared_history.rbs +33 -22
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +15 -13
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +15 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +56 -11
- data/sig/generated/paper_trail_diff/recorded_changes.rbs +47 -0
- data/sig/generated/paper_trail_diff/recorded_class.rbs +22 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +185 -0
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +117 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +41 -7
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +6 -0
- data/sig/generated/paper_trail_diff/support.rbs +5 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +4 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -4
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +7 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +4 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -0
- data/sig/generated/paper_trail_diff.rbs +21 -2
- metadata +20 -4
|
@@ -5,16 +5,147 @@ module PaperTrailDiff
|
|
|
5
5
|
# Resolves and validates ActiveRecord reflections for a bounded association tree.
|
|
6
6
|
class AssociationTraversal
|
|
7
7
|
# The class a `belongs_to` points at for this record, reading a polymorphic
|
|
8
|
-
# type column when there is one. Nil when
|
|
8
|
+
# type column when there is one. Nil when the record points at nothing.
|
|
9
9
|
#: (untyped, untyped) -> untyped
|
|
10
10
|
def self.belongs_to_target(reflection, record)
|
|
11
11
|
return reflection.klass unless reflection.polymorphic?
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
type = record.public_send(reflection.foreign_type).to_s
|
|
14
|
+
polymorphic_target(reflection, record, type) unless type.empty?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# A through with `source_type:` reaches only the join records whose type
|
|
18
|
+
# column names that class, as Active Record's `where(type => source_type)`.
|
|
19
|
+
# A through's `foreign_type` is its polymorphic source's.
|
|
20
|
+
#: (untyped, untyped) -> bool
|
|
21
|
+
def self.source_type_join?(reflection, join)
|
|
22
|
+
reflection.options[:source_type].nil? ||
|
|
23
|
+
source_type_member?(reflection, join.public_send(reflection.foreign_type))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The same check for a join row read as stored attributes, given the
|
|
27
|
+
# value of its type column.
|
|
28
|
+
#: (untyped, untyped) -> bool
|
|
29
|
+
def self.source_type_member?(reflection, type)
|
|
30
|
+
source_type = reflection.options[:source_type]
|
|
31
|
+
source_type.nil? || type == source_type.to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The class a stored type names, or nil when it names none.
|
|
35
|
+
# This class defines no constants of its own, so the one mutation left,
|
|
36
|
+
# `const_get` on it rather than on Object, finds the same class.
|
|
37
|
+
# mutant:disable
|
|
38
|
+
#: (String) -> untyped
|
|
39
|
+
def self.named_class(type)
|
|
40
|
+
Object.const_get(type)
|
|
14
41
|
rescue NameError
|
|
15
42
|
nil
|
|
16
43
|
end
|
|
17
44
|
|
|
45
|
+
# A type left by a renamed or removed model names no class. Active Record
|
|
46
|
+
# and PT-AT raise a bare NameError for it, which does not say where it is.
|
|
47
|
+
#: (untyped, untyped, String) -> untyped
|
|
48
|
+
def self.polymorphic_target(reflection, record, type)
|
|
49
|
+
model_class = named_class(type)
|
|
50
|
+
return model_class if model_class
|
|
51
|
+
|
|
52
|
+
raise IncompleteAssociationHistoryError,
|
|
53
|
+
"association #{record.class.name}##{reflection.name} cannot be read: " \
|
|
54
|
+
"#{record.class.name} #{record.id} has #{reflection.foreign_type} #{type}, which " \
|
|
55
|
+
'names no class. If the model was renamed, replace the old name in that column ' \
|
|
56
|
+
'and in the versions that recorded it.'
|
|
57
|
+
end
|
|
58
|
+
private_class_method :polymorphic_target
|
|
59
|
+
|
|
60
|
+
# Why a request cannot name this association of the model, or nil when it
|
|
61
|
+
# can; `root` says whether the model is the record the request compares.
|
|
62
|
+
# Discovery lists only the associations this accepts, so every path it
|
|
63
|
+
# lists can be requested. A has_many :through Active Record cannot follow
|
|
64
|
+
# raises Active Record's own error.
|
|
65
|
+
#: (untyped, untyped, root: bool) -> String?
|
|
66
|
+
def self.unsupported_reason(model_class, reflection, root:)
|
|
67
|
+
return 'PaperTrail versions' if paper_trail_versions?(model_class, reflection)
|
|
68
|
+
return reflection.macro.to_s unless SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro)
|
|
69
|
+
return habtm_reason(reflection, root) if reflection.macro == :has_and_belongs_to_many
|
|
70
|
+
|
|
71
|
+
habtm = habtm_in_chain(reflection)
|
|
72
|
+
return unless habtm
|
|
73
|
+
|
|
74
|
+
"it goes through #{habtm.active_record.name}##{habtm.name}, a has_and_belongs_to_many, " \
|
|
75
|
+
'whose hidden join model has no history to compare'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# PT-AT records a HABTM's membership only when its owner saves a version,
|
|
79
|
+
# in rows keyed by transaction and association name, not by owner. A root
|
|
80
|
+
# checkpoint so holds the root's membership and no one else's: another
|
|
81
|
+
# record's HABTM would read the root's rows, or none.
|
|
82
|
+
#: (untyped) -> String
|
|
83
|
+
def self.habtm_below_root(reflection)
|
|
84
|
+
"#{reflection.active_record.name}##{reflection.name} is a has_and_belongs_to_many " \
|
|
85
|
+
'below the root, and a root checkpoint records HABTM membership for the root alone'
|
|
86
|
+
end
|
|
87
|
+
private_class_method :habtm_below_root
|
|
88
|
+
|
|
89
|
+
# PT-AT records a HABTM's membership by loading the association, scope and
|
|
90
|
+
# target default scope applied, so a target outside the scope when the
|
|
91
|
+
# owner saved is never recorded. One that enters it before the next save
|
|
92
|
+
# is a member then that history cannot hold.
|
|
93
|
+
#: (untyped, bool) -> String?
|
|
94
|
+
def self.habtm_reason(reflection, root)
|
|
95
|
+
return habtm_below_root(reflection) unless root
|
|
96
|
+
return unless ScopeCondition.scoped?(reflection)
|
|
97
|
+
|
|
98
|
+
"#{reflection.active_record.name}##{reflection.name} is a scoped " \
|
|
99
|
+
'has_and_belongs_to_many, and PaperTrail records only the members its scope ' \
|
|
100
|
+
'admitted when the owner saved, so membership between saves cannot be rebuilt; ' \
|
|
101
|
+
'replace it with a has_many :through a versioned join model'
|
|
102
|
+
end
|
|
103
|
+
private_class_method :habtm_reason
|
|
104
|
+
|
|
105
|
+
#: (untyped, untyped) -> bool
|
|
106
|
+
def self.paper_trail_versions?(model_class, reflection)
|
|
107
|
+
model_class.respond_to?(:versions_association_name) &&
|
|
108
|
+
reflection.name.to_s == model_class.versions_association_name.to_s
|
|
109
|
+
end
|
|
110
|
+
private_class_method :paper_trail_versions?
|
|
111
|
+
|
|
112
|
+
# Active Record joins a HABTM through a hidden model of its own, which has
|
|
113
|
+
# no versions and no reader that PT-AT or prepared history can follow, so a
|
|
114
|
+
# through that crosses one would fail partway through reconstruction.
|
|
115
|
+
#
|
|
116
|
+
# The through and source reflections of a through are the hidden ones, a
|
|
117
|
+
# has_many in place of each HABTM, so only the public reflection of the
|
|
118
|
+
# same name shows the HABTM. Active Record's own check, which it runs on
|
|
119
|
+
# first use anyway, explains a through it cannot follow before the chain
|
|
120
|
+
# below it is read.
|
|
121
|
+
#: (untyped) -> untyped
|
|
122
|
+
def self.habtm_in_chain(reflection)
|
|
123
|
+
return unless reflection.through_reflection?
|
|
124
|
+
|
|
125
|
+
reflection.check_validity!
|
|
126
|
+
parts = [reflection.through_reflection, reflection.source_reflection]
|
|
127
|
+
parts.filter_map do |part|
|
|
128
|
+
link = part.active_record.reflect_on_association(part.name)
|
|
129
|
+
link.macro == :has_and_belongs_to_many ? link : habtm_in_chain(link)
|
|
130
|
+
end.first
|
|
131
|
+
end
|
|
132
|
+
private_class_method :habtm_in_chain
|
|
133
|
+
|
|
134
|
+
attr_reader :tree #: AssociationTree
|
|
135
|
+
|
|
136
|
+
# Whether a row belongs to an association whose class is `model_class`.
|
|
137
|
+
# Children are found by key across the whole table, so an association to an
|
|
138
|
+
# STI subclass has to leave out its siblings, as Active Record's type
|
|
139
|
+
# condition does for the live association: the row's type must name the
|
|
140
|
+
# class or one of its descendants. A base class takes every row.
|
|
141
|
+
#: (untyped, Hash[String, untyped]) -> bool
|
|
142
|
+
def self.sti_member?(model_class, attributes)
|
|
143
|
+
return true if model_class.descends_from_active_record?
|
|
144
|
+
|
|
145
|
+
names = [model_class, *model_class.descendants].map(&:sti_name)
|
|
146
|
+
names.include?(attributes[model_class.inheritance_column])
|
|
147
|
+
end
|
|
148
|
+
|
|
18
149
|
#: (AssociationTree) -> void
|
|
19
150
|
def initialize(tree)
|
|
20
151
|
@tree = tree
|
|
@@ -60,24 +191,23 @@ module PaperTrailDiff
|
|
|
60
191
|
raise UnsupportedLiveActivityError, message
|
|
61
192
|
end
|
|
62
193
|
|
|
194
|
+
# A model and a tree resolve to the same reflections wherever they are
|
|
195
|
+
# read: a tree is only ever walked from one place, the root tree at the
|
|
196
|
+
# root and a subtree below it. Beyond that, the path only names a failure,
|
|
197
|
+
# and a failure is not cached.
|
|
63
198
|
#: (untyped, AssociationTree, path: String) -> Array[untyped]
|
|
64
199
|
def reflections_for(model_class, tree, path:)
|
|
65
|
-
|
|
66
|
-
@reflection_cache[key] ||= requested_reflections(model_class, tree, path)
|
|
200
|
+
@reflection_cache[[model_class, tree]] ||= requested_reflections(model_class, tree, path)
|
|
67
201
|
end
|
|
68
202
|
|
|
203
|
+
# Active Record names keys as strings. A polymorphic child also points back
|
|
204
|
+
# with its type column; `type` is nil for any other.
|
|
69
205
|
#: (untyped) -> Array[String]
|
|
70
206
|
def incoming_relationship_columns(reflection)
|
|
71
|
-
direct = %i[has_one has_many].include?(reflection.macro) && !reflection.
|
|
207
|
+
direct = %i[has_one has_many].include?(reflection.macro) && !reflection.through_reflection?
|
|
72
208
|
return [] unless direct
|
|
73
209
|
|
|
74
|
-
|
|
75
|
-
# @type var foreign_keys: Array[untyped]
|
|
76
|
-
columns = foreign_keys.map do |column| # rubocop:disable Style/SymbolProc
|
|
77
|
-
column.to_s
|
|
78
|
-
end
|
|
79
|
-
columns << reflection.type.to_s if reflection.options[:as]
|
|
80
|
-
columns
|
|
210
|
+
Array(reflection.foreign_key) + Array(reflection.type)
|
|
81
211
|
end
|
|
82
212
|
|
|
83
213
|
private
|
|
@@ -88,11 +218,12 @@ module PaperTrailDiff
|
|
|
88
218
|
#: (untyped, AssociationTree, path: String) -> Array[Array[untyped]]
|
|
89
219
|
def collect_reflections(model_class, tree, path:)
|
|
90
220
|
reflections_for(model_class, tree, path: path).flat_map do |reflection|
|
|
91
|
-
child_path =
|
|
221
|
+
child_path = Support.association_path(path, reflection.name)
|
|
92
222
|
result = [[child_path, reflection]]
|
|
93
|
-
|
|
94
|
-
next result unless subtree && !subtree.empty? && !reflection.polymorphic?
|
|
223
|
+
next result if reflection.polymorphic?
|
|
95
224
|
|
|
225
|
+
# A leaf's subtree is empty and adds nothing.
|
|
226
|
+
subtree = tree.child(reflection.name) #: AssociationTree
|
|
96
227
|
result + collect_reflections(reflection.klass, subtree, path: child_path)
|
|
97
228
|
end
|
|
98
229
|
end
|
|
@@ -100,55 +231,48 @@ module PaperTrailDiff
|
|
|
100
231
|
#: (untyped, AssociationTree, path: String) -> void
|
|
101
232
|
def validate_tree!(model_class, tree, path:)
|
|
102
233
|
reflections_for(model_class, tree, path: path).each do |reflection|
|
|
103
|
-
subtree = tree.child(reflection.name)
|
|
104
|
-
next
|
|
234
|
+
subtree = tree.child(reflection.name) #: AssociationTree
|
|
235
|
+
next if subtree.empty?
|
|
105
236
|
|
|
106
|
-
child_path =
|
|
237
|
+
child_path = Support.association_path(path, reflection.name)
|
|
238
|
+
reject_polymorphic_descent!(reflection, subtree, child_path)
|
|
107
239
|
validate_tree!(reflection.klass, subtree, path: child_path)
|
|
108
240
|
end
|
|
109
241
|
end
|
|
110
242
|
|
|
243
|
+
# A polymorphic target's class differs from record to record, so the
|
|
244
|
+
# associations a path names below it may not exist on every target.
|
|
245
|
+
#: (untyped, AssociationTree, String) -> void
|
|
246
|
+
def reject_polymorphic_descent!(reflection, subtree, path)
|
|
247
|
+
return unless reflection.polymorphic?
|
|
248
|
+
|
|
249
|
+
raise UnsupportedAssociationError,
|
|
250
|
+
"unsupported association #{subtree.paths(prefix: path).first}: " \
|
|
251
|
+
"#{path} is polymorphic, so a path cannot continue past it"
|
|
252
|
+
end
|
|
253
|
+
|
|
111
254
|
#: (untyped, AssociationTree, String) -> Array[untyped]
|
|
112
255
|
def requested_reflections(model_class, tree, path)
|
|
113
256
|
tree.children.map do |name, _subtree|
|
|
114
|
-
resolve_reflection(model_class, name,
|
|
257
|
+
resolve_reflection(model_class, name, Support.association_path(path, name),
|
|
258
|
+
root: path.empty?)
|
|
115
259
|
end.freeze
|
|
116
260
|
end
|
|
117
261
|
|
|
118
|
-
#: (untyped, String, String) -> untyped
|
|
119
|
-
def resolve_reflection(model_class, name, full_path)
|
|
120
|
-
reflection = model_class.reflect_on_association(name
|
|
262
|
+
#: (untyped, String, String, root: bool) -> untyped
|
|
263
|
+
def resolve_reflection(model_class, name, full_path, root:)
|
|
264
|
+
reflection = model_class.reflect_on_association(name)
|
|
121
265
|
raise UnknownAssociationError, "unknown association: #{full_path}" unless reflection
|
|
122
266
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
raise UnsupportedAssociationError, message
|
|
126
|
-
end
|
|
127
|
-
unless SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro)
|
|
128
|
-
raise UnsupportedAssociationError,
|
|
129
|
-
"unsupported association #{full_path}: #{reflection.macro}"
|
|
130
|
-
end
|
|
267
|
+
reason = self.class.unsupported_reason(model_class, reflection, root: root)
|
|
268
|
+
raise UnsupportedAssociationError, "unsupported association #{full_path}: #{reason}" if reason
|
|
131
269
|
|
|
132
270
|
reflection
|
|
133
271
|
end
|
|
134
272
|
|
|
135
|
-
#: (untyped, untyped) -> bool
|
|
136
|
-
def paper_trail_versions?(model_class, reflection)
|
|
137
|
-
model_class.respond_to?(:versions_association_name) &&
|
|
138
|
-
reflection.name.to_s == model_class.versions_association_name.to_s
|
|
139
|
-
end
|
|
140
|
-
|
|
141
273
|
#: (untyped) -> bool
|
|
142
274
|
def habtm_reflection?(reflection)
|
|
143
|
-
|
|
144
|
-
return false unless reflection.options[:through]
|
|
145
|
-
|
|
146
|
-
habtm_reflection?(reflection.through_reflection)
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
#: (String, String) -> String
|
|
150
|
-
def join_path(parent, name)
|
|
151
|
-
parent.empty? ? name : "#{parent}.#{name}"
|
|
275
|
+
reflection.macro == :has_and_belongs_to_many
|
|
152
276
|
end
|
|
153
277
|
end
|
|
154
278
|
end
|
|
@@ -14,12 +14,12 @@ module PaperTrailDiff
|
|
|
14
14
|
@allow_expensive = allow_expensive
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
#: (untyped
|
|
18
|
-
def call(model
|
|
17
|
+
#: (untyped) -> void
|
|
18
|
+
def call(model)
|
|
19
19
|
return if @allow_expensive
|
|
20
20
|
|
|
21
21
|
@traversal.selected_reflections(model).each do |path, reflection|
|
|
22
|
-
check(reflection, path
|
|
22
|
+
check(reflection, path)
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
@@ -28,28 +28,36 @@ module PaperTrailDiff
|
|
|
28
28
|
# @rbs @traversal: AssociationTraversal
|
|
29
29
|
# @rbs @allow_expensive: bool
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
# A has_many through reads its parts' conditions only, at any depth. A
|
|
32
|
+
# has_one through over a ranked part is refused whole, so its parts, too,
|
|
33
|
+
# are checked for conditions alone.
|
|
34
|
+
#: (untyped, String, ?ranked: bool) -> void
|
|
35
|
+
def check(reflection, path, ranked: true)
|
|
36
|
+
reason = reason_for(reflection, ranked)
|
|
34
37
|
if reason
|
|
35
38
|
raise UnsupportedBatchPathError,
|
|
36
39
|
"association #{path} requires per-record reconstruction (#{reason}); " \
|
|
37
40
|
'remove this path or explicitly pass allow_expensive: true'
|
|
38
41
|
end
|
|
39
|
-
return unless reflection.
|
|
42
|
+
return unless reflection.through_reflection?
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
[reflection.through_reflection, reflection.source_reflection].each do |part|
|
|
45
|
+
check(part, path, ranked: false)
|
|
46
|
+
end
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
# A scope prepared history can read as conditions on stored attributes
|
|
50
|
+
# is batched like any other path. A polymorphic target's class, and so
|
|
51
|
+
# its scope, is known only per record.
|
|
45
52
|
#: (untyped, bool) -> String?
|
|
46
|
-
def reason_for(reflection,
|
|
47
|
-
return 'association scope' if reflection.scope
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
def reason_for(reflection, ranked)
|
|
54
|
+
return ('association scope' if reflection.scope) if reflection.polymorphic?
|
|
55
|
+
if ranked && ScopeCondition.ranked_through?(reflection)
|
|
56
|
+
return 'limit, offset or has_one order inside a has_one through'
|
|
57
|
+
end
|
|
58
|
+
return if ScopeCondition.build(reflection, ranked: ranked)
|
|
51
59
|
|
|
52
|
-
'
|
|
60
|
+
reflection.scope ? 'association scope' : 'default-scoped target'
|
|
53
61
|
end
|
|
54
62
|
end
|
|
55
63
|
end
|
|
@@ -6,9 +6,8 @@ module PaperTrailDiff
|
|
|
6
6
|
# queries. Only the range forms that mean the same thing for every root are
|
|
7
7
|
# supported: a shared wall-clock window, or each root's own whole history.
|
|
8
8
|
class BatchedRootVersions
|
|
9
|
-
#: (Array[untyped], time_range: TimeRange?,
|
|
10
|
-
def initialize(records, time_range:, version_scope
|
|
11
|
-
context_required: false)
|
|
9
|
+
#: (Array[untyped], time_range: TimeRange?, version_scope: untyped, live_endpoints: Hash[Array[String], untyped]?, context_required: bool) -> void
|
|
10
|
+
def initialize(records, time_range:, version_scope:, live_endpoints:, context_required:)
|
|
12
11
|
@records = records
|
|
13
12
|
# An activity view needs a root to reconstruct from even in a window with
|
|
14
13
|
# no root version, exactly as a single-record activity timeline does.
|
|
@@ -21,8 +20,6 @@ module PaperTrailDiff
|
|
|
21
20
|
# Returns a plan per record identity.
|
|
22
21
|
#: () -> Hash[Array[String], RootVersionPlan]
|
|
23
22
|
def call
|
|
24
|
-
return {} if @records.empty?
|
|
25
|
-
|
|
26
23
|
selected = {} #: Hash[Array[String], RootVersionPlan]
|
|
27
24
|
@records.group_by(&:class).each do |model_class, records|
|
|
28
25
|
select_model(model_class, records, selected)
|
|
@@ -40,10 +37,9 @@ module PaperTrailDiff
|
|
|
40
37
|
|
|
41
38
|
#: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
|
|
42
39
|
def select_model(model_class, records, selected)
|
|
43
|
-
|
|
44
|
-
in_range, chosen, trailing = model_versions(model_class, ids)
|
|
40
|
+
in_range, chosen, trailing = model_versions(model_class, records)
|
|
45
41
|
records.each do |record|
|
|
46
|
-
key = identity(
|
|
42
|
+
key = Endpoint.identity(record)
|
|
47
43
|
selected[key] = versions_for(
|
|
48
44
|
in_range.fetch(record.id.to_s, []), chosen, trailing[record.id.to_s],
|
|
49
45
|
live_endpoint(key, record)
|
|
@@ -52,13 +48,13 @@ module PaperTrailDiff
|
|
|
52
48
|
end
|
|
53
49
|
|
|
54
50
|
#: (untyped, Array[untyped]) -> [Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped]]
|
|
55
|
-
def model_versions(model_class,
|
|
51
|
+
def model_versions(model_class, records)
|
|
56
52
|
range = @time_range
|
|
57
53
|
empty = {} #: Hash[String, untyped]
|
|
58
54
|
[
|
|
59
|
-
grouped_versions(model_class,
|
|
60
|
-
chosen_version_ids(model_class,
|
|
61
|
-
range ? trailing_versions(model_class,
|
|
55
|
+
grouped_versions(model_class, records, range),
|
|
56
|
+
chosen_version_ids(model_class, records, range),
|
|
57
|
+
range ? trailing_versions(model_class, records, range) : empty
|
|
62
58
|
]
|
|
63
59
|
end
|
|
64
60
|
|
|
@@ -89,48 +85,34 @@ module PaperTrailDiff
|
|
|
89
85
|
# One extra query names the selected mutations without discarding the
|
|
90
86
|
# unfiltered versions the successor lookup still needs.
|
|
91
87
|
#: (untyped, Array[untyped], TimeRange?) -> Set[untyped]?
|
|
92
|
-
def chosen_version_ids(model_class,
|
|
93
|
-
|
|
94
|
-
return unless scope
|
|
95
|
-
|
|
96
|
-
# A narrowed relation is the expected return. Active Support also gives
|
|
97
|
-
# `pluck` to plain enumerables, so an array of versions works too.
|
|
98
|
-
filtered = scope.call(range_scope(model_class, ids, range))
|
|
99
|
-
Set.new(filtered.pluck(:id))
|
|
88
|
+
def chosen_version_ids(model_class, records, range)
|
|
89
|
+
VersionScopeFilter.new(@version_scope).chosen_ids(range_scope(model_class, records, range))
|
|
100
90
|
end
|
|
101
91
|
|
|
102
92
|
#: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
|
|
103
|
-
def grouped_versions(model_class,
|
|
104
|
-
|
|
93
|
+
def grouped_versions(model_class, records, range)
|
|
94
|
+
VersionQuery.chronological(range_scope(model_class, records, range))
|
|
95
|
+
.group_by { |version| version.item_id.to_s }
|
|
105
96
|
end
|
|
106
97
|
|
|
107
98
|
#: (untyped, Array[untyped], TimeRange?) -> untyped
|
|
108
|
-
def range_scope(model_class,
|
|
109
|
-
scope = base_scope(model_class,
|
|
99
|
+
def range_scope(model_class, records, range)
|
|
100
|
+
scope = base_scope(model_class, records)
|
|
110
101
|
range ? range.scope(scope) : scope
|
|
111
102
|
end
|
|
112
103
|
|
|
113
104
|
#: (untyped, Array[untyped], TimeRange) -> Hash[String, untyped]
|
|
114
|
-
def trailing_versions(model_class,
|
|
115
|
-
range.first_trailing_versions(base_scope(model_class,
|
|
105
|
+
def trailing_versions(model_class, records, range)
|
|
106
|
+
range.first_trailing_versions(base_scope(model_class, records))
|
|
116
107
|
end
|
|
117
108
|
|
|
118
109
|
#: (untyped, Array[untyped]) -> untyped
|
|
119
|
-
def base_scope(model_class,
|
|
110
|
+
def base_scope(model_class, records)
|
|
111
|
+
# Active Record reads each record's id.
|
|
120
112
|
model_class.paper_trail.version_class.where(
|
|
121
|
-
item_type: model_class.base_class.name
|
|
122
|
-
item_id:
|
|
113
|
+
item_type: model_class.base_class.name,
|
|
114
|
+
item_id: records
|
|
123
115
|
)
|
|
124
116
|
end
|
|
125
|
-
|
|
126
|
-
#: (untyped) -> Array[untyped]
|
|
127
|
-
def ordered(relation)
|
|
128
|
-
VersionQuery.chronological(relation).to_a
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
#: (untyped, untyped) -> Array[String]
|
|
132
|
-
def identity(model_class, id)
|
|
133
|
-
[model_class.base_class.name.to_s, id.to_s]
|
|
134
|
-
end
|
|
135
117
|
end
|
|
136
118
|
end
|
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Validates and executes independent comparisons with shared live endpoint loading.
|
|
6
6
|
class ComparisonBatch
|
|
7
|
-
|
|
7
|
+
# `history_preparer` prepares roots that have a live endpoint to collect a
|
|
8
|
+
# graph from; `version_history_preparer` prepares roots compared only
|
|
9
|
+
# between versions.
|
|
10
|
+
#: (Array[comparison_input], live_loader: untyped, preparer: untyped, history_preparer: untyped, version_history_preparer: untyped, historical_snapshotter: untyped, live_normalizer: untyped) -> void
|
|
8
11
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
12
|
comparisons,
|
|
10
13
|
live_loader:,
|
|
11
14
|
preparer:,
|
|
12
15
|
history_preparer:,
|
|
16
|
+
version_history_preparer:,
|
|
13
17
|
historical_snapshotter:,
|
|
14
18
|
live_normalizer:
|
|
15
19
|
)
|
|
@@ -17,6 +21,7 @@ module PaperTrailDiff
|
|
|
17
21
|
@live_loader = live_loader
|
|
18
22
|
@preparer = preparer
|
|
19
23
|
@history_preparer = history_preparer
|
|
24
|
+
@version_history_preparer = version_history_preparer
|
|
20
25
|
@historical_snapshotter = historical_snapshotter
|
|
21
26
|
@live_normalizer = live_normalizer
|
|
22
27
|
end
|
|
@@ -35,6 +40,7 @@ module PaperTrailDiff
|
|
|
35
40
|
# @rbs @live_loader: untyped
|
|
36
41
|
# @rbs @preparer: untyped
|
|
37
42
|
# @rbs @history_preparer: untyped
|
|
43
|
+
# @rbs @version_history_preparer: untyped
|
|
38
44
|
# @rbs @historical_snapshotter: untyped
|
|
39
45
|
# @rbs @live_normalizer: untyped
|
|
40
46
|
|
|
@@ -97,20 +103,41 @@ module PaperTrailDiff
|
|
|
97
103
|
|
|
98
104
|
#: (Array[[untyped, untyped]], Hash[identity, untyped]) -> void
|
|
99
105
|
def prepare_historical_batches!(pairs, live_records)
|
|
100
|
-
|
|
101
|
-
Endpoint.
|
|
106
|
+
loaded, unloaded = historical_versions(pairs).partition do |version|
|
|
107
|
+
live_records.key?(Endpoint.identity(version))
|
|
102
108
|
end
|
|
103
|
-
|
|
109
|
+
loaded.group_by { |version| RecordedClass.for(version) }.each_value do |versions|
|
|
104
110
|
records = versions.map { |version| live_records.fetch(Endpoint.identity(version)) }.uniq
|
|
105
111
|
@history_preparer.call(records, versions)
|
|
106
112
|
end
|
|
113
|
+
prepare_version_roots!(unloaded)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A root compared only between versions has no live endpoint, and its row
|
|
117
|
+
# may be gone. Without prepared history, PT-AT finds a has_many's children
|
|
118
|
+
# by the keys each version recorded after its change, so it misses a child
|
|
119
|
+
# that later moved to another owner. Such roots are prepared from identity
|
|
120
|
+
# alone, like deleted ones. Their history still reads the live rows it
|
|
121
|
+
# needs: a record's current row is its state after its last version.
|
|
122
|
+
#: (Array[untyped]) -> void
|
|
123
|
+
def prepare_version_roots!(versions)
|
|
124
|
+
versions.group_by { |version| RecordedClass.for(version) }.each do |model, group|
|
|
125
|
+
ids = group.map(&:item_id).uniq(&:to_s)
|
|
126
|
+
@version_history_preparer.call(ids.map { |id| historical_root(model, id) }, group)
|
|
127
|
+
end
|
|
107
128
|
end
|
|
108
129
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
130
|
+
# The record supplies identity and model metadata only.
|
|
131
|
+
#: (untyped, untyped) -> untyped
|
|
132
|
+
def historical_root(model, id)
|
|
133
|
+
record = model.new
|
|
134
|
+
record[model.primary_key] = id
|
|
135
|
+
record
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
#: (Array[[untyped, untyped]]) -> Array[untyped]
|
|
139
|
+
def historical_versions(pairs)
|
|
140
|
+
selected = pairs.flatten.select { |endpoint| Endpoint.version?(endpoint) }
|
|
114
141
|
selected.uniq { |version| [version.class.name, version.id] }
|
|
115
142
|
end
|
|
116
143
|
|
|
@@ -167,7 +194,7 @@ module PaperTrailDiff
|
|
|
167
194
|
requirements = {} #: Hash[untyped, bool]
|
|
168
195
|
pairs.flatten.each_with_object(requirements) do |endpoint, collected|
|
|
169
196
|
historical = Endpoint.version?(endpoint)
|
|
170
|
-
model_class = historical ?
|
|
197
|
+
model_class = historical ? RecordedClass.for(endpoint) : endpoint.class
|
|
171
198
|
collected[model_class] = collected.fetch(model_class, false) || historical
|
|
172
199
|
end
|
|
173
200
|
end
|
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Analyzes missing roots together without pretending that they have live rows.
|
|
6
6
|
class DeletedAnalysisBatch
|
|
7
|
-
|
|
7
|
+
# `context_required` reads a root that did not change in the window from
|
|
8
|
+
# the version after it, so its associations' activity can be reported.
|
|
9
|
+
#: (untyped, identities: Array[Array[String]], time_range: TimeRange?, analyzer: untyped, history_preparer: untyped, version_scope: untyped, context_required: bool) -> void
|
|
8
10
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
|
-
model, identities:, time_range:, analyzer:, history_preparer:, version_scope
|
|
11
|
+
model, identities:, time_range:, analyzer:, history_preparer:, version_scope:,
|
|
12
|
+
context_required:
|
|
10
13
|
)
|
|
11
14
|
@model = model
|
|
15
|
+
@context_required = context_required
|
|
12
16
|
@identities = identities
|
|
13
17
|
@time_range = time_range
|
|
14
18
|
@analyzer = analyzer
|
|
@@ -18,13 +22,9 @@ module PaperTrailDiff
|
|
|
18
22
|
|
|
19
23
|
#: () -> Hash[Array[String], Analysis?]
|
|
20
24
|
def call
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
records = historical_roots
|
|
25
|
-
selected = BatchedRootVersions.new(
|
|
26
|
-
records, time_range: @time_range, version_scope: @version_scope
|
|
27
|
-
).call
|
|
25
|
+
destroys = destroy_versions
|
|
26
|
+
records = historical_roots(destroys)
|
|
27
|
+
selected = plans(records, destroys)
|
|
28
28
|
prepare(records, selected)
|
|
29
29
|
analyses = records.to_h do |record|
|
|
30
30
|
key = Endpoint.identity(record)
|
|
@@ -42,19 +42,39 @@ module PaperTrailDiff
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# @rbs @model: untyped
|
|
45
|
+
# @rbs @context_required: bool
|
|
45
46
|
# @rbs @identities: Array[Array[String]]
|
|
46
47
|
# @rbs @time_range: TimeRange?
|
|
47
48
|
# @rbs @analyzer: untyped
|
|
48
49
|
# @rbs @history_preparer: untyped
|
|
49
50
|
# @rbs @version_scope: untyped
|
|
50
51
|
|
|
52
|
+
# A root destroyed before the window had no state inside it to read, so
|
|
53
|
+
# only the others take the version after the window as context.
|
|
54
|
+
#: (Array[untyped], Array[untyped]) -> Hash[Array[String], RootVersionPlan]
|
|
55
|
+
def plans(records, destroys)
|
|
56
|
+
start = @time_range&.begin_time
|
|
57
|
+
early = destroys.select { |version| start && version.created_at < start }
|
|
58
|
+
.map { |version| version.item_id.to_s }
|
|
59
|
+
before, during = records.partition { |record| early.include?(record.id.to_s) }
|
|
60
|
+
plans_for(during, @context_required).merge(plans_for(before, false))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: (Array[untyped], bool) -> Hash[Array[String], RootVersionPlan]
|
|
64
|
+
def plans_for(records, context_required)
|
|
65
|
+
BatchedRootVersions.new(
|
|
66
|
+
records, time_range: @time_range, version_scope: @version_scope,
|
|
67
|
+
live_endpoints: nil, context_required: context_required
|
|
68
|
+
).call
|
|
69
|
+
end
|
|
70
|
+
|
|
51
71
|
# These records supply identity and model metadata only. Every reported
|
|
52
72
|
# value comes from the batched historical plan; no per-root reify or live
|
|
53
73
|
# lookup is needed to get into the comparison engine.
|
|
54
|
-
#: () -> Array[untyped]
|
|
55
|
-
def historical_roots
|
|
74
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
75
|
+
def historical_roots(destroys)
|
|
56
76
|
loader = PreparedVersionStateLoader.new
|
|
57
|
-
|
|
77
|
+
destroys.map do |version|
|
|
58
78
|
model = loader.call(version)&.model_class || Endpoint.model_class(version)
|
|
59
79
|
record = model.new
|
|
60
80
|
record[model.primary_key] = version.item_id
|
|
@@ -67,10 +87,8 @@ module PaperTrailDiff
|
|
|
67
87
|
destroys = @model.paper_trail.version_class.where(
|
|
68
88
|
item_type: @model.base_class.name, item_id: @identities.map(&:last), event: 'destroy'
|
|
69
89
|
)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
Support.chronological_sort(history).last
|
|
73
|
-
end
|
|
90
|
+
# Oldest first, so each item keeps its latest destroy.
|
|
91
|
+
VersionQuery.chronological(destroys).index_by(&:item_id).values
|
|
74
92
|
end
|
|
75
93
|
|
|
76
94
|
#: (Array[untyped]) -> Hash[Symbol, untyped]
|