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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +287 -0
  3. data/QUICKSTART.md +9 -6
  4. data/README.md +84 -9
  5. data/docs/architecture.md +32 -9
  6. data/docs/performance.md +106 -13
  7. data/docs/reference.md +242 -29
  8. data/docs/testing.md +34 -11
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +19 -9
  10. data/lib/paper_trail_diff/activity_collection_event_applier.rb +11 -1
  11. data/lib/paper_trail_diff/activity_collection_record_updater.rb +16 -2
  12. data/lib/paper_trail_diff/activity_event_record_resolver.rb +20 -1
  13. data/lib/paper_trail_diff/activity_event_route_finder.rb +2 -1
  14. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +1 -1
  15. data/lib/paper_trail_diff/activity_range.rb +14 -2
  16. data/lib/paper_trail_diff/activity_relationship.rb +12 -0
  17. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +5 -2
  18. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +131 -0
  19. data/lib/paper_trail_diff/activity_timeline_builder.rb +30 -24
  20. data/lib/paper_trail_diff/activity_version_collector.rb +31 -20
  21. data/lib/paper_trail_diff/association_activity_edge.rb +236 -0
  22. data/lib/paper_trail_diff/association_activity_selection.rb +197 -0
  23. data/lib/paper_trail_diff/association_discovery.rb +8 -13
  24. data/lib/paper_trail_diff/association_traversal.rb +169 -45
  25. data/lib/paper_trail_diff/batch_safety.rb +23 -15
  26. data/lib/paper_trail_diff/batched_root_versions.rb +21 -39
  27. data/lib/paper_trail_diff/comparison_batch.rb +37 -10
  28. data/lib/paper_trail_diff/deleted_analysis_batch.rb +34 -16
  29. data/lib/paper_trail_diff/diagnostics.rb +26 -8
  30. data/lib/paper_trail_diff/direct_record_state_loader.rb +10 -1
  31. data/lib/paper_trail_diff/errors.rb +2 -1
  32. data/lib/paper_trail_diff/habtm_transaction_owners.rb +68 -0
  33. data/lib/paper_trail_diff/historical_association_reifier.rb +32 -2
  34. data/lib/paper_trail_diff/historical_snapshot_store.rb +6 -9
  35. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +35 -2
  36. data/lib/paper_trail_diff/live_graph_collector.rb +1 -1
  37. data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -6
  38. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +1 -1
  39. data/lib/paper_trail_diff/prepared_activity_index.rb +110 -36
  40. data/lib/paper_trail_diff/prepared_edge_loader.rb +29 -45
  41. data/lib/paper_trail_diff/prepared_history.rb +63 -54
  42. data/lib/paper_trail_diff/prepared_history_loader.rb +33 -43
  43. data/lib/paper_trail_diff/prepared_record_index.rb +26 -0
  44. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +1 -1
  45. data/lib/paper_trail_diff/prepared_state_association_reader.rb +119 -29
  46. data/lib/paper_trail_diff/recorded_changes.rb +83 -0
  47. data/lib/paper_trail_diff/recorded_class.rb +53 -0
  48. data/lib/paper_trail_diff/scope_condition.rb +331 -0
  49. data/lib/paper_trail_diff/scope_ranking.rb +196 -0
  50. data/lib/paper_trail_diff/scoped_analysis_builder.rb +4 -3
  51. data/lib/paper_trail_diff/scoped_root_selection.rb +93 -43
  52. data/lib/paper_trail_diff/snapshot_normalizer.rb +10 -3
  53. data/lib/paper_trail_diff/support.rb +9 -4
  54. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -1
  55. data/lib/paper_trail_diff/time_range.rb +9 -12
  56. data/lib/paper_trail_diff/time_version_range.rb +5 -5
  57. data/lib/paper_trail_diff/version.rb +1 -1
  58. data/lib/paper_trail_diff/version_association_candidate_scope.rb +13 -3
  59. data/lib/paper_trail_diff/version_range.rb +8 -15
  60. data/lib/paper_trail_diff/version_scope_filter.rb +11 -3
  61. data/lib/paper_trail_diff.rb +31 -2
  62. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +10 -2
  63. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +5 -0
  64. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +5 -0
  65. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +7 -0
  66. data/sig/generated/paper_trail_diff/activity_range.rbs +6 -2
  67. data/sig/generated/paper_trail_diff/activity_relationship.rbs +3 -0
  68. data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +2 -0
  69. data/sig/generated/paper_trail_diff/activity_target_candidate_loader.rbs +79 -0
  70. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +11 -5
  71. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +10 -4
  72. data/sig/generated/paper_trail_diff/association_activity_edge.rbs +148 -0
  73. data/sig/generated/paper_trail_diff/association_activity_selection.rbs +112 -0
  74. data/sig/generated/paper_trail_diff/association_discovery.rbs +3 -5
  75. data/sig/generated/paper_trail_diff/association_traversal.rbs +84 -9
  76. data/sig/generated/paper_trail_diff/batch_safety.rbs +10 -4
  77. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +2 -8
  78. data/sig/generated/paper_trail_diff/comparison_batch.rbs +22 -4
  79. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +16 -4
  80. data/sig/generated/paper_trail_diff/diagnostics.rbs +7 -2
  81. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +6 -0
  82. data/sig/generated/paper_trail_diff/errors.rbs +2 -1
  83. data/sig/generated/paper_trail_diff/habtm_transaction_owners.rbs +40 -0
  84. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +13 -0
  85. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +3 -2
  86. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +17 -0
  87. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +52 -10
  89. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +4 -12
  90. data/sig/generated/paper_trail_diff/prepared_history.rbs +33 -22
  91. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +15 -13
  92. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +15 -0
  93. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +56 -11
  94. data/sig/generated/paper_trail_diff/recorded_changes.rbs +47 -0
  95. data/sig/generated/paper_trail_diff/recorded_class.rbs +22 -0
  96. data/sig/generated/paper_trail_diff/scope_condition.rbs +185 -0
  97. data/sig/generated/paper_trail_diff/scope_ranking.rbs +117 -0
  98. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +2 -2
  99. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +41 -7
  100. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +6 -0
  101. data/sig/generated/paper_trail_diff/support.rbs +5 -2
  102. data/sig/generated/paper_trail_diff/time_range.rbs +4 -0
  103. data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -4
  104. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +7 -0
  105. data/sig/generated/paper_trail_diff/version_range.rbs +4 -2
  106. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -0
  107. data/sig/generated/paper_trail_diff.rbs +21 -2
  108. 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
- #: (untyped) -> Array[untyped]
102
- def supported_reflections(model_class)
103
- model_class.reflect_on_all_associations
104
- .select { |reflection| SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro) }
105
- .reject { |reflection| paper_trail_versions?(model_class, reflection) }
106
- .sort_by { |reflection| reflection.name.to_s }
107
- end
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