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
@@ -21,9 +21,7 @@ module PaperTrailDiff
21
21
  #: (String, AssociationSnapshot) -> AssociationSnapshot
22
22
  def association(path, snapshot)
23
23
  previous = @associations[path]
24
- if previous && previous.kind == snapshot.kind && previous.records == snapshot.records
25
- return previous
26
- end
24
+ return previous if previous && same_association?(previous, snapshot)
27
25
 
28
26
  @associations[path] = snapshot
29
27
  end
@@ -38,10 +36,14 @@ module PaperTrailDiff
38
36
  left.attributes == right.attributes &&
39
37
  left.associations.keys == right.associations.keys &&
40
38
  left.associations.all? do |name, association|
41
- other = right.associations[name]
42
- other && association.kind == other.kind && association.records == other.records
39
+ same_association?(association, right.associations.fetch(name))
43
40
  end
44
41
  end
42
+
43
+ #: (AssociationSnapshot, AssociationSnapshot) -> bool
44
+ def same_association?(left, right)
45
+ left.kind == right.kind && left.records == right.records
46
+ end
45
47
  end
46
48
 
47
49
  # Converts an ActiveRecord graph into immutable, persistence-independent snapshots.
@@ -53,11 +55,13 @@ module PaperTrailDiff
53
55
  @traversal = traversal
54
56
  @pool = pool
55
57
  @structural_columns = {} #: Hash[String, Array[String]]
56
- @excluded_attributes = {} #: Hash[Array[untyped], Array[String]]
58
+ @excluded_attributes = {} #: Hash[Array[untyped], Array[String?]]
57
59
  end
58
60
 
59
61
  #: (untyped, reifier: untyped) -> RecordSnapshot?
60
62
  def call(record, reifier:)
63
+ return unless record
64
+
61
65
  reflections = reflections_for(record, @tree, '')
62
66
  normalize_record(record, @tree, '', reifier, reflections)
63
67
  end
@@ -76,23 +80,54 @@ module PaperTrailDiff
76
80
  # Normalizes scalar attributes without traversing or loading associations.
77
81
  #: (untyped, tree: AssociationTree, path: String) -> snapshot_attributes
78
82
  def attributes_for(record, tree:, path:)
83
+ if record.instance_of?(PreparedRecordState)
84
+ return from_state(record, tree: tree,
85
+ path: path).attributes
86
+ end
87
+
79
88
  reflections = reflections_for(record, tree, path)
80
89
  normalized_attributes(record, reflections, path)
81
90
  end
82
91
 
92
+ # Same exclusions as AR normalization, applied to an already typed state.
93
+ #: (PreparedRecordState, tree: AssociationTree, path: String, ?incoming: untyped, ?associations: snapshot_associations) -> RecordSnapshot
94
+ def from_state(state, tree:, path:, incoming: nil, associations: {})
95
+ model = state.model_class
96
+ @structural_columns[path] ||= @traversal.incoming_relationship_columns(incoming) if incoming
97
+ reflections = @traversal.reflections_for(model, tree, path: path)
98
+ excluded = @excluded_attributes[[model, path]] ||=
99
+ build_excluded_attributes(model, reflections, path)
100
+ snapshot = RecordSnapshot.new(
101
+ type: model.name, id: state_id(model, state),
102
+ attributes: state.attributes.except(*excluded), associations: associations
103
+ )
104
+ pool_state(path, snapshot)
105
+ end
106
+
83
107
  private
84
108
 
109
+ # The id a live record of the same row reports: one value, or one per
110
+ # column of a composite key.
111
+ #: (untyped, PreparedRecordState) -> untyped
112
+ def state_id(model, state)
113
+ key = model.primary_key
114
+ key.instance_of?(Array) ? state.attributes.fetch_values(*key) : state.attributes.fetch(key)
115
+ end
116
+
117
+ #: (String, RecordSnapshot) -> RecordSnapshot
118
+ def pool_state(path, snapshot)
119
+ path.empty? ? snapshot : @pool.record(path, snapshot)
120
+ end
121
+
85
122
  # @rbs @tree: AssociationTree
