paper_trail_diff 0.4.0 → 0.5.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 +20 -0
- data/README.md +56 -2
- data/lib/paper_trail_diff/analysis_batch.rb +74 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +137 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +50 -0
- data/lib/paper_trail_diff/batched_root_versions.rb +131 -0
- data/lib/paper_trail_diff/comparison_batch.rb +37 -13
- data/lib/paper_trail_diff/historical_snapshot_store.rb +3 -7
- data/lib/paper_trail_diff/paper_trail_adapter.rb +49 -33
- data/lib/paper_trail_diff/timeline_builder.rb +3 -3
- data/lib/paper_trail_diff/timeline_range.rb +9 -2
- data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff.rb +36 -5
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +42 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +62 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +28 -0
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +62 -0
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +15 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +0 -1
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +16 -7
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +6 -2
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
- data/sig/generated/paper_trail_diff.rbs +13 -3
- metadata +14 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4d3c1e15ef20c4e3d05f5c0442d7e55cd0eb811a15766d2119a3cf35b2e729c
|
|
4
|
+
data.tar.gz: 4cc8dceb10e7aa71f1feb7d9fcc3917040bec4b2cc69641b4b3817d6761aad94
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3887a515ee90f1c14b890a21d4a91b4892f4097b74c9ec27f136fd8476f01cd285c0a310a03b0c528e709cdca299bca2e243522844d09a86d272c043e48879b1
|
|
7
|
+
data.tar.gz: a1390db78cc31cbf20420fd18b536ad8cd826de4927943cbdddc08b7a31f9dad5f81acee77db97059f044ad435bd3170e54f8ef2ccd5c68f3e256bfa5d848642
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,26 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file. The
|
|
4
4
|
project follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.5.0] - 2026-08-11
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Add `analyze_many`, which analyzes many roots over one shared `within:` window,
|
|
11
|
+
or over each root's whole history when the window is omitted, selecting their
|
|
12
|
+
versions and preparing their history once for the batch. Query cost is flat in
|
|
13
|
+
the number of roots for the endpoint diff and checkpoint timeline; descendant
|
|
14
|
+
event discovery under `activity: true` remains per-root. A root with no
|
|
15
|
+
versions in range returns an empty `Analysis`, and roots are supplied as live
|
|
16
|
+
records so a root deleted inside the window cannot be included.
|
|
17
|
+
- Accept `:first` and `:last` as `compare_many` endpoints, resolved against the
|
|
18
|
+
record the pair's other endpoint names, in two queries per model class rather
|
|
19
|
+
than one lookup per root. A root with no recorded history compares as an empty
|
|
20
|
+
`Diff`, and an unanchored `{ from: :first, to: :last }` raises.
|
|
21
|
+
- Accept `reload_live_endpoints:` on `activity_timeline`, which reads live state
|
|
22
|
+
whenever `to:` is a current record but previously had no way to reuse an
|
|
23
|
+
already-preloaded graph. `timeline` and `analyze` are bounded by versions and
|
|
24
|
+
never read live state, so the option is deliberately absent there.
|
|
25
|
+
|
|
6
26
|
## [0.4.0] - 2026-08-11
|
|
7
27
|
|
|
8
28
|
### Added
|
data/README.md
CHANGED
|
@@ -213,6 +213,21 @@ diffs = PaperTrailDiff.compare_many(
|
|
|
213
213
|
diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
|
|
214
214
|
```
|
|
215
215
|
|
|
216
|
+
Endpoints may also be given as `:first` or `:last`, resolved against the record
|
|
217
|
+
the pair's other endpoint names. That replaces the lookup above entirely, and
|
|
218
|
+
resolves every root in two queries per model class rather than one per root:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
diffs = PaperTrailDiff.compare_many(
|
|
222
|
+
orders.map { |order| { from: :first, to: order } },
|
|
223
|
+
associations: [:line_items]
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
A symbol carries no identity of its own, so `{ from: :first, to: :last }` raises
|
|
228
|
+
rather than guessing. A root with no recorded history resolves to an empty
|
|
229
|
+
`Diff`, matching how the timeline APIs answer the same question.
|
|
230
|
+
|
|
216
231
|
Root identities must be unique within one call. Historical reconstruction for
|
|
217
232
|
ordinary versioned, unscoped association paths is also prepared across the
|
|
218
233
|
collection. Paths that require the existing point-in-time PT-AT fallback retain
|
|
@@ -225,8 +240,10 @@ must observe one atomic snapshot.
|
|
|
225
240
|
|
|
226
241
|
### Reuse already-preloaded current endpoints
|
|
227
242
|
|
|
228
|
-
`compare` and `
|
|
229
|
-
already owns a consistent, fully
|
|
243
|
+
`compare`, `compare_many`, and `activity_timeline(..., to: record)` reload
|
|
244
|
+
current endpoints by default. A caller that already owns a consistent, fully
|
|
245
|
+
preloaded graph may opt out. The option has no effect on `timeline` or
|
|
246
|
+
`analyze`, which are bounded by versions and never read live state:
|
|
230
247
|
|
|
231
248
|
```ruby
|
|
232
249
|
orders = Order.where(id: order_ids).preload(line_items: :product).to_a
|
|
@@ -285,6 +302,43 @@ ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
|
|
|
285
302
|
end
|
|
286
303
|
```
|
|
287
304
|
|
|
305
|
+
## Analyze many records over one window
|
|
306
|
+
|
|
307
|
+
`analyze_many` answers "what changed for these records during this period" for a
|
|
308
|
+
whole collection, selecting every root's versions and preparing their selected
|
|
309
|
+
association history once for the batch:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
results = PaperTrailDiff.analyze_many(
|
|
313
|
+
Order.where(status: "open").to_a,
|
|
314
|
+
within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
|
|
315
|
+
associations: [:line_items]
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
results.fetch(["Order", order.id.to_s]).diff # net change across the window
|
|
319
|
+
results.fetch(["Order", order.id.to_s]).timeline # its checkpoint steps
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Results are a frozen hash keyed by `[item_type, item_id]` strings, and each
|
|
323
|
+
value is the same `Analysis` that `analyze` returns for one record. A root with
|
|
324
|
+
no versions inside the window gets an empty `Analysis` rather than raising, so a
|
|
325
|
+
listing page needs no special case. Root identities must be unique.
|
|
326
|
+
|
|
327
|
+
Omit `within:` to analyze each root's whole recorded history instead, which is
|
|
328
|
+
the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
|
|
329
|
+
version endpoints are not accepted, because a single pair cannot mean the same
|
|
330
|
+
thing for every root.
|
|
331
|
+
|
|
332
|
+
Query cost is flat in the number of roots for the diff and timeline views:
|
|
333
|
+
selecting versions and preparing history are both shared across the batch. On a
|
|
334
|
+
twenty-root batch that is 7 queries against 120 for the same work done one
|
|
335
|
+
record at a time. Passing `activity: true` also works and returns the same
|
|
336
|
+
results, but discovering descendant events is inherently per-root, so that view
|
|
337
|
+
batches far less.
|
|
338
|
+
|
|
339
|
+
Roots are supplied as live records, so a root deleted inside the window cannot
|
|
340
|
+
be included; use `activity_timeline` for a history that ends in a deletion.
|
|
341
|
+
|
|
288
342
|
## Build a root-checkpoint timeline
|
|
289
343
|
|
|
290
344
|
`timeline` accepts two version objects from the supplied record's history, or
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Builds one Analysis per root over a shared range, selecting every root's
|
|
6
|
+
# versions and preparing their association history once for the whole batch
|
|
7
|
+
# rather than once per root.
|
|
8
|
+
class AnalysisBatch
|
|
9
|
+
#: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped) -> void
|
|
10
|
+
def initialize(records, time_range:, live_loader:, history_preparer:, analyzer:)
|
|
11
|
+
@records = records
|
|
12
|
+
@time_range = time_range
|
|
13
|
+
@live_loader = live_loader
|
|
14
|
+
@history_preparer = history_preparer
|
|
15
|
+
@analyzer = analyzer
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: () -> Hash[identity, Analysis]
|
|
19
|
+
def call
|
|
20
|
+
records = validated_records
|
|
21
|
+
selected = BatchedRootVersions.new(records, time_range: @time_range).call
|
|
22
|
+
prepare(records, selected)
|
|
23
|
+
records.to_h do |record|
|
|
24
|
+
key = Endpoint.identity(record)
|
|
25
|
+
[Support.immutable_copy(key), analysis_for(record, selected.fetch(key, []))]
|
|
26
|
+
end.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# @rbs @records: Array[untyped]
|
|
32
|
+
# @rbs @time_range: TimeRange?
|
|
33
|
+
# @rbs @live_loader: untyped
|
|
34
|
+
# @rbs @history_preparer: untyped
|
|
35
|
+
# @rbs @analyzer: untyped
|
|
36
|
+
|
|
37
|
+
#: () -> Array[untyped]
|
|
38
|
+
def validated_records
|
|
39
|
+
raise ConfigurationError, 'records: must be an array' unless @records.is_a?(Array)
|
|
40
|
+
|
|
41
|
+
@records.each { |record| Endpoint.validate!(record) }
|
|
42
|
+
identities = @records.map { |record| Endpoint.identity(record) }
|
|
43
|
+
return @records if identities.uniq.length == identities.length
|
|
44
|
+
|
|
45
|
+
raise ConfigurationError, 'records: identities must be unique'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Association history is prepared per model class across every selected root
|
|
49
|
+
# version, which is the work that would otherwise repeat for each record.
|
|
50
|
+
# The roots are preloaded first, because preparation reads their current
|
|
51
|
+
# association state as a fallback and would otherwise walk it one root at a
|
|
52
|
+
# time.
|
|
53
|
+
#: (Array[untyped], Hash[Array[String], Array[untyped]]) -> void
|
|
54
|
+
def prepare(records, selected)
|
|
55
|
+
loaded = @live_loader.call(records)
|
|
56
|
+
records.group_by(&:class).each_value do |grouped|
|
|
57
|
+
versions = grouped.flat_map { |record| selected.fetch(Endpoint.identity(record), []) }
|
|
58
|
+
next if versions.empty?
|
|
59
|
+
|
|
60
|
+
roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
|
|
61
|
+
@history_preparer.call(roots, versions)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A root with no versions in range has nothing to report, which is an empty
|
|
66
|
+
# result rather than a failed request.
|
|
67
|
+
#: (untyped, Array[untyped]) -> Analysis
|
|
68
|
+
def analysis_for(record, versions)
|
|
69
|
+
return Analysis.empty if versions.empty?
|
|
70
|
+
|
|
71
|
+
@analyzer.call(record, versions)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Resolves `:first` and `:last` endpoints for a whole batch in two queries per
|
|
6
|
+
# model class. Left to the caller this is a per-root lookup, which reintroduces
|
|
7
|
+
# exactly the queries a batched comparison exists to remove.
|
|
8
|
+
class BatchBoundaryResolver
|
|
9
|
+
BOUNDARIES = %i[first last].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
#: (untyped) -> bool
|
|
13
|
+
def symbolic?(endpoint)
|
|
14
|
+
endpoint.is_a?(Symbol)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: (Array[[untyped, untyped]]) -> void
|
|
19
|
+
def initialize(pairs)
|
|
20
|
+
@pairs = pairs
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns the pairs with every resolvable symbol replaced. A symbol that
|
|
24
|
+
# names history the record does not have is left in place, so the caller can
|
|
25
|
+
# decide what an absent history means rather than being handed nil.
|
|
26
|
+
#: () -> Array[[untyped, untyped]]
|
|
27
|
+
def call
|
|
28
|
+
return @pairs unless @pairs.flatten(1).any? { |endpoint| symbolic?(endpoint) }
|
|
29
|
+
|
|
30
|
+
index = boundary_index
|
|
31
|
+
@pairs.map do |from, to|
|
|
32
|
+
[resolve(from, to, index), resolve(to, from, index)]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# @rbs @pairs: Array[[untyped, untyped]]
|
|
39
|
+
|
|
40
|
+
#: (untyped) -> bool
|
|
41
|
+
def symbolic?(endpoint)
|
|
42
|
+
self.class.symbolic?(endpoint)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#: (untyped, untyped, Hash[Array[String], Hash[Symbol, untyped]]) -> untyped
|
|
46
|
+
def resolve(endpoint, other, index)
|
|
47
|
+
return endpoint unless symbolic?(endpoint)
|
|
48
|
+
|
|
49
|
+
unless BOUNDARIES.include?(endpoint)
|
|
50
|
+
raise ConfigurationError,
|
|
51
|
+
"unsupported boundary: #{endpoint.inspect}; use :first, :last, a version, or a record"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
identity = anchor_identity(other)
|
|
55
|
+
unless identity
|
|
56
|
+
raise ConfigurationError,
|
|
57
|
+
'a :first or :last endpoint needs the other endpoint to name a record'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
index.dig(identity, endpoint) || endpoint
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A symbol carries no identity of its own, so the pair's other endpoint has
|
|
64
|
+
# to say which record is meant.
|
|
65
|
+
#: (untyped) -> Array[String]?
|
|
66
|
+
def anchor_identity(endpoint)
|
|
67
|
+
return if endpoint.nil? || symbolic?(endpoint)
|
|
68
|
+
|
|
69
|
+
Endpoint.identity(endpoint)
|
|
70
|
+
rescue InvalidEndpointError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
#: () -> Hash[Array[String], Hash[Symbol, untyped]]
|
|
75
|
+
def boundary_index
|
|
76
|
+
index = {} #: Hash[Array[String], Hash[Symbol, untyped]]
|
|
77
|
+
anchors.group_by { |model_class, _id| model_class }.each do |model_class, entries|
|
|
78
|
+
add_model_boundaries(index, model_class, entries.map { |_klass, id| id }.uniq)
|
|
79
|
+
end
|
|
80
|
+
index
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
#: () -> Array[[untyped, untyped]]
|
|
84
|
+
def anchors
|
|
85
|
+
@pairs.flatten(1).filter_map { |endpoint| anchor(endpoint) }.uniq
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
#: (untyped) -> [untyped, untyped]?
|
|
89
|
+
def anchor(endpoint)
|
|
90
|
+
return if endpoint.nil? || symbolic?(endpoint)
|
|
91
|
+
|
|
92
|
+
if Endpoint.version?(endpoint)
|
|
93
|
+
[Endpoint.model_class(endpoint), endpoint.item_id]
|
|
94
|
+
elsif Endpoint.record?(endpoint)
|
|
95
|
+
[endpoint.class, endpoint.id]
|
|
96
|
+
end
|
|
97
|
+
rescue InvalidEndpointError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (Hash[Array[String], Hash[Symbol, untyped]], untyped, Array[untyped]) -> void
|
|
102
|
+
def add_model_boundaries(index, model_class, ids)
|
|
103
|
+
first_ids, last_ids, versions = boundary_versions(model_class, ids)
|
|
104
|
+
ids.each do |id|
|
|
105
|
+
index[identity_key(model_class, id)] = {
|
|
106
|
+
first: versions[boundary_key(first_ids, id)],
|
|
107
|
+
last: versions[boundary_key(last_ids, id)]
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Two grouped queries name each root's outermost versions, and one more
|
|
113
|
+
# loads them, whatever the size of the batch.
|
|
114
|
+
#: (untyped, Array[untyped]) -> [Hash[untyped, untyped], Hash[untyped, untyped], Hash[untyped, untyped]]
|
|
115
|
+
def boundary_versions(model_class, ids)
|
|
116
|
+
version_class = model_class.paper_trail.version_class
|
|
117
|
+
scope = version_class.where(item_type: model_class.base_class.name.to_s, item_id: ids)
|
|
118
|
+
first_ids = scope.group(:item_id).minimum(:id)
|
|
119
|
+
last_ids = scope.group(:item_id).maximum(:id)
|
|
120
|
+
loaded = version_class.where(id: (first_ids.values + last_ids.values).uniq).index_by(&:id)
|
|
121
|
+
[first_ids, last_ids, loaded]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Grouped keys come back with whatever type the column uses, so match on the
|
|
125
|
+
# string form rather than assuming integers.
|
|
126
|
+
#: (Hash[untyped, untyped], untyped) -> untyped
|
|
127
|
+
def boundary_key(grouped, id)
|
|
128
|
+
key = grouped.keys.find { |candidate| candidate.to_s == id.to_s }
|
|
129
|
+
grouped[key]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
#: (untyped, untyped) -> Array[String]
|
|
133
|
+
def identity_key(model_class, id)
|
|
134
|
+
[model_class.base_class.name.to_s, id.to_s]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Builds one root's Analysis inside a batch, from versions the batch already
|
|
6
|
+
# selected and history it already prepared.
|
|
7
|
+
class BatchedRootAnalyzer
|
|
8
|
+
#: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool) -> void
|
|
9
|
+
def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:)
|
|
10
|
+
@tree = tree
|
|
11
|
+
@timeline_snapshotter = timeline_snapshotter
|
|
12
|
+
@activity_snapshotter = activity_snapshotter
|
|
13
|
+
@preparer = preparer
|
|
14
|
+
@activity = activity
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (untyped, Array[untyped]) -> Analysis
|
|
18
|
+
def call(record, versions)
|
|
19
|
+
@preparer.call(record.class, historical: true)
|
|
20
|
+
return activity_analysis(record, versions) if @activity
|
|
21
|
+
|
|
22
|
+
TimelineBuilder.new(
|
|
23
|
+
record,
|
|
24
|
+
from: versions.first,
|
|
25
|
+
to: versions.last,
|
|
26
|
+
within: nil,
|
|
27
|
+
versions: versions,
|
|
28
|
+
snapshotter: @timeline_snapshotter
|
|
29
|
+
).analyze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# @rbs @tree: AssociationTree
|
|
35
|
+
# @rbs @timeline_snapshotter: untyped
|
|
36
|
+
# @rbs @activity_snapshotter: untyped
|
|
37
|
+
# @rbs @preparer: untyped
|
|
38
|
+
# @rbs @activity: bool
|
|
39
|
+
|
|
40
|
+
#: (untyped, Array[untyped]) -> Analysis
|
|
41
|
+
def activity_analysis(record, versions)
|
|
42
|
+
range = TimelineRange.new(
|
|
43
|
+
record, from: versions.first, to: versions.last, within: nil, versions: versions
|
|
44
|
+
)
|
|
45
|
+
ActivityTimelineBuilder.new(
|
|
46
|
+
record, range: range, tree: @tree, snapshotter: @activity_snapshotter
|
|
47
|
+
).analyze
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Selects each root's versions for a batched range in a fixed number of
|
|
6
|
+
# queries. Only the range forms that mean the same thing for every root are
|
|
7
|
+
# supported: a shared wall-clock window, or each root's own whole history.
|
|
8
|
+
class BatchedRootVersions
|
|
9
|
+
#: (Array[untyped], time_range: TimeRange?) -> void
|
|
10
|
+
def initialize(records, time_range:)
|
|
11
|
+
@records = records
|
|
12
|
+
@time_range = time_range
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns root versions per record identity, in chronological order.
|
|
16
|
+
#: () -> Hash[Array[String], Array[untyped]]
|
|
17
|
+
def call
|
|
18
|
+
return {} if @records.empty?
|
|
19
|
+
|
|
20
|
+
selected = {} #: Hash[Array[String], Array[untyped]]
|
|
21
|
+
@records.group_by(&:class).each do |model_class, records|
|
|
22
|
+
select_model(model_class, records, selected)
|
|
23
|
+
end
|
|
24
|
+
selected
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# @rbs @records: Array[untyped]
|
|
30
|
+
# @rbs @time_range: TimeRange?
|
|
31
|
+
|
|
32
|
+
#: (untyped, Array[untyped], Hash[Array[String], Array[untyped]]) -> void
|
|
33
|
+
def select_model(model_class, records, selected)
|
|
34
|
+
ids = records.map(&:id)
|
|
35
|
+
range = @time_range
|
|
36
|
+
grouped = grouped_versions(model_class, ids, range)
|
|
37
|
+
empty = {} #: Hash[String, untyped]
|
|
38
|
+
trailing = range ? trailing_versions(model_class, ids, range) : empty
|
|
39
|
+
records.each do |record|
|
|
40
|
+
key = identity(model_class, record.id)
|
|
41
|
+
selected[key] = versions_for(grouped.fetch(record.id.to_s, []), trailing[record.id.to_s])
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The window's own versions plus, when it has one, the later version needed
|
|
46
|
+
# to reveal the last mutation inside it. A window closing on the root's own
|
|
47
|
+
# destruction needs no later version, because none can exist.
|
|
48
|
+
#: (Array[untyped], untyped) -> Array[untyped]
|
|
49
|
+
def versions_for(in_range, trailing)
|
|
50
|
+
return in_range if @time_range.nil?
|
|
51
|
+
return in_range.freeze if in_range.empty?
|
|
52
|
+
return in_range.freeze if trailing.nil? && terminal_destroy?(in_range)
|
|
53
|
+
|
|
54
|
+
unless trailing
|
|
55
|
+
raise IncompleteTimeRangeError,
|
|
56
|
+
'time range requires a later root version to reconstruct its final change'
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
(in_range + [trailing]).freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: (Array[untyped]) -> bool
|
|
63
|
+
def terminal_destroy?(versions)
|
|
64
|
+
versions.last&.event.to_s == 'destroy'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
|
|
68
|
+
def grouped_versions(model_class, ids, range)
|
|
69
|
+
scope = base_scope(model_class, ids)
|
|
70
|
+
scope = range.scope(scope) if range
|
|
71
|
+
ordered(scope).group_by { |version| version.item_id.to_s }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# One row per root: the earliest version after the window, found without a
|
|
75
|
+
# window function so the query stays portable.
|
|
76
|
+
#: (untyped, Array[untyped], TimeRange) -> Hash[String, untyped]
|
|
77
|
+
def trailing_versions(model_class, ids, range)
|
|
78
|
+
table = model_class.paper_trail.version_class.arel_table
|
|
79
|
+
relation = range.trailing_scope(base_scope(model_class, ids))
|
|
80
|
+
ordered(relation.where(no_earlier_trailing(table, range)))
|
|
81
|
+
.to_h { |version| [version.item_id.to_s, version] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
#: (untyped, TimeRange) -> untyped
|
|
85
|
+
def no_earlier_trailing(table, range)
|
|
86
|
+
arel = Object.const_get(:Arel) #: untyped
|
|
87
|
+
later = table.alias('paper_trail_diff_later_roots')
|
|
88
|
+
arel.const_get(:SelectManager).new
|
|
89
|
+
.from(later)
|
|
90
|
+
.project(arel.sql('1'))
|
|
91
|
+
.where(preceding(table, later, range))
|
|
92
|
+
.exists
|
|
93
|
+
.not
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Mirrors the window's own end handling, so a version sitting exactly on an
|
|
97
|
+
# inclusive boundary stays inside the window instead of masking the trailing
|
|
98
|
+
# version that follows it.
|
|
99
|
+
#: (untyped, untyped, TimeRange) -> untyped
|
|
100
|
+
def preceding(table, later, range)
|
|
101
|
+
same_item = later[:item_type].eq(table[:item_type])
|
|
102
|
+
.and(later[:item_id].eq(table[:item_id]))
|
|
103
|
+
same_item.and(after_window(later, range)).and(later[:created_at].lt(table[:created_at]))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: (untyped, TimeRange) -> untyped
|
|
107
|
+
def after_window(later, range)
|
|
108
|
+
return later[:created_at].gteq(range.end_time) if range.exclude_end?
|
|
109
|
+
|
|
110
|
+
later[:created_at].gt(range.end_time)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
#: (untyped, Array[untyped]) -> untyped
|
|
114
|
+
def base_scope(model_class, ids)
|
|
115
|
+
model_class.paper_trail.version_class.where(
|
|
116
|
+
item_type: model_class.base_class.name.to_s,
|
|
117
|
+
item_id: ids
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: (untyped) -> Array[untyped]
|
|
122
|
+
def ordered(relation)
|
|
123
|
+
relation.reorder(created_at: :asc, id: :asc).to_a
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
#: (untyped, untyped) -> Array[String]
|
|
127
|
+
def identity(model_class, id)
|
|
128
|
+
[model_class.base_class.name.to_s, id.to_s]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -23,18 +23,10 @@ module PaperTrailDiff
|
|
|
23
23
|
|
|
24
24
|
#: () -> comparison_results
|
|
25
25
|
def call
|
|
26
|
-
|
|
27
|
-
pairs.
|
|
28
|
-
ensure_unique_identities!(
|
|
29
|
-
|
|
30
|
-
live_records = load_live_records(pairs.flatten)
|
|
31
|
-
prepare_historical_batches!(pairs, live_records)
|
|
32
|
-
live_snapshots = normalize_live_records(live_records)
|
|
33
|
-
|
|
34
|
-
pairs.to_h do |from, to|
|
|
35
|
-
identity = Support.immutable_copy(Endpoint.identity(from))
|
|
36
|
-
[identity, compare(from, to, live_snapshots)]
|
|
37
|
-
end.freeze
|
|
26
|
+
resolved = BatchBoundaryResolver.new(comparison_pairs).call
|
|
27
|
+
pairs = resolved.reject { |pair| pair.any? { |endpoint| symbolic?(endpoint) } }
|
|
28
|
+
ensure_unique_identities!(resolved)
|
|
29
|
+
results(resolved, prepared_live_snapshots(pairs)).freeze
|
|
38
30
|
end
|
|
39
31
|
|
|
40
32
|
private
|
|
@@ -46,6 +38,38 @@ module PaperTrailDiff
|
|
|
46
38
|
# @rbs @historical_snapshotter: untyped
|
|
47
39
|
# @rbs @live_normalizer: untyped
|
|
48
40
|
|
|
41
|
+
#: (Array[[untyped, untyped]]) -> Hash[identity, RecordSnapshot]
|
|
42
|
+
def prepared_live_snapshots(pairs)
|
|
43
|
+
pairs.each { |from, to| Endpoint.validate_pair!(from, to) }
|
|
44
|
+
prepare_endpoint_classes!(pairs)
|
|
45
|
+
live_records = load_live_records(pairs.flatten)
|
|
46
|
+
prepare_historical_batches!(pairs, live_records)
|
|
47
|
+
normalize_live_records(live_records)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
#: (untyped) -> bool
|
|
51
|
+
def symbolic?(endpoint)
|
|
52
|
+
BatchBoundaryResolver.symbolic?(endpoint)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A boundary symbol that stayed unresolved means the root has no recorded
|
|
56
|
+
# history. An absent history is an empty result rather than a failed
|
|
57
|
+
# request, matching how the timeline APIs answer the same question.
|
|
58
|
+
#: (Array[[untyped, untyped]], Hash[identity, RecordSnapshot]) -> comparison_results
|
|
59
|
+
def results(resolved, live_snapshots)
|
|
60
|
+
resolved.to_h do |from, to|
|
|
61
|
+
identity = Support.immutable_copy(entry_identity(from, to))
|
|
62
|
+
next [identity, Diff.new] if symbolic?(from) || symbolic?(to)
|
|
63
|
+
|
|
64
|
+
[identity, compare(from, to, live_snapshots)]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: (untyped, untyped) -> identity
|
|
69
|
+
def entry_identity(from, to)
|
|
70
|
+
Endpoint.identity(symbolic?(from) ? to : from)
|
|
71
|
+
end
|
|
72
|
+
|
|
49
73
|
#: (untyped, untyped, Hash[identity, RecordSnapshot]) -> Diff
|
|
50
74
|
def compare(from, to, live_snapshots)
|
|
51
75
|
Engine.compare(snapshot(from, live_snapshots), snapshot(to, live_snapshots))
|
|
@@ -125,7 +149,7 @@ module PaperTrailDiff
|
|
|
125
149
|
|
|
126
150
|
#: (Array[[untyped, untyped]]) -> void
|
|
127
151
|
def ensure_unique_identities!(pairs)
|
|
128
|
-
identities = pairs.map { |from,
|
|
152
|
+
identities = pairs.map { |from, to| entry_identity(from, to) }
|
|
129
153
|
return if identities.uniq.length == identities.length
|
|
130
154
|
|
|
131
155
|
raise ConfigurationError, 'comparisons: root identities must be unique'
|
|
@@ -24,6 +24,8 @@ module PaperTrailDiff
|
|
|
24
24
|
end_at: root_versions.last.created_at
|
|
25
25
|
)
|
|
26
26
|
return if @tree.empty?
|
|
27
|
+
# A batched preparation already covers these roots.
|
|
28
|
+
return if root_versions.any? { |v| @prepared_histories.key?(context_key(v)) }
|
|
27
29
|
|
|
28
30
|
@prepared_history = PreparedHistoryLoader.new(
|
|
29
31
|
record,
|
|
@@ -35,7 +37,6 @@ module PaperTrailDiff
|
|
|
35
37
|
).call
|
|
36
38
|
end
|
|
37
39
|
|
|
38
|
-
# Prepares selected history for several roots of the same model class.
|
|
39
40
|
#: (Array[untyped], Array[untyped]) -> void
|
|
40
41
|
def prepare_batch(records, root_versions)
|
|
41
42
|
return if @tree.empty? || records.empty? || root_versions.empty?
|
|
@@ -50,12 +51,7 @@ module PaperTrailDiff
|
|
|
50
51
|
key = snapshot_key(root_endpoint, context_endpoint)
|
|
51
52
|
return @snapshots[key] if @snapshots.key?(key)
|
|
52
53
|
|
|
53
|
-
@snapshots[key] =
|
|
54
|
-
root_endpoint,
|
|
55
|
-
context_endpoint,
|
|
56
|
-
tree: @tree,
|
|
57
|
-
normalizer: @normalizer
|
|
58
|
-
)
|
|
54
|
+
@snapshots[key] = uncached(root_endpoint, context_endpoint)
|
|
59
55
|
end
|
|
60
56
|
|
|
61
57
|
#: (untyped, untyped) -> RecordSnapshot?
|