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
@@ -25,7 +25,7 @@ module PaperTrailDiff
25
25
  routes = @route_finder.belongs_to_routes(
26
26
  endpoint_model_class(root_endpoint), tree, version.item_type.to_s
27
27
  )
28
- return [false, nil] if routes.empty?
28
+ return [false, nil] if routes.empty? || scoped_route?(routes)
29
29
 
30
30
  snapshot = previous
31
31
  routes.each do |route|
@@ -43,6 +43,15 @@ module PaperTrailDiff
43
43
  # @rbs @record_resolver: ActivityEventRecordResolver
44
44
  # @rbs @record_normalizer: ActivityEventRecordNormalizer
45
45
 
46
+ # An edit can move a target into or out of a scope, which a replacement
47
+ # in place cannot show, so a scoped route's branch is read again instead.
48
+ #: (Array[Array[untyped]]) -> bool
49
+ def scoped_route?(routes)
50
+ routes.any? do |route|
51
+ route.any? { |entry| ScopeCondition.scoped_path?(entry.fetch(1)) }
52
+ end
53
+ end
54
+
46
55
  #: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
47
56
  def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
48
57
  record = @record_resolver.record_after(version, direct: route.last.fetch(2).empty?)
@@ -91,8 +100,7 @@ module PaperTrailDiff
91
100
  #: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> [Array[RecordSnapshot], bool]
92
101
  def replace_existing_target(records, version, replacement)
93
102
  index = records.index do |record|
94
- record.id.to_s == version.item_id.to_s &&
95
- record.type.to_s == replacement_type(version, replacement)
103
+ record.id.to_s == version.item_id.to_s && same_table?(record, version)
96
104
  end
97
105
  return [records, false] unless index
98
106
 
@@ -101,16 +109,18 @@ module PaperTrailDiff
101
109
  [updated.freeze, true]
102
110
  end
103
111
 
104
- #: (untyped, RecordSnapshot?) -> String
105
- def replacement_type(version, replacement)
106
- return replacement.type.to_s if replacement
107
-
108
- Endpoint.model_class(version).name.to_s
112
+ # The target is matched by id within its table rather than by type name.
113
+ # Without `item_subtype`, a version names only the STI base class, and a
114
+ # row whose type changes is replaced by a record of another class.
115
+ #: (RecordSnapshot, untyped) -> bool
116
+ def same_table?(record, version)
117
+ model = AssociationTraversal.named_class(record.type.to_s)
118
+ !model.nil? && model.base_class == Endpoint.model_class(version).base_class
109
119
  end
110
120
 
111
121
  #: (untyped) -> untyped
112
122
  def endpoint_model_class(endpoint)
113
- Endpoint.version?(endpoint) ? Endpoint.model_class(endpoint) : endpoint.class
123
+ Endpoint.version?(endpoint) ? RecordedClass.for(endpoint) : endpoint.class
114
124
  end
115
125
  end
116
126
  end
@@ -14,7 +14,8 @@ module PaperTrailDiff
14
14
 
15
15
  #: (Array[Array[untyped]], untyped, ActivitySnapshotDelta?) -> bool
16
16
  def safe?(routes, version, delta)
17
- !unsafe_through_membership_event?(routes, version) &&
17
+ !scoped_route?(routes) &&
18
+ !unsafe_through_membership_event?(routes, version) &&
18
19
  !unsafe_nested_membership_delta?(routes, delta)
19
20
  end
20
21
 
@@ -62,6 +63,15 @@ module PaperTrailDiff
62
63
  @route_updater.call(snapshot, change).fetch(0)
63
64
  end
64
65
 
66
+ # Any edit can move a record into or out of a scope, so a scoped route's
67
+ # branch is read again from history instead.
68
+ #: (Array[Array[untyped]]) -> bool
69
+ def scoped_route?(routes)
70
+ routes.any? do |route|
71
+ route.any? { |entry| ScopeCondition.scoped_path?(entry.fetch(1)) }
72
+ end
73
+ end
74
+
65
75
  #: (Array[Array[untyped]], untyped) -> bool
