paper_trail_diff 0.3.0 → 0.4.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 +76 -0
- data/README.md +269 -30
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
- data/lib/paper_trail_diff/activity_boundary.rb +21 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
- data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
- data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
- data/lib/paper_trail_diff/activity_event_route_finder.rb +92 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +56 -650
- data/lib/paper_trail_diff/activity_relationship.rb +55 -0
- data/lib/paper_trail_diff/activity_root_steps.rb +27 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +8 -3
- data/lib/paper_trail_diff/activity_timeline_builder.rb +47 -32
- data/lib/paper_trail_diff/analysis.rb +9 -0
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +2 -0
- data/lib/paper_trail_diff/collection_comparator.rb +14 -1
- data/lib/paper_trail_diff/collection_transition.rb +6 -3
- data/lib/paper_trail_diff/endpoint.rb +27 -2
- data/lib/paper_trail_diff/errors.rb +3 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +8 -5
- data/lib/paper_trail_diff/historical_snapshot_store.rb +9 -2
- data/lib/paper_trail_diff/prepared_association_reifier.rb +8 -5
- data/lib/paper_trail_diff/prepared_edge_loader.rb +6 -4
- data/lib/paper_trail_diff/prepared_history_loader.rb +3 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +90 -15
- data/lib/paper_trail_diff/snapshot.rb +26 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +14 -2
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +52 -32
- data/lib/paper_trail_diff/time_version_range.rb +11 -0
- data/lib/paper_trail_diff/timeline_range.rb +57 -4
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +46 -19
- data/lib/paper_trail_diff.rb +10 -0
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
- data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
- data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
- data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
- data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +47 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +18 -110
- data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
- data/sig/generated/paper_trail_diff/activity_root_steps.rbs +13 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +20 -10
- data/sig/generated/paper_trail_diff/analysis.rbs +5 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +7 -0
- data/sig/generated/paper_trail_diff/collection_transition.rbs +4 -1
- data/sig/generated/paper_trail_diff/endpoint.rbs +12 -0
- data/sig/generated/paper_trail_diff/errors.rbs +4 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +1 -1
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +2 -2
- data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +1 -1
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +1 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -2
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +39 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +20 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +8 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +21 -10
- data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -0
- data/sig/generated/paper_trail_diff/timeline_range.rbs +29 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +17 -4
- metadata +24 -4
|
@@ -21,9 +21,14 @@ module PaperTrailDiff
|
|
|
21
21
|
state
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# Mirrors PaperTrail's reifier, which writes attributes directly. Mass
|
|
25
|
+
# assignment would route reconstructed state through application-defined
|
|
26
|
+
# attribute writers and reify a different record than the version stored.
|
|
24
27
|
#: () -> untyped
|
|
25
28
|
def instantiate
|
|
26
|
-
@model_class.new
|
|
29
|
+
record = @model_class.new
|
|
30
|
+
@attributes.each { |name, value| record[name] = value }
|
|
31
|
+
record
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
private
|
|
@@ -108,6 +113,7 @@ module PaperTrailDiff
|
|
|
108
113
|
@version_positions = @versions.each_with_index.to_h do |version, index|
|
|
109
114
|
[version.id.to_s, index]
|
|
110
115
|
end.freeze
|
|
116
|
+
@transaction_positions = build_transaction_positions
|
|
111
117
|
@live = live
|
|
112
118
|
@state_loader = state_loader
|
|
113
119
|
@states = {} #: Hash[untyped, PreparedRecordState?]
|
|
@@ -115,8 +121,8 @@ module PaperTrailDiff
|
|
|
115
121
|
|
|
116
122
|
#: (untyped) -> untyped
|
|
117
123
|
def record_before(boundary)
|
|
118
|
-
|
|
119
|
-
return state_for(
|
|
124
|
+
index = boundary_index(boundary)
|
|
125
|
+
return state_for(versions.fetch(index))&.instantiate if index
|
|
120
126
|
|
|
121
127
|
@live&.instantiate
|
|
122
128
|
end
|
|
@@ -146,6 +152,7 @@ module PaperTrailDiff
|
|
|
146
152
|
# @rbs @state_loader: PreparedVersionStateLoader
|
|
147
153
|
# @rbs @states: Hash[untyped, PreparedRecordState?]
|
|
148
154
|
# @rbs @version_positions: Hash[String, Integer]
|
|
155
|
+
# @rbs @transaction_positions: Hash[untyped, Integer]
|
|
149
156
|
|
|
150
157
|
#: (Integer) -> PreparedRecordState?
|
|
151
158
|
def state_after(index)
|
|
@@ -153,16 +160,41 @@ module PaperTrailDiff
|
|
|
153
160
|
successor ? state_for(successor) : @live
|
|
154
161
|
end
|
|
155
162
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
163
|
+
# A version is eligible when it is at or after the boundary, or shares the
|
|
164
|
+
# boundary's transaction. The first is monotonic over the chronological
|
|
165
|
+
# list and the second is indexed, so neither rescans a long history once
|
|
166
|
+
# per boundary.
|
|
167
|
+
#: (untyped) -> Integer?
|
|
168
|
+
def boundary_index(boundary)
|
|
169
|
+
created_at = boundary.created_at
|
|
170
|
+
chronological = versions.bsearch_index do |candidate|
|
|
171
|
+
candidate.created_at >= created_at
|
|
172
|
+
end
|
|
173
|
+
transactional = transaction_index(boundary)
|
|
174
|
+
return chronological unless transactional
|
|
175
|
+
return transactional unless chronological
|
|
176
|
+
|
|
177
|
+
[chronological, transactional].min
|
|
159
178
|
end
|
|
160
179
|
|
|
161
|
-
#: (untyped
|
|
162
|
-
def
|
|
180
|
+
#: (untyped) -> Integer?
|
|
181
|
+
def transaction_index(boundary)
|
|
163
182
|
transaction_id = boundary.transaction_id if boundary.respond_to?(:transaction_id)
|
|
164
|
-
|
|
165
|
-
|
|
183
|
+
return unless transaction_id
|
|
184
|
+
|
|
185
|
+
@transaction_positions[transaction_id]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
#: () -> Hash[untyped, Integer]
|
|
189
|
+
def build_transaction_positions
|
|
190
|
+
positions = {} #: Hash[untyped, Integer]
|
|
191
|
+
@versions.each_with_index do |version, index|
|
|
192
|
+
next unless version.respond_to?(:transaction_id)
|
|
193
|
+
|
|
194
|
+
transaction_id = version.transaction_id
|
|
195
|
+
positions[transaction_id] ||= index if transaction_id
|
|
196
|
+
end
|
|
197
|
+
positions.freeze
|
|
166
198
|
end
|
|
167
199
|
|
|
168
200
|
#: (untyped) -> PreparedRecordState?
|
|
@@ -185,9 +217,10 @@ module PaperTrailDiff
|
|
|
185
217
|
|
|
186
218
|
# Request-scoped scalar history loaded once per model identity.
|
|
187
219
|
class PreparedRecordIndex
|
|
188
|
-
#: (untyped, ?live_records: Array[untyped]) -> void
|
|
189
|
-
def initialize(start_at, live_records: [])
|
|
190
|
-
@start_time = start_at
|
|
220
|
+
#: (untyped, ?end_at: untyped, ?live_records: Array[untyped]) -> void
|
|
221
|
+
def initialize(start_at, end_at: nil, live_records: [])
|
|
222
|
+
@start_time = boundary_time(start_at)
|
|
223
|
+
@end_time = boundary_time(end_at)
|
|
191
224
|
@seeded_live_records = live_records.to_h do |record|
|
|
192
225
|
[identity(record.class, record.id), record]
|
|
193
226
|
end
|
|
@@ -230,10 +263,17 @@ module PaperTrailDiff
|
|
|
230
263
|
private
|
|
231
264
|
|
|
232
265
|
# @rbs @start_time: untyped
|
|
266
|
+
# @rbs @end_time: untyped
|
|
233
267
|
# @rbs @seeded_live_records: Hash[Array[String], untyped]
|
|
234
268
|
# @rbs @state_loader: PreparedVersionStateLoader
|
|
235
269
|
# @rbs @series: Hash[Array[String], PreparedRecordSeries]
|
|
236
270
|
|
|
271
|
+
# Callers bound the range with either a version or a bare timestamp.
|
|
272
|
+
#: (untyped) -> untyped
|
|
273
|
+
def boundary_time(value)
|
|
274
|
+
value.respond_to?(:created_at) ? value.created_at : value
|
|
275
|
+
end
|
|
276
|
+
|
|
237
277
|
#: (untyped, untyped) -> Array[String]
|
|
238
278
|
def identity(model_class, id)
|
|
239
279
|
[model_class.base_class.name.to_s, id.to_s]
|
|
@@ -254,12 +294,47 @@ module PaperTrailDiff
|
|
|
254
294
|
)
|
|
255
295
|
end
|
|
256
296
|
|
|
297
|
+
# A PaperTrail version is a pre-change snapshot, so the state at the last
|
|
298
|
+
# selected boundary can only live in the next version after it. One
|
|
299
|
+
# trailing version per identity is retained, rather than every version
|
|
300
|
+
# recorded between the requested range and the present.
|
|
257
301
|
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
258
302
|
def versions_for(model_class, ids)
|
|
259
|
-
model_class.paper_trail.version_class
|
|
303
|
+
version_class = model_class.paper_trail.version_class
|
|
304
|
+
scope = version_class.where(
|
|
260
305
|
item_type: model_class.base_class.name,
|
|
261
306
|
item_id: ids
|
|
262
|
-
).where(
|
|
307
|
+
).where(created_at: @start_time..)
|
|
308
|
+
scope = scope.where(window_condition(version_class)) if @end_time
|
|
309
|
+
scope.order(:id).to_a
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# In range, or the earliest version after it, resolved in the same query
|
|
313
|
+
# rather than with a window function or one query per identity.
|
|
314
|
+
#: (untyped) -> untyped
|
|
315
|
+
def window_condition(version_class)
|
|
316
|
+
table = version_class.arel_table
|
|
317
|
+
table[:created_at].lteq(@end_time).or(first_after_range(table))
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
#: (untyped) -> untyped
|
|
321
|
+
def first_after_range(table)
|
|
322
|
+
arel = Object.const_get(:Arel) #: untyped
|
|
323
|
+
later = table.alias('paper_trail_diff_later_versions')
|
|
324
|
+
arel.const_get(:SelectManager).new
|
|
325
|
+
.from(later)
|
|
326
|
+
.project(arel.sql('1'))
|
|
327
|
+
.where(preceding_trailing_version(table, later))
|
|
328
|
+
.exists
|
|
329
|
+
.not
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
#: (untyped, untyped) -> untyped
|
|
333
|
+
def preceding_trailing_version(table, later)
|
|
334
|
+
later[:item_type].eq(table[:item_type])
|
|
335
|
+
.and(later[:item_id].eq(table[:item_id]))
|
|
336
|
+
.and(later[:created_at].gt(@end_time))
|
|
337
|
+
.and(later[:created_at].lt(table[:created_at]))
|
|
263
338
|
end
|
|
264
339
|
|
|
265
340
|
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
@@ -57,6 +57,30 @@ module PaperTrailDiff
|
|
|
57
57
|
end
|
|
58
58
|
private_constant :IdentityIndexCache
|
|
59
59
|
|
|
60
|
+
# Identifies one snapshot for internal carry-forward. A transition cannot
|
|
61
|
+
# hold its origin without retaining every earlier snapshot at that path,
|
|
62
|
+
# and `object_id` is only guaranteed unique among live objects, so Ruby may
|
|
63
|
+
# hand a collected snapshot's id to a later one.
|
|
64
|
+
class SerialSequence
|
|
65
|
+
#: () -> void
|
|
66
|
+
def initialize
|
|
67
|
+
@mutex = Mutex.new
|
|
68
|
+
@counter = 0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
#: () -> Integer
|
|
72
|
+
def next_serial
|
|
73
|
+
@mutex.synchronize { @counter += 1 }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @rbs @mutex: Thread::Mutex
|
|
77
|
+
# @rbs @counter: Integer
|
|
78
|
+
end
|
|
79
|
+
private_constant :SerialSequence
|
|
80
|
+
|
|
81
|
+
SERIALS = SerialSequence.new
|
|
82
|
+
private_constant :SERIALS
|
|
83
|
+
|
|
60
84
|
class << self
|
|
61
85
|
#: (Symbol) -> bool
|
|
62
86
|
def collection_kind?(kind)
|
|
@@ -66,6 +90,7 @@ module PaperTrailDiff
|
|
|
66
90
|
|
|
67
91
|
attr_reader :kind #: Symbol
|
|
68
92
|
attr_reader :records #: Array[RecordSnapshot]
|
|
93
|
+
attr_reader :serial #: Integer
|
|
69
94
|
|
|
70
95
|
#: (kind: Symbol, records: Array[RecordSnapshot], ?identity_index_cache: untyped, ?transition: CollectionTransition?) -> void
|
|
71
96
|
def initialize(kind:, records:, identity_index_cache: nil, transition: nil)
|
|
@@ -80,6 +105,7 @@ module PaperTrailDiff
|
|
|
80
105
|
@records = records.frozen? ? records : records.dup.freeze
|
|
81
106
|
@identity_index_cache = identity_index_cache || IdentityIndexCache.new
|
|
82
107
|
@transition = transition
|
|
108
|
+
@serial = SERIALS.next_serial
|
|
83
109
|
freeze
|
|
84
110
|
end
|
|
85
111
|
|
|
@@ -53,6 +53,7 @@ module PaperTrailDiff
|
|
|
53
53
|
@traversal = traversal
|
|
54
54
|
@pool = pool
|
|
55
55
|
@structural_columns = {} #: Hash[String, Array[String]]
|
|
56
|
+
@excluded_attributes = {} #: Hash[Array[untyped], Array[String]]
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
#: (untyped, reifier: untyped) -> RecordSnapshot?
|
|
@@ -86,6 +87,7 @@ module PaperTrailDiff
|
|
|
86
87
|
# @rbs @traversal: AssociationTraversal
|
|
87
88
|
# @rbs @pool: SnapshotPool
|
|
88
89
|
# @rbs @structural_columns: Hash[String, Array[String]]
|
|
90
|
+
# @rbs @excluded_attributes: Hash[Array[untyped], Array[String]]
|
|
89
91
|
|
|
90
92
|
#: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot?
|
|
91
93
|
def normalize_record(record, tree, path, reifier, reflections)
|
|
@@ -107,15 +109,25 @@ module PaperTrailDiff
|
|
|
107
109
|
attributes
|
|
108
110
|
end
|
|
109
111
|
|
|
112
|
+
# Every input is fixed for one model class at one selected path, and the
|
|
113
|
+
# path's structural columns are recorded before its records normalize, so
|
|
114
|
+
# this is resolved once instead of per record per boundary.
|
|
110
115
|
#: (untyped, Array[untyped], String) -> Array[String]
|
|
111
116
|
def excluded_attributes(record, reflections, path)
|
|
112
|
-
|
|
117
|
+
model_class = record.class
|
|
118
|
+
@excluded_attributes[[model_class, path]] ||=
|
|
119
|
+
build_excluded_attributes(model_class, reflections, path)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
#: (untyped, Array[untyped], String) -> Array[String]
|
|
123
|
+
def build_excluded_attributes(model_class, reflections, path)
|
|
124
|
+
primary_key = model_class.primary_key #: untyped
|
|
113
125
|
primary_keys = primary_key.is_a?(Array) ? primary_key : [primary_key]
|
|
114
126
|
# @type var primary_keys: Array[untyped]
|
|
115
127
|
primary_keys = primary_keys.map { |key| key.to_s } # rubocop:disable Style/SymbolProc
|
|
116
128
|
ignored = @ignore_policy.attributes_for(path)
|
|
117
129
|
structural = @structural_columns.fetch(path, [])
|
|
118
|
-
(primary_keys + ignored + relationship_columns(reflections) + structural).uniq
|
|
130
|
+
(primary_keys + ignored + relationship_columns(reflections) + structural).uniq.freeze
|
|
119
131
|
end
|
|
120
132
|
|
|
121
133
|
#: (Array[untyped]) -> Array[String]
|
|
@@ -14,17 +14,17 @@ module PaperTrailDiff
|
|
|
14
14
|
|
|
15
15
|
#: () -> Array[ActivityStep]
|
|
16
16
|
def build
|
|
17
|
-
history, = history_and_versions
|
|
18
|
-
history
|
|
17
|
+
history, _root_versions, closing = history_and_versions
|
|
18
|
+
activity_steps(history, closing)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
#: () -> Analysis
|
|
22
22
|
def analyze
|
|
23
|
-
history, root_versions = history_and_versions
|
|
23
|
+
history, root_versions, closing = history_and_versions
|
|
24
24
|
Analysis.new(
|
|
25
25
|
diff: Engine.compare(history.first_snapshot, history.last_snapshot),
|
|
26
|
-
timeline:
|
|
27
|
-
activity_timeline: history
|
|
26
|
+
timeline: ActivityRootSteps.call(root_versions, history.root_snapshots),
|
|
27
|
+
activity_timeline: activity_steps(history, closing)
|
|
28
28
|
)
|
|
29
29
|
end
|
|
30
30
|
|
|
@@ -35,22 +35,61 @@ module PaperTrailDiff
|
|
|
35
35
|
# @rbs @tree: AssociationTree
|
|
36
36
|
# @rbs @snapshotter: untyped
|
|
37
37
|
|
|
38
|
-
#: () -> [ActivityHistory, Array[untyped]]
|
|
38
|
+
#: () -> [ActivityHistory, Array[untyped], ActivityStep?]
|
|
39
39
|
def history_and_versions
|
|
40
40
|
root_versions = @range.select(context_required: !@tree.empty?)
|
|
41
|
-
return [ActivityHistory.empty, root_versions] if root_versions.empty?
|
|
41
|
+
return [ActivityHistory.empty, root_versions, nil] if root_versions.empty?
|
|
42
42
|
|
|
43
43
|
prepare_history(root_versions)
|
|
44
44
|
events = collect_events(root_versions)
|
|
45
|
-
|
|
45
|
+
selected = selected_events(events)
|
|
46
|
+
return [ActivityHistory.empty, root_versions, nil] unless time_events?(selected, events)
|
|
46
47
|
|
|
47
|
-
history =
|
|
48
|
+
history = build_history(root_versions, events)
|
|
49
|
+
[history, root_versions, closing_step(history, selected.last)]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
|
|
53
|
+
def build_history(root_versions, events)
|
|
54
|
+
ActivityHistoryBuilder.new(
|
|
48
55
|
root_versions,
|
|
49
56
|
events,
|
|
50
57
|
@snapshotter,
|
|
51
58
|
include_step: ->(event) { @range.include?(event.version) }
|
|
52
59
|
).call
|
|
53
|
-
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: (ActivityHistory, ActivityStep?) -> Array[ActivityStep]
|
|
63
|
+
def activity_steps(history, closing)
|
|
64
|
+
return history.steps unless closing
|
|
65
|
+
|
|
66
|
+
(history.steps + [closing]).freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
#: (Array[ActivityEvent]) -> Array[ActivityEvent]
|
|
70
|
+
def selected_events(events)
|
|
71
|
+
events.select { |event| @range.include?(event.version) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The window's last selected mutation is the root's own destruction, so the
|
|
75
|
+
# timeline closes on the absence it leaves rather than on a later boundary.
|
|
76
|
+
#: (ActivityHistory, ActivityEvent?) -> ActivityStep?
|
|
77
|
+
def closing_step(history, event)
|
|
78
|
+
return unless event && terminal_destroy?(event)
|
|
79
|
+
|
|
80
|
+
version = event.version
|
|
81
|
+
ActivityStep.new(
|
|
82
|
+
from_boundary: ActivityBoundary.from_version(version),
|
|
83
|
+
to_boundary: ActivityBoundary.destroyed(version),
|
|
84
|
+
diff: Engine.compare(history.root_snapshots[ActivityRootSteps.version_key(version)], nil)
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
#: (ActivityEvent?) -> bool
|
|
89
|
+
def terminal_destroy?(event)
|
|
90
|
+
return false unless event
|
|
91
|
+
|
|
92
|
+
event.root? && event.version.event.to_s == 'destroy'
|
|
54
93
|
end
|
|
55
94
|
|
|
56
95
|
#: (Array[untyped]) -> void
|
|
@@ -72,11 +111,11 @@ module PaperTrailDiff
|
|
|
72
111
|
).call
|
|
73
112
|
end
|
|
74
113
|
|
|
75
|
-
#: (Array[ActivityEvent]) -> bool
|
|
76
|
-
def time_events?(events)
|
|
77
|
-
selected = events.select { |event| @range.include?(event.version) }
|
|
114
|
+
#: (Array[ActivityEvent], Array[ActivityEvent]) -> bool
|
|
115
|
+
def time_events?(selected, events)
|
|
78
116
|
return false if selected.empty?
|
|
79
117
|
return true if later_event?(selected.last, events)
|
|
118
|
+
return true if terminal_destroy?(selected.last)
|
|
80
119
|
|
|
81
120
|
message = 'time range requires a later activity boundary to reconstruct its final change'
|
|
82
121
|
raise IncompleteTimeRangeError, message
|
|
@@ -88,24 +127,5 @@ module PaperTrailDiff
|
|
|
88
127
|
Support.compare_versions(selected.version, event.version).negative?
|
|
89
128
|
end
|
|
90
129
|
end
|
|
91
|
-
|
|
92
|
-
#: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
|
|
93
|
-
def root_steps(root_versions, root_snapshots)
|
|
94
|
-
snapshots = root_versions.map do |version|
|
|
95
|
-
root_snapshots.fetch(version_key(version))
|
|
96
|
-
end
|
|
97
|
-
root_versions.each_cons(2).with_index.map do |versions, index|
|
|
98
|
-
Step.new(
|
|
99
|
-
from_version: versions.fetch(0),
|
|
100
|
-
to_version: versions.fetch(1),
|
|
101
|
-
diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
|
|
102
|
-
)
|
|
103
|
-
end.freeze
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
#: (untyped) -> Array[untyped]
|
|
107
|
-
def version_key(version)
|
|
108
|
-
[version.class.name, version.id]
|
|
109
|
-
end
|
|
110
130
|
end
|
|
111
131
|
end
|
|
@@ -19,6 +19,8 @@ module PaperTrailDiff
|
|
|
19
19
|
|
|
20
20
|
trailing = trailing_version(relation)
|
|
21
21
|
unless trailing
|
|
22
|
+
return selected.freeze if terminal_destroy?(selected)
|
|
23
|
+
|
|
22
24
|
message = 'time range requires a later root version to reconstruct its final change'
|
|
23
25
|
raise IncompleteTimeRangeError, message
|
|
24
26
|
end
|
|
@@ -40,6 +42,15 @@ module PaperTrailDiff
|
|
|
40
42
|
raise InvalidTimelineRangeError, message, cause: e
|
|
41
43
|
end
|
|
42
44
|
|
|
45
|
+
# A window closing on the record's own destruction needs no later version:
|
|
46
|
+
# the destroy reveals the preceding mutation and nothing can follow it, so
|
|
47
|
+
# demanding a checkpoint that can never be written would reject the range
|
|
48
|
+
# permanently.
|
|
49
|
+
#: (Array[untyped]) -> bool
|
|
50
|
+
def terminal_destroy?(versions)
|
|
51
|
+
versions.last&.event.to_s == 'destroy'
|
|
52
|
+
end
|
|
53
|
+
|
|
43
54
|
#: (untyped) -> untyped
|
|
44
55
|
def trailing_version(relation)
|
|
45
56
|
@time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
|
|
@@ -4,6 +4,8 @@
|
|
|
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
|
|
8
|
+
|
|
7
9
|
attr_reader :from #: untyped
|
|
8
10
|
attr_reader :to #: untyped
|
|
9
11
|
attr_reader :time_range #: TimeRange?
|
|
@@ -11,13 +13,22 @@ module PaperTrailDiff
|
|
|
11
13
|
#: (untyped, from: untyped, to: untyped, within: untyped) -> void
|
|
12
14
|
def initialize(record, from:, to:, within:)
|
|
13
15
|
@record = record
|
|
14
|
-
@
|
|
15
|
-
@
|
|
16
|
+
@requested_from = from
|
|
17
|
+
@requested_to = to
|
|
16
18
|
@time_range = build_time_range(within)
|
|
17
19
|
validate_mode!
|
|
20
|
+
@from = resolve(from)
|
|
21
|
+
@to = resolve(to)
|
|
18
22
|
freeze
|
|
19
23
|
end
|
|
20
24
|
|
|
25
|
+
# A record with no versions has no first or last boundary to resolve, which
|
|
26
|
+
# is an empty history rather than a bad request.
|
|
27
|
+
#: () -> bool
|
|
28
|
+
def unresolved?
|
|
29
|
+
(symbolic?(@requested_from) && @from.nil?) || (symbolic?(@requested_to) && @to.nil?)
|
|
30
|
+
end
|
|
31
|
+
|
|
21
32
|
#: (?context_required: bool) -> Array[untyped]
|
|
22
33
|
def select(context_required: false)
|
|
23
34
|
range = time_range
|
|
@@ -26,6 +37,7 @@ module PaperTrailDiff
|
|
|
26
37
|
context_required: context_required
|
|
27
38
|
)
|
|
28
39
|
end
|
|
40
|
+
return empty_versions if unresolved?
|
|
29
41
|
|
|
30
42
|
VersionRange.new(@record, from: @from, to: @to).select
|
|
31
43
|
end
|
|
@@ -49,9 +61,50 @@ module PaperTrailDiff
|
|
|
49
61
|
private
|
|
50
62
|
|
|
51
63
|
# @rbs @record: untyped
|
|
64
|
+
# @rbs @requested_from: untyped
|
|
65
|
+
# @rbs @requested_to: untyped
|
|
52
66
|
# @rbs @from: untyped
|
|
53
67
|
# @rbs @to: untyped
|
|
54
68
|
|
|
69
|
+
#: () -> Array[untyped]
|
|
70
|
+
def empty_versions
|
|
71
|
+
versions = [] #: Array[untyped]
|
|
72
|
+
versions.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
#: (untyped) -> bool
|
|
76
|
+
def symbolic?(boundary)
|
|
77
|
+
boundary.is_a?(Symbol)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Resolving `:first` and `:last` here keeps callers from depending on the
|
|
81
|
+
# order PaperTrail happens to give its versions association, which a caller
|
|
82
|
+
# is also free to reorder.
|
|
83
|
+
#: (untyped) -> untyped
|
|
84
|
+
def resolve(boundary)
|
|
85
|
+
return boundary unless symbolic?(boundary)
|
|
86
|
+
|
|
87
|
+
unless BOUNDARY_SYMBOLS.include?(boundary)
|
|
88
|
+
raise InvalidTimelineRangeError,
|
|
89
|
+
"unsupported boundary: #{boundary.inspect}; use :first, :last, a version, or a record"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
boundary == :first ? ordered_versions.first : ordered_versions.last
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
#: () -> untyped
|
|
96
|
+
def ordered_versions
|
|
97
|
+
versions_relation.reorder(created_at: :asc, id: :asc)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
#: () -> untyped
|
|
101
|
+
def versions_relation
|
|
102
|
+
@record.public_send(@record.class.versions_association_name)
|
|
103
|
+
rescue NoMethodError => e
|
|
104
|
+
message = 'record does not expose a PaperTrail version history'
|
|
105
|
+
raise InvalidTimelineRangeError, message, cause: e
|
|
106
|
+
end
|
|
107
|
+
|
|
55
108
|
#: (untyped) -> TimeRange?
|
|
56
109
|
def build_time_range(within)
|
|
57
110
|
TimeRange.new(within) unless within.nil?
|
|
@@ -60,11 +113,11 @@ module PaperTrailDiff
|
|
|
60
113
|
#: () -> void
|
|
61
114
|
def validate_mode!
|
|
62
115
|
if time?
|
|
63
|
-
return if @
|
|
116
|
+
return if @requested_from.nil? && @requested_to.nil?
|
|
64
117
|
|
|
65
118
|
raise InvalidTimelineRangeError, '`within` cannot be combined with `from` or `to`'
|
|
66
119
|
end
|
|
67
|
-
return unless @
|
|
120
|
+
return unless @requested_from.nil? || @requested_to.nil?
|
|
68
121
|
|
|
69
122
|
raise InvalidTimelineRangeError, 'provide both `from` and `to`, or provide `within`'
|
|
70
123
|
end
|
|
@@ -24,45 +24,72 @@ module PaperTrailDiff
|
|
|
24
24
|
# @rbs @start_at: untyped
|
|
25
25
|
# @rbs @end_at: untyped
|
|
26
26
|
|
|
27
|
+
# Only quoting helpers are needed, so the connection is borrowed for the
|
|
28
|
+
# duration of building the condition rather than checked out permanently,
|
|
29
|
+
# which Active Record 7.2 and newer can deprecate. Active Record 7.1 has no
|
|
30
|
+
# `with_connection` and reaches the same connection through the accessor.
|
|
31
|
+
#: () { (untyped) -> String } -> String
|
|
32
|
+
def with_connection(&block)
|
|
33
|
+
return @version_class.with_connection(&block) if @version_class.respond_to?(:with_connection)
|
|
34
|
+
|
|
35
|
+
block.call(@version_class.connection)
|
|
36
|
+
end
|
|
37
|
+
|
|
27
38
|
#: () -> String
|
|
28
|
-
def candidate_condition
|
|
29
|
-
connection
|
|
39
|
+
def candidate_condition
|
|
40
|
+
with_connection { |connection| condition_sql(connection) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped) -> String
|
|
44
|
+
def condition_sql(connection) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
30
45
|
outer = connection.quote_table_name(@version_class.table_name)
|
|
31
46
|
later = connection.quote_table_name('paper_trail_diff_later_versions')
|
|
32
47
|
start_at = connection.quote(@start_at)
|
|
33
|
-
|
|
34
|
-
activity = activity_condition(outer, start_at)
|
|
48
|
+
columns = quoted_columns(connection, outer, later)
|
|
35
49
|
compact_sql(<<~SQL)
|
|
36
50
|
(
|
|
37
|
-
#{
|
|
51
|
+
#{activity_condition(columns, start_at, connection)}
|
|
38
52
|
OR (
|
|
39
|
-
#{
|
|
40
|
-
AND #{
|
|
53
|
+
#{columns.fetch(:outer_created_at)} < #{start_at}
|
|
54
|
+
AND #{columns.fetch(:outer_event)} != #{connection.quote('destroy')}
|
|
41
55
|
AND NOT EXISTS (
|
|
42
56
|
SELECT 1
|
|
43
57
|
FROM #{outer} #{later}
|
|
44
|
-
WHERE #{
|
|
45
|
-
AND #{
|
|
46
|
-
AND #{
|
|
47
|
-
AND #{
|
|
58
|
+
WHERE #{columns.fetch(:later_item_type)} = #{columns.fetch(:outer_item_type)}
|
|
59
|
+
AND #{columns.fetch(:later_item_id)} = #{columns.fetch(:outer_item_id)}
|
|
60
|
+
AND #{columns.fetch(:later_created_at)} < #{start_at}
|
|
61
|
+
AND #{columns.fetch(:later_created_at)} > #{columns.fetch(:outer_created_at)}
|
|
48
62
|
)
|
|
49
63
|
)
|
|
50
64
|
)
|
|
51
65
|
SQL
|
|
52
66
|
end
|
|
53
67
|
|
|
54
|
-
#: (String, String) -> String
|
|
55
|
-
def
|
|
56
|
-
|
|
68
|
+
#: (untyped, String, String) -> Hash[Symbol, String]
|
|
69
|
+
def quoted_columns(connection, outer, later)
|
|
70
|
+
{
|
|
71
|
+
outer_created_at: column(connection, outer, 'created_at'),
|
|
72
|
+
outer_event: column(connection, outer, 'event'),
|
|
73
|
+
outer_item_type: column(connection, outer, 'item_type'),
|
|
74
|
+
outer_item_id: column(connection, outer, 'item_id'),
|
|
75
|
+
later_created_at: column(connection, later, 'created_at'),
|
|
76
|
+
later_item_type: column(connection, later, 'item_type'),
|
|
77
|
+
later_item_id: column(connection, later, 'item_id')
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#: (Hash[Symbol, String], String, untyped) -> String
|
|
82
|
+
def activity_condition(columns, start_at, connection)
|
|
83
|
+
created_at = columns.fetch(:outer_created_at)
|
|
84
|
+
condition = "#{created_at} >= #{start_at}"
|
|
57
85
|
return condition unless @end_at
|
|
58
86
|
|
|
59
|
-
|
|
60
|
-
"(#{condition} AND #{column(table, 'created_at')} <= #{end_at})"
|
|
87
|
+
"(#{condition} AND #{created_at} <= #{connection.quote(@end_at)})"
|
|
61
88
|
end
|
|
62
89
|
|
|
63
|
-
#: (String, String) -> String
|
|
64
|
-
def column(table, name)
|
|
65
|
-
"#{table}.#{
|
|
90
|
+
#: (untyped, String, String) -> String
|
|
91
|
+
def column(connection, table, name)
|
|
92
|
+
"#{table}.#{connection.quote_column_name(name)}"
|
|
66
93
|
end
|
|
67
94
|
|
|
68
95
|
#: (String) -> String
|
data/lib/paper_trail_diff.rb
CHANGED
|
@@ -40,12 +40,22 @@ require_relative 'paper_trail_diff/comparison_batch'
|
|
|
40
40
|
require_relative 'paper_trail_diff/snapshot_normalizer'
|
|
41
41
|
require_relative 'paper_trail_diff/historical_snapshot_store'
|
|
42
42
|
require_relative 'paper_trail_diff/timeline_snapshot_provider'
|
|
43
|
+
require_relative 'paper_trail_diff/activity_event_record_resolver'
|
|
44
|
+
require_relative 'paper_trail_diff/activity_event_route_finder'
|
|
45
|
+
require_relative 'paper_trail_diff/activity_relationship'
|
|
46
|
+
require_relative 'paper_trail_diff/activity_collection_route_change'
|
|
47
|
+
require_relative 'paper_trail_diff/activity_collection_record_updater'
|
|
48
|
+
require_relative 'paper_trail_diff/activity_collection_route_updater'
|
|
49
|
+
require_relative 'paper_trail_diff/activity_event_record_normalizer'
|
|
50
|
+
require_relative 'paper_trail_diff/activity_collection_event_applier'
|
|
51
|
+
require_relative 'paper_trail_diff/activity_belongs_to_event_applier'
|
|
43
52
|
require_relative 'paper_trail_diff/activity_event_snapshot_refresher'
|
|
44
53
|
require_relative 'paper_trail_diff/activity_root_snapshot_refresher'
|
|
45
54
|
require_relative 'paper_trail_diff/branch_snapshot_refresher'
|
|
46
55
|
require_relative 'paper_trail_diff/activity_boundary'
|
|
47
56
|
require_relative 'paper_trail_diff/step'
|
|
48
57
|
require_relative 'paper_trail_diff/analysis'
|
|
58
|
+
require_relative 'paper_trail_diff/activity_root_steps'
|
|
49
59
|
require_relative 'paper_trail_diff/version_range'
|
|
50
60
|
require_relative 'paper_trail_diff/time_range'
|
|
51
61
|
require_relative 'paper_trail_diff/time_version_range'
|