paper_trail_diff 0.11.0 → 0.13.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1367
  5. data/docs/architecture.md +88 -0
  6. data/docs/performance.md +309 -0
  7. data/docs/reference.md +977 -0
  8. data/docs/testing.md +146 -0
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
  10. data/lib/paper_trail_diff/activity_boundary.rb +20 -8
  11. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
  12. data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
  13. data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
  14. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
  15. data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
  16. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
  17. data/lib/paper_trail_diff/activity_grouping.rb +12 -0
  18. data/lib/paper_trail_diff/activity_history.rb +9 -3
  19. data/lib/paper_trail_diff/activity_relationship.rb +2 -0
  20. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
  21. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
  22. data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
  23. data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
  24. data/lib/paper_trail_diff/analysis.rb +9 -4
  25. data/lib/paper_trail_diff/analysis_batch.rb +28 -7
  26. data/lib/paper_trail_diff/association_traversal.rb +23 -0
  27. data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
  28. data/lib/paper_trail_diff/batch_safety.rb +55 -0
  29. data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
  30. data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
  33. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  34. data/lib/paper_trail_diff/endpoint.rb +10 -0
  35. data/lib/paper_trail_diff/engine.rb +4 -2
  36. data/lib/paper_trail_diff/errors.rb +9 -0
  37. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  38. data/lib/paper_trail_diff/historical_filter.rb +181 -0
  39. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  40. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  41. data/lib/paper_trail_diff/nested_comparator.rb +59 -24
  42. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  43. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  44. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  45. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  46. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  47. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  48. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  49. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  50. data/lib/paper_trail_diff/root_version_plan.rb +25 -2
  51. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  52. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  53. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  54. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  55. data/lib/paper_trail_diff/snapshot.rb +9 -0
  56. data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
  57. data/lib/paper_trail_diff/step.rb +10 -5
  58. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  59. data/lib/paper_trail_diff/support.rb +13 -3
  60. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  61. data/lib/paper_trail_diff/time_range.rb +22 -2
  62. data/lib/paper_trail_diff/time_version_range.rb +1 -15
  63. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  64. data/lib/paper_trail_diff/timeline_range.rb +25 -43
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  66. data/lib/paper_trail_diff/traversal.rb +1 -1
  67. data/lib/paper_trail_diff/value_objects.rb +66 -0
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  70. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  71. data/lib/paper_trail_diff/version_query.rb +33 -0
  72. data/lib/paper_trail_diff/version_range.rb +10 -38
  73. data/lib/paper_trail_diff.rb +81 -43
  74. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  76. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  77. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  78. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  79. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  80. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  81. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  82. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  83. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  84. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  85. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  86. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  87. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  88. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  89. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  90. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  93. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  94. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  95. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  96. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  97. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  98. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  99. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  100. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  101. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  102. data/sig/generated/paper_trail_diff/nested_comparator.rbs +31 -18
  103. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  104. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  105. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  106. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  107. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  108. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  109. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  110. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  111. data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
  112. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  113. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  114. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  115. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  116. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  117. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
  118. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  119. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  120. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  121. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  122. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  123. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  124. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  125. data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
  126. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  127. data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
  128. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  129. data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
  130. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  131. data/sig/generated/paper_trail_diff.rbs +37 -27
  132. metadata +31 -5
@@ -4,17 +4,21 @@
4
4
  module PaperTrailDiff
5
5
  # Batched record states and relationship candidates for one activity range.
6
6
  class PreparedHistory
7
+ attr_reader :activity_index #: PreparedActivityIndex?
8
+
7
9
  DIRECT_RESOLVERS = {
8
10
  belongs_to: :resolve_belongs_to,
9
11
  has_one: :resolve_children,
10
12
  has_many: :resolve_children,
11
- has_and_belongs_to_many: :resolve_habtm
13
+ has_and_belongs_to_many: :habtm_records
12
14
  }.freeze
13
15
  private_constant :DIRECT_RESOLVERS
14
16
 
15
- #: (PreparedRecordIndex) -> void
16
- def initialize(records)
17
+ #: (PreparedRecordIndex, ?activity_index: PreparedActivityIndex?) -> void
18
+ def initialize(records, activity_index: nil)
17
19
  @records = records