66
76
  def unsafe_through_membership_event?(routes, version)
67
77
  return false if version.event.to_s == 'update'
@@ -33,12 +33,13 @@ module PaperTrailDiff
33
33
  #: (AssociationSnapshot, untyped, RecordSnapshot?) -> [Array[RecordSnapshot], RecordSnapshot?, RecordSnapshot?, bool]
34
34
  def call(association, version, replacement)
35
35
  records = association.records
36
- position = association.position(replacement_type(version, replacement), version.item_id)
36
+ position = association.position(replacement_type(version, replacement), version.item_id) ||
37
+ retyped_position(association, version)
37
38
  return [records, nil, nil, true] unless replacement || position
38
39
 
39
40
  before = records.fetch(position) if position
40
41
  updated = replacement_records(records, position, replacement)
41
- [updated, before, replacement, !before.nil? && !replacement.nil?]
42
+ [updated, before, replacement, !!before && before.identity == replacement&.identity]
42
43
  end
43
44
 
44
45
  #: (untyped, RecordSnapshot?) -> String
@@ -52,6 +53,19 @@ module PaperTrailDiff
52
53
 
53
54
  # @rbs @relationship: ActivityRelationship
54
55
 
56
+ # A row whose STI type changed is the same row under a new identity, so it
57
+ # is found by id among the records of its table.
58
+ #: (AssociationSnapshot, untyped) -> Integer?
59
+ def retyped_position(association, version)
60
+ position = association.position_for_id(version.item_id)
61
+ return unless position
62
+
63
+ type = association.records.fetch(position).type
64
+ position if Object.const_get(type).base_class == Endpoint.model_class(version).base_class
65
+ rescue NameError
66
+ nil
67
+ end
68
+
55
69
  #: (Array[RecordSnapshot], Integer?, RecordSnapshot?) -> Array[RecordSnapshot]
56
70
  def replacement_records(records, position, replacement)
57
71
  updated = records.dup
@@ -129,7 +129,26 @@ module PaperTrailDiff
129
129
  return unless before
130
130
 
131
131
  attributes = before.attributes.merge(after_attributes(changes, model))
132
- @direct_loader.complete(PreparedRecordState.from_attributes(model, attributes))
132
+ model_after = class_after(model, attributes)
133
+ return unless model_after
134
+
135
+ @direct_loader.complete(PreparedRecordState.from_attributes(model_after, attributes))
136
+ end
137
+
138
+ # Without `item_subtype` a version names only the STI base class, and a
139
+ # change can move the row to another class, so the type the row holds
140
+ # after the change decides. A type that names no class has no direct
141
+ # state; the caller falls back to reifying the record.
142
+ #: (untyped, Hash[untyped, untyped]) -> untyped
143
+ def class_after(model, attributes)
144
+ column = model.inheritance_column.to_s
145
+ return model unless model.columns_hash.key?(column)
146
+
147
+ type = attributes[column].to_s
148
+ type.empty? ? model.base_class : model.base_class.sti_class_for(type)
149
+ rescue StandardError => e
150
+ active_record = Object.const_get(:ActiveRecord) #: untyped
151
+ raise unless e.is_a?(active_record.const_get(:SubclassNotFound))
133
152
  end
134
153
 
135
154
  #: (untyped) -> untyped
@@ -70,7 +70,8 @@ module PaperTrailDiff
70
70
  entry = [name, reflection, subtree, child_path]
71
71
  direct = [] #: Array[Array[untyped]]
72
72
  direct << [entry] if direct_route?(reflection, target_type, macro)
73
- return direct if subtree.empty? || reflection.polymorphic?
73
+ # Validation rejects a path that continues past a polymorphic target.
74
+ return direct if subtree.empty?
74
75
 
75
76
  nested = routes(reflection.klass, subtree, target_type, macro, path: child_path)
