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
@@ -4,25 +4,21 @@
4
4
  module PaperTrailDiff
5
5
  # Loads every historical identity that can participate in one association edge.
6
6
  class PreparedEdgeLoader
7
- EDGE_LOADERS = {
8
- belongs_to: :belongs_to_edge,
9
- has_one: :child_edge,
10
- has_many: :child_edge,
11
- has_and_belongs_to_many: :habtm_edge
12
- }.freeze
13
- private_constant :EDGE_LOADERS
14
-
15
7
  #: (PreparedRecordIndex, Array[untyped], ?start_at: untyped) -> void
16
8
  def initialize(records, root_versions, start_at: root_versions.first&.created_at)
17
9
  @records = records
18
- @transaction_ids = root_versions.filter_map(&:transaction_id).uniq.freeze
10
+ @transaction_ids = root_versions.filter_map(&:transaction_id)
19
11
  @start_at = start_at
20
12
  end
21
13
 
14
+ # HABTM alone also returns each root transaction's members.
22
15
  #: (untyped, Array[untyped], untyped) -> [Hash[String, Hash[Symbol, untyped]], Hash[String, Array[untyped]]?]
23
16
  def call(owner_class, owner_ids, reflection)
24
- loader = EDGE_LOADERS.fetch(reflection.macro)
25
- send(loader, owner_class, owner_ids, reflection)
17
+ case reflection.macro
18
+ when :belongs_to then [belongs_to_groups(owner_class, owner_ids, reflection), nil]
19
+ when :has_and_belongs_to_many then habtm_groups(owner_class, reflection)
20
+ else [child_groups(owner_class, owner_ids, reflection), nil]
21
+ end
26
22
  end
27
23
 
28
24
  private
@@ -31,21 +27,6 @@ module PaperTrailDiff
31
27
  # @rbs @transaction_ids: Array[untyped]
32
28
  # @rbs @start_at: untyped
33
29
 
34
- #: (untyped, Array[untyped], untyped) -> [Hash[String, Hash[Symbol, untyped]], nil]
35
- def belongs_to_edge(owner_class, owner_ids, reflection)
36
- [belongs_to_groups(owner_class, owner_ids, reflection), nil]
37
- end
38
-
39
- #: (untyped, Array[untyped], untyped) -> [Hash[String, Hash[Symbol, untyped]], nil]
40
- def child_edge(owner_class, owner_ids, reflection)
41
- [child_groups(owner_class, owner_ids, reflection), nil]
42
- end
43
-
44
- #: (untyped, Array[untyped], untyped) -> [Hash[String, Hash[Symbol, untyped]], Hash[String, Array[untyped]]]
45
- def habtm_edge(owner_class, _owner_ids, reflection)
46
- habtm_groups(owner_class, reflection)
47
- end
48
-
49
30
  #: (untyped, Array[untyped], untyped) -> Hash[String, Hash[Symbol, untyped]]
50
31
  def belongs_to_groups(owner_class, owner_ids, reflection)
51
32
  key = scalar_foreign_key(reflection)
@@ -56,9 +37,12 @@ module PaperTrailDiff
56
37
  end
57
38
 
58
39
  grouped = {} #: Hash[String, Hash[Symbol, untyped]]
40
+ # Grouping only decides what to preload, so a type that names no class is
41
+ # left to raise where that state is read.
59
42
  @records.records_for(owner_class, owner_ids).each do |owner|
60
43
  id = owner.public_send(key)
61
- model_class = AssociationTraversal.belongs_to_target(reflection, owner)
44
+ type = owner.public_send(reflection.foreign_type).to_s
45
+ model_class = AssociationTraversal.named_class(type)
62
46
  add_identity(grouped, model_class, id) if model_class && id
63
47
  end
64
48
  grouped
@@ -75,28 +59,31 @@ module PaperTrailDiff
75
59
  group_for(reflection.klass, owners.values.flatten, owners: owners)