20
+ @state_reader = PreparedStateAssociationReader.new(records, method(:edge))
21
+ @activity_index = activity_index
18
22
  @edges = {} #: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
19
23
  @unsupported = {} #: Hash[Array[String], bool]
20
24
  @habtm = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
@@ -24,7 +28,7 @@ module PaperTrailDiff
24
28
  def merge_edge(owner_class, reflection, groups)
25
29
  key = edge_key(owner_class, reflection)
26
30
  empty = {} #: Hash[String, Hash[Symbol, untyped]]
27
- @edges[key] = merge_groups(@edges.fetch(key, empty), groups)
31
+ @edges[key] = Support.merge_record_groups(@edges.fetch(key, empty), groups)
28
32
  end
29
33
 
30
34
  #: (untyped, untyped) -> Hash[String, Hash[Symbol, untyped]]
@@ -48,6 +52,16 @@ module PaperTrailDiff
48
52
  @records.transition(model_class, id, version)
49
53
  end
50
54
 
55
+ #: (untyped, untyped, untyped, ?direct: bool) -> [bool, untyped]
56
+ def record_after(model_class, id, version, direct: false)
57
+ @records.record_after(model_class, id, version, direct: direct)
58
+ end
59
+
60
+ #: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
61
+ def snapshot_states(owner, reflection, boundary)
62
+ @state_reader.call(owner, reflection, boundary)
63
+ end
64
+
51
65
  #: (untyped, untyped, untyped, habtm_boundary: untyped) -> [bool, Array[untyped]]
52
66
  def resolve(record, reflection, boundary, habtm_boundary:)
53
67
  return [false, []] unless supported?(record.class, reflection)
@@ -63,6 +77,7 @@ module PaperTrailDiff
63
77
  private
64
78
 
65
79
  # @rbs @records: PreparedRecordIndex
80
+ # @rbs @state_reader: PreparedStateAssociationReader
66
81
  # @rbs @edges: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
67
82
  # @rbs @unsupported: Hash[Array[String], bool]
68
83
  # @rbs @habtm: Hash[Array[String], Hash[String, Array[untyped]]]
@@ -70,12 +85,7 @@ module PaperTrailDiff
70
85
  #: (untyped, untyped) -> bool
71
86
  def supported?(owner_class, reflection)
72
87
  !@unsupported[edge_key(owner_class, reflection)] &&
73
- reflection.scope.nil? && !default_scoped?(reflection.klass)
74
- end
75
-
76
- #: (untyped) -> bool
77
- def default_scoped?(model_class)
78
- model_class.default_scopes?
88
+ reflection.scope.nil? && !reflection.klass.default_scopes?
79
89
  end
80
90
 
81
91
  #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
@@ -94,11 +104,6 @@ module PaperTrailDiff
94
104
  child_records(record, reflection, boundary)
95
105
  end
96
106
 
97
- #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
98
- def resolve_habtm(record, reflection, boundary, habtm_boundary)
99
- habtm_records(record, reflection, boundary, habtm_boundary)
100
- end
101
-
102
107
  #: (untyped, untyped, untyped) -> Array[untyped]?
103
108
  def belongs_to_records(record, reflection, boundary)
104
109
  foreign_key = scalar_foreign_key(reflection)
@@ -107,7 +112,7 @@ module PaperTrailDiff
107
112
  id = record.public_send(foreign_key)
108
113
  return [] if id.nil?
109
114
 
110
- model_class = belongs_to_class(record, reflection)
115
+ model_class = AssociationTraversal.belongs_to_target(reflection, record)
111
116
  return unless model_class && @records.loaded?(model_class, id)
112
117
 
113
118
  Array(@records.record_before(model_class, id, boundary)).compact
@@ -189,15 +194,6 @@ module PaperTrailDiff
189
194
  key.to_s unless key.is_a?(Array)
190
195
  end
191
196
 
192
- #: (untyped, untyped) -> untyped
193
- def belongs_to_class(record, reflection)
194
- return reflection.klass unless reflection.polymorphic?
195
-
196
- Object.const_get(record.public_send(reflection.foreign_type).to_s)
197
- rescue NameError
198
- nil
199
- end
200
-
201
197
  #: (untyped, untyped) -> Array[String]
202
198
  def owner_types(reflection, owner)
