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
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# One association edge read backwards for a window: given the ids of records
|
|
6
|
+
# on its far side, the query for the owners that held them while it was open.
|
|
7
|
+
#
|
|
8
|
+
# `filter_type` is the type of the column those ids are compared with, and
|
|
9
|
+
# `type` that of the owner column the query selects, so a caller can keep a
|
|
10
|
+
# subquery only where the database can compare the two.
|
|
11
|
+
class AssociationActivityEdge
|
|
12
|
+
attr_reader :column #: untyped
|
|
13
|
+
attr_reader :type #: Symbol?
|
|
14
|
+
attr_reader :filter_type #: Symbol?
|
|
15
|
+
|
|
16
|
+
#: (untyped, TimeRange?) -> void
|
|
17
|
+
def initialize(reflection, time_range)
|
|
18
|
+
@reflection = reflection
|
|
19
|
+
@time_range = time_range
|
|
20
|
+
@owner = reflection.active_record
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
#: () -> bool
|
|
24
|
+
def available?
|
|
25
|
+
!@reflection.foreign_key.is_a?(Array)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: (untyped) -> untyped
|
|
29
|
+
def relation(_filter)
|
|
30
|
+
raise NotImplementedError, "#{self.class} must build its relation"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# @rbs @reflection: untyped
|
|
36
|
+
# @rbs @time_range: TimeRange?
|
|
37
|
+
# @rbs @owner: untyped
|
|
38
|
+
|
|
39
|
+
# The window plus the state when it opened, which is what
|
|
40
|
+
# VersionAssociationCandidateScope selects for an activity analysis.
|
|
41
|
+
#: (untyped, untyped) -> untyped
|
|
42
|
+
def candidates(versions, relation)
|
|
43
|
+
range = @time_range
|
|
44
|
+
return relation unless range
|
|
45
|
+
|
|
46
|
+
VersionAssociationCandidateScope.new(versions, range.begin_time, end_at: range.end_time)
|
|
47
|
+
.call(relation)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
#: (untyped, String) -> Symbol?
|
|
51
|
+
def column_type(model, name)
|
|
52
|
+
model.type_for_attribute(name).type
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A relation is compared as its SQL; ids are compared as values.
|
|
56
|
+
#: (untyped) -> untyped
|
|
57
|
+
def arel_value(filter)
|
|
58
|
+
filter.respond_to?(:arel) ? filter.arel : filter
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The owners a child pointed at when the window opened or after a change
|
|
62
|
+
# inside it, as PT-AT recorded them on the child's own versions.
|
|
63
|
+
class Child < AssociationActivityEdge
|
|
64
|
+
#: (untyped, TimeRange?) -> void
|
|
65
|
+
def initialize(reflection, time_range)
|
|
66
|
+
super
|
|
67
|
+
@versions = reflection.klass.paper_trail.version_class
|
|
68
|
+
@associations = reflection.klass.paper_trail.version_association_class
|
|
69
|
+
@column = @associations.arel_table[:foreign_key_id]
|
|
70
|
+
@type = column_type(@associations, 'foreign_key_id')
|
|
71
|
+
@filter_type = column_type(@versions, 'item_id')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
#: (untyped) -> untyped
|
|
75
|
+
def relation(filter)
|
|
76
|
+
versions = { item_type: @reflection.klass.base_class.name, item_id: filter }
|
|
77
|
+
relation = @associations.joins(:version)
|
|
78
|
+
.where(foreign_key_name: @reflection.foreign_key,
|
|
79
|
+
foreign_type: owner_types)
|
|
80
|
+
.where(@versions.table_name => versions)
|
|
81
|
+
candidates(@versions, relation)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# @rbs @versions: untyped
|
|
87
|
+
# @rbs @associations: untyped
|
|
88
|
+
|
|
89
|
+
#: () -> Array[String?]
|
|
90
|
+
def owner_types
|
|
91
|
+
[nil, '', @owner.name, @owner.base_class.name]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The owners that pointed at a target when the window opened or after a
|
|
96
|
+
# change inside it, as PT-AT recorded them on the owners' versions.
|
|
97
|
+
class BelongsTo < AssociationActivityEdge
|
|
98
|
+
#: (untyped, TimeRange?, ?foreign_type: String?) -> void
|
|
99
|
+
def initialize(reflection, time_range, foreign_type: nil)
|
|
100
|
+
super(reflection, time_range)
|
|
101
|
+
@foreign_type = foreign_type
|
|
102
|
+
@versions = @owner.paper_trail.version_class
|
|
103
|
+
@associations = @owner.paper_trail.version_association_class
|
|
104
|
+
@column = @versions.arel_table[:item_id]
|
|
105
|
+
@type = column_type(@versions, 'item_id')
|
|
106
|
+
@filter_type = column_type(@associations, 'foreign_key_id')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The types a polymorphic key's rows name, the only way to know its targets.
|
|
110
|
+
#: () -> Array[String]
|
|
111
|
+
def target_types
|
|
112
|
+
candidates(@versions, owner_rows(foreign_key_name: @reflection.foreign_key))
|
|
113
|
+
.distinct.pluck(@associations.arel_table[:foreign_type]).compact
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The owners that pointed at a target of this edge's type when the window
|
|
117
|
+
# opened or after a change inside it, whichever target that was.
|
|
118
|
+
#: () -> untyped
|
|
119
|
+
def typed_owners
|
|
120
|
+
conditions = { foreign_key_name: @reflection.foreign_key, foreign_type: @foreign_type }
|
|
121
|
+
candidates(@versions, owner_rows(conditions)).select(@column)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
#: (untyped) -> untyped
|
|
125
|
+
def relation(filter)
|
|
126
|
+
conditions = { foreign_key_name: @reflection.foreign_key, foreign_key_id: filter }
|
|
127
|
+
conditions[:foreign_type] = @foreign_type if @foreign_type
|
|
128
|
+
candidates(@versions, owner_rows(conditions))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
# @rbs @foreign_type: String?
|
|
134
|
+
# @rbs @versions: untyped
|
|
135
|
+
# @rbs @associations: untyped
|
|
136
|
+
|
|
137
|
+
#: (Hash[Symbol, untyped]) -> untyped
|
|
138
|
+
def owner_rows(conditions)
|
|
139
|
+
@associations.joins(:version).where(conditions)
|
|
140
|
+
.where(@versions.table_name => { item_type: @owner.base_class.name })
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The owners a HABTM join row links to a target now. A join table has no
|
|
145
|
+
# model to type its columns, so the owner column is read by quoted name.
|
|
146
|
+
class LiveHabtm < AssociationActivityEdge
|
|
147
|
+
#: (untyped, TimeRange?) -> void
|
|
148
|
+
def initialize(reflection, time_range)
|
|
149
|
+
super
|
|
150
|
+
@table = arel::Table.new(reflection.join_table)
|
|
151
|
+
columns = join_columns
|
|
152
|
+
@type = columns.fetch(reflection.foreign_key)
|
|
153
|
+
@filter_type = columns.fetch(reflection.association_foreign_key)
|
|
154
|
+
@column = quoted_owner_column
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
#: (untyped) -> untyped
|
|
158
|
+
def relation(filter)
|
|
159
|
+
target = @table[@reflection.association_foreign_key]
|
|
160
|
+
@owner.base_class.unscoped.from(@table).where(target.in(arel_value(filter)))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# @rbs @table: untyped
|
|
166
|
+
|
|
167
|
+
#: () -> Hash[String, Symbol?]
|
|
168
|
+
def join_columns
|
|
169
|
+
connected do |connection|
|
|
170
|
+
connection.columns(@reflection.join_table).to_h { |column| [column.name, column.type] }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Some adapters take the quoted name as a plain string too, but a raw SQL
|
|
175
|
+
# check may not, so it is marked as SQL; no SQLite spec can tell.
|
|
176
|
+
# mutant:disable
|
|
177
|
+
#: () -> untyped
|
|
178
|
+
def quoted_owner_column
|
|
179
|
+
connected do |connection|
|
|
180
|
+
arel.sql("#{connection.quote_table_name(@reflection.join_table)}." \
|
|
181
|
+
"#{connection.quote_column_name(@reflection.foreign_key)}")
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Arel ships no signatures; see VersionQuery. Any spelling of the
|
|
186
|
+
# constant finds the same module.
|
|
187
|
+
# mutant:disable
|
|
188
|
+
#: () -> untyped
|
|
189
|
+
def arel
|
|
190
|
+
Object.const_get(:Arel)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Active Record 7.1 has no `with_connection`; see VersionAssociationCandidateScope.
|
|
194
|
+
# Only that version takes the fallback, so no spec run on a newer one can
|
|
195
|
+
# tell its mutations apart.
|
|
196
|
+
# mutant:disable
|
|
197
|
+
#: [T] () { (untyped) -> T } -> T
|
|
198
|
+
def connected(&block)
|
|
199
|
+
return @owner.with_connection(&block) if @owner.respond_to?(:with_connection)
|
|
200
|
+
|
|
201
|
+
yield(@owner.connection)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# The owners whose versions PT-AT recorded a HABTM target with, keyed by
|
|
206
|
+
# the transaction each version was saved in.
|
|
207
|
+
class RecordedHabtm < AssociationActivityEdge
|
|
208
|
+
#: (untyped, TimeRange?) -> void
|
|
209
|
+
def initialize(reflection, time_range)
|
|
210
|
+
super
|
|
211
|
+
@versions = @owner.paper_trail.version_class
|
|
212
|
+
@associations = @owner.paper_trail.version_association_class
|
|
213
|
+
@column = @versions.arel_table[:item_id]
|
|
214
|
+
@type = column_type(@versions, 'item_id')
|
|
215
|
+
@filter_type = column_type(@associations, 'foreign_key_id')
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
#: () -> bool
|
|
219
|
+
def available?
|
|
220
|
+
@versions.column_names.include?('transaction_id')
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
#: (untyped) -> untyped
|
|
224
|
+
def relation(filter)
|
|
225
|
+
transactions = @associations.where(foreign_key_name: @reflection.name,
|
|
226
|
+
foreign_key_id: filter).select(:version_id)
|
|
227
|
+
relation = @versions.where(item_type: @owner.base_class.name, transaction_id: transactions)
|
|
228
|
+
range = @time_range
|
|
229
|
+
range ? relation.where(created_at: range.begin_time..) : relation
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @rbs @versions: untyped
|
|
233
|
+
# @rbs @associations: untyped
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Finds the roots whose requested associations changed in a window, so that
|
|
6
|
+
# selecting by activity includes a record whose own row did not change.
|
|
7
|
+
#
|
|
8
|
+
# A root qualifies when a record on one of its requested paths has a version
|
|
9
|
+
# in the window while it belonged to that root: when the window opened, or
|
|
10
|
+
# after a change inside it. Those are the memberships PT-AT recorded, and the
|
|
11
|
+
# ones an activity analysis reads. For `has_many :through`, the join rows are
|
|
12
|
+
# on the path too, since creating or destroying one changes membership.
|
|
13
|
+
#
|
|
14
|
+
# Each edge is one query, walked from the leaves toward the root, never one
|
|
15
|
+
# per root. Ids stay in a subquery while the columns compared share a type;
|
|
16
|
+
# otherwise they are read and the edge runs over them in slices.
|
|
17
|
+
class AssociationActivitySelection
|
|
18
|
+
SLICE = 250
|
|
19
|
+
private_constant :SLICE
|
|
20
|
+
|
|
21
|
+
# The ids a one-column subquery selects, with that column's type: a
|
|
22
|
+
# database compares columns of one type only, and PostgreSQL rejects an
|
|
23
|
+
# integer key against a string item_id outright.
|
|
24
|
+
class Query
|
|
25
|
+
attr_reader :relation #: untyped
|
|
26
|
+
attr_reader :column #: untyped
|
|
27
|
+
attr_reader :type #: Symbol?
|
|
28
|
+
|
|
29
|
+
#: (untyped, untyped, Symbol?) -> void
|
|
30
|
+
def initialize(relation, column, type)
|
|
31
|
+
@relation = relation.select(column)
|
|
32
|
+
@column = column
|
|
33
|
+
@type = type
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A key PT-AT recorded as empty names no record, so it is left out.
|
|
38
|
+
#: () -> Array[untyped]
|
|
39
|
+
def values
|
|
40
|
+
@relation.distinct.pluck(@column).compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The ids with no match in `keys`, a subquery of the keys that exist.
|
|
44
|
+
#: (untyped) -> Array[untyped]
|
|
45
|
+
def missing(keys)
|
|
46
|
+
@relation.where(@column.not_in(keys)).distinct.pluck(@column)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @rbs!
|
|
51
|
+
# type source = Query | Array[untyped]
|
|
52
|
+
|
|
53
|
+
# Every id the sources name, read from the database where needed.
|
|
54
|
+
#: (Array[source]) -> Array[untyped]
|
|
55
|
+
def self.values(sources)
|
|
56
|
+
sources.flat_map { |ids| ids.is_a?(Query) ? ids.values : ids }.uniq
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
#: (untyped, tree: AssociationTree, time_range: TimeRange?) -> void
|
|
60
|
+
def initialize(model, tree:, time_range:)
|
|
61
|
+
@model = model
|
|
62
|
+
@traversal = AssociationTraversal.new(tree)
|
|
63
|
+
@time_range = time_range
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The roots' ids, each source a subquery or an array of values.
|
|
67
|
+
#: () -> Array[source]
|
|
68
|
+
def call
|
|
69
|
+
reach(@model, @traversal.tree, '')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# @rbs @model: untyped
|
|
75
|
+
# @rbs @traversal: AssociationTraversal
|
|
76
|
+
# @rbs @time_range: TimeRange?
|
|
77
|
+
|
|
78
|
+
# Ids of `model` records with a change somewhere below them in `tree`: the
|
|
79
|
+
# targets that changed, or that reach a change further down.
|
|
80
|
+
#: (untyped, AssociationTree, String) -> Array[source]
|
|
81
|
+
def reach(model, tree, path)
|
|
82
|
+
@traversal.reflections_for(model, tree, path: path).flat_map do |reflection|
|
|
83
|
+
next polymorphic_owners(reflection) if reflection.polymorphic?
|
|
84
|
+
|
|
85
|
+
target = reflection.klass
|
|
86
|
+
subtree = tree.child(reflection.name) #: AssociationTree
|
|
87
|
+
child_path = Support.association_path(path, reflection.name)
|
|
88
|
+
owners(reflection, changed(target) + reach(target, subtree, child_path))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
#: (untyped, Array[source]) -> Array[source]
|
|
93
|
+
def owners(reflection, ids)
|
|
94
|
+
return [] if ids.empty?
|
|
95
|
+
return through_owners(reflection, ids) if reflection.through_reflection?
|
|
96
|
+
|
|
97
|
+
edges(reflection).flat_map { |edge| follow(edge, ids) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A join row that changes moves membership as surely as a target that does.
|
|
101
|
+
#: (untyped, Array[source]) -> Array[source]
|
|
102
|
+
def through_owners(reflection, ids)
|
|
103
|
+
through = reflection.through_reflection
|
|
104
|
+
source_type = reflection.options[:source_type]
|
|
105
|
+
joins = if source_type
|
|
106
|
+
typed_joins(reflection, ids, source_type)
|
|
107
|
+
else
|
|
108
|
+
owners(reflection.source_reflection, ids) + changed(through.klass)
|
|
109
|
+
end
|
|
110
|
+
owners(through, joins)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Under `source_type:`, only join rows of that type are members, so only
|
|
114
|
+
# targets of that type and changes to those rows count.
|
|
115
|
+
#: (untyped, Array[source], untyped) -> Array[source]
|
|
116
|
+
def typed_joins(reflection, ids, source_type)
|
|
117
|
+
edge = AssociationActivityEdge::BelongsTo.new(reflection.source_reflection, @time_range,
|
|
118
|
+
foreign_type: source_type)
|
|
119
|
+
follow(edge, ids) + changed(reflection.through_reflection.klass, edge.typed_owners)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A polymorphic key's targets are known only from the types its rows name,
|
|
123
|
+
# and a path stops at one, so only the targets' own changes count.
|
|
124
|
+
#: (untyped) -> Array[source]
|
|
125
|
+
def polymorphic_owners(reflection)
|
|
126
|
+
edge = AssociationActivityEdge::BelongsTo.new(reflection, @time_range)
|
|
127
|
+
edge.target_types.flat_map do |name|
|
|
128
|
+
typed = AssociationActivityEdge::BelongsTo.new(reflection, @time_range, foreign_type: name)
|
|
129
|
+
follow(typed, changed(target_class(name)))
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
#: (untyped) -> Array[AssociationActivityEdge]
|
|
134
|
+
def edges(reflection)
|
|
135
|
+
kinds = case reflection.macro
|
|
136
|
+
when :belongs_to then [AssociationActivityEdge::BelongsTo]
|
|
137
|
+
when :has_and_belongs_to_many
|
|
138
|
+
[AssociationActivityEdge::LiveHabtm, AssociationActivityEdge::RecordedHabtm]
|
|
139
|
+
else [AssociationActivityEdge::Child]
|
|
140
|
+
end
|
|
141
|
+
kinds.map { |kind| kind.new(reflection, @time_range) }.select(&:available?)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Records with a version in the window, or only those of them `item_ids`
|
|
145
|
+
# selects: under `source_type:`, the join rows that had that type.
|
|
146
|
+
#: (untyped, ?untyped) -> Array[source]
|
|
147
|
+
def changed(model, item_ids = nil)
|
|
148
|
+
return [] unless Support.versioned?(model)
|
|
149
|
+
|
|
150
|
+
versions = model.paper_trail.version_class
|
|
151
|
+
relation = versions.where(item_type: model.base_class.name)
|
|
152
|
+
relation = relation.where(item_id: item_ids) if item_ids
|
|
153
|
+
range = @time_range
|
|
154
|
+
relation = range.scope(relation) if range
|
|
155
|
+
[Query.new(relation, versions.arel_table[:item_id],
|
|
156
|
+
versions.type_for_attribute('item_id').type)]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# One edge for each source. A subquery whose column type matches the
|
|
160
|
+
# filtered column stays a subquery; anything else is read, and the edge
|
|
161
|
+
# runs over its values in slices.
|
|
162
|
+
#: (AssociationActivityEdge, Array[source]) -> Array[source]
|
|
163
|
+
def follow(edge, sources)
|
|
164
|
+
sources.filter_map do |source|
|
|
165
|
+
query = subquery(source, edge.filter_type)
|
|
166
|
+
next Query.new(edge.relation(query.relation), edge.column, edge.type) if query
|
|
167
|
+
|
|
168
|
+
found = read(edge, self.class.values([source]))
|
|
169
|
+
found unless found.empty?
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
#: (AssociationActivityEdge, Array[untyped]) -> Array[untyped]
|
|
174
|
+
def read(edge, values)
|
|
175
|
+
values.each_slice(SLICE).flat_map do |slice|
|
|
176
|
+
edge.relation(slice).pluck(edge.column)
|
|
177
|
+
end.compact.uniq
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Sources are only ever a Query or a plain Array, so the one mutation
|
|
181
|
+
# left, `instance_of?` for `is_a?`, cannot change an answer.
|
|
182
|
+
# mutant:disable
|
|
183
|
+
#: (source, Symbol?) -> Query?
|
|
184
|
+
def subquery(source, type)
|
|
185
|
+
source if source.is_a?(Query) && source.type == type
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A type naming no class has no versions to read; `changed` skips one that
|
|
189
|
+
# names an unversioned class.
|
|
190
|
+
#: (String) -> untyped
|
|
191
|
+
def target_class(name)
|
|
192
|
+
Object.const_get(name)
|
|
193
|
+
rescue NameError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -62,7 +62,7 @@ module PaperTrailDiff
|
|
|
62
62
|
|
|
63
63
|
#: (untyped, prefix: String, ancestry: Array[untyped], depth: Integer) -> Array[AssociationDescriptor]
|
|
64
64
|
def discover(model_class, prefix:, ancestry:, depth:)
|
|
65
|
-
supported_reflections(model_class).flat_map do |reflection|
|
|
65
|
+
supported_reflections(model_class, root: prefix.empty?).flat_map do |reflection|
|
|
66
66
|
describe_and_descend(reflection, prefix: prefix, ancestry: ancestry, depth: depth)
|
|
67
67
|
end
|
|
68
68
|
end
|
|
@@ -98,18 +98,13 @@ module PaperTrailDiff
|
|
|
98
98
|
)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
#: (untyped, untyped) -> bool
|
|
110
|
-
def paper_trail_versions?(model_class, reflection)
|
|
111
|
-
model_class.respond_to?(:versions_association_name) &&
|
|
112
|
-
reflection.name.to_s == model_class.versions_association_name.to_s
|
|
101
|
+
# Only what a request accepts, so every listed path can be requested.
|
|
102
|
+
#: (untyped, root: bool) -> Array[untyped]
|
|
103
|
+
def supported_reflections(model_class, root:)
|
|
104
|
+
reflections = model_class.reflect_on_all_associations.reject do |reflection|
|
|
105
|
+
AssociationTraversal.unsupported_reason(model_class, reflection, root: root)
|
|
106
|
+
end
|
|
107
|
+
reflections.sort_by { |reflection| reflection.name.to_s }
|
|
113
108
|
end
|
|
114
109
|
|
|
115
110
|
#: (untyped) -> untyped
|