76
60
  end
77
61
 
62
+ # A transaction more than one owner recorded members in is left out, so
63
+ # its read falls back to HistoricalAssociationReifier, which raises.
78
64
  #: (untyped, untyped) -> [Hash[String, Hash[Symbol, untyped]], Hash[String, Array[untyped]]]
79
65
  def habtm_groups(owner_class, reflection)
80
66
  rows = habtm_rows(owner_class, reflection)
67
+ shared = HabtmTransactionOwners.new(owner_class, reflection).shared(rows.map(&:version_id))
81
68
  memberships = @transaction_ids.to_h do |transaction_id|
82
69
  [transaction_id.to_s, membership_ids(rows, transaction_id)]
83
70
  end
84
- [group_for(reflection.klass, rows.filter_map(&:foreign_key_id)), memberships]
71
+ [group_for(reflection.klass, rows.map(&:foreign_key_id)), memberships.except(*shared)]
85
72
  end
86
73
 
87
74
  #: (untyped, untyped) -> Array[untyped]
88
75
  def habtm_rows(owner_class, reflection)
89
76
  owner_class.paper_trail.version_association_class.where(
90
77
  version_id: @transaction_ids,
91
- foreign_key_name: reflection.name.to_s
92
- ).to_a
78
+ foreign_key_name: reflection.name
79
+ )
93
80
  end
94
81
 
95
82
  #: (Array[untyped], untyped) -> Array[untyped]
96
83
  def membership_ids(rows, transaction_id)
97
84
  rows.filter_map do |row|
98
85
  row.foreign_key_id if row.version_id.to_s == transaction_id.to_s
99
- end.uniq
86
+ end
100
87
  end
101
88
 
102
89
  #: (untyped, Array[untyped], untyped, String) -> Hash[String, Array[untyped]]
@@ -109,7 +96,7 @@ module PaperTrailDiff
109
96
  version_class = owner_class.paper_trail.version_class
110
97
  association_class = owner_class.paper_trail.version_association_class
111
98
  relation = historical_child_relation(owner_class, owner_ids, reflection, foreign_key)