203
199
  types = [owner.class.name, owner.class.base_class.name, reflection.active_record.name]
@@ -218,10 +214,5 @@ module PaperTrailDiff
218
214
  def edge_key(owner_class, reflection)
219
215
  [owner_class.base_class.name.to_s, reflection.name.to_s]
220
216
  end
221
-
222
- #: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
223
- def merge_groups(existing, incoming)
224
- Support.merge_record_groups(existing, incoming)
225
- end
226
217
  end
227
218
  end
@@ -17,16 +17,21 @@ module PaperTrailDiff
17
17
  )
18
18
  @record = record
19
19
  @root_ids = root_ids
20
+ @root_versions = root_versions
20
21
  @tree = tree
21
22
  @traversal = traversal
22
23
  @records = PreparedRecordIndex.new(start_at, end_at: end_at, live_records: live_records)
23
- @history = PreparedHistory.new(@records)
24
+ activity = PreparedActivityIndex.new(
25
+ @records, root_versions, start_at: start_at, end_at: end_at || root_versions.last.created_at
26
+ )
27
+ @history = PreparedHistory.new(@records, activity_index: activity)
24
28
  @edges = PreparedEdgeLoader.new(@records, root_versions, start_at: start_at)
25
29
  @prepared = {} #: Hash[Array[String], Array[String]]
26
30
  end
27
31
 
28
32
  #: () -> PreparedHistory
29
33
  def call
34
+ @records.seed(@record.class, @root_ids, @root_versions)
30
35
  load_node(@record.class, @root_ids, @tree, path: '')
31
36
  @history
32
37
  end
@@ -35,6 +40,7 @@ module PaperTrailDiff
35
40
 
36
41
  # @rbs @record: untyped
37
42
  # @rbs @root_ids: Array[untyped]
43
+ # @rbs @root_versions: Array[untyped]
38
44
  # @rbs @tree: AssociationTree
39
45
  # @rbs @traversal: AssociationTraversal
40
46
  # @rbs @records: PreparedRecordIndex
@@ -44,6 +50,7 @@ module PaperTrailDiff
44
50
 
45
51
  #: (untyped, Array[untyped], AssociationTree, path: String) -> void
46
52
  def load_node(model_class, ids, tree, path:)
53
+ @history.activity_index&.register(model_class, ids)
47
54
  @traversal.reflections_for(model_class, tree, path: path).each do |reflection|
48
55
  subtree = tree.child(reflection.name)
49
56
  next unless subtree
@@ -119,7 +126,12 @@ module PaperTrailDiff
119
126
 
120
127
  #: (Hash[String, Hash[Symbol, untyped]]) -> void
121
128
  def load_groups(groups)
122
- groups.each_value { |group| load_records(group.fetch(:model), group.fetch(:ids)) }
129
+ groups.each_value do |group|
130
+ model = group.fetch(:model)
131
+ ids = group.fetch(:ids)
132
+ @history.activity_index&.register(model, ids)
133
+ load_records(model, ids)
134
+ end
123
135
  end
124
136
 
125
137
  #: (untyped, Array[untyped]) -> void
@@ -10,7 +10,7 @@ module PaperTrailDiff
10
10
  #: (untyped) -> void
11
11
  def initialize(record)
12
12
  @model_class = record.class
13
- @attributes = Support.immutable_copy(record.attributes)
13
+ @attributes = stored_attributes(record.class, record.attributes)
14
14
  freeze
15
15
  end
16
16
 
@@ -36,9 +36,18 @@ module PaperTrailDiff
36
36
  #: (untyped, Hash[untyped, untyped]) -> void
37
37
  def initialize_attributes(model_class, attributes)
38
38
  @model_class = model_class
39
- @attributes = Support.immutable_copy(attributes)
39
+ @attributes = stored_attributes(model_class, attributes)
40
40
  freeze
41
41
  end
42
+
43
+ # A live fallback and a version decoded from history must agree on what
44
+ # the stored history could represent, or the gap between them reads as a
45
+ # change.
46
+ #: (untyped, Hash[untyped, untyped]) -> Hash[untyped, untyped]
47
+ def stored_attributes(model_class, attributes)
48
+ digits = StoredTimePrecision.digits(model_class)
49
+ Support.immutable_copy(StoredTimePrecision.apply(attributes, digits))
50
+ end
42
51
  end