76
77
  direct.concat(nested.map { |route| [entry, *route] })
@@ -111,7 +111,7 @@ module PaperTrailDiff
111
111
 
112
112
  #: (untyped) -> untyped
113
113
  def endpoint_model_class(endpoint)
114
- Endpoint.version?(endpoint) ? Endpoint.model_class(endpoint) : endpoint.class
114
+ Endpoint.version?(endpoint) ? RecordedClass.for(endpoint) : endpoint.class
115
115
  end
116
116
  end
117
117
  end
@@ -9,7 +9,6 @@ module PaperTrailDiff
9
9
 
10
10
  #: (untyped, untyped) -> void
11
11
  def initialize(start_boundary, end_boundary)
12
- @start_boundary = start_boundary
13
12
  @end_boundary = end_boundary
14
13
  @start_key = if Endpoint.version?(start_boundary)
15
14
  Support.chronological_version_key(start_boundary)
@@ -39,9 +38,22 @@ module PaperTrailDiff
39
38
  !Endpoint.version?(@end_boundary)
40
39
  end
41
40
 
41
+ # Whether the state a version recorded after its change held inside the
42
+ # range. A range ending at a version ends at the state before it, so the
43
+ # change that version records is past the end, unlike #include?.
44
+ #: (untyped, untyped) -> bool
45
+ def recorded_before_end?(created_at, id)
46
+ end_key = @end_key
47
+ return created_at <= @end_boundary unless end_key
48
+
49
+ comparison = Support.version_key(created_at, id) <=> end_key
50
+ raise ConfigurationError, 'versions have incomparable timestamps' unless comparison
51
+
52
+ comparison.negative?
53
+ end
54
+
42
55
  private
43
56
 
44
- # @rbs @start_boundary: untyped
45
57
  # @rbs @end_boundary: untyped
46
58
  # @rbs @start_key: Array[untyped]?
47
59
  # @rbs @start_time: untyped
@@ -11,6 +11,7 @@ module PaperTrailDiff
11
11
  # @type var expected_ids: Array[untyped]
12
12
  expected = expected_ids.map { |id| id.to_s } # rubocop:disable Style/SymbolProc
13
13
  return false unless actual == expected
14
+ return false unless sti_member?(record, reflection, state)
14
15
  return true unless reflection.options[:as]
15
16
 
16
17
  type = event_attribute(record, reflection.type, state: state).to_s
@@ -27,6 +28,17 @@ module PaperTrailDiff
27
28
 
28
29
  private
29
30
 
31
+ #: (untyped, untyped, Symbol) -> bool
32
+ def sti_member?(record, reflection, state)
33
+ model_class = reflection.klass
34
+ return true if model_class.descends_from_active_record?
35
+
36
+ column = model_class.inheritance_column
37
+ type = event_attribute(record, column, state: state)
38
+ attributes = { column => type } #: Hash[untyped, untyped]
39
+ AssociationTraversal.sti_member?(model_class, attributes)
40
+ end
41
+
30
42
  #: (untyped, untyped, state: Symbol) -> Array[String]
31
43
  def relationship_owner_values(record, reflection, state:)
32
44
  Array(reflection.foreign_key).map do |key|
@@ -33,7 +33,7 @@ module PaperTrailDiff
33
33
  return [false, nil] unless previous && event.root?
34
34
 
35
35
  version = event.version
36
- model_class = Endpoint.model_class(version)
36
+ model_class = RecordedClass.for(version)
37
37
  return [false, nil] unless same_record?(previous, version)
38
38
 
39
39
  changes = @changeset.call(version, model_class)
@@ -108,9 +108,12 @@ module PaperTrailDiff
108
108
  end.uniq.sort.freeze
109
109
  end
110
110
 
111
+ # A polymorphic target's class is known only per record, so it cannot be
112
+ # checked here; a target that is not versioned raises when it is read.
111
113
  #: (untyped) -> bool