112
- pairs = child_candidate_scope(version_class).call(relation).distinct.pluck(
99
+ pairs = child_candidate_scope(version_class).call(relation).pluck(
113
100
  association_class.arel_table[:foreign_key_id],
114
101
  version_class.arel_table[:item_id]
115
102
  )
@@ -141,10 +128,10 @@ module PaperTrailDiff
141
128
 
142
129
  #: (untyped, Array[untyped], untyped, String) -> Hash[String, Array[untyped]]
143
130
  def live_child_ids(owner_class, owner_ids, reflection, foreign_key)
131
+ # Every row of the table, as history reads them: an STI subclass's
132
+ # siblings are left out where children are resolved.
144
133
  relation = reflection.klass.base_class.unscoped.where(foreign_key => owner_ids)
145
- if reflection.options[:as]
146
- relation = relation.where(reflection.type => parent_types(owner_class))
147
- end
134
+ relation = relation.where(reflection.type => parent_types(owner_class)) if reflection.type
148
135
  group_ids_by_owner(relation.pluck(foreign_key, reflection.klass.primary_key))
149
136
  end
150
137
 
@@ -161,18 +148,15 @@ module PaperTrailDiff
161
148
 
162
149
  group = { model: model_class, ids: unique } #: Hash[Symbol, untyped]
163
150
  group[:owners] = owners if owners
164
- { model_class.name.to_s => group }
151
+ { model_class.name => group }
165
152
  end
166
153
 
167
- # Keyed rather than scanned so that a wide edge stays linear in its width.
154
+ # A set rather than a scan, so that a wide edge stays linear in its width.
168
155
  #: (Array[Array[untyped]]) -> Hash[String, Array[untyped]]
169
156
  def group_ids_by_owner(pairs)
170
- grouped = {} #: Hash[String, Hash[untyped, true]]
171
- pairs.each do |owner_id, child_id|
172
- ids = grouped[owner_id.to_s] ||= {} #: Hash[untyped, true]
173
- ids[child_id] = true
174
- end
175
- grouped.transform_values(&:keys)
157
+ grouped = {} #: Hash[String, Set[untyped]]
158
+ pairs.each { |owner_id, child_id| (grouped[owner_id.to_s] ||= Set.new) << child_id }
159
+ grouped.transform_values(&:to_a)
176
160
  end
177
161
 
178
162
  #: (Hash[String, Array[untyped]], Hash[String, Array[untyped]]) -> Hash[String, Array[untyped]]
@@ -183,12 +167,12 @@ module PaperTrailDiff
183
167
  #: (untyped) -> String?
184
168
  def scalar_foreign_key(reflection)
185
169
  key = reflection.foreign_key
186
- key.to_s unless key.is_a?(Array)
170
+ key unless key.is_a?(Array)
187
171
  end
188
172
 
189
173
  #: (untyped) -> Array[String?]
190
174
  def parent_types(owner_class)
191
- [nil, '', owner_class.name.to_s, owner_class.base_class.name.to_s].uniq
175
+ [nil, '', owner_class.name, owner_class.base_class.name]
192
176
  end
193
177
  end
194
178
  end
@@ -17,14 +17,16 @@ module PaperTrailDiff
17
17
  #: (PreparedRecordIndex, ?activity_index: PreparedActivityIndex?) -> void
18
18
  def initialize(records, activity_index: nil)
19
19
  @records = records
20
- @state_reader = PreparedStateAssociationReader.new(records, method(:edge))
20
+ @conditions = ScopeCondition::Cache.new
21
+ @state_reader = PreparedStateAssociationReader.new(records, method(:edge), @conditions)
21
22
  @activity_index = activity_index
22
23
  @edges = {} #: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
23
24
  @unsupported = {} #: Hash[Array[String], bool]
24
25
  @habtm = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
25
26
  end
26
27
 
27
- #: (untyped, untyped, Hash[String, Hash[Symbol, untyped]]) -> void
28
+ # Returns every group the edge now holds.
29
+ #: (untyped, untyped, Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
28
30
  def merge_edge(owner_class, reflection, groups)
29
31
  key = edge_key(owner_class, reflection)
30
32
  empty = {} #: Hash[String, Hash[Symbol, untyped]]
@@ -62,14 +64,16 @@ module PaperTrailDiff
62
64
  @state_reader.call(owner, reflection, boundary)
63
65
  end
64
66
 
65
- #: (untyped, untyped, untyped, habtm_boundary: untyped) -> [bool, Array[untyped]]
66
- def resolve(record, reflection, boundary, habtm_boundary:)
67
- return [false, []] unless supported?(record.class, reflection)
67
+ # Unranked, as a part of a has_many through is read, it returns every row
68
+ # the part's conditions admit, a record reached twice appearing twice.
69
+ #: (untyped, untyped, untyped, habtm_boundary: untyped, ?ranked: bool) -> [bool, Array[untyped]]
70
+ def resolve(record, reflection, boundary, habtm_boundary:, ranked: true)
71
+ return [false, []] unless supported?(record.class, reflection, ranked)
68
72
 
69
73
  records = if reflection.options[:through]
70
- through_records(record, reflection, boundary, habtm_boundary)
74
+ through_records(record, reflection, boundary, habtm_boundary, ranked)
71
75
  else
72
- direct_records(record, reflection, boundary, habtm_boundary)
76
+ direct_records(record, reflection, boundary, habtm_boundary, ranked)
73
77
  end
74
78
  records ? [true, records] : [false, []]
75
79
  end
@@ -81,31 +85,34 @@ module PaperTrailDiff
81
85
  # @rbs @edges: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
82
86
  # @rbs @unsupported: Hash[Array[String], bool]
83
87
  # @rbs @habtm: Hash[Array[String], Hash[String, Array[untyped]]]
88
+ # @rbs @conditions: ScopeCondition::Cache
84
89
 
85
- #: (untyped, untyped) -> bool
86
- def supported?(owner_class, reflection)
90
+ # A polymorphic target's class is known only once a record names it, so
91
+ # belongs_to_records reads its scope for each record instead.
92
+ #: (untyped, untyped, bool) -> bool
93
+ def supported?(owner_class, reflection, ranked)
87
94
  !@unsupported[edge_key(owner_class, reflection)] &&
88
- reflection.scope.nil? && !reflection.klass.default_scopes?
95
+ (reflection.polymorphic? || !@conditions.call(reflection, ranked: ranked).nil?)
89
96
  end
90
97
 
91
- #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
92
- def direct_records(record, reflection, boundary, habtm_boundary)
98
+ #: (untyped, untyped, untyped, untyped, bool) -> Array[untyped]?
99
+ def direct_records(record, reflection, boundary, habtm_boundary, ranked)
93
100
  resolver = DIRECT_RESOLVERS.fetch(reflection.macro)
94
- send(resolver, record, reflection, boundary, habtm_boundary)
101
+ send(resolver, record, reflection, boundary, habtm_boundary, ranked)
95
102
  end
96
103
 
97
- #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
98
- def resolve_belongs_to(record, reflection, boundary, _habtm_boundary)
99
- belongs_to_records(record, reflection, boundary)
104
+ #: (untyped, untyped, untyped, untyped, bool) -> Array[untyped]?
105
+ def resolve_belongs_to(record, reflection, boundary, _habtm_boundary, ranked)
106
+ belongs_to_records(record, reflection, boundary, ranked)
100
107
  end
101
108
 
102
- #: (untyped, untyped, untyped, untyped) -> Array[untyped]
103
- def resolve_children(record, reflection, boundary, _habtm_boundary)
104
- child_records(record, reflection, boundary)
109
+ #: (untyped, untyped, untyped, untyped, bool) -> Array[untyped]
110
+ def resolve_children(record, reflection, boundary, _habtm_boundary, ranked)
111
+ child_records(record, reflection, boundary, ranked)
105
112
  end
106
113
 
107
- #: (untyped, untyped, untyped) -> Array[untyped]?
108
- def belongs_to_records(record, reflection, boundary)
114
+ #: (untyped, untyped, untyped, bool) -> Array[untyped]?
115
+ def belongs_to_records(record, reflection, boundary, ranked)
109
116
  foreign_key = scalar_foreign_key(reflection)
110
117
  return unless foreign_key
111
118
 
@@ -113,20 +120,23 @@ module PaperTrailDiff
113
120
  return [] if id.nil?
114
121
 
115
122
  model_class = AssociationTraversal.belongs_to_target(reflection, record)
116
- return unless model_class && @records.loaded?(model_class, id)
123
+ return unless model_class && @conditions.call(reflection, model_class, ranked: ranked) &&
124
+ @records.loaded?(model_class, id)
117
125
 
118
- Array(@records.record_before(model_class, id, boundary)).compact
126
+ target = Array(@records.record_before(model_class, id, boundary)).compact
127
+ @conditions.select(reflection, target, model_class, ranked: ranked)
119
128
  end
120
129
 
121
- #: (untyped, untyped, untyped) -> Array[untyped]
122
- def child_records(record, reflection, boundary)
123
- edge(record.class, reflection).values.flat_map do |group|
130
+ #: (untyped, untyped, untyped, bool) -> Array[untyped]
131
+ def child_records(record, reflection, boundary, ranked)
132
+ children = edge(record.class, reflection).values.flat_map do |group|
124
133
  model_class = group.fetch(:model)
125
134
  child_ids_for(group, record).filter_map do |id|
126
135
  child = @records.record_before(model_class, id, boundary)
127
136
  child if child && member_of?(child, reflection, record)
128
137
  end
129
138
  end
139
+ @conditions.select(reflection, children, ranked: ranked)
130
140
  end
131
141
 
132
142
  #: (Hash[Symbol, untyped], untyped) -> Array[untyped]
@@ -136,8 +146,8 @@ module PaperTrailDiff
136
146
  owners ? owners.fetch(owner.id.to_s, empty) : group.fetch(:ids)
137
147
  end
138
148
 
139
- #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
140
- def habtm_records(record, reflection, boundary, habtm_boundary)
149
+ #: (untyped, untyped, untyped, untyped, bool) -> Array[untyped]?
150
+ def habtm_records(record, reflection, boundary, habtm_boundary, ranked)
141
151
  transaction_id = transaction_id_for(habtm_boundary)
142
152
  return unless transaction_id
143
153
 
@@ -145,37 +155,35 @@ module PaperTrailDiff
145
155
  ids = @habtm.fetch(edge_key(record.class, reflection), empty)[transaction_id.to_s]
146
156
  return unless ids
147
157
 
148
- ids.filter_map do |id|
149
- @records.record_before(reflection.klass, id, boundary)
150
- end
158
+ members = ids.filter_map { |id| @records.record_before(reflection.klass, id, boundary) }
159
+ @conditions.select(reflection, members, ranked: ranked)
151
160
  end
152
161
 
153
- #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
154
- def through_records(record, reflection, boundary, habtm_boundary)
155
- handled, intermediates = resolve(
156
- record,
157
- reflection.through_reflection,
158
- boundary,
159
- habtm_boundary: habtm_boundary
160
- )
161
- return unless handled
162
-
163
- resolved = resolve_sources(intermediates, reflection, boundary, habtm_boundary)
164
- return unless resolved.all?(&:first)
162
+ # A has_many through reads its parts unranked, as its query does. Its own
163
+ # scope applies to each row, and a ranked read then keeps a record reached
164
+ # twice as one member.
165
+ #: (untyped, untyped, untyped, untyped, bool) -> Array[untyped]?
166
+ def through_records(record, reflection, boundary, habtm_boundary, ranked)
167
+ read = lambda do |owner, part|
168
+ resolve(owner, part, boundary, habtm_boundary: habtm_boundary,
169
+ ranked: ranked && !reflection.collection?)
170
+ end
171
+ rows = through_rows(record, reflection, read)
172
+ return unless rows
165
173
 
166
- unique_records(resolved.flat_map(&:last))
174
+ rows = @conditions.select(reflection, rows, ranked: ranked)
175
+ ranked ? unique_records(rows) : rows
167
176
  end
168
177
 
169
- #: (Array[untyped], untyped, untyped, untyped) -> Array[[bool, Array[untyped]]]
170
- def resolve_sources(intermediates, reflection, boundary, habtm_boundary)
171
- intermediates.map do |intermediate|
172
- resolve(
173
- intermediate,
174
- reflection.source_reflection,
175
- boundary,
176
- habtm_boundary: habtm_boundary
177
- )
178
- end
178
+ # The source records of each join record, once for each join.
179
+ #: (untyped, untyped, ^(untyped, untyped) -> [bool, Array[untyped]]) -> Array[untyped]?
180
+ def through_rows(record, reflection, read)
181
+ handled, joins = read.call(record, reflection.through_reflection)
182
+ return unless handled
183
+
184
+ joins = joins.select { |join| AssociationTraversal.source_type_join?(reflection, join) }
185
+ resolved = joins.map { |join| read.call(join, reflection.source_reflection) }
186
+ resolved.flat_map(&:last) if resolved.all?(&:first)
179
187
  end
180
188
 
181
189
  #: (untyped, untyped, untyped) -> bool
@@ -183,6 +191,7 @@ module PaperTrailDiff
183
191
  foreign_key = scalar_foreign_key(reflection)
184
192
  return false unless foreign_key
185
193
  return false unless child.public_send(foreign_key).to_s == owner.id.to_s
194
+ return false unless AssociationTraversal.sti_member?(reflection.klass, child.attributes)
186
195
  return true unless reflection.options[:as]
187
196
 
188
197
  owner_types(reflection, owner).include?(child.public_send(reflection.type).to_s)
@@ -4,16 +4,17 @@
4
4
  module PaperTrailDiff
5
5
  # Expands the explicit association tree into a request-scoped PreparedHistory.
6
6
  class PreparedHistoryLoader
7
- #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?root_ids: Array[untyped], ?start_at: untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
7
+ # Without `end_at`, the activity range closes on the last root version.
8
+ #: (untyped, root_ids: Array[untyped], root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, start_at: untyped, end_at: untyped, live_records: Array[untyped]) -> void
8
9
  def initialize( # rubocop:disable Metrics/ParameterLists
9
10
  record,
11
+ root_ids:,
10
12
  root_versions:,
11
13
  tree:,
12
14
  traversal:,
13
- root_ids: [record.id],
14
- start_at: root_versions.first.created_at,
15
- end_at: nil,
16
- live_records: []
15
+ start_at:,
16
+ end_at:,
17
+ live_records:
17
18
  )
18
19
  @record = record
19
20
  @root_ids = root_ids
@@ -21,12 +22,12 @@ module PaperTrailDiff
21
22
  @tree = tree
22
23
  @traversal = traversal
23
24
  @records = PreparedRecordIndex.new(start_at, end_at: end_at, live_records: live_records)
24
- activity = PreparedActivityIndex.new(
25
+ @activity = PreparedActivityIndex.new(
25
26
  @records, root_versions, start_at: start_at, end_at: end_at || root_versions.last.created_at
26
27
  )
27
- @history = PreparedHistory.new(@records, activity_index: activity)
28
+ @history = PreparedHistory.new(@records, activity_index: @activity)
28
29
  @edges = PreparedEdgeLoader.new(@records, root_versions, start_at: start_at)
29
- @prepared = {} #: Hash[Array[String], Array[String]]
30
+ @prepared = {} #: Hash[untyped, Array[String]]
30
31
  end
31
32
 
32
33
  #: () -> PreparedHistory
@@ -44,20 +45,18 @@ module PaperTrailDiff
44
45
  # @rbs @tree: AssociationTree
45
46
  # @rbs @traversal: AssociationTraversal
46
47
  # @rbs @records: PreparedRecordIndex
48
+ # @rbs @activity: PreparedActivityIndex
47
49
  # @rbs @history: PreparedHistory
48
50
  # @rbs @edges: PreparedEdgeLoader
49
- # @rbs @prepared: Hash[Array[String], Array[String]]
51
+ # @rbs @prepared: Hash[untyped, Array[String]]
50
52
 
51
53
  #: (untyped, Array[untyped], AssociationTree, path: String) -> void
52
54
  def load_node(model_class, ids, tree, path:)
53
- @history.activity_index&.register(model_class, ids)
55
+ @activity.register(model_class, ids)
54
56
  @traversal.reflections_for(model_class, tree, path: path).each do |reflection|
55
- subtree = tree.child(reflection.name)
56
- next unless subtree
57
-
57
+ subtree = tree.child(reflection.name) #: AssociationTree
58
58
  groups = prepare_reflection(model_class, ids, reflection)
59
- load_groups(groups)
60
- child_path = Support.association_path(path, reflection.name.to_s)
59
+ child_path = Support.association_path(path, reflection.name)
61
60
  groups.each_value do |group|
62
61
  load_node(group.fetch(:model), group.fetch(:ids), subtree, path: child_path)
63
62
  end
@@ -66,70 +65,66 @@ module PaperTrailDiff
66
65
 
67
66
  #: (untyped, Array[untyped], untyped) -> Hash[String, Hash[Symbol, untyped]]
68
67
  def prepare_reflection(owner_class, owner_ids, reflection)
69
- return prepare_through(owner_class, owner_ids, reflection) if reflection.options[:through]
68
+ return prepare_through(owner_class, owner_ids, reflection) if reflection.through_reflection?
70
69
 
71
70
  prepare_edge(owner_class, owner_ids, reflection)
72
71
  end
73
72
 
73
+ # Either part can itself be a through, which has no key of its own to
74
+ # read, so each part is prepared as what it is. Like an edge, it returns
75
+ # every group it holds, not only those of these owners.
74
76
  #: (untyped, Array[untyped], untyped) -> Hash[String, Hash[Symbol, untyped]]
75
77
  def prepare_through(owner_class, owner_ids, reflection)
76
- through_groups = prepare_edge(owner_class, owner_ids, reflection.through_reflection)
78
+ through_groups = prepare_reflection(owner_class, owner_ids, reflection.through_reflection)
77
79
  targets = {} #: Hash[String, Hash[Symbol, untyped]]
78
80
  through_groups.each_value do |group|
79
- load_groups({ group.fetch(:model).name.to_s => group })
80
- source = prepare_edge(group.fetch(:model), group.fetch(:ids), reflection.source_reflection)
81
+ source = prepare_reflection(group.fetch(:model), group.fetch(:ids),
82
+ reflection.source_reflection)
81
83
  targets = merge_groups(targets, source)
82
84
  end
83
85
  @history.merge_edge(owner_class, reflection, targets)
84
- @history.mark_unsupported(owner_class, reflection) if deferred_macro?(reflection)
85
- targets
86
86
  end
87
87
 
88
88
  #: (untyped, Array[untyped], untyped) -> Hash[String, Hash[Symbol, untyped]]
89
89
  def prepare_edge(owner_class, owner_ids, reflection)
90
- missing = unprepared_ids(owner_class, owner_ids, reflection)
90
+ missing = unprepared_ids(owner_ids, reflection)
91
91
  return @history.edge(owner_class, reflection) if missing.empty?
92
92
 
93
- load_records(owner_class, missing) if reflection.macro == :belongs_to
94
93
  groups, memberships = @edges.call(owner_class, missing, reflection)
95
94
  @history.merge_edge(owner_class, reflection, groups)
96
95
  @history.register_habtm(owner_class, reflection, memberships) if memberships
97
- mark_prepared(owner_class, missing, reflection)
96
+ mark_prepared(missing, reflection)
98
97
  mark_unsupported(owner_class, reflection, groups)
99
98
  load_groups(groups)
100
99
  @history.edge(owner_class, reflection)
101
100
  end
102
101
 
103
- #: (untyped, Array[untyped], untyped) -> Array[untyped]
104
- def unprepared_ids(owner_class, ids, reflection)
105
- prepared = @prepared.fetch(edge_key(owner_class, reflection), [])
102
+ # A reflection belongs to the class that declares it, so it alone names
103
+ # the edge; a subclass that inherits it reads the same records.
104
+ #: (Array[untyped], untyped) -> Array[untyped]
105
+ def unprepared_ids(ids, reflection)
106
+ prepared = @prepared.fetch(reflection, [])
106
107
  ids.reject { |id| prepared.include?(id.to_s) }
107
108
  end
108
109
 
109
- #: (untyped, Array[untyped], untyped) -> void
110
- def mark_prepared(owner_class, ids, reflection)
111
- key = edge_key(owner_class, reflection)
112
- @prepared[key] = (@prepared.fetch(key, []) | ids.map(&:to_s))
110
+ #: (Array[untyped], untyped) -> void
111
+ def mark_prepared(ids, reflection)
112
+ @prepared[reflection] = (@prepared.fetch(reflection, []) | ids.map(&:to_s))
113
113
  end
114
114
 
115
115
  #: (untyped, untyped, Hash[String, Hash[Symbol, untyped]]) -> void
116
116
  def mark_unsupported(owner_class, reflection, groups)
117
- unsupported = deferred_macro?(reflection) || reflection.foreign_key.is_a?(Array) ||
117
+ unsupported = reflection.foreign_key.is_a?(Array) ||
118
118
  groups.values.any? { |group| !versioned?(group.fetch(:model)) }
119
119
  @history.mark_unsupported(owner_class, reflection) if unsupported
120
120
  end
121
121
 
122
- #: (untyped) -> bool
123
- def deferred_macro?(reflection)
124
- reflection.options[:through] && !reflection.source_reflection.belongs_to?
125
- end
126
-
127
122
  #: (Hash[String, Hash[Symbol, untyped]]) -> void
128
123
  def load_groups(groups)
129
124
  groups.each_value do |group|
130
125
  model = group.fetch(:model)
131
126
  ids = group.fetch(:ids)
132
- @history.activity_index&.register(model, ids)
127
+ @activity.register(model, ids)
133
128
  load_records(model, ids)
134
129
  end
135
130
  end
@@ -144,11 +139,6 @@ module PaperTrailDiff
144
139
  Support.versioned?(model_class)
145
140
  end
146
141
 
147
- #: (untyped, untyped) -> Array[String]
148
- def edge_key(owner_class, reflection)
149
- [owner_class.base_class.name.to_s, reflection.name.to_s]
150
- end
151
-
152
142
  #: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
153
143
  def merge_groups(left, right)
154
144
  Support.merge_record_groups(left, right)
@@ -163,6 +163,22 @@ module PaperTrailDiff
163
163
  live ? [*recorded, live] : recorded
164
164
  end
165
165
 
166
+ # The classes the record had at any point, or either side of one version.
167
+ #: (?untyped) -> Array[untyped]
168
+ def model_classes(version = nil)
169
+ return states.map(&:model_class) unless version
170
+
171
+ states_around(version).map(&:model_class)
172
+ end
173
+
174
+ # The states either side of one version; a create has none before it and a
175
+ # destroy none after.
176
+ #: (untyped) -> Array[PreparedRecordState]
177
+ def states_around(version)
178
+ index = @version_positions.fetch(version.id.to_s)
179
+ [state_for(version), state_after(index)].compact
180
+ end
181
+
166
182
  #: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
167
183
  def transition(version)
168
184
  index = @version_positions[version.id.to_s]
@@ -337,6 +353,16 @@ module PaperTrailDiff
337
353
  @series.fetch(identity(model_class, id)).versions
338
354
  end
339
355
 
356
+ #: (untyped, untyped, ?untyped) -> Array[untyped]
357
+ def model_classes(model_class, id, version = nil)
358
+ @series.fetch(identity(model_class, id)).model_classes(version)
359
+ end
360
+
361
+ #: (untyped, untyped, untyped) -> Array[PreparedRecordState]
362
+ def states_around(model_class, id, version)
363
+ @series.fetch(identity(model_class, id)).states_around(version)
364
+ end
365
+
340
366
  #: (untyped, untyped) -> bool
341
367
  def loaded?(model_class, id)
342
368
  @series.key?(identity(model_class, id))
@@ -49,7 +49,7 @@ module PaperTrailDiff
49
49
  subtree = tree.child(reflection.name)
50
50
  return [false, nil] unless subtree
51
51
 
52
- child_path = Support.association_path(path, reflection.name.to_s)
52
+ child_path = Support.association_path(path, reflection.name)
53
53
  records = [] #: Array[RecordSnapshot]
54
54
  states.each do |state|
55
55
  supported, snapshot = build(state, boundary, subtree, normalizer, history,