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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +343 -0
- data/QUICKSTART.md +21 -4
- data/README.md +58 -55
- data/docs/architecture.md +32 -11
- data/docs/performance.md +116 -391
- data/docs/reference.md +345 -106
- data/docs/testing.md +63 -7
- data/lib/paper_trail_diff/activity_boundary.rb +83 -26
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
- data/lib/paper_trail_diff/activity_event.rb +41 -10
- data/lib/paper_trail_diff/activity_history.rb +34 -9
- data/lib/paper_trail_diff/activity_range.rb +18 -3
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
- data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
- data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
- data/lib/paper_trail_diff/activity_view.rb +69 -0
- data/lib/paper_trail_diff/analysis.rb +13 -1
- data/lib/paper_trail_diff/analysis_batch.rb +24 -30
- data/lib/paper_trail_diff/approximate_paths.rb +48 -0
- data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
- data/lib/paper_trail_diff/association_discovery.rb +9 -1
- data/lib/paper_trail_diff/association_key.rb +56 -0
- data/lib/paper_trail_diff/association_traversal.rb +13 -4
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
- data/lib/paper_trail_diff/batch_preload.rb +53 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
- data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
- data/lib/paper_trail_diff/change_row.rb +133 -0
- data/lib/paper_trail_diff/collection_comparator.rb +5 -32
- data/lib/paper_trail_diff/configuration.rb +35 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
- data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
- data/lib/paper_trail_diff/deprecation.rb +61 -0
- data/lib/paper_trail_diff/diagnostics.rb +84 -99
- data/lib/paper_trail_diff/endpoint.rb +26 -3
- data/lib/paper_trail_diff/errors.rb +3 -1
- data/lib/paper_trail_diff/fallback_paths.rb +38 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
- data/lib/paper_trail_diff/historical_filter.rb +19 -10
- data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
- data/lib/paper_trail_diff/nested_comparator.rb +19 -18
- data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
- data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
- data/lib/paper_trail_diff/prepared_history.rb +9 -1
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
- data/lib/paper_trail_diff/root_version_selection.rb +4 -2
- data/lib/paper_trail_diff/scope_condition.rb +76 -21
- data/lib/paper_trail_diff/scope_ranking.rb +12 -2
- data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
- data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
- data/lib/paper_trail_diff/snapshot.rb +16 -1
- data/lib/paper_trail_diff/support.rb +29 -2
- data/lib/paper_trail_diff/time_range.rb +32 -10
- data/lib/paper_trail_diff/timeline_builder.rb +7 -7
- data/lib/paper_trail_diff/timeline_range.rb +9 -1
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
- data/lib/paper_trail_diff/transaction_cut.rb +181 -0
- data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +37 -8
- data/lib/paper_trail_diff/version_query.rb +34 -10
- data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
- data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
- data/lib/paper_trail_diff/version_table.rb +60 -0
- data/lib/paper_trail_diff.rb +98 -56
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
- data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
- data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
- data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
- data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
- data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
- data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
- data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
- data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
- data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
- data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
- data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
- data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
- data/sig/generated/paper_trail_diff/errors.rbs +3 -1
- data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
- data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
- data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
- data/sig/generated/paper_trail_diff/support.rbs +15 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
- data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
- data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
- data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
- data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
- data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
- data/sig/generated/paper_trail_diff.rbs +29 -26
- metadata +46 -10
- data/lib/paper_trail_diff/activity_grouping.rb +0 -38
- data/lib/paper_trail_diff/batch_safety.rb +0 -63
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
- data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
- 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) -> [
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
19
|
-
'
|
|
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
|
|
156
|
-
# with nil,
|
|
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?
|
|
160
|
-
in: ->(value, expected) {
|
|
161
|
-
not_in: ->(value, expected) { !
|
|
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
|
-
|
|
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.
|
|
176
|
-
when :or then first.
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 =
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
@
|
|
18
|
-
@
|
|
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
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
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?(
|
|
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
|
-
|
|
91
|
-
|
|
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)
|
|
50
|
-
steps = build_steps(plan)
|
|
51
|
-
final = plan.closes_on_destroy ? nil :
|
|
52
|
-
[steps,
|
|
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(
|
|
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
|
-
|
|
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
|