paper_trail_diff 0.14.0 → 0.15.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 (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +343 -0
  3. data/QUICKSTART.md +21 -4
  4. data/README.md +58 -55
  5. data/docs/architecture.md +32 -11
  6. data/docs/performance.md +116 -391
  7. data/docs/reference.md +345 -106
  8. data/docs/testing.md +63 -7
  9. data/lib/paper_trail_diff/activity_boundary.rb +83 -26
  10. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
  11. data/lib/paper_trail_diff/activity_event.rb +41 -10
  12. data/lib/paper_trail_diff/activity_history.rb +34 -9
  13. data/lib/paper_trail_diff/activity_range.rb +18 -3
  14. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
  15. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
  16. data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
  17. data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
  18. data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
  19. data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
  20. data/lib/paper_trail_diff/activity_view.rb +69 -0
  21. data/lib/paper_trail_diff/analysis.rb +13 -1
  22. data/lib/paper_trail_diff/analysis_batch.rb +24 -30
  23. data/lib/paper_trail_diff/approximate_paths.rb +48 -0
  24. data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
  25. data/lib/paper_trail_diff/association_discovery.rb +9 -1
  26. data/lib/paper_trail_diff/association_key.rb +56 -0
  27. data/lib/paper_trail_diff/association_traversal.rb +13 -4
  28. data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
  29. data/lib/paper_trail_diff/batch_preload.rb +53 -0
  30. data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
  31. data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
  32. data/lib/paper_trail_diff/change_row.rb +133 -0
  33. data/lib/paper_trail_diff/collection_comparator.rb +5 -32
  34. data/lib/paper_trail_diff/configuration.rb +35 -12
  35. data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
  36. data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
  37. data/lib/paper_trail_diff/deprecation.rb +61 -0
  38. data/lib/paper_trail_diff/diagnostics.rb +84 -99
  39. data/lib/paper_trail_diff/endpoint.rb +26 -3
  40. data/lib/paper_trail_diff/errors.rb +3 -1
  41. data/lib/paper_trail_diff/fallback_paths.rb +38 -0
  42. data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
  43. data/lib/paper_trail_diff/historical_filter.rb +19 -10
  44. data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
  45. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
  46. data/lib/paper_trail_diff/nested_comparator.rb +19 -18
  47. data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
  48. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
  49. data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
  50. data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
  51. data/lib/paper_trail_diff/prepared_history.rb +9 -1
  52. data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
  53. data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
  54. data/lib/paper_trail_diff/root_version_selection.rb +4 -2
  55. data/lib/paper_trail_diff/scope_condition.rb +76 -21
  56. data/lib/paper_trail_diff/scope_ranking.rb +12 -2
  57. data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
  58. data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
  59. data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
  60. data/lib/paper_trail_diff/snapshot.rb +16 -1
  61. data/lib/paper_trail_diff/support.rb +29 -2
  62. data/lib/paper_trail_diff/time_range.rb +32 -10
  63. data/lib/paper_trail_diff/timeline_builder.rb +7 -7
  64. data/lib/paper_trail_diff/timeline_range.rb +9 -1
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
  66. data/lib/paper_trail_diff/transaction_cut.rb +181 -0
  67. data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +37 -8
  70. data/lib/paper_trail_diff/version_query.rb +34 -10
  71. data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
  72. data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
  73. data/lib/paper_trail_diff/version_table.rb +60 -0
  74. data/lib/paper_trail_diff.rb +98 -56
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
  76. data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
  77. data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
  78. data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
  79. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
  80. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
  81. data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
  82. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
  83. data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
  84. data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
  85. data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
  86. data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
  87. data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
  90. data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
  93. data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
  94. data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
  95. data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
  96. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
  97. data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
  98. data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
  99. data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
  100. data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +3 -1
  102. data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
  105. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
  106. data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
  107. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
  108. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
  110. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
  112. data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
  113. data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
  114. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
  115. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
  116. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
  117. data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
  118. data/sig/generated/paper_trail_diff/support.rbs +15 -0
  119. data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
  120. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  121. data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
  122. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
  123. data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
  124. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
  125. data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
  126. data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
  127. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
  128. data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
  129. data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
  130. data/sig/generated/paper_trail_diff.rbs +29 -26
  131. metadata +46 -10
  132. data/lib/paper_trail_diff/activity_grouping.rb +0 -38
  133. data/lib/paper_trail_diff/batch_safety.rb +0 -63
  134. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
  135. data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
  136. data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
  137. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +0 -92
@@ -133,6 +133,17 @@ module PaperTrailDiff
133
133
  state_before(boundary)&.instantiate
134
134
  end
135
135
 
136
+ # A record created in the boundary's own transaction has no state before
137
+ # it, yet a HABTM checkpoint saved later in that transaction records it as
138
+ # a member. Such a member is read as it was created.
139
+ #: (untyped) -> untyped
140
+ def record_created_in(boundary)
141
+ index = transaction_index(boundary)
142
+ return unless index && versions.fetch(index).event == 'create'
143
+
144
+ state_after(index)&.instantiate
145
+ end
146
+
136
147
  #: (untyped) -> PreparedRecordState?
137
148
  def state_before(boundary)
138
149
  index = boundary_index(boundary)
@@ -179,7 +190,7 @@ module PaperTrailDiff
179
190
  [state_for(version), state_after(index)].compact
180
191
  end
181
192
 
182
- #: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
193
+ #: (untyped) -> [PreparedRecordState, PreparedRecordState]?
183
194
  def transition(version)
184
195
  index = @version_positions[version.id.to_s]
185
196
  return unless index
@@ -188,7 +199,7 @@ module PaperTrailDiff
188
199
  after = state_after(index)
189
200
  return unless before && after && before.model_class == after.model_class
190
201
 
191
- [before.attributes, after.attributes]
202
+ [before, after]
192
203
  end
193
204
 
194
205
  private
@@ -228,7 +239,7 @@ module PaperTrailDiff
228
239
 
229
240
  #: (untyped) -> Integer?
230
241
  def transaction_index(boundary)
231
- transaction_id = boundary.transaction_id if boundary.respond_to?(:transaction_id)
242
+ transaction_id = VersionBoundary.transaction_id(boundary)
232
243
  return unless transaction_id
233
244
 
234
245
  @transaction_positions[transaction_id]
@@ -238,9 +249,7 @@ module PaperTrailDiff
238
249
  def build_transaction_positions
239
250
  positions = {} #: Hash[untyped, Integer]
240
251
  @versions.each_with_index do |version, index|
241
- next unless version.respond_to?(:transaction_id)
242
-
243
- transaction_id = version.transaction_id
252
+ transaction_id = VersionBoundary.transaction_id(version)
244
253
  positions[transaction_id] ||= index if transaction_id
245
254
  end
246
255
  positions.freeze
@@ -304,6 +313,11 @@ module PaperTrailDiff
304
313
  @series.fetch(identity(model_class, id)).record_before(boundary)
305
314
  end
306
315
 
316
+ #: (untyped, untyped, untyped) -> untyped
317
+ def record_created_in(model_class, id, boundary)
318
+ @series.fetch(identity(model_class, id)).record_created_in(boundary)
319
+ end
320
+
307
321
  #: (untyped, untyped, untyped) -> [bool, PreparedRecordState?]
308
322
  def snapshot_state_before(model_class, id, boundary)
309
323
  series = @series[identity(model_class, id)]
@@ -317,9 +331,15 @@ module PaperTrailDiff
317
331
  [!prepared.nil?, prepared]
318
332
  end
319
333
 
334
+ # Typed as a reified record's attributes are. A JSON or JSONB `object`
335
+ # column stores times and dates as strings, which PaperTrail does not cast
336
+ # back, and the transition is carried into snapshots built from records.
320
337
  #: (untyped, untyped, untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
321
338
  def transition(model_class, id, version)
322
- @series[identity(model_class, id)]&.transition(version)
339
+ states = @series[identity(model_class, id)]&.transition(version)
340
+ return unless states
341
+
342
+ [typed_attributes(states.fetch(0)), typed_attributes(states.fetch(1))]
323
343
  end
324
344
 
325
345
  #: (untyped, untyped, untyped, ?direct: bool) -> [bool, untyped]
@@ -370,6 +390,17 @@ module PaperTrailDiff
370
390
 
371
391
  private
372
392
 
393
+ # A model whose records need their own initialization is cast by
394
+ # instantiating it, as reify does.
395
+ #: (PreparedRecordState) -> Hash[untyped, untyped]
396
+ def typed_attributes(state)
397
+ @direct_states[state] = @direct_loader.complete(state) unless @direct_states.key?(state)
398
+ typed = @direct_states[state]
399
+ return typed.attributes if typed
400
+
401
+ PreparedRecordState.new(state.instantiate).attributes
402
+ end
403
+
373
404
  #: (untyped, String) -> bool
374
405
  def generated_reader?(model, name)
375
406
  model.define_attribute_methods
@@ -14,9 +14,11 @@ module PaperTrailDiff
14
14
  # An error is a specification, so it names the way out as well as the wall.
15
15
  # A window ending at the present can never gain a later version, which makes
16
16
  # "write a checkpoint afterwards" impossible advice on its own.
17
+ # A window closes on the current record when it reaches the present, so
18
+ # this is raised only when there is none to read.
17
19
  INCOMPLETE = 'time range requires a later root version to reconstruct its ' \
18
- 'final change: pass close_on: :current to end at current state, ' \
19
- 'or narrow the window to end before the last recorded version'
20
+ 'final change, and there is no current record to end at: narrow ' \
21
+ 'the window to end before the last recorded version'
20
22
 
21
23
  #: (in_range: Array[untyped], selected: Array[untyped], after_range: untyped, windowed: bool, ?context_required: bool, ?filtered: bool, ?live_endpoint: untyped) -> void
22
24
  def initialize( # rubocop:disable Metrics/ParameterLists
@@ -14,6 +14,8 @@ module PaperTrailDiff
14
14
  # ranking alone.
15
15
  RELATION_VALUES = %i[where order reordering limit offset readonly strict_loading
16
16
  extending].freeze
17
+ # Relation parts that load or shape rows without choosing them.
18
+ LOADING_VALUES = %i[includes preload select distinct].freeze
17
19
  # Types whose stored values compare in Ruby as the database compares them,
18
20
  # named rather than referenced so that loading needs no Active Model.
19
21
  VALUE_TYPES = %w[ActiveModel::Type::Integer ActiveModel::Type::Boolean
@@ -63,6 +65,23 @@ module PaperTrailDiff
63
65
  nil
64
66
  end
65
67
 
68
+ # A relation's own where clause, default scope included, read as
69
+ # conditions on stored attributes; nil when it cannot be. Its order,
70
+ # limit, and offset choose a page of current rows rather than membership,
71
+ # and loading or selecting columns changes neither, so they are left out.
72
+ # A single-table subclass's type condition is left to the caller, as for
73
+ # an association.
74
+ #: (untyped, untyped) -> ScopeCondition?
75
+ def self.for_relation(relation, model_class)
76
+ extra = relation.values.except(*RELATION_VALUES, *LOADING_VALUES).values
77
+ return unless extra.all? { |value| value.respond_to?(:empty?) && value.empty? }
78
+
79
+ clause = relation.where_clause - model_class.unscoped.where_clause
80
+ new(model_class, Reader.new(model_class).call(clause.ast))
81
+ rescue Unreadable
82
+ nil
83
+ end
84
+
66
85
  # nil when the relation picks its members by conditions alone.
67
86
  #: (untyped, untyped, untyped) -> ScopeRanking?
68
87
  def self.ranking(relation, reflection, model_class)
@@ -132,9 +151,11 @@ module PaperTrailDiff
132
151
  freeze
133
152
  end
134
153
 
154
+ # SQL admits a row only when its condition is true, not when it is
155
+ # unknown.
135
156
  #: (Hash[String, untyped]) -> bool
136
157
  def matches?(attributes)
137
- evaluate(@predicate, attributes)
158
+ evaluate(@predicate, attributes) == true
138
159
  end
139
160
 
140
161
  # The members among records that are each the owner's by key: those that
@@ -152,13 +173,15 @@ module PaperTrailDiff
152
173
  ranking ? ranking.call(records) : records
153
174
  end
154
175
 
155
- # SQL compares NULL with nothing, so only an IS NULL test, an equality
156
- # with nil, matches it.
176
+ # SQL's three values: a comparison with NULL is unknown (nil) rather than
177
+ # false, except an IS NULL or IS NOT NULL test, an equality with nil, which
178
+ # is always known. Unknown matters only under NOT, which leaves it
179
+ # unknown where false would become true.
157
180
  COMPARISONS = {
158
- eq: ->(value, expected) { value == expected },
159
- not_eq: ->(value, expected) { !value.nil? && value != expected },
160
- in: ->(value, expected) { !value.nil? && expected.include?(value) },
161
- not_in: ->(value, expected) { !value.nil? && !expected.include?(value) }
181
+ eq: ->(value, expected) { expected.nil? || !value.nil? ? value == expected : nil },
182
+ not_eq: ->(value, expected) { expected.nil? || !value.nil? ? value != expected : nil },
183
+ in: ->(value, expected) { expected.include?(value) unless value.nil? },
184
+ not_in: ->(value, expected) { !expected.include?(value) unless value.nil? }
162
185
  }.freeze
163
186
  private_constant :COMPARISONS
164
187
 
@@ -168,16 +191,39 @@ module PaperTrailDiff
168
191
  # @rbs @predicate: Array[untyped]
169
192
  # @rbs @ranking: ScopeRanking?
170
193
 
171
- #: (Array[untyped], Hash[String, untyped]) -> bool
194
+ # true, false, or nil for unknown.
195
+ #: (Array[untyped], Hash[String, untyped]) -> bool?
172
196
  def evaluate(predicate, attributes)
173
197
  operator, first, second = predicate
174
198
  case operator
175
- when :and then first.all? { |part| evaluate(part, attributes) }
176
- when :or then first.any? { |part| evaluate(part, attributes) }
199
+ when :and then all(first.map { |part| evaluate(part, attributes) })
200
+ when :or then any(first.map { |part| evaluate(part, attributes) })
201
+ when :not then negate(evaluate(first, attributes))
177
202
  else COMPARISONS.fetch(operator).call(stored(first, attributes), second)
178
203
  end
179
204
  end
180
205
 
206
+ # False if any part is, otherwise unknown if any part is.
207
+ #: (Array[bool?]) -> bool?
208
+ def all(values)
209
+ return false if values.include?(false)
210
+
211
+ true unless values.include?(nil)
212
+ end
213
+
214
+ # True if any part is, otherwise unknown if any part is.
215
+ #: (Array[bool?]) -> bool?
216
+ def any(values)
217
+ return true if values.include?(true)
218
+
219
+ false unless values.include?(nil)
220
+ end
221
+
222
+ #: (bool?) -> bool?
223
+ def negate(value)
224
+ !value unless value.nil?
225
+ end
226
+
181
227
  # The form the database holds, as the scope's own values were cast.
182
228
  #: (String, Hash[String, untyped]) -> untyped
183
229
  def stored(name, attributes)
@@ -224,6 +270,7 @@ module PaperTrailDiff
224
270
  when 'Arel::Nodes::And' then [:and, node.children.map { |child| call(child) }]
225
271
  when 'Arel::Nodes::Or' then [:or, either(node).map { |child| call(child) }]
226
272
  when 'Arel::Nodes::Grouping' then call(node.expr)
273
+ when 'Arel::Nodes::Not' then [:not, call(node.expr)]
227
274
  else comparison(node)
228
275
  end
229
276
  end
@@ -250,9 +297,11 @@ module PaperTrailDiff
250
297
  end
251
298
  end
252
299
 
300
+ # Whether a value is NULL compares alike on every type.
253
301
  #: (Symbol, untyped) -> Array[untyped]
254
302
  def binary(operator, node)
255
- [operator, column(node.left), value(node.right)]
303
+ expected = value(node.right)
304
+ [operator, column(node.left, null_test: expected.nil?), expected]
256
305
  end
257
306
 
258
307
  #: (untyped) -> Array[untyped]
@@ -272,29 +321,35 @@ module PaperTrailDiff
272
321
  end
273
322
 
274
323
  # Ruby comparison stands in for SQL only on the record's own column of
275
- # a type both compare alike.
276
- #: (untyped) -> String
277
- def column(attribute)
324
+ # a type both compare alike. Whether a value is NULL, any column's
325
+ # type compares alike.
326
+ #: (untyped, ?null_test: bool) -> String
327
+ def column(attribute, null_test: false)
278
328
  raise Unreadable unless ATTRIBUTE.eql?(attribute.class.name)
279
329
 
280
330
  table = attribute.relation
281
331
  raise Unreadable unless table.name == @model_class.table_name && table.table_alias.nil?
282
332
 
283
333
  name = attribute.name
284
- raise Unreadable unless comparable?(name)
334
+ column = @model_class.columns_hash[name]
335
+ raise Unreadable unless column && (null_test || comparable?(name, column))
285
336
 
286
337
  name
287
338
  end
288
339
 
289
- #: (String) -> bool
290
- def comparable?(name)
291
- column = @model_class.columns_hash[name]
340
+ #: (String, untyped) -> bool
341
+ def comparable?(name, column)
292
342
  kinds = value_type(@model_class.type_for_attribute(name)).class.ancestors.map(&:name)
293
- return false unless column && VALUE_TYPES.intersect?(kinds)
294
- return true unless kinds.include?(TEXT_TYPE)
343
+ return false unless VALUE_TYPES.intersect?(kinds)
344
+
345
+ !kinds.include?(TEXT_TYPE) || exact_text?(column)
346
+ end
295
347
 
348
+ # PostgreSQL's citext is text that compares without regard to case.
349
+ #: (untyped) -> bool
350
+ def exact_text?(column)
296
351
  EXACT_TEXT_ADAPTERS.include?(@model_class.connection_db_config.adapter) &&
297
- column.collation.nil?
352
+ column.collation.nil? && !column.sql_type.casecmp?('citext')
298
353
  end
299
354
 
300
355
  # An enum compares the values it stores, which its subtype describes. A
@@ -11,6 +11,9 @@ module PaperTrailDiff
11
11
  DIRECTIONS = { 'Arel::Nodes::Ascending' => false, 'Arel::Nodes::Descending' => true }.freeze
12
12
  ATTRIBUTE = 'Arel::Attributes::Attribute'
13
13
  ENUM_TYPE = 'ActiveRecord::Enum::EnumType'
14
+ # Rails wraps a time-zone-aware attribute's type, by default every
15
+ # datetime's, in this delegator; the time it stores is the wrapped type's.
16
+ TIME_ZONE_TYPE = 'ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter'
14
17
  # Integers, booleans and dates sort alike everywhere. Strings follow a
15
18
  # collation, so they are not read.
16
19
  ORDER_TYPES = %w[ActiveModel::Type::Integer ActiveModel::Type::Boolean
@@ -107,14 +110,21 @@ module PaperTrailDiff
107
110
  def self.orderable?(model_class, name)
108
111
  return false unless model_class.columns_hash.key?(name)
109
112
 
110
- type = model_class.type_for_attribute(name)
111
- type = type.subtype if ENUM_TYPE.eql?(type.class.name)
113
+ type = stored_type(model_class.type_for_attribute(name))
112
114
  kinds = type.class.ancestors.map(&:name)
113
115
  ORDER_TYPES.intersect?(kinds) ||
114
116
  (kinds.include?(DATETIME_TYPE) && exact_time?(model_class, type))
115
117
  end
116
118
  private_class_method :orderable?
117
119
 
120
+ # The type of the value a column stores, under an enum or time zone wrapper.
121
+ #: (untyped) -> untyped
122
+ def self.stored_type(type)
123
+ type = type.subtype if ENUM_TYPE.eql?(type.class.name)
124
+ TIME_ZONE_TYPE.eql?(type.class.name) ? type.__getobj__ : type
125
+ end
126
+ private_class_method :stored_type
127
+
118
128
  # Active Record truncates a time to its column's precision before writing
119
129
  # it, so history holds the stored value when it keeps that many digits.
120
130
  # Times the type stores in UTC sort as the instants they name; local
@@ -36,6 +36,17 @@ module PaperTrailDiff
36
36
  freeze
37
37
  end
38
38
 
39
+ # Each root's change rows, keyed as `analyses` is: the roots that still
40
+ # exist, then the deleted ones that have history to report. A deleted root
41
+ # without destroy history has no rows and is left out.
42
+ #: () -> Hash[Array[String], Array[ChangeRow]]
43
+ def change_rows
44
+ rows = {} #: Hash[Array[String], Array[ChangeRow]]
45
+ analyses.each { |identity, analysis| rows[identity] = analysis.change_rows }
46
+ deleted.each { |identity, analysis| rows[identity] = analysis.change_rows if analysis }
47
+ rows.freeze
48
+ end
49
+
39
50
  # Two elements, not three: `roots` is reached by name so that existing
40
51
  # destructuring keeps working.
41
52
  #: () -> [Hash[Array[String], Analysis], Array[Array[String]]]
@@ -19,8 +19,8 @@ module PaperTrailDiff
19
19
  historical_filter: historical_filter, max_filter_versions: max_filter_versions,
20
20
  activity_tree: activity_tree
21
21
  )
22
+ @deleted_filter = DeletedRootFilter.new(scope.all) if include_deleted
22
23
  @limit = limit
23
- @include_deleted = include_deleted
24
24
  @analyzer = analyzer
25
25
  @deleted_analyzer = deleted_analyzer
26
26
  end
@@ -28,20 +28,28 @@ module PaperTrailDiff
28
28
  #: () -> ScopedAnalysis
29
29
  def call
30
30
  selection = @selection.call
31
- if @include_deleted && selection.records.length + selection.unreachable.length > @limit
31
+ deleted_filter = @deleted_filter
32
+ return analysis(selection, selection.unreachable, {}) unless deleted_filter
33
+
34
+ unreachable = deleted_filter.call(selection.unreachable)
35
+ if selection.records.length + unreachable.length > @limit
32
36
  raise BatchLimitExceededError, 'include_deleted: live and missing roots exceed limit:'
33
37
  end
34
38
 
35
- ScopedAnalysis.new(
36
- analyses: @analyzer.call(selection.records), roots: selection.records,
37
- unreachable: selection.unreachable,
38
- deleted: @include_deleted ? @deleted_analyzer.call(selection.unreachable) : {}
39
- )
39
+ analysis(selection, unreachable, @deleted_analyzer.call(unreachable))
40
+ end
41
+
42
+ private
43
+
44
+ #: (ScopedRootSelection::Result, Array[Array[String]], Hash[Array[String], Analysis?]) -> ScopedAnalysis
45
+ def analysis(selection, unreachable, deleted)
46
+ ScopedAnalysis.new(analyses: @analyzer.call(selection.records), roots: selection.records,
47
+ unreachable: unreachable, deleted: deleted)
40
48
  end
41
49
 
42
50
  # @rbs @selection: ScopedRootSelection
51
+ # @rbs @deleted_filter: DeletedRootFilter?
43
52
  # @rbs @limit: Integer
44
- # @rbs @include_deleted: bool
45
53
  # @rbs @analyzer: untyped
46
54
  # @rbs @deleted_analyzer: untyped
47
55
  end
@@ -106,14 +106,22 @@ module PaperTrailDiff
106
106
  matching if matching.length == related.length
107
107
  end
108
108
 
109
+ # Which candidates still have a row is asked without the relation's
110
+ # conditions, so that "filtered out" and "no longer exists" stay
111
+ # separable. A filter then reads the history of the relation's records
112
+ # only, whose order and page choose among those that match, and of the
113
+ # deleted ones, which no relation can test. Those it lists, as without a
114
+ # filter, but does not count toward `limit:`.
109
115
  #: (Array[String]) -> Result
110
116
  def plucked_selection(candidates)
111
- candidates = @historical_filter.call(candidates) if @historical_filter
112
- live = live_ids(candidates)
113
- Result.new(
114
- records: selected_records([live]),
115
- unreachable: (candidates - live).sort.map { |id| identity(id) }.freeze
116
- )
117
+ live = ids_in(base_class.unscoped, candidates)
118
+ missing = candidates - live
119
+ if (filter = @historical_filter)
120
+ live = filter.call(ids_in(@scope.except(:order, :limit, :offset), live).uniq)
121
+ missing = filter.call(missing, counted: false)
122
+ end
123
+ Result.new(records: selected_records([live]),
124
+ unreachable: missing.sort.map { |id| identity(id) }.freeze)
117
125
  end
118
126
 
119
127
  # Comparing the two columns in SQL needs them to share a type: PostgreSQL
@@ -144,7 +152,7 @@ module PaperTrailDiff
144
152
 
145
153
  #: () -> untyped
146
154
  def window_versions
147
- relation = version_class.where(item_type: item_type)
155
+ relation = VersionQuery.for_model(model)
148
156
  range = @time_range
149
157
  range ? range.scope(relation) : relation
150
158
  end
@@ -185,12 +193,10 @@ module PaperTrailDiff
185
193
  window_versions.distinct.pluck(:item_id).map(&:to_s)
186
194
  end
187
195
 
188
- # Which of those candidates still have a row, asked without the relation's
189
- # conditions so that "filtered out" and "no longer exists" stay separable.
190
- #: (Array[String]) -> Array[String]
191
- def live_ids(candidates)
196
+ #: (untyped, Array[String]) -> Array[String]
197
+ def ids_in(relation, candidates)
192
198
  candidates.each_slice(PAGE_SIZE).flat_map do |slice|
193
- base_class.unscoped.where(primary_key => slice).pluck(primary_key).map(&:to_s)
199
+ relation.where(primary_key => slice).pluck(primary_key).map(&:to_s)
194
200
  end
195
201
  end
196
202
 
@@ -102,7 +102,7 @@ module PaperTrailDiff
102
102
  end
103
103
 
104
104
  @kind = kind
105
- @records = records.frozen? ? records : records.dup.freeze
105
+ @records = ordered(records)
106
106
  @identity_index_cache = identity_index_cache || IdentityIndexCache.new
107
107
  @transition = transition
108
108
  @serial = SERIALS.next_serial
@@ -124,6 +124,21 @@ module PaperTrailDiff
124
124
  )
125
125
  end
126
126
 
127
+ # Collections are documented in record type, then ID, order wherever they
128
+ # were read from. Most arrive sorted, so they are checked before sorting.
129
+ #: (Array[RecordSnapshot]) -> Array[RecordSnapshot]
130
+ def ordered(records)
131
+ keys = records.map { |record| Support.sortable_identity(record.type, record.id) }
132
+ return records.sort_by.with_index { |_, index| keys.fetch(index) }.freeze unless sorted?(keys)
133
+
134
+ records.frozen? ? records : records.dup.freeze
135
+ end
136
+
137
+ #: (Array[Array[untyped]]) -> bool
138
+ def sorted?(keys)
139
+ keys.each_cons(2).all? { |left, right| (left <=> right).to_i <= 0 }
140
+ end
141
+
127
142
  #: (AssociationSnapshot) -> CollectionTransition?
128
143
  def transition_from(association)
129
144
  @transition if @transition&.from?(association)
@@ -44,6 +44,20 @@ module PaperTrailDiff
44
44
  end
45
45
  end
46
46
 
47
+ # Records sort by type first so that mixed id types stay comparable, then
48
+ # naturally within one type. Ordering by the printed form instead would put
49
+ # id 10 before id 2, which is deterministic but reads as unsorted wherever a
50
+ # result is rendered.
51
+ #: (untyped, untyped) -> Array[untyped]
52
+ def sortable_identity(type, id)
53
+ key = case id
54
+ when Numeric then [0, id]
55
+ when String, Symbol then [1, id.to_s]
56
+ else [2, id.inspect]
57
+ end
58
+ [type.to_s, key]
59
+ end
60
+
47
61
  #: (untyped) -> Array[untyped]
48
62
  def chronological_version_key(version)
49
63
  version_key(version.created_at, version.id)
@@ -70,11 +84,24 @@ module PaperTrailDiff
70
84
  #: (Array[untyped]) -> Array[untyped]?
71
85
  def ambiguous_pair(sorted)
72
86
  sorted.each_cons(2).find do |left, right|
73
- left.created_at == right.created_at &&
74
- !(sequential_id?(left.id) && sequential_id?(right.id))
87
+ left.created_at == right.created_at && !orderable_tie?(left, right)
75
88
  end
76
89
  end
77
90
 
91
+ # Ids order versions saved in one instant only when they count up in one
92
+ # table. Two version tables count separately, so ids there say nothing.
93
+ #: (untyped, untyped) -> bool
94
+ def orderable_tie?(left, right)
95
+ version_table(left) == version_table(right) &&
96
+ sequential_id?(left.id) && sequential_id?(right.id)
97
+ end
98
+
99
+ #: (untyped) -> untyped
100
+ def version_table(version)
101
+ version.class.respond_to?(:table_name) ? version.class.table_name : version.class.name
102
+ end
103
+ private_class_method :version_table
104
+
78
105
  # Versions sharing a timestamp, whether or not their ids order them. PT-AT
79
106
  # indexes association membership per version but resolves it by timestamp,
80
107
  # so association state cannot be told apart across such a pair even when the
@@ -3,6 +3,10 @@
3
3
 
4
4
  module PaperTrailDiff
5
5
  # Validates and applies a finite wall-clock range without loading model state.
6
+ #
7
+ # A Date is a whole day and a String a local time, both in Time.zone, as
8
+ # Rails reads them, or in UTC without one: never the server's own zone. An
9
+ # inclusive Date end runs through that day.
6
10
  class TimeRange
7
11
  attr_reader :begin_time #: Time
8
12
  attr_reader :end_time #: Time
@@ -14,8 +18,8 @@ module PaperTrailDiff
14
18
  end
15
19
 
16
20
  @begin_time = coerce_time(value.begin, boundary: :begin)
17
- @end_time = coerce_time(value.end, boundary: :end)
18
- @exclude_end = value.exclude_end?
21
+ @exclude_end = value.exclude_end? || calendar_date?(value.end)
22
+ @end_time = coerce_time(end_value(value), boundary: :end)
19
23
  validate_order!
20
24
  freeze
21
25
  end
@@ -79,16 +83,34 @@ module PaperTrailDiff
79
83
  raise InvalidTimeRangeError, "`within` #{boundary} must be time-like"
80
84
  end
81
85
 
82
- # Active Support 8.0 and older warn, once per process, that `to_time` will
83
- # change on a zoned time, so read zoned values without it. Every branch
84
- # gives the same instant, so no spec (and no mutation) can tell them apart.
85
- # mutant:disable
86
- #: (untyped) -> Time
86
+ # Anything else names its instant, and `getutc` reads it without
87
+ # `to_time`, which Active Support 8.0 and older warn will change on a
88
+ # zoned time, and without changing the caller's own value as `utc` does.
89
+ #: (untyped) -> untyped
87
90
  def time_value(value)
88
- return value if value.is_a?(Time)
91
+ return zone.parse(value) || raise(ArgumentError) if value.is_a?(String)
92
+ return zone.local(value.year, value.month, value.day) if calendar_date?(value)
93
+
94
+ value
95
+ end
96
+
97
+ # An inclusive date end runs through the day: to the next one's start.
98
+ #: (Range[untyped]) -> untyped
99
+ def end_value(range)
100
+ last = range.end
101
+ calendar_date?(last) && !range.exclude_end? ? last.next_day : last
102
+ end
103
+
104
+ # A DateTime is a Date too, but names its instant.
105
+ #: (untyped) -> bool
106
+ def calendar_date?(value)
107
+ value.instance_of?(Date)
108
+ end
89
109
 
90
- utc = value.utc if value.respond_to?(:utc)
91
- utc.is_a?(Time) ? utc : value.to_time
110
+ #: () -> untyped
111
+ def zone
112
+ time = Time #: untyped
113
+ time.zone || time.find_zone!('UTC')
92
114
  end
93
115
 
94
116
  #: () -> void
@@ -46,21 +46,21 @@ module PaperTrailDiff
46
46
  return empty_history if plan.empty?
47
47
 
48
48
  versions = plan.versions
49
- @snapshotter.prepare(@record, versions) if @snapshotter.respond_to?(:prepare)
50
- steps = build_steps(plan)
51
- final = plan.closes_on_destroy ? nil : @snapshotter.call(plan.final_endpoint)
52
- [steps, @snapshotter.call(versions.first), final]
49
+ snapshots = @snapshotter.prepare(@record, versions)
50
+ steps = build_steps(plan, snapshots)
51
+ final = plan.closes_on_destroy ? nil : snapshots.call(plan.final_endpoint)
52
+ [steps, snapshots.call(versions.first), final]
53
53
  end
54
54
 
55
- #: (RootVersionPlan) -> Array[Step]
56
- def build_steps(plan)
55
+ #: (RootVersionPlan, untyped) -> Array[Step]
56
+ def build_steps(plan, snapshots)
57
57
  captured_at = Time.now.utc if plan.closing_record
58
58
  plan.steps.map do |from_endpoint, to_endpoint|
59
59
  Step.new(
60
60
  from_version: from_endpoint,
61
61
  to_version: to_endpoint,
62
62
  captured_at: captured_at,
63
- diff: Engine.compare(@snapshotter.call(from_endpoint), @snapshotter.call(to_endpoint))
63
+ diff: Engine.compare(snapshots.call(from_endpoint), snapshots.call(to_endpoint))
64
64
  )
65
65
  end.freeze
66
66
  end
@@ -122,9 +122,17 @@ module PaperTrailDiff
122
122
  VersionQuery.chronological(Endpoint.versions(@record))
123
123
  end
124
124
 
125
+ # A batch passes each root the window it already moved to transaction
126
+ # edges; a single record's window is moved here.
125
127
  #: (untyped) -> TimeRange?
126
128
  def build_time_range(within)
127
- TimeRange.new(within) unless within.nil?
129
+ return if within.nil?
130
+ return within if within.instance_of?(TimeRange)
131
+
132
+ range = TimeRange.new(within)
133
+ return range unless Support.versioned?(@record.class)
134
+
135
+ TransactionCut.windows(@record.class, [@record], range).fetch(@record.id.to_s, range)
128
136
  end
129
137
 
130
138
  #: () -> void