43
52
 
44
53
  # Extracts a version's scalar state without constructing a disposable AR object.
@@ -121,17 +130,37 @@ module PaperTrailDiff
121
130
 
122
131
  #: (untyped) -> untyped
123
132
  def record_before(boundary)
133
+ state_before(boundary)&.instantiate
134
+ end
135
+
136
+ #: (untyped) -> PreparedRecordState?
137
+ def state_before(boundary)
124
138
  index = boundary_index(boundary)
125
- return state_for(versions.fetch(index))&.instantiate if index
139
+ return state_for(versions.fetch(index)) if index
140
+
141
+ @live
142
+ end
143
+
144
+ # A handled nil is known absence, not permission to query the current row.
145
+ #: (untyped, ?direct: bool) -> [bool, untyped]
146
+ def record_after(version, direct: false)
147
+ index = @version_positions[version.id.to_s]
148
+ return [false, nil] unless index
126
149
 
127
- @live&.instantiate
150
+ state = state_after(index)
151
+ [true, direct ? state : state&.instantiate]
128
152
  end
129
153
 
130
154
  #: () -> Array[untyped]
131
155
  def records
132
- version_records = versions.filter_map { |version| state_for(version)&.instantiate }
156
+ states.map(&:instantiate)
157
+ end
158
+
159
+ #: () -> Array[PreparedRecordState]
160
+ def states
161
+ recorded = versions.filter_map { |version| state_for(version) }
133
162
  live = @live
134
- live ? [*version_records, live.instantiate] : version_records
163
+ live ? [*recorded, live] : recorded
135
164
  end
136
165
 
137
166
  #: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
@@ -164,11 +193,15 @@ module PaperTrailDiff
164
193
  # boundary's transaction. The first is monotonic over the chronological
165
194
  # list and the second is indexed, so neither rescans a long history once
166
195
  # per boundary.
196
+ #
197
+ # "At or after" is decided by the version order rather than the timestamp
198
+ # alone. A version written in the same instant as the boundary but before
199
+ # it already took effect there; reading its pre-change state would put the
200
+ # boundary on the wrong side of a change it follows.
167
201
  #: (untyped) -> Integer?
168
202
  def boundary_index(boundary)
169
- created_at = boundary.created_at
170
203
  chronological = versions.bsearch_index do |candidate|
171
- candidate.created_at >= created_at
204
+ VersionBoundary.at_or_after?(candidate, boundary)
172
205
  end
173
206
  transactional = transaction_index(boundary)
174
207
  return chronological unless transactional
@@ -200,6 +233,7 @@ module PaperTrailDiff
200
233
  #: (untyped) -> PreparedRecordState?
201
234
  def state_for(version)
202
235
  return @states[version.id] if @states.key?(version.id)
236
+ return @states[version.id] = nil unless version.object
203
237
 
204
238
  prepared = @state_loader.call(version)
205
239
  return @states[version.id] = prepared if prepared
@@ -226,6 +260,8 @@ module PaperTrailDiff
226
260
  end
227
261
  @state_loader = PreparedVersionStateLoader.new
228
262
  @series = {} #: Hash[Array[String], PreparedRecordSeries]
263
+ @direct_loader = DirectRecordStateLoader.new
264
+ @direct_states = {} #: Hash[PreparedRecordState, PreparedRecordState?]
229
265
  end
230
266
 
231
267
  #: (untyped, Array[untyped]) -> void
@@ -240,21 +276,67 @@ module PaperTrailDiff
240
276
  missing.each { |id| add_series(model_class, id, versions, live) }
241
277
  end
242
278
 
279
+ #: (untyped, Array[untyped], Array[untyped]) -> void
280
+ def seed(model_class, ids, versions)
281
+ grouped = versions.group_by { |version| version.item_id.to_s }
282
+ live = available_live_records(model_class, ids).to_h { |record| [record.id.to_s, record] }
283
+ ids.each { |id| add_series(model_class, id, grouped, live) }
284
+ end
285
+
243
286
  #: (untyped, untyped, untyped) -> untyped
244
287
  def record_before(model_class, id, boundary)
245
288
  @series.fetch(identity(model_class, id)).record_before(boundary)
246
289
  end
247
290
 
