paper_trail_diff 0.12.0 → 0.13.1

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +158 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1388
  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 +169 -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 +10 -38
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/collection_comparator.rb +18 -20
  33. data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
  34. data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
  35. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  36. data/lib/paper_trail_diff/endpoint.rb +10 -0
  37. data/lib/paper_trail_diff/engine.rb +4 -2
  38. data/lib/paper_trail_diff/errors.rb +9 -0
  39. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  40. data/lib/paper_trail_diff/historical_filter.rb +176 -0
  41. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  42. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  43. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  44. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  45. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  46. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  47. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  48. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  49. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  50. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  51. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  52. data/lib/paper_trail_diff/root_version_plan.rb +35 -3
  53. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  54. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  55. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  56. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  57. data/lib/paper_trail_diff/snapshot.rb +14 -1
  58. data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
  59. data/lib/paper_trail_diff/step.rb +10 -5
  60. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  61. data/lib/paper_trail_diff/support.rb +13 -3
  62. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  63. data/lib/paper_trail_diff/time_range.rb +21 -2
  64. data/lib/paper_trail_diff/time_version_range.rb +2 -16
  65. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  66. data/lib/paper_trail_diff/timeline_range.rb +29 -46
  67. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  68. data/lib/paper_trail_diff/traversal.rb +1 -1
  69. data/lib/paper_trail_diff/version.rb +1 -1
  70. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  71. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  72. data/lib/paper_trail_diff/version_query.rb +41 -0
  73. data/lib/paper_trail_diff/version_range.rb +10 -38
  74. data/lib/paper_trail_diff.rb +78 -42
  75. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  76. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  77. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  78. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  79. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  80. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  81. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  82. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  83. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  84. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  85. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  86. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  87. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  88. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  90. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  91. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  92. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  93. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  94. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  95. data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
  96. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
  97. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  98. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  99. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  100. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  102. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  105. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  106. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  107. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  108. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  110. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  111. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  112. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  113. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  114. data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
  115. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  116. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  117. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  118. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  119. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  120. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
  121. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  122. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  123. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  124. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  125. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  126. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  127. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  128. data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
  129. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  130. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  131. data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
  132. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  133. data/sig/generated/paper_trail_diff.rbs +31 -25
  134. metadata +31 -5
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Shares association event discovery across every root in a prepared graph.
6
+ class PreparedActivityIndex
7
+ #: (PreparedRecordIndex, Array[untyped], start_at: untyped, end_at: untyped) -> void
8
+ def initialize(records, root_versions, start_at:, end_at:)
9
+ @records = records
10
+ @range = ActivityRange.new(start_at, end_at)
11
+ @roots = root_versions.group_by { |version| [version.item_type, version.item_id.to_s] }
12
+ @nodes = {} #: Hash[String, Array[untyped]]
13
+ @children = {} #: Hash[Array[String], untyped]
14
+ @rows = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
15
+ end
16
+
17
+ #: (untyped, Array[untyped]) -> void
18
+ def register(model, ids)
19
+ name = model.base_class.name.to_s
20
+ @nodes[name] = (@nodes.fetch(name, []) | ids)
21
+ end
22
+
23
+ #: (untyped, Array[untyped]) -> Array[untyped]
24
+ def versions(model, ids)
25
+ ids.flat_map do |id|
26
+ key = [model.base_class.name.to_s, id.to_s]
27
+ @roots[key] || @records.versions_for_id(model, id)
28
+ end
29
+ end
30
+
31
+ #: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
32
+ def child_ids(model, ids, reflection, range)
33
+ key = [model.base_class.name.to_s, reflection.name.to_s]
34
+ historical, live = @children[key] ||= child_rows(model, reflection)
35
+ empty = [] #: Array[untyped]
36
+ selected = historical_ids(reflection.klass, ids, historical, range)
37
+ selected |= ids.flat_map { |id| live.fetch(id.to_s, empty) } if range.current_end?
38
+ selected.uniq
39
+ end
40
+
41
+ #: (untyped, Array[untyped], untyped, ?transactions: bool) -> Array[untyped]
42
+ def association_rows(model, selected_versions, reflection, transactions: false)
43
+ key = [model.base_class.name.to_s, reflection.name.to_s]
44
+ rows = @rows[key] ||= load_association_rows(model, reflection, transactions)
45
+ version_ids(selected_versions, transactions).flat_map { |id| rows.fetch(id.to_s, []) }
46
+ end
47
+
48
+ private
49
+
50
+ # @rbs @records: PreparedRecordIndex
51
+ # @rbs @range: ActivityRange
52
+ # @rbs @roots: Hash[Array[String], Array[untyped]]
53
+ # @rbs @nodes: Hash[String, Array[untyped]]
54
+ # @rbs @children: Hash[Array[String], untyped]
55
+ # @rbs @rows: Hash[Array[String], Hash[String, Array[untyped]]]
56
+
57
+ #: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
58
+ def historical_ids(model, ids, historical, range)
59
+ empty = [] #: Array[untyped]
60
+ ids.flat_map do |id|
61
+ historical.fetch(id.to_s, empty).filter_map do |row|
62
+ row[1] if eligible_child?(model, row, range)
63
+ end
64
+ end
65
+ end
66
+
67
+ #: (untyped) -> Array[untyped]
68
+ def node_ids(model)
69
+ name = model.base_class.name.to_s
70
+ @nodes.fetch(name) do
71
+ raise ConfigurationError,
72
+ "prepared activity graph is missing #{name}; " \
73
+ 'register the model before collecting activity'
74
+ end
75
+ end
76
+
77
+ #: (untyped, untyped) -> untyped
78
+ def child_rows(model, reflection)
79
+ loader = ActivityChildCandidateLoader.new(
80
+ model, node_ids(model), reflection, @range
81
+ )
82
+ [loader.historical_rows.group_by { |row| row.first.to_s },
83
+ loader.current_pairs.group_by { |row| row.first.to_s }
84
+ .transform_values { |rows| rows.map(&:last) }]
85
+ end
86
+
87
+ # Mirrors VersionAssociationCandidateScope's before-window rule. The loaded
88
+ # series includes changes to other owners too, so an old membership cannot
89
+ # survive a later pre-window reassignment just because that owner is absent.
90
+ #: (untyped, Array[untyped], ActivityRange) -> bool
91
+ def eligible_child?(model, row, range)
92
+ _owner, id, timestamp, event = row
93
+ return timestamp <= range.end_time if timestamp >= range.start_time
94
+ return false if event == 'destroy'
95
+
96
+ later = @records.versions_for_id(model, id)
97
+ .bsearch { |version| version.created_at > timestamp }
98
+ !later || later.created_at >= range.start_time
99
+ end
100
+
101
+ #: (untyped, untyped, bool) -> Hash[String, Array[untyped]]
102
+ def load_association_rows(model, reflection, transactions)
103
+ all_versions = versions(model, node_ids(model))
104
+ foreign_key = transactions ? reflection.name.to_s : reflection.foreign_key.to_s
105
+ model.paper_trail.version_association_class.where(
106
+ version_id: version_ids(all_versions, transactions), foreign_key_name: foreign_key
107
+ ).to_a.group_by { |row| row.version_id.to_s }
108
+ end
109
+
110
+ #: (Array[untyped], bool) -> Array[untyped]
111
+ def version_ids(versions, transactions)
112
+ if transactions
113
+ versions.filter_map do |version|
114
+ version.transaction_id if version.respond_to?(:transaction_id)
115
+ end.uniq
116
+ else
117
+ versions.map(&:id)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -50,11 +50,15 @@ module PaperTrailDiff
50
50
  def belongs_to_groups(owner_class, owner_ids, reflection)