112
114
  def direct_versioned_reflection?(reflection)
113
- return false if reflection.polymorphic? || reflection.options[:through]
115
+ return true if reflection.polymorphic?
116
+ return false if reflection.options[:through]
114
117
  return false if reflection.macro == :has_and_belongs_to_many
115
118
 
116
119
  paper_trail = Object.const_get(:PaperTrail) #: untyped
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Loads the `belongs_to` targets owners pointed at during one activity range:
6
+ # at its start and after each change inside it.
7
+ #
8
+ # PT-AT records a version's foreign keys as they stood after its change, so
9
+ # the target at the start comes from the latest version before it, and the
10
+ # version a range ends at names a target only after the range. Reading only
11
+ # the owners' versions inside the range misses a target whose owner did not
12
+ # change there -- an author renamed under an article nobody edited.
13
+ class ActivityTargetCandidateLoader
14
+ # A plain class rather than Data.define, which arrived in Ruby 3.2 while this
15
+ # gem supports 3.1.
16
+ class Row
17
+ attr_reader :owner_id #: untyped
18
+ attr_reader :foreign_key_id #: untyped
19
+ attr_reader :foreign_type #: String?
20
+ attr_reader :created_at #: untyped
21
+ attr_reader :event #: String?
22
+ attr_reader :version_id #: untyped
23
+
24
+ #: (untyped, untyped, String?, ?untyped, ?String?, ?untyped) -> void
25
+ def initialize(owner_id, foreign_key_id, foreign_type, created_at = nil, event = nil, # rubocop:disable Metrics/ParameterLists
26
+ version_id = nil)
27
+ @owner_id = owner_id
28
+ @foreign_key_id = foreign_key_id
29
+ @foreign_type = foreign_type
30
+ @created_at = created_at
31
+ @event = event
32
+ @version_id = version_id
33
+ freeze
34
+ end
35
+ end
36
+
37
+ #: (untyped, Array[untyped], untyped, ActivityRange) -> void
38
+ def initialize(owner_class, owner_ids, reflection, range)
39
+ @owner_class = owner_class
40
+ @owner_ids = owner_ids
41
+ @reflection = reflection
42
+ @range = range
43
+ end
44
+
45
+ #: () -> Array[Row]
46
+ def call
47
+ rows = historical_rows.select do |row|
48
+ @range.recorded_before_end?(row.created_at, row.version_id)
49
+ end
50
+ @range.current_end? ? rows + current_rows : rows
51
+ end
52
+
53
+ # One shared query keeps the owner and timestamp needed to partition a
54
+ # batch's candidates back into each root's exact activity range.
55
+ #: () -> Array[Row]
56
+ def historical_rows
57
+ return [] if composite_foreign_key?
58
+
59
+ version_class = @owner_class.paper_trail.version_class
60
+ relation = candidate_scope(version_class).call(historical_relation(version_class))
61
+ historical_columns(relation, version_class).map do |row|
62
+ owner_id, id, type, created_at, event, version_id = row
63
+ Row.new(Support.record_id(@owner_class, owner_id), id, type, created_at, event, version_id)
64
+ end
65
+ end
66
+
67
+ # The live row holds the state at a range that ends at the current record.
68
+ #: () -> Array[Row]
69
+ def current_rows
70
+ return [] if composite_foreign_key? || @owner_class.composite_primary_key?
71
+
72
+ live_rows.filter_map do |owner_id, id, type|
73
+ Row.new(owner_id, id, type) unless id.nil?
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ # @rbs @owner_class: untyped
80
+ # @rbs @owner_ids: Array[untyped]
81
+ # @rbs @reflection: untyped
82
+ # @rbs @range: ActivityRange
83
+
84
+ #: (untyped) -> untyped
85
+ def historical_relation(version_class)
86
+ association_class.joins(:version)
87
+ .where(foreign_key_name: @reflection.foreign_key)
88
+ .where(version_class.table_name => {
89
+ item_type: @owner_class.base_class.name, item_id: @owner_ids
90
+ })
91
+ end
92
+
93
+ #: (untyped, untyped) -> Array[Array[untyped]]
94
+ def historical_columns(relation, version_class)
95
+ versions = version_class.arel_table
96
+ associations = association_class.arel_table
97
+ relation.pluck(versions[:item_id], associations[:foreign_key_id],
98
+ associations[:foreign_type], versions[:created_at], versions[:event],
99
+ versions[:id])
100
+ end
101
+
102
+ # A polymorphic key names its target in a type column; any other names it
103
+ # in the reflection, so no type is read. Like the history, the live read
104
+ # takes owners of every class under the base class: a batch can ask for
105
+ # the owners of an association through one subclass.
106
+ #: () -> Array[Array[untyped]]
107
+ def live_rows
108
+ relation = @owner_class.base_class.unscoped.where(@owner_class.primary_key => @owner_ids)
109
+ key = @reflection.foreign_key
110
+ return relation.pluck(@owner_class.primary_key, key) unless @reflection.polymorphic?
111
+
112
+ relation.pluck(@owner_class.primary_key, key, @reflection.foreign_type)
113
+ end
114
+
115
+ #: (untyped) -> VersionAssociationCandidateScope
116
+ def candidate_scope(version_class)
117
+ VersionAssociationCandidateScope.new(version_class, @range.start_time,
118
+ end_at: @range.end_time)
119
+ end
120
+
121
+ #: () -> untyped
122
+ def association_class
123
+ @owner_class.paper_trail.version_association_class
124
+ end
125
+
126
+ #: () -> bool
127
+ def composite_foreign_key?
128
+ @reflection.foreign_key.is_a?(Array)
129
+ end
130
+ end
131
+ end
@@ -6,8 +6,8 @@ module PaperTrailDiff
6
6
  class ActivityTimelineBuilder