291
+ #: (untyped, untyped, untyped) -> [bool, PreparedRecordState?]
292
+ def snapshot_state_before(model_class, id, boundary)
293
+ series = @series[identity(model_class, id)]
294
+ return [false, nil] unless series
295
+
296
+ state = series.state_before(boundary)
297
+ return [true, nil] unless state
298
+
299
+ @direct_states[state] = @direct_loader.complete(state) unless @direct_states.key?(state)
300
+ prepared = @direct_states[state]
301
+ [!prepared.nil?, prepared]
302
+ end
303
+
248
304
  #: (untyped, untyped, untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
249
305
  def transition(model_class, id, version)
250
306
  @series[identity(model_class, id)]&.transition(version)
251
307
  end
252
308
 
309
+ #: (untyped, untyped, untyped, ?direct: bool) -> [bool, untyped]
310
+ def record_after(model_class, id, version, direct: false)
311
+ handled, state = @series[identity(model_class, id)]&.record_after(version, direct: direct)
312
+ return [false, nil] unless handled
313
+ return [true, state] unless direct && state
314
+
315
+ prepared = @direct_loader.complete(state)
316
+ [!prepared.nil?, prepared]
317
+ end
318
+
253
319
  #: (untyped, Array[untyped]) -> Array[untyped]
254
320
  def records_for(model_class, ids)
255
321
  ids.flat_map { |id| @series.fetch(identity(model_class, id)).records }
256
322
  end
257
323
 
324
+ #: (untyped, Array[untyped], String) -> Array[untyped]
325
+ def attribute_values_for(model_class, ids, name)
326
+ plain_reader = generated_reader?(model_class, name)
327
+ ids.flat_map do |id|
328
+ @series.fetch(identity(model_class, id)).states.map do |state|
329
+ direct = @direct_loader.complete(state) if plain_reader
330
+ direct ? direct.attributes[name] : state.instantiate.public_send(name)
331
+ end
332
+ end
333
+ end
334
+
335
+ #: (untyped, untyped) -> Array[untyped]
336
+ def versions_for_id(model_class, id)
337
+ @series.fetch(identity(model_class, id)).versions
338
+ end
339
+
258
340
  #: (untyped, untyped) -> bool
259
341
  def loaded?(model_class, id)
260
342
  @series.key?(identity(model_class, id))
@@ -262,11 +344,19 @@ module PaperTrailDiff
262
344
 
263
345
  private
264
346
 
347
+ #: (untyped, String) -> bool
348
+ def generated_reader?(model, name)
349
+ model.define_attribute_methods
350
+ model.instance_method(name).owner.name.to_s.end_with?('GeneratedAttributeMethods')
351
+ end
352
+
265
353
  # @rbs @start_time: untyped
266
354
  # @rbs @end_time: untyped
267
355
  # @rbs @seeded_live_records: Hash[Array[String], untyped]
268
356
  # @rbs @state_loader: PreparedVersionStateLoader
269
357
  # @rbs @series: Hash[Array[String], PreparedRecordSeries]
358
+ # @rbs @direct_loader: DirectRecordStateLoader
359
+ # @rbs @direct_states: Hash[PreparedRecordState, PreparedRecordState?]
270
360
 
271
361
  # Callers bound the range with either a version or a bare timestamp.
272
362
  #: (untyped) -> untyped
@@ -314,27 +404,10 @@ module PaperTrailDiff
314
404
  #: (untyped) -> untyped
315
405
  def window_condition(version_class)
316
406
  table = version_class.arel_table
317
- table[:created_at].lteq(@end_time).or(first_after_range(table))
318
- end
319
-
320
- #: (untyped) -> untyped
321
- def first_after_range(table)
322
- arel = Object.const_get(:Arel) #: untyped
323
- later = table.alias('paper_trail_diff_later_versions')
324
- arel.const_get(:SelectManager).new
325
- .from(later)
326
- .project(arel.sql('1'))
327
- .where(preceding_trailing_version(table, later))
328
- .exists
329
- .not
330
- end
331
-
332
- #: (untyped, untyped) -> untyped
333
- def preceding_trailing_version(table, later)
334
- later[:item_type].eq(table[:item_type])
335
- .and(later[:item_id].eq(table[:item_id]))
336
- .and(later[:created_at].gt(@end_time))
337
- .and(later[:created_at].lt(table[:created_at]))
407
+ first_after = VersionQuery.no_earlier_version(table) do |earlier|
408
+ earlier[:created_at].gt(@end_time)
409
+ end
410
+ table[:created_at].lteq(@end_time).or(first_after)
338
411
  end