51
51
  key = scalar_foreign_key(reflection)
52
52
  return {} unless key
53
+ unless reflection.polymorphic?
54
+ return group_for(reflection.klass,
55
+ @records.attribute_values_for(owner_class, owner_ids, key))
56
+ end
53
57
 
54
58
  grouped = {} #: Hash[String, Hash[Symbol, untyped]]
55
59
  @records.records_for(owner_class, owner_ids).each do |owner|
56
60
  id = owner.public_send(key)
57
- model_class = target_class(reflection, owner)
61
+ model_class = AssociationTraversal.belongs_to_target(reflection, owner)
58
62
  add_identity(grouped, model_class, id) if model_class && id
59
63
  end
60
64
  grouped
@@ -109,7 +113,12 @@ module PaperTrailDiff
109
113
  association_class.arel_table[:foreign_key_id],
110
114
  version_class.arel_table[:item_id]
111
115
  )
112
- group_ids_by_owner(pairs)
116
+ group_ids_by_owner(typed_child_ids(reflection.klass, pairs))
117
+ end
118
+
119
+ #: (untyped, Array[Array[untyped]]) -> Array[Array[untyped]]
120
+ def typed_child_ids(model_class, pairs)
121
+ pairs.map { |owner_id, child_id| [owner_id, Support.record_id(model_class, child_id)] }
113
122
  end
114
123
 
115
124
  #: (untyped, Array[untyped], untyped, String) -> untyped
@@ -139,15 +148,6 @@ module PaperTrailDiff
139
148
  group_ids_by_owner(relation.pluck(foreign_key, reflection.klass.primary_key))
140
149
  end
141
150
 
142
- #: (untyped, untyped) -> untyped
143
- def target_class(reflection, owner)
144
- return reflection.klass unless reflection.polymorphic?
145
-
146
- Object.const_get(owner.public_send(reflection.foreign_type).to_s)
147
- rescue NameError
148
- nil
149
- end
150
-
151
151
  #: (Hash[String, Hash[Symbol, untyped]], untyped, untyped) -> void
152
152
  def add_identity(groups, model_class, id)
153
153
  group = groups[model_class.name.to_s] ||= { model: model_class, ids: [] }
@@ -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