86
123
  # @rbs @ignore_policy: IgnorePolicy
87
124
  # @rbs @traversal: AssociationTraversal
88
125
  # @rbs @pool: SnapshotPool
89
126
  # @rbs @structural_columns: Hash[String, Array[String]]
90
- # @rbs @excluded_attributes: Hash[Array[untyped], Array[String]]
127
+ # @rbs @excluded_attributes: Hash[Array[untyped], Array[String?]]
91
128
 
92
- #: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot?
129
+ #: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot
93
130
  def normalize_record(record, tree, path, reifier, reflections)
94
- return unless record
95
-
96
131
  snapshot = RecordSnapshot.new(
97
132
  type: record.class.name,
98
133
  id: record.id,
@@ -104,38 +139,48 @@ module PaperTrailDiff
104
139
 
105
140
  #: (untyped, Array[untyped], String) -> snapshot_attributes
106
141
  def normalized_attributes(record, reflections, path)
107
- attributes = record.attributes.dup
142
+ attributes = record.attributes
108
143
  excluded_attributes(record, reflections, path).each { |name| attributes.delete(name) }
109
- attributes
144
+ StoredTimePrecision.apply(attributes, StoredTimePrecision.digits(record.class))
110
145
  end
111
146
 
112
147
  # Every input is fixed for one model class at one selected path, and the
113
148
  # path's structural columns are recorded before its records normalize, so
114
149
  # this is resolved once instead of per record per boundary.
115
- #: (untyped, Array[untyped], String) -> Array[String]
150
+ #: (untyped, Array[untyped], String) -> Array[String?]
116
151
  def excluded_attributes(record, reflections, path)
117
152
  model_class = record.class
118
153
  @excluded_attributes[[model_class, path]] ||=
119
154
  build_excluded_attributes(model_class, reflections, path)
120
155
  end
121
156
 
122
- #: (untyped, Array[untyped], String) -> Array[String]
157
+ #: (untyped, Array[untyped], String) -> Array[String?]
123
158
  def build_excluded_attributes(model_class, reflections, path)
124
- primary_key = model_class.primary_key #: untyped
125
- primary_keys = primary_key.is_a?(Array) ? primary_key : [primary_key]
126
- # @type var primary_keys: Array[untyped]
127
- primary_keys = primary_keys.map { |key| key.to_s } # rubocop:disable Style/SymbolProc
159
+ primary_keys = Array(model_class.primary_key) #: Array[String]
128
160
  ignored = @ignore_policy.attributes_for(path)
129
161
  structural = @structural_columns.fetch(path, [])
130
- (primary_keys + ignored + relationship_columns(reflections) + structural).uniq.freeze
162
+ primary_keys + ignored + skipped_columns(model_class) +
163
+ relationship_columns(reflections) + structural
164
+ end
165
+
166
+ # `has_paper_trail skip:` columns are never written to a version, so history
167
+ # holds only their defaults. Compared with a live row they would report a
168
+ # change nobody recorded.
169
+ #: (untyped) -> Array[String]
170
+ def skipped_columns(model_class)
171
+ return [] unless Support.versioned?(model_class)
172
+
173
+ # PaperTrail stores these as strings, or hashes keyed by strings.
174
+ entries = model_class.paper_trail_options.fetch(:skip) #: Array[untyped]
175
+ entries.flat_map { |entry| entry.instance_of?(Hash) ? entry.keys : entry }
131
176
  end
132
177
 
133
- #: (Array[untyped]) -> Array[String]
178
+ # A non-polymorphic reflection has no foreign type, so it adds nil, which
179
+ # excludes nothing.
180
+ #: (Array[untyped]) -> Array[String?]
134
181
  def relationship_columns(reflections)
135
182
  reflections.select { |reflection| reflection.macro == :belongs_to }.flat_map do |reflection|
136
- columns = [reflection.foreign_key.to_s]
137
- columns << reflection.foreign_type.to_s if reflection.polymorphic?
138
- columns
183
+ [reflection.foreign_key, reflection.foreign_type]
139
184
  end
140
185
  end
141
186
 
@@ -143,27 +188,20 @@ module PaperTrailDiff
143
188
  def normalize_associations(record, tree, path, reifier, reflections)
144
189
  reflections.to_h do |reflection|
145
190
  name = reflection.name.to_s
146
- subtree = tree.child(name)
147
- raise ConfigurationError, "missing traversal subtree: #{name}" unless subtree
148
-
149
- [name, association_snapshot(record, reflection, subtree, path, reifier)]
191
+ [name, association_snapshot(record, reflection, tree.children.fetch(name), path, reifier)]
150
192
  end
151
193
  end
152
194
 
153
195
  #: (untyped, untyped, AssociationTree, String, untyped) -> AssociationSnapshot
154
- def association_snapshot( # rubocop:disable Metrics/AbcSize
196
+ def association_snapshot(
155
197
  record,
156
198
  reflection,
157
199
  subtree,
158
200
  path,
159
201
  reifier
160
202
  )
161
- associated = record.public_send(reflection.name)
162
- records = if AssociationSnapshot.collection_kind?(reflection.macro)
163
- associated.to_a
164
- else
165
- Array(associated).compact
166
- end
203
+ # A reified history can hold a member PaperTrail could not rebuild.
204
+ records = Array(record.public_send(reflection.name)).compact
167
205
  child_path = Support.association_path(path, reflection.name.to_s)
168
206
  @structural_columns[child_path] ||= @traversal.incoming_relationship_columns(reflection)
169
207
  snapshot = AssociationSnapshot.new(
@@ -175,10 +213,9 @@ module PaperTrailDiff
175
213
 
176
214
  #: (Array[untyped], AssociationTree, String, untyped) -> Array[RecordSnapshot]
177
215
  def normalize_children(records, tree, path, reifier)
178
- reflections_by_class = {} #: Hash[String, Array[untyped]]
179
- records.filter_map do |child|
180
- type = child.class.name
181
- reflections = reflections_by_class[type] ||= reflections_for(child, tree, path)
216
+ reflections_by_class = {} #: Hash[untyped, Array[untyped]]
217
+ records.map do |child|
218
+ reflections = reflections_by_class[child.class] ||= reflections_for(child, tree, path)
182
219
  reifier.reify(child, reflections) unless reflections.empty?
183
220
  normalize_record(child, tree, path, reifier, reflections)
184
221
  end
@@ -186,8 +223,6 @@ module PaperTrailDiff
186
223
 
187
224
  #: (untyped, AssociationTree, String) -> Array[untyped]
188
225
  def reflections_for(record, tree, path)
189
- return [] unless record
190
-
191
226
  @traversal.reflections_for(record.class, tree, path: path)
192
227
  end
193
228
  end
@@ -33,12 +33,17 @@ module PaperTrailDiff
33
33
  diff.empty?
34
34
  end
35
35
 
36
- #: () -> Hash[Symbol, untyped]
37
- def to_h
36
+ # The root event whose change this step reports.
37
+ #: () -> ActivityBoundary
38
+ def source_boundary = from_boundary
39
+
40
+ # The same shape as `ActivityStep#to_h`, so a consumer reads both step
41
+ # types alike; each boundary carries its own `version_id`.
42
+ #: (?metadata: bool) -> Hash[Symbol, untyped]
43
+ def to_h(metadata: false)
38
44
  {
39
- from_version_id: from_version.id,
40
- to_version_id: to_version&.id,
41
- to_boundary: to_boundary.to_h,
45
+ from: from_boundary.to_h(metadata: metadata),
46
+ to: to_boundary.to_h(metadata: metadata),
42
47
  diff: diff.to_h
43
48
  }
44
49
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Matches time values to the precision PaperTrail actually stored.
6
+ #
7
+ # PaperTrail's JSON serializer, and a `json` or `jsonb` object column, write
8
+ # times through ActiveSupport's JSON encoder, which keeps only
9
+ # `ActiveSupport::JSON::Encoding.time_precision` fractional digits
10
+ # (milliseconds by default). The live row keeps the database's precision, so
11
+ # comparing the two would report every timestamp as changed even though
12
+ # nothing was edited. Truncating every side to the stored precision is
13
+ # idempotent, so values that came from history are unaffected and only the
14
+ # digits history could never have held are dropped.
15
+ module StoredTimePrecision
16
+ NANOSECOND_DIGITS = 9
17
+ JSON_COLUMN_TYPES = %i[json jsonb].freeze
18
+ private_constant :NANOSECOND_DIGITS, :JSON_COLUMN_TYPES
19
+
20
+ module_function
21
+
22
+ # Fractional digits a model's history keeps for times, or nil when the
23
+ # history is stored losslessly.
24
+ #: (untyped) -> Integer?
25
+ def digits(model_class)
26
+ return unless Support.versioned?(model_class)
27
+ return unless json_history?(model_class.paper_trail.version_class)
28
+
29
+ active_support = Object.const_get(:ActiveSupport) #: untyped
30
+ active_support.const_get(:JSON).const_get(:Encoding).time_precision
31
+ end
32
+
33
+ #: (Hash[untyped, untyped], Integer?) -> Hash[untyped, untyped]
34
+ def apply(attributes, digits)
35
+ return attributes unless digits
36
+
37
+ attributes.transform_values { |value| truncate(value, digits) }
38
+ end
39
+
40
+ #: (untyped, Integer) -> untyped
41
+ def truncate(value, digits)
42
+ return value unless value.is_a?(Time) && digits < NANOSECOND_DIGITS
43
+
44
+ # ActiveSupport's Time#change, which also serves TimeWithZone.
45
+ time = value #: untyped
46
+ step = 10**(NANOSECOND_DIGITS - [digits, 0].max) #: Integer
47
+ remainder = time.nsec % step
48
+ remainder.zero? ? time : time.change(nsec: time.nsec - remainder)
49
+ end
50
+
51
+ #: (untyped) -> bool
52
+ def json_history?(version_class)
53
+ columns = version_class.columns_hash
54
+ json_column = %w[object object_changes].any? do |name|
55
+ JSON_COLUMN_TYPES.include?(columns[name]&.type)
56
+ end
57
+ json_column || json_serializer?
58
+ end
59
+ private_class_method :json_history?
60
+
61
+ #: () -> bool
62
+ def json_serializer?
63
+ paper_trail = Object.const_get(:PaperTrail) #: untyped
64
+ paper_trail.serializer.equal?(paper_trail.const_get(:Serializers).const_get(:JSON))
65
+ end
66
+ private_class_method :json_serializer?
67
+ end
68
+ end
@@ -8,15 +8,13 @@ module PaperTrailDiff
8
8
 
9
9
  #: (untyped) -> untyped
10
10
  def immutable_copy(value)
11
- return value if value.frozen?
12
-
13
11
  case value
14
12
  when Hash
15
13
  immutable_hash(value)
16
14
  when Array
17
15
  value.map { |item| immutable_copy(item) }.freeze
18
16
  else
19
- duplicate_and_freeze(value)
17
+ value.frozen? ? value : duplicate_and_freeze(value)
20
18
  end
21
19
  end
22
20
 
@@ -94,6 +92,18 @@ module PaperTrailDiff
94
92
  model_class.respond_to?(:paper_trail_options) && !model_class.paper_trail_options.nil?
95
93
  end
96
94
 
95
+ # `versions.item_id` is often a string column -- PaperTrail's setup for UUID
96
+ # keys -- while the model's own key is an integer. An id read from history
97
+ # is cast to the key's type, or the same record read once from history and
98
+ # once from its live row would count as two.
99
+ #: (untyped, untyped) -> untyped
100
+ def record_id(model_class, id)
101
+ key = model_class.primary_key
102
+ return id if id.nil? || !key.is_a?(String)
103
+
104
+ model_class.type_for_attribute(key).cast(id)
105
+ end
106
+
97
107
  #: (untyped) -> bool
98
108
  def sequential_id?(id)
99
109
  id.is_a?(Integer) || id.to_s.match?(/\A\d+\z/)
@@ -28,7 +28,7 @@ module PaperTrailDiff
28
28
  #: () -> Analysis
29
29
  def analyze
30
30
  history, plan, closing, closing_snapshot, captured_at = history_and_versions
31
- final = closing_snapshot || history.last_snapshot
31
+ final = plan.closes_on_destroy ? nil : closing_snapshot || history.last_snapshot
32
32
  Analysis.new(
33
33
  diff: Engine.compare(history.first_snapshot, final),
34
34
  timeline: ActivityRootSteps.call(
@@ -52,7 +52,10 @@ module PaperTrailDiff
52
52
  #: () -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
53
53
  def history_and_versions
54
54
  plan = @range.select_plan(context_required: !@tree.empty?)
55
- return [ActivityHistory.empty, plan, nil, nil, nil] if plan.reconstruction_versions.empty?
55
+ reject_live_habtm! if plan.closing_record
56
+ if plan.reconstruction_versions.empty? && !plan.closing_record
57
+ return [ActivityHistory.empty, plan, nil, nil, nil]
58
+ end
56
59
 
57
60
  # A window closing on current state runs to the instant it is captured,
58
61
  # not to the last root version, or descendants that moved after that
@@ -60,6 +63,11 @@ module PaperTrailDiff
60
63
  history_for(plan, plan.closing_record ? Time.now.utc : nil)
61
64
  end
62
65
 
66
+ #: () -> void
67
+ def reject_live_habtm!
68
+ AssociationTraversal.new(@tree).reject_live_habtm_activity!(@record.class)
69
+ end
70
+
63
71
  #: (RootVersionPlan, untyped) -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
64
72
  def history_for(plan, captured_at)
65
73
  root_versions = plan.reconstruction_versions
@@ -69,7 +77,7 @@ module PaperTrailDiff
69
77
  return [ActivityHistory.empty, plan, nil, nil, nil]
70
78
  end
71
79
 
72
- history = build_history(root_versions, events)
80
+ history = build_history(root_versions, events, current: plan.closing_record)
73
81
  snapshot = current_snapshot(plan)
74
82
  closing = closing_step(history, selected.last, plan, captured_at, snapshot)
75
83
  [history, plan, closing, snapshot, captured_at]
@@ -91,12 +99,15 @@ module PaperTrailDiff
91
99
  collect_events(root_versions, range_end: captured_at)
92
100
  end
93
101
 
94
- #: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
95
- def build_history(root_versions, events)
102
+ # Events after the last root version read the root from the live record
103
+ # when the window closes on it, as an explicit `to: record` range does.
104
+ #: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
105
+ def build_history(root_versions, events, current: nil)
96
106
  ActivityHistoryBuilder.new(
97
107
  root_versions,
98
108
  events,
99
109
  @snapshotter,
110
+ current: current,
100
111
  include_step: ->(event) { @range.include?(event.version) },
101
112
  snapshots: @retain
102
113
  ).call
@@ -150,13 +161,13 @@ module PaperTrailDiff
150
161
  # have moved since it.
151
162
  #: (ActivityHistory, untyped, untyped, RecordSnapshot?) -> ActivityStep?
152
163
  def current_step(history, record, captured_at, snapshot)
153
- previous = history.steps.last&.to_boundary
164
+ previous = history.last_boundary
154
165
  return unless previous
155
166
 
156
167
  ActivityStep.between(
157
168
  from_boundary: previous,
158
169
  to_boundary: ActivityBoundary.current(record, captured_at: captured_at),
159
- from_snapshot: history.last_snapshot, to_snapshot: snapshot, retain: @retain
170
+ from_snapshot: history.last_event_snapshot, to_snapshot: snapshot, retain: @retain
160
171
  )
161
172
  end
162
173
 
@@ -184,6 +195,7 @@ module PaperTrailDiff
184
195
  root_versions: root_versions,
185
196
  tree: @tree,
186
197
  traversal: AssociationTraversal.new(@tree),
198
+ prepared: (@snapshotter.activity_index if @snapshotter.respond_to?(:activity_index)),
187
199
  range_start: @range.begin_time,
188
200
  range_end: range_end || root_versions.last
189
201
  ).call
@@ -41,14 +41,33 @@ module PaperTrailDiff
41
41
  #: (untyped) -> untyped
42
42
  def trailing_scope(relation)
43
43
  column = relation.klass.arel_table[:created_at]
44
- predicate = exclude_end? ? column.gteq(end_time) : column.gt(end_time)
45
- relation.where(predicate)
44
+ relation.where(after_end(column))
45
+ end
46
+
47
+ # Each item's earliest version after the range, keyed by item id. One row
48
+ # per item comes back from a NOT EXISTS rather than a window function, so
49
+ # the query stays portable; a timestamp tie keeps the lower id.
50
+ #: (untyped) -> Hash[String, untyped]
51
+ def first_trailing_versions(relation)
52
+ table = relation.klass.arel_table
53
+ first_after = VersionQuery.no_earlier_version(table) do |earlier|
54
+ after_end(earlier[:created_at])
55
+ end
56
+ versions = VersionQuery.chronological(trailing_scope(relation).where(first_after)).to_a
57
+ first = {} #: Hash[String, untyped]
58
+ versions.each { |version| first[version.item_id.to_s] ||= version }
59
+ first
46
60
  end
47
61
 
48
62
  private
49
63
 
50
64
  # @rbs @exclude_end: bool
51
65
 
66
+ #: (untyped) -> untyped
67
+ def after_end(column)
68
+ exclude_end? ? column.gteq(end_time) : column.gt(end_time)
69
+ end
70
+
52
71
  #: () -> Range[Time]
53
72
  def query_range
54
73
  exclude_end? ? (begin_time...end_time) : (begin_time..end_time)
@@ -12,14 +12,9 @@ module PaperTrailDiff
12
12
  @live_endpoint = live_endpoint
13
13
  end
14
14
 
15
- #: (?context_required: bool) -> Array[untyped]
16
- def select(context_required: false)
17
- select_plan(context_required: context_required).versions
18
- end
19
-
20
15
  #: (?context_required: bool) -> RootVersionPlan
21
16
  def select_plan(context_required: false)
22
- relation = versions_relation
17
+ relation = Endpoint.versions(@record)
23
18
  in_range = ordered(@time_range.scope(relation).to_a)
24
19
  RootVersionSelection.new(
25
20
  in_range: in_range,
@@ -40,18 +35,9 @@ module PaperTrailDiff
40
35
  # @rbs @version_scope: untyped
41
36
  # @rbs @live_endpoint: untyped
42
37
 
43
- #: () -> untyped
44
- def versions_relation
45
- association_name = @record.class.versions_association_name
46
- @record.public_send(association_name)
47
- rescue NoMethodError => e
48
- message = 'record does not expose a PaperTrail version history'
49
- raise InvalidTimelineRangeError, message, cause: e
50
- end
51
-
52
38
  #: (untyped) -> untyped
53
39
  def trailing_version(relation)
54
- @time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
40
+ VersionQuery.chronological(@time_range.trailing_scope(relation)).first
55
41
  end
56
42
 
57
43
  #: (Array[untyped]) -> Array[untyped]
@@ -4,15 +4,15 @@
4
4
  module PaperTrailDiff
5
5
  # Selects and compares a chronological slice of a record's version history.
6
6
  class TimelineBuilder
7
- #: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
7
+ #: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
8
8
  def initialize( # rubocop:disable Metrics/ParameterLists
9
- record, from:, to:, snapshotter:, within: nil, versions: nil, version_scope: nil, plan: nil,
9
+ record, from:, to:, snapshotter:, within: nil, version_scope: nil, plan: nil,
10
10
  live_endpoint: nil
11
11
  )
12
12
  @record = record
13
13
  @range = TimelineRange.new(
14
14
  record, from: from, to: to, within: within,
15
- versions: versions, version_scope: version_scope, plan: plan,
15
+ version_scope: version_scope, plan: plan,
16
16
  live_endpoint: live_endpoint
17
17
  )
18
18
  @snapshotter = snapshotter
@@ -48,7 +48,8 @@ module PaperTrailDiff
48
48
  versions = plan.versions
49
49
  @snapshotter.prepare(@record, versions) if @snapshotter.respond_to?(:prepare)
50
50
  steps = build_steps(plan)
51
- [steps, @snapshotter.call(versions.first), @snapshotter.call(plan.final_endpoint)]
51
+ final = plan.closes_on_destroy ? nil : @snapshotter.call(plan.final_endpoint)
52
+ [steps, @snapshotter.call(versions.first), final]
52
53
  end
53
54
 
54
55
  #: (RootVersionPlan) -> Array[Step]
@@ -4,18 +4,21 @@
4
4
  module PaperTrailDiff
5
5
  # Chooses explicit-version or wall-clock selection for one timeline request.
6
6
  class TimelineRange
7
- BOUNDARY_SYMBOLS = %i[first last].freeze
7
+ BOUNDARY_SYMBOLS = %i[first].freeze
8
+ # `:last` once named the last version, whose recorded state is the one
9
+ # *before* the latest change, so a range ending there silently dropped it.
10
+ LAST_REMOVED = '`:last` was removed because it left out the latest change: omit `to:` ' \
11
+ 'to end at current state, or pass a version'
8
12
 
9
13
  attr_reader :from #: untyped
10
14
  attr_reader :to #: untyped
11
15
  attr_reader :time_range #: TimeRange?
12
16
 
13
- #: (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
14
- def initialize(record, from:, to:, within:, versions: nil, version_scope: nil, plan: nil, live_endpoint: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
17
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
18
+ def initialize(record, from:, to:, within:, version_scope: nil, plan: nil, live_endpoint: nil) # rubocop:disable Metrics/ParameterLists
15
19
  @record = record
16
20
  @plan = plan
17
21
  @live_endpoint = live_endpoint
18
- @versions = (plan ? plan.versions : versions)&.freeze
19
22
  @version_scope = version_scope
20
23
  @requested_from = from
21
24
  @requested_to = to
@@ -26,11 +29,12 @@ module PaperTrailDiff
26
29
  freeze
27
30
  end
28
31
 
29
- # A record with no versions has no first or last boundary to resolve, which
30
- # is an empty history rather than a bad request.
32
+ # A record with no versions has no first boundary to resolve, which
33
+ # is an empty history rather than a bad request. Both endpoints are
34
+ # required, so only `:first` can resolve to nothing; a window has none.
31
35
  #: () -> bool
32
36
  def unresolved?
33
- (symbolic?(@requested_from) && @from.nil?) || (symbolic?(@requested_to) && @to.nil?)
37
+ !time? && (@from.nil? || @to.nil?)
34
38
  end
35
39
 
36
40
  # A batch may have selected these versions already, in which case reselecting
@@ -49,27 +53,7 @@ module PaperTrailDiff
49
53
  live_endpoint: @live_endpoint
50
54
  ).select_plan(context_required: context_required)
51
55
  end
52
- return RootVersionPlan.empty if unresolved?
53
-
54
- VersionRange.new(
55
- @record, from: @from, to: @to, version_scope: @version_scope
56
- ).select_plan_for_range
57
- end
58
-
59
- #: (?context_required: bool) -> Array[untyped]
60
- def select(context_required: false)
61
- preselected = @versions
62
- return preselected if preselected
63
-
64
- range = time_range
65
- if range
66
- return TimeVersionRange.new(
67
- @record, time_range: range, version_scope: @version_scope
68
- ).select(context_required: context_required)
69
- end
70
- return empty_versions if unresolved?
71
-
72
- VersionRange.new(@record, from: @from, to: @to, version_scope: @version_scope).select
56
+ explicit_plan
73
57
  end
74
58
 
75
59
  #: () -> bool
@@ -91,7 +75,6 @@ module PaperTrailDiff
91
75
  private
92
76
 
93
77
  # @rbs @record: untyped
94
- # @rbs @versions: Array[untyped]?
95
78
  # @rbs @plan: RootVersionPlan?
96
79
  # @rbs @version_scope: untyped
97
80
  # @rbs @live_endpoint: untyped
@@ -100,43 +83,43 @@ module PaperTrailDiff
100
83
  # @rbs @from: untyped
101
84
  # @rbs @to: untyped
102
85
 
103
- #: () -> Array[untyped]
104
- def empty_versions
105
- versions = [] #: Array[untyped]
106
- versions.freeze
86
+ #: () -> RootVersionPlan
87
+ def explicit_plan
88
+ return RootVersionPlan.empty if unresolved?
89
+ if Endpoint.record?(@to)
90
+ return VersionRange.new(@record, from: @from, to: @from, version_scope: @version_scope)
91
+ .select_plan_through_current(@to)
92
+ end
93
+
94
+ VersionRange.new(
95
+ @record, from: @from, to: @to, version_scope: @version_scope
96
+ ).select_plan_for_range
107
97
  end
108
98
 
109
99
  #: (untyped) -> bool
110
100
  def symbolic?(boundary)
111
- boundary.is_a?(Symbol)
101
+ boundary.instance_of?(Symbol)
112
102
  end
113
103
 
114
- # Resolving `:first` and `:last` here keeps callers from depending on the
104
+ # Resolving `:first` here keeps callers from depending on the
115
105
  # order PaperTrail happens to give its versions association, which a caller
116
106
  # is also free to reorder.
117
107
  #: (untyped) -> untyped
118
108
  def resolve(boundary)
119
109
  return boundary unless symbolic?(boundary)
110
+ raise InvalidTimelineRangeError, LAST_REMOVED if boundary == :last
120
111
 
121
112
  unless BOUNDARY_SYMBOLS.include?(boundary)
122
113
  raise InvalidTimelineRangeError,
123
- "unsupported boundary: #{boundary.inspect}; use :first, :last, a version, or a record"
114
+ "unsupported boundary: #{boundary.inspect}; use :first, a version, or a record"
124
115
  end
125
116
 
126
- boundary == :first ? ordered_versions.first : ordered_versions.last
117
+ ordered_versions.first
127
118
  end
128
119
 
129
120
  #: () -> untyped
130
121
  def ordered_versions
131
- versions_relation.reorder(created_at: :asc, id: :asc)
132
- end
133
-
134
- #: () -> untyped
135
- def versions_relation
136
- @record.public_send(@record.class.versions_association_name)
137
- rescue NoMethodError => e
138
- message = 'record does not expose a PaperTrail version history'
139
- raise InvalidTimelineRangeError, message, cause: e
122
+ VersionQuery.chronological(Endpoint.versions(@record))
140
123
  end
141
124
 
142
125
  #: (untyped) -> TimeRange?