339
412
 
340
413
  #: (untyped, Array[untyped]) -> Array[untyped]
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Normalizes supported recorded graphs without historical ActiveRecord instances.
6
+ class PreparedSnapshotBuilder
7
+ #: (AssociationTraversal) -> void
8
+ def initialize(traversal)
9
+ @traversal = traversal
10
+ @loader = DirectRecordStateLoader.new
11
+ end
12
+
13
+ #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, history: PreparedHistory?) -> [bool, RecordSnapshot?]
14
+ def call(version, boundary, tree:, normalizer:, history:)
15
+ return [true, nil] unless version.object
16
+
17
+ state = @loader.call(version)
18
+ return [false, nil] unless state
19
+
20
+ build(state, boundary, tree, normalizer, history, path: '')
21
+ end
22
+
23
+ private
24
+
25
+ # @rbs @traversal: AssociationTraversal
26
+ # @rbs @loader: DirectRecordStateLoader
27
+
28
+ #: (PreparedRecordState, untyped, AssociationTree, SnapshotNormalizer, PreparedHistory?, path: String, ?incoming: untyped) -> [bool, RecordSnapshot?]
29
+ def build(state, boundary, tree, normalizer, history, path:, incoming: nil) # rubocop:disable Metrics/ParameterLists
30
+ associations = {} #: snapshot_associations
31
+ @traversal.reflections_for(state.model_class, tree, path: path).each do |reflection|
32
+ return [false, nil] unless history
33
+
34
+ handled, snapshot = association(state, reflection, boundary, tree, normalizer, history,
35
+ path)
36
+ return [false, nil] unless handled && snapshot
37
+
38
+ associations[reflection.name.to_s] = snapshot
39
+ end
40
+ [true, normalizer.from_state(state, tree: tree, path: path,
41
+ incoming: incoming, associations: associations)]
42
+ end
43
+
44
+ #: (PreparedRecordState, untyped, untyped, AssociationTree, SnapshotNormalizer, PreparedHistory, String) -> [bool, AssociationSnapshot?]
45
+ def association(owner, reflection, boundary, tree, normalizer, history, path) # rubocop:disable Metrics/ParameterLists
46
+ handled, states = history.snapshot_states(owner, reflection, boundary)
47
+ return [false, nil] unless handled
48
+
49
+ subtree = tree.child(reflection.name)
50
+ return [false, nil] unless subtree
51
+
52
+ child_path = Support.association_path(path, reflection.name.to_s)
53
+ records = [] #: Array[RecordSnapshot]
54
+ states.each do |state|
55
+ supported, snapshot = build(state, boundary, subtree, normalizer, history,
56
+ path: child_path, incoming: reflection)
57
+ return [false, nil] unless supported && snapshot
58
+
59
+ records << snapshot
60
+ end
61
+ [true, AssociationSnapshot.new(kind: reflection.macro, records: records)]
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Reads plain direct edges without creating AR records for their members.
6
+ class PreparedStateAssociationReader
7
+ #: (PreparedRecordIndex, untyped) -> void
8
+ def initialize(records, edges)
9
+ @records = records
10
+ @edges = edges
11
+ end
12
+
13
+ #: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
14
+ def call(owner, reflection, boundary)
15
+ return [false, []] unless supported?(reflection)
16
+
17
+ if reflection.belongs_to?
18
+ id = owner.attributes[reflection.foreign_key.to_s]
19
+ return [true, []] if id.nil?
20
+
21
+ handled, state = @records.snapshot_state_before(reflection.klass, id, boundary)
22
+ return [handled, state ? [state] : []]
23
+ end
24
+ children(owner, reflection, boundary)
25
+ end
26
+
27
+ private
28
+
29
+ # @rbs @records: PreparedRecordIndex
30
+ # @rbs @edges: untyped
31
+
32
+ #: (untyped) -> bool
33
+ def supported?(reflection)
34
+ !reflection.options[:through] && !reflection.polymorphic? && reflection.scope.nil? &&
35
+ !reflection.klass.default_scopes? && !reflection.foreign_key.is_a?(Array) &&
36
+ %i[belongs_to has_one has_many].include?(reflection.macro)
37
+ end
38
+
39
+ #: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
40
+ def children(owner, reflection, boundary)
41
+ selected = [] #: Array[PreparedRecordState]
42
+ @edges.call(owner.model_class, reflection).each_value do |group|
43
+ ids_for(group, owner).each do |id|
44
+ handled, child = @records.snapshot_state_before(group.fetch(:model), id, boundary)
45
+ return [false, []] unless handled
46
+
47
+ selected << child if child && member?(child, owner, reflection)
48
+ end
49
+ end
50
+ [true, reflection.collection? ? selected : selected.first(1)]
51
+ end
52
+
53
+ #: (untyped, PreparedRecordState) -> Array[untyped]
54
+ def ids_for(group, owner)
55
+ owners = group[:owners]
56
+ id = owner.attributes[owner.model_class.primary_key].to_s
57
+ empty = [] #: Array[untyped]
58
+ owners ? owners.fetch(id, empty) : group.fetch(:ids)
59
+ end
60
+
61
+ #: (PreparedRecordState, PreparedRecordState, untyped) -> bool
62
+ def member?(child, owner, reflection)
63
+ owner_id = owner.attributes[owner.model_class.primary_key].to_s
64
+ return false unless child.attributes[reflection.foreign_key.to_s].to_s == owner_id
65
+ return true unless reflection.options[:as]
66
+
67
+ matching_type?(child, owner.model_class, reflection)
68
+ end
69
+
70
+ #: (PreparedRecordState, untyped, untyped) -> bool
71
+ def matching_type?(child, model, reflection)
72
+ types = [model.name, model.base_class.name,
73
+ reflection.active_record.name]
74
+ types.include?(child.attributes[reflection.type.to_s].to_s)
75
+ end
76
+ end
77
+ end
@@ -27,6 +27,10 @@ module PaperTrailDiff
27
27
  # The root versions this plan reports as mutations, which excludes any