7
7
  include ActivityGrouping
8
8
 
9
- #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
10
- def initialize(record, range:, tree:, snapshotter:, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists
9
+ #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, snapshots: bool, group: Symbol?) -> void
10
+ def initialize(record, range:, tree:, snapshotter:, snapshots:, group:) # rubocop:disable Metrics/ParameterLists
11
11
  ActivityGrouping.validate!(group)
12
12
  @snapshots = snapshots
13
13
  @group = group
@@ -15,7 +15,6 @@ module PaperTrailDiff
15
15
  # the build even when the caller did not ask to keep them.
16
16
  @retain = snapshots || grouping?
17
17
  @record = record
18
- @from = range.from
19
18
  @to = range.to
20
19
  @range = range
21
20
  @tree = tree
@@ -40,6 +39,8 @@ module PaperTrailDiff
40
39
  return analyze_current if Endpoint.record?(@to)
41
40
 
42
41
  plan = @range.select_plan
42
+ return Analysis.empty if plan.empty?
43
+
43
44
  root_versions = plan.reconstruction_versions
44
45
  prepare_history(root_versions)
45
46
  events = collect_events(root_versions)
@@ -49,7 +50,6 @@ module PaperTrailDiff
49
50
  private
50
51
 
51
52
  # @rbs @record: untyped
52
- # @rbs @from: untyped
53
53
  # @rbs @to: untyped
54
54
  # @rbs @range: TimelineRange
55
55
  # @rbs @tree: AssociationTree
@@ -65,10 +65,14 @@ module PaperTrailDiff
65
65
  # Every boundary in the span, filtered out or not. A filter narrows where the
66
66
  # span starts and ends, but the sequence inside it stays complete: dropping
67
67
  # boundaries would fold the changes they carried into a neighbouring step and
68
- # credit them to whoever made that one.
68
+ # credit them to whoever made that one. A filter that keeps no version
69
+ # leaves no span, as it does for a checkpoint timeline.
69
70
  #: () -> Array[ActivityStep]
70
71
  def build_between_versions
71
- root_versions = @range.select_plan.reconstruction_versions
72
+ plan = @range.select_plan
73
+ return no_steps if plan.empty?
74
+
75
+ root_versions = plan.reconstruction_versions
72
76
  prepare_history(root_versions)
73
77
  events = collect_events(root_versions)
74
78
  history = event_history(root_versions, events)
@@ -102,7 +106,7 @@ module PaperTrailDiff
102
106
  def current_history
103
107
  validate_current_range!
104
108
  plan = @range.select_plan
105
- return [plan, [], ActivityHistory.empty, nil, nil] if plan.empty?
109
+ return empty_history(plan) if plan.empty?
106
110
 
107
111
  snapshot, captured_at = capture_current if plan.closing_record
108
112
  versions = plan.reconstruction_versions
@@ -113,6 +117,14 @@ module PaperTrailDiff
113
117
  [plan, events, history, snapshot, boundary]
114
118
  end
115
119
 
120
+ # Without a closing boundary nothing reads the events or the closing pair,
121
+ # so their mutations cannot change an answer.
122
+ # mutant:disable
123
+ #: (RootVersionPlan) -> [RootVersionPlan, Array[ActivityEvent], ActivityHistory, RecordSnapshot?, ActivityBoundary?]
124
+ def empty_history(plan)
125
+ [plan, [], ActivityHistory.empty, nil, nil]
126
+ end
127
+
116
128
  #: () -> [RecordSnapshot?, untyped]
117
129
  def capture_current
118
130
  [@snapshotter.call(@to, @to), Time.now.utc]
@@ -120,12 +132,11 @@ module PaperTrailDiff
120
132
 
121
133
  #: () -> void
122
134
  def validate_current_range!
123
- unless Endpoint.version?(@from)
135
+ unless Endpoint.version?(@range.from)
124
136
  raise InvalidTimelineRangeError, '`from` must be a root PaperTrail version'
125
137
  end
126
138
 
127
- Endpoint.validate_pair!(@from, @to)
128
- Endpoint.validate_pair!(@record, @to)
139
+ Endpoint.validate_pair!(@range.from, @to)
129
140
  AssociationTraversal.new(@tree).reject_live_habtm_activity!(@record.class)
130
141
  end
131
142
 
@@ -139,7 +150,6 @@ module PaperTrailDiff
139
150
  @record,
140
151
  root_versions: root_versions,
141
152
  tree: @tree,
142
- traversal: AssociationTraversal.new(@tree),
143
153
  prepared: (@snapshotter.activity_index if @snapshotter.respond_to?(:activity_index)),
144
154
  range_start: range_start,
145
155
  range_end: range_end
@@ -156,14 +166,15 @@ module PaperTrailDiff
156
166
 
157
167
  # Appends the transition into an explicit closing boundary, which is either
158
168
  # a requested current record or the absence a final root destroy leaves.
169
+ # Either follows the root versions, which are themselves events.
159
170
  #: (ActivityHistory, Array[ActivityEvent], ActivityBoundary?, RecordSnapshot?) -> Array[ActivityStep]
160
171
  def activity_steps(history, events, final_boundary, final_snapshot)
161
172
  steps = history.steps.dup
162
- previous_event = events.last
163
- previous_boundary = ActivityBoundary.from_version(previous_event.version) if previous_event
164
- if final_boundary && previous_boundary
173
+ if final_boundary
174
+ last_event = events.last #: ActivityEvent
165
175
  steps << ActivityStep.between(
166
- from_boundary: previous_boundary, to_boundary: final_boundary,
176
+ from_boundary: ActivityBoundary.from_version(last_event.version),
177
+ to_boundary: final_boundary,
167
178
  from_snapshot: history.last_snapshot, to_snapshot: final_snapshot,
168
179
  retain: @retain
169
180
  )
@@ -176,7 +187,7 @@ module PaperTrailDiff
176
187
  #: (Array[untyped]) -> ActivityBoundary?
177
188
  def destroyed_boundary(root_versions)
178
189
  version = root_versions.last
179
- return unless version && version.event.to_s == 'destroy'
190
+ return unless version.event == 'destroy'
180
191
 
181
192
  ActivityBoundary.destroyed(version)
182
193
  end
@@ -210,14 +221,9 @@ module PaperTrailDiff
210
221
 
211
222
  #: () -> TimeActivityTimelineBuilder
212
223
  def time_builder
213
- TimeActivityTimelineBuilder.new(
214
- @record,
215
- range: @range,
216
- tree: @tree,
217
- snapshotter: @snapshotter,
218
- snapshots: @snapshots,
219
- group: @group
220
- )
224
+ TimeActivityTimelineBuilder.new(@record, range: @range, tree: @tree,
225
+ snapshotter: @snapshotter, snapshots: @snapshots,
226
+ group: @group)
221
227
  end
222
228
  end
223
229
  end
@@ -4,12 +4,11 @@
4
4
  module PaperTrailDiff
5
5
  # Collects root and selected-descendant versions relevant to one root history range.
6
6
  class ActivityVersionCollector
7
- #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal, ?range_start: untyped, ?range_end: untyped, ?prepared: PreparedActivityIndex?) -> void
7
+ #: (untyped, root_versions: Array[untyped], tree: AssociationTree, ?range_start: untyped, ?range_end: untyped, ?prepared: PreparedActivityIndex?) -> void
8
8
  def initialize( # rubocop:disable Metrics/ParameterLists
9
9
  record,
10
10
  root_versions:,
11
11
  tree:,
12
- traversal:,
13
12
  range_start: root_versions.first,
14
13
  range_end: root_versions.last,
15
14
  prepared: nil
@@ -18,7 +17,7 @@ module PaperTrailDiff
18
17
  @record = record
19
18
  @root_versions = root_versions
20
19
  @tree = tree
21
- @traversal = traversal
20
+ @traversal = AssociationTraversal.new(tree)
22
21
  @range = ActivityRange.new(range_start, range_end)
23
22
  @registry = ActivityEventRegistry.new
24
23
  end
@@ -62,7 +61,7 @@ module PaperTrailDiff
62
61
  @registry.add(group.fetch(:versions), branch: branch)
63
62
  next if subtree.empty?
64
63
 
65
- child_path = Support.association_path(path, reflection.name.to_s)
64
+ child_path = Support.association_path(path, reflection.name)
66
65
  collect_tree(group.fetch(:model), group.fetch(:ids), subtree,
67
66
  path: child_path, branch: branch)
68
67
  end
@@ -74,7 +73,7 @@ module PaperTrailDiff
74
73
 
75
74
  case reflection.macro
76
75
  when :belongs_to
77
- belongs_to_groups(parent.fetch(:model), parent.fetch(:versions), reflection)
76
+ belongs_to_groups(parent.fetch(:model), parent.fetch(:ids), reflection)
78
77
  when :has_one, :has_many
79
78
  direct_child_groups(parent.fetch(:model), parent.fetch(:ids), reflection)
80
79
  when :has_and_belongs_to_many
@@ -90,24 +89,35 @@ module PaperTrailDiff
90
89
  intermediate = edge_groups(parent, through, branch)
91
90
  result = {} #: Hash[String, untyped]
92
91
  intermediate.each_value do |group|
93
- @registry.add(group.fetch(:versions), branch: branch)
92
+ # A through is always read with prepared history, which knows the
93
+ # join row's state either side of each version.
94
+ @registry.add(@prepared&.join_versions(group, reflection) || group.fetch(:versions),
95
+ branch: branch)
94
96
  source = reflection.source_reflection
95
97
  children = edge_groups(group, source, branch)
96
98
  merge_groups!(result, children)
97
99
  end
98
- result
100
+ # Under `source_type:`, only targets of that type are members.
101
+ reflection.options[:source_type]&.then { |type| result.slice(type.to_s) } || result
99
102
  end
100
103
 
104
+ # Every target the owners pointed at in the range, not only those their own
105
+ # changes there recorded: an author renamed under an article nobody edited
106
+ # is still that article's activity.
101
107
  #: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
102
- def belongs_to_groups(parent_class, parent_versions, reflection)
103
- if (prepared = @prepared)
104
- rows = prepared.association_rows(parent_class, parent_versions, reflection)
105
- return identity_groups(rows, reflection)
106
- end
107
- rows = parent_class.paper_trail.version_association_class.where(
108
- version_id: parent_versions.map(&:id),
109
- foreign_key_name: reflection.foreign_key.to_s
110
- ).to_a
108
+ def belongs_to_groups(parent_class, parent_ids, reflection)
109
+ prepared = @prepared
110
+ return unprepared_belongs_to_groups(parent_class, parent_ids, reflection) unless prepared
111
+
112
+ # Prepared history holds every target its snapshots can contain. One it
113
+ # did not load -- a key changed without a version -- has no state to compare.
114
+ rows = prepared.target_rows(parent_class, parent_ids, reflection, @range)
115
+ identity_groups(rows, reflection) { |model_class, id| prepared.loaded?(model_class, id) }
116
+ end
117
+
118
+ #: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
119
+ def unprepared_belongs_to_groups(parent_class, parent_ids, reflection)
120
+ rows = ActivityTargetCandidateLoader.new(parent_class, parent_ids, reflection, @range).call
111
121
  identity_groups(rows, reflection)
112
122
  end
113
123
 
@@ -127,8 +137,7 @@ module PaperTrailDiff
127
137
  #: (untyped, Array[untyped], untyped) -> Hash[String, untyped]
128
138
  def habtm_groups(parent_class, parent_versions, reflection)
129
139
  if (prepared = @prepared)
130
- rows = prepared.association_rows(parent_class, parent_versions, reflection,
131
- transactions: true)
140
+ rows = prepared.habtm_rows(parent_class, parent_versions, reflection)
132
141
  return identity_groups(rows, reflection)
133
142
  end
134
143
  transaction_ids = parent_versions.filter_map do |version|
@@ -141,7 +150,7 @@ module PaperTrailDiff
141
150
  identity_groups(rows, reflection)
142
151
  end
143
152
 
144
- #: (Array[untyped], untyped) -> Hash[String, untyped]
153
+ #: (Array[untyped], untyped) ?{ (untyped, untyped) -> bool } -> Hash[String, untyped]
145
154
  def identity_groups(rows, reflection)
146
155
  identities = rows.group_by { |row| target_class(reflection, row) }
147
156
  groups = {} #: Hash[String, untyped]
@@ -149,6 +158,7 @@ module PaperTrailDiff
149
158
  next unless model_class
150
159
 
151
160
  ids = class_rows.filter_map(&:foreign_key_id).uniq
161
+ ids = ids.select { |id| yield(model_class, id) } if block_given?
152
162
  groups[model_class.name] = group(model_class, ids)
153
163
  end
154
164
  groups
@@ -156,6 +166,7 @@ module PaperTrailDiff
156
166
 
157
167
  #: (untyped, Array[untyped]) -> Hash[Symbol, untyped]
158
168
  def group(model_class, ids)
169
+ ids = @prepared&.member_ids(model_class, ids) || ids
159
170
  { model: model_class, ids: ids, versions: versions_for(model_class, ids) }
160
171
  end
161
172
 
@@ -178,7 +189,7 @@ module PaperTrailDiff
178
189
  return [] if ids.empty?
179
190
 
180
191
  if (prepared = @prepared)
181
- return prepared.versions(model_class, ids).select { |version| @range.include?(version) }
192
+ return prepared.member_versions(model_class, ids, @range)
182
193
  end
183
194
 
184
195
  version_class = model_class.paper_trail.version_class