28
28
  # version present only to reveal what the last of them produced.
29
29
  attr_reader :mutations #: Array[untyped]
30
+ # Whether a wall-clock window closes on the root's own destruction. Its
31
+ # final state is then the absence the destroy left, just as a window
32
+ # closing on current state ends at the live record.
33
+ attr_reader :closes_on_destroy #: bool
30
34
 
31
35
  class << self
32
36
  # Adjacent boundaries, which is what an unfiltered range reports.
@@ -45,14 +49,24 @@ module PaperTrailDiff
45
49
  steps = [] #: Array[[untyped, untyped]]
46
50
  new(versions: versions, steps: steps)
47
51
  end
52
+
53
+ # No root version to report or replay: only descendants moved, and the
54
+ # live record is the root's state throughout.
55
+ #: (untyped) -> RootVersionPlan
56
+ def closing_on(record)
57
+ versions = [] #: Array[untyped]
58
+ steps = [] #: Array[[untyped, untyped]]
59
+ new(versions: versions, steps: steps, closing_record: record)
60
+ end
48
61
  end
49
62
 
50
- #: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped) -> void
63
+ #: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped, ?closes_on_destroy: bool) -> void
51
64
  def initialize( # rubocop:disable Metrics/ParameterLists
52
65
  versions:, steps:, context_version: nil, reconstruction_versions: nil, mutations: nil,
53
- closing_record: nil
66
+ closing_record: nil, closes_on_destroy: false
54
67
  )
55
68
  @closing_record = closing_record
69
+ @closes_on_destroy = closes_on_destroy
56
70
  @versions = versions.freeze
57
71
  @reconstruction_versions = (reconstruction_versions || versions).freeze
58
72
  @steps = steps.freeze
@@ -67,6 +81,15 @@ module PaperTrailDiff
67
81
  versions.empty?
68
82
  end
69
83
 
84
+ #: () -> RootVersionPlan
85
+ def closing_on_destroy
86
+ self.class.new(
87
+ versions: versions, steps: steps, context_version: context_version,
88
+ reconstruction_versions: reconstruction_versions, mutations: mutations,
89
+ closing_record: closing_record, closes_on_destroy: true
90
+ )
91
+ end
92
+
70
93
  # What the range's final state is read from, which is the live record when
71
94
  # the window closes on current state and the last version otherwise.
72
95
  #: () -> untyped
@@ -37,10 +37,8 @@ module PaperTrailDiff
37
37
  return without_selection if @selected.empty?
38
38
 
39
39
  closing = revealing_version || @live_endpoint
40
- raise IncompleteTimeRangeError, INCOMPLETE if !closing && @windowed && !terminal_destroy?
41
- return filtered_plan(closing) if @filtered
42
-
43
- contiguous_plan(closing)
40
+ plan = @filtered ? filtered_plan(closing) : contiguous_plan(closing)
41
+ closes_on_destroy?(closing) ? plan.closing_on_destroy : plan
44
42
  end
45
43
 
46
44
  private
@@ -69,13 +67,16 @@ module PaperTrailDiff
69
67
  end
70
68
 
71
69
  # An activity view still needs a root to reconstruct from even when no root
72
- # version falls inside the window, because descendants may have moved.
70
+ # version falls inside the window, because descendants may have moved. With
71
+ # no later version, a window closing on current state reads the root from
72
+ # the live record, which is then its state throughout the window.
73
73
  #: () -> RootVersionPlan
74
74
  def without_selection
75
75
  return RootVersionPlan.empty unless @context_required
76
76
  if @after_range
77
77
  return RootVersionPlan.contiguous([@after_range], context_version: @after_range)
78
78
  end
79
+ return RootVersionPlan.closing_on(@live_endpoint) if @live_endpoint && @windowed
79
80
 
80
81
  raise IncompleteTimeRangeError, INCOMPLETE
81
82
  end
@@ -149,6 +150,16 @@ module PaperTrailDiff
149
150
  immediate_successor(@selected.last) || @after_range
150
151
  end
151
152
 
153
+ # A window with nothing after its last selected mutation can only close on
154
+ # that mutation being the record's destruction.
155
+ #: (untyped) -> bool
156
+ def closes_on_destroy?(closing)
157
+ return false if closing || !@windowed
158
+ raise IncompleteTimeRangeError, INCOMPLETE unless terminal_destroy?
159
+
160
+ true
161
+ end
162
+
152
163
  # A range closing on the record's own destruction needs no later version:
153
164
  # nothing can follow it, so demanding one would reject the range forever.
154
165
  #: () -> bool
@@ -15,14 +15,29 @@ module PaperTrailDiff
15
15
  class ScopedAnalysis
16
16
  attr_reader :analyses #: Hash[Array[String], Analysis]
17
17
  attr_reader :unreachable #: Array[Array[String]]
18
+ # The live records the selection loaded, in the order it loaded them. The
19
+ # gem already has them; withholding them only makes a caller query the same
20
+ # rows again to attach its own presentation data. A destroyed root has no
21
+ # record to hand back, so `roots` and `unreachable` stay complementary.
22
+ attr_reader :roots #: Array[untyped]
23
+ # Requested missing-root analyses, kept separate because live SQL conditions
24
+ # cannot prove which deleted rows matched. nil means no destroy history.
25
+ attr_reader :deleted #: Hash[Array[String], Analysis?]
18
26
 
19
- #: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
20
- def initialize(analyses:, unreachable:)
27
+ #: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]], ?roots: Array[untyped], ?deleted: Hash[Array[String], Analysis?]) -> void
28
+ def initialize(analyses:, unreachable:, roots: [], deleted: {})
21
29
  @analyses = analyses
22
30
  @unreachable = Support.immutable_copy(unreachable)
31
+ # Not deep-copied: these are the caller's own model instances, and
32
+ # freezing them would make an ordinary `record.title` on a lazy attribute
33
+ # fail. The array itself is frozen so the collection cannot be reshaped.
34
+ @roots = roots.dup.freeze
35
+ @deleted = Support.immutable_copy(deleted)
23
36
  freeze
24
37
  end
25
38
 
39
+ # Two elements, not three: `roots` is reached by name so that existing
40
+ # destructuring keeps working.
26
41
  #: () -> [Hash[Array[String], Analysis], Array[Array[String]]]
27
42
  def to_ary
28
43
  [analyses, unreachable]