paper_trail_diff 0.4.0 → 0.6.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 +44 -0
- data/README.md +120 -3
- data/lib/paper_trail_diff/activity_root_steps.rb +9 -7
- data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
- data/lib/paper_trail_diff/analysis.rb +19 -5
- data/lib/paper_trail_diff/analysis_batch.rb +92 -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 +149 -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 +70 -47
- data/lib/paper_trail_diff/root_version_plan.rb +86 -0
- data/lib/paper_trail_diff/root_version_selection.rb +131 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +25 -13
- data/lib/paper_trail_diff/time_version_range.rb +19 -24
- data/lib/paper_trail_diff/timeline_builder.rb +21 -18
- data/lib/paper_trail_diff/timeline_range.rb +37 -6
- data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_range.rb +45 -5
- data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
- data/lib/paper_trail_diff.rb +54 -12
- data/sig/generated/paper_trail_diff/activity_root_steps.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
- data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +49 -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 +69 -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 +24 -15
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +56 -0
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +77 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +9 -4
- data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -9
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -6
- data/sig/generated/paper_trail_diff/timeline_range.rbs +15 -2
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
- data/sig/generated/paper_trail_diff.rbs +17 -7
- metadata +20 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 88e9b952c5cfe214974d5eab1a9d6e5a30dcfeb34ebc4193110f478d0cd9884a
|
|
4
|
+
data.tar.gz: 3a66b3ae3a77b65097ece227010c423beb05ec4ebd981edee396b824638c801c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 413fcd69a9ef2b49abb2238d77b279ac2dd4001cc8b5d98d1bbcb92843a6b428aeed967ba7ba8efb10dca1fcd5e912ca4221b0a8a854e0cd624a7b95fcc21ae1
|
|
7
|
+
data.tar.gz: f5968acfec8ce8189e1fcd3122b4fc3934b7cf989278c1b8feb87ed087da93132c35f7d20df8d64e49832fabe052b5cd80d6fdc5182af3e9be6c2d308ab3acf1
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,50 @@
|
|
|
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.6.0] - 2026-08-11
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Accept `version_scope:` on `timeline`, `activity_timeline`, `analyze`, and
|
|
11
|
+
`analyze_many`, narrowing which root versions count as selected mutations.
|
|
12
|
+
Excluded versions are still loaded, because a version records the state before
|
|
13
|
+
its own event and the one following a selected change is what reveals it;
|
|
14
|
+
their own changes are never attributed to a selected mutation. Each selected
|
|
15
|
+
mutation is bounded by the version that immediately followed it rather than by
|
|
16
|
+
the next selected one, so its diff is exactly what that mutation did however
|
|
17
|
+
many excluded changes follow it. A selected mutation nothing follows yet is
|
|
18
|
+
not reported, since no version records the state it produced; a selected
|
|
19
|
+
destruction is the exception, because the absence it leaves is what it
|
|
20
|
+
produced and `activity_timeline` closes on it. A root left with
|
|
21
|
+
no selected mutation reports an empty `Analysis`. Under `activity: true` the
|
|
22
|
+
filter decides the span; `activity_timeline` still lists every boundary inside
|
|
23
|
+
it, because dropping one would fold its change into a neighbouring step.
|
|
24
|
+
- Expose `from_snapshot` and `to_snapshot` on `Analysis`, the reconstructed
|
|
25
|
+
states its diff was taken between, so a report can render unchanged columns
|
|
26
|
+
without selecting versions or reifying them itself. `Analysis#to_h` keeps its
|
|
27
|
+
shape by default and takes `snapshots: true` to include them, because they
|
|
28
|
+
carry the whole selected graph whether or not anything changed.
|
|
29
|
+
|
|
30
|
+
## [0.5.0] - 2026-08-11
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- Add `analyze_many`, which analyzes many roots over one shared `within:` window,
|
|
35
|
+
or over each root's whole history when the window is omitted, selecting their
|
|
36
|
+
versions and preparing their history once for the batch. Query cost is flat in
|
|
37
|
+
the number of roots for the endpoint diff and checkpoint timeline; descendant
|
|
38
|
+
event discovery under `activity: true` remains per-root. A root with no
|
|
39
|
+
versions in range returns an empty `Analysis`, and roots are supplied as live
|
|
40
|
+
records so a root deleted inside the window cannot be included.
|
|
41
|
+
- Accept `:first` and `:last` as `compare_many` endpoints, resolved against the
|
|
42
|
+
record the pair's other endpoint names, in two queries per model class rather
|
|
43
|
+
than one lookup per root. A root with no recorded history compares as an empty
|
|
44
|
+
`Diff`, and an unanchored `{ from: :first, to: :last }` raises.
|
|
45
|
+
- Accept `reload_live_endpoints:` on `activity_timeline`, which reads live state
|
|
46
|
+
whenever `to:` is a current record but previously had no way to reuse an
|
|
47
|
+
already-preloaded graph. `timeline` and `analyze` are bounded by versions and
|
|
48
|
+
never read live state, so the option is deliberately absent there.
|
|
49
|
+
|
|
6
50
|
## [0.4.0] - 2026-08-11
|
|
7
51
|
|
|
8
52
|
### 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,89 @@ 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
|
+
### Reporting on a subset of mutations
|
|
328
|
+
|
|
329
|
+
`version_scope:` narrows which root versions count as *selected mutations*,
|
|
330
|
+
which is what a "changes made by a user" report needs:
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
|
|
334
|
+
|
|
335
|
+
PaperTrailDiff.analyze_many(articles, within: window, version_scope: user_edits)
|
|
336
|
+
PaperTrailDiff.timeline(article, within: window, version_scope: user_edits)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
The hook receives the version relation for the range and returns a narrowed one.
|
|
340
|
+
It is accepted by `timeline`, `activity_timeline`, `analyze`, and
|
|
341
|
+
`analyze_many`, with any range form.
|
|
342
|
+
|
|
343
|
+
It filters *selected mutations only*. Versions the filter excludes are still
|
|
344
|
+
loaded, because a version records the state before its own event: without the
|
|
345
|
+
one that follows a selected change, whatever that change produced cannot be
|
|
346
|
+
shown at all. Those extra versions are reconstruction context, so their own
|
|
347
|
+
changes are never attributed to a selected mutation.
|
|
348
|
+
|
|
349
|
+
Each selected mutation is bounded by the version that immediately followed it,
|
|
350
|
+
not by the next selected one. So each step's diff is exactly what that mutation
|
|
351
|
+
did, and it reads the same however many excluded changes happen to follow it:
|
|
352
|
+
|
|
353
|
+
```
|
|
354
|
+
versions system → alice → system → bob → system
|
|
355
|
+
reported alice: what alice changed bob: what bob changed
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Given a user edit followed by a system edit, filtering to user changes yields
|
|
359
|
+
one step running from the user version to the system version, whose diff is
|
|
360
|
+
exactly the user's change. A selected mutation that nothing follows yet is not
|
|
361
|
+
reported, since no version records the state it produced — the same blind spot
|
|
362
|
+
an unfiltered timeline has at its `to:` boundary. A selected *destruction* is
|
|
363
|
+
the exception: nothing can follow one, but the absence it leaves is what it
|
|
364
|
+
produced, so `activity_timeline` still closes on it. A root with no selected
|
|
365
|
+
mutation reports an empty `Analysis` rather than raising.
|
|
366
|
+
|
|
367
|
+
The hook applies to root versions only. Under `activity: true` the filter
|
|
368
|
+
decides where the span starts and ends, and `timeline` within that span reports
|
|
369
|
+
only selected mutations, but `activity_timeline` still lists every boundary
|
|
370
|
+
inside it — dropping one would fold the change it carried into a neighbouring
|
|
371
|
+
step and credit it to whoever made that one.
|
|
372
|
+
|
|
373
|
+
Omit `within:` to analyze each root's whole recorded history instead, which is
|
|
374
|
+
the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
|
|
375
|
+
version endpoints are not accepted, because a single pair cannot mean the same
|
|
376
|
+
thing for every root.
|
|
377
|
+
|
|
378
|
+
Query cost is flat in the number of roots for the diff and timeline views:
|
|
379
|
+
selecting versions and preparing history are both shared across the batch. On a
|
|
380
|
+
twenty-root batch that is 7 queries against 120 for the same work done one
|
|
381
|
+
record at a time. Passing `activity: true` also works and returns the same
|
|
382
|
+
results, but discovering descendant events is inherently per-root, so that view
|
|
383
|
+
batches far less.
|
|
384
|
+
|
|
385
|
+
Roots are supplied as live records, so a root deleted inside the window cannot
|
|
386
|
+
be included; use `activity_timeline` for a history that ends in a deletion.
|
|
387
|
+
|
|
288
388
|
## Build a root-checkpoint timeline
|
|
289
389
|
|
|
290
390
|
`timeline` accepts two version objects from the supplied record's history, or
|
|
@@ -868,10 +968,27 @@ The public result types are:
|
|
|
868
968
|
- `PaperTrailDiff::DiagnosticReport`
|
|
869
969
|
- `PaperTrailDiff::DiagnosticIssue`
|
|
870
970
|
|
|
971
|
+
`Analysis` also exposes the reconstructed states its diff was taken between, as
|
|
972
|
+
`from_snapshot` and `to_snapshot`. A report that renders unchanged columns needs
|
|
973
|
+
the whole final state, not only what moved, and these are the states the gem
|
|
974
|
+
already reconstructed:
|
|
975
|
+
|
|
976
|
+
```ruby
|
|
977
|
+
analysis.to_snapshot.attributes # every selected scalar, changed or not
|
|
978
|
+
analysis.to_snapshot.associations # the selected association tree
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
Either is `nil` when that endpoint has no reconstructable state — most often a
|
|
982
|
+
`from_snapshot` at a `create` boundary, whose pre-change state is the absence of
|
|
983
|
+
the record. Following the precedent set by `Step`, `Analysis#to_h` is unchanged;
|
|
984
|
+
serialize `to_snapshot.to_h` when a serialized form is wanted.
|
|
985
|
+
|
|
871
986
|
They expose readers, are frozen after construction, and provide deterministic
|
|
872
987
|
`to_h` output. Collection results are ordered by record identity: by type, then
|
|
873
988
|
naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
|
|
874
|
-
unusual id types still order totally rather than raising.
|
|
989
|
+
unusual id types still order totally rather than raising.
|
|
990
|
+
|
|
991
|
+
Structural hash keys are symbols; attribute and association
|
|
875
992
|
names are strings. Attribute values retain their Ruby types. `RecordChange#record`
|
|
876
993
|
is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
|
|
877
994
|
`#association` return the final components of their corresponding paths. `Step`
|
|
@@ -7,14 +7,16 @@ module PaperTrailDiff
|
|
|
7
7
|
module ActivityRootSteps
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
#: (
|
|
11
|
-
def call(
|
|
12
|
-
|
|
13
|
-
root_versions.each_cons(2).with_index.map do |versions, index|
|
|
10
|
+
#: (RootVersionPlan, Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
|
|
11
|
+
def call(plan, root_snapshots)
|
|
12
|
+
plan.steps.map do |from_version, to_version|
|
|
14
13
|
Step.new(
|
|
15
|
-
from_version:
|
|
16
|
-
to_version:
|
|
17
|
-
diff: Engine.compare(
|
|
14
|
+
from_version: from_version,
|
|
15
|
+
to_version: to_version,
|
|
16
|
+
diff: Engine.compare(
|
|
17
|
+
root_snapshots[version_key(from_version)],
|
|
18
|
+
root_snapshots[version_key(to_version)]
|
|
19
|
+
)
|
|
18
20
|
)
|
|
19
21
|
end.freeze
|
|
20
22
|
end
|
|
@@ -34,10 +34,11 @@ module PaperTrailDiff
|
|
|
34
34
|
raise InvalidTimelineRangeError, '`to` must be a root PaperTrail version'
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
plan = @range.select_plan
|
|
38
|
+
root_versions = plan.reconstruction_versions
|
|
38
39
|
prepare_history(root_versions)
|
|
39
40
|
events = collect_events(root_versions)
|
|
40
|
-
build_analysis(
|
|
41
|
+
build_analysis(plan, events, event_history(root_versions, events))
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
private
|
|
@@ -55,9 +56,13 @@ module PaperTrailDiff
|
|
|
55
56
|
steps.freeze
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
# Every boundary in the span, filtered out or not. A filter narrows where the
|
|
60
|
+
# span starts and ends, but the sequence inside it stays complete: dropping
|
|
61
|
+
# boundaries would fold the changes they carried into a neighbouring step and
|
|
62
|
+
# credit them to whoever made that one.
|
|
58
63
|
#: () -> Array[ActivityStep]
|
|
59
64
|
def build_between_versions
|
|
60
|
-
root_versions = @range.
|
|
65
|
+
root_versions = @range.select_plan.reconstruction_versions
|
|
61
66
|
prepare_history(root_versions)
|
|
62
67
|
events = collect_events(root_versions)
|
|
63
68
|
build_event_steps(root_versions, events)
|
|
@@ -177,14 +182,16 @@ module PaperTrailDiff
|
|
|
177
182
|
# Only the activity view gains the closing removal. The endpoint diff and
|
|
178
183
|
# the root timeline keep their `compare` and `timeline` semantics, under
|
|
179
184
|
# which the state at a destroy version is the state before the deletion.
|
|
180
|
-
#: (
|
|
181
|
-
def build_analysis(
|
|
185
|
+
#: (RootVersionPlan, Array[ActivityEvent], ActivityHistory) -> Analysis
|
|
186
|
+
def build_analysis(plan, events, history)
|
|
182
187
|
Analysis.new(
|
|
183
188
|
diff: Engine.compare(history.first_snapshot, history.last_snapshot),
|
|
184
|
-
timeline: ActivityRootSteps.call(
|
|
189
|
+
timeline: ActivityRootSteps.call(plan, history.root_snapshots),
|
|
185
190
|
activity_timeline: activity_steps(
|
|
186
|
-
history, events, destroyed_boundary(
|
|
187
|
-
)
|
|
191
|
+
history, events, destroyed_boundary(plan.reconstruction_versions), nil
|
|
192
|
+
),
|
|
193
|
+
from_snapshot: history.first_snapshot,
|
|
194
|
+
to_snapshot: history.last_snapshot
|
|
188
195
|
)
|
|
189
196
|
end
|
|
190
197
|
|
|
@@ -7,6 +7,8 @@ module PaperTrailDiff
|
|
|
7
7
|
attr_reader :diff #: Diff
|
|
8
8
|
attr_reader :timeline #: Array[Step]
|
|
9
9
|
attr_reader :activity_timeline #: Array[ActivityStep]?
|
|
10
|
+
attr_reader :from_snapshot #: RecordSnapshot?
|
|
11
|
+
attr_reader :to_snapshot #: RecordSnapshot?
|
|
10
12
|
|
|
11
13
|
# The result for a record whose requested history contains no versions,
|
|
12
14
|
# which is an empty history rather than a failed request.
|
|
@@ -17,19 +19,31 @@ module PaperTrailDiff
|
|
|
17
19
|
new(diff: Diff.new, timeline: timeline, activity_timeline: activity_timeline)
|
|
18
20
|
end
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
# The reconstructed states the diff was taken between. A report that has to
|
|
23
|
+
# render unchanged columns needs the whole final state, not only what moved.
|
|
24
|
+
#: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
|
|
25
|
+
def initialize(diff:, timeline:, activity_timeline: nil, from_snapshot: nil, to_snapshot: nil)
|
|
22
26
|
@diff = diff
|
|
23
27
|
@timeline = timeline.dup.freeze
|
|
24
28
|
@activity_timeline = activity_timeline&.dup&.freeze
|
|
29
|
+
@from_snapshot = from_snapshot
|
|
30
|
+
@to_snapshot = to_snapshot
|
|
25
31
|
freeze
|
|
26
32
|
end
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
# Reconstructed endpoint states are opt-in, because they carry the whole
|
|
35
|
+
# selected graph whether or not anything changed, which dwarfs the rest of
|
|
36
|
+
# the payload for a wide graph.
|
|
37
|
+
#: (?snapshots: bool) -> Hash[Symbol, untyped]
|
|
38
|
+
def to_h(snapshots: false)
|
|
30
39
|
value = { diff: diff.to_h, timeline: Support.serialize(timeline) }
|
|
31
40
|
value[:activity_timeline] = Support.serialize(activity_timeline) if activity_timeline
|
|
32
|
-
value
|
|
41
|
+
return value unless snapshots
|
|
42
|
+
|
|
43
|
+
value.merge(
|
|
44
|
+
from_snapshot: Support.serialize(from_snapshot),
|
|
45
|
+
to_snapshot: Support.serialize(to_snapshot)
|
|
46
|
+
)
|
|
33
47
|
end
|
|
34
48
|
end
|
|
35
49
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
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, ?version_scope: untyped) -> void
|
|
10
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
11
|
+
records, time_range:, live_loader:, history_preparer:, analyzer:, version_scope: nil
|
|
12
|
+
)
|
|
13
|
+
@records = records
|
|
14
|
+
@time_range = time_range
|
|
15
|
+
@version_scope = validated_scope(version_scope)
|
|
16
|
+
@live_loader = live_loader
|
|
17
|
+
@history_preparer = history_preparer
|
|
18
|
+
@analyzer = analyzer
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: () -> Hash[identity, Analysis]
|
|
22
|
+
def call
|
|
23
|
+
records = validated_records
|
|
24
|
+
selected = BatchedRootVersions.new(
|
|
25
|
+
records, time_range: @time_range, version_scope: @version_scope
|
|
26
|
+
).call
|
|
27
|
+
prepare(records, selected)
|
|
28
|
+
records.to_h do |record|
|
|
29
|
+
key = Endpoint.identity(record)
|
|
30
|
+
plan = selected.fetch(key, RootVersionPlan.empty)
|
|
31
|
+
[Support.immutable_copy(key), analysis_for(record, plan)]
|
|
32
|
+
end.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# @rbs @records: Array[untyped]
|
|
38
|
+
# @rbs @time_range: TimeRange?
|
|
39
|
+
# @rbs @version_scope: untyped
|
|
40
|
+
# @rbs @live_loader: untyped
|
|
41
|
+
# @rbs @history_preparer: untyped
|
|
42
|
+
# @rbs @analyzer: untyped
|
|
43
|
+
|
|
44
|
+
# A filter is a callable that narrows the version relation, so it is checked
|
|
45
|
+
# up front rather than failing partway through a batch.
|
|
46
|
+
#: (untyped) -> untyped
|
|
47
|
+
def validated_scope(scope)
|
|
48
|
+
return scope if scope.nil? || scope.respond_to?(:call)
|
|
49
|
+
|
|
50
|
+
raise ConfigurationError, 'version_scope: must respond to call'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
#: () -> Array[untyped]
|
|
54
|
+
def validated_records
|
|
55
|
+
raise ConfigurationError, 'records: must be an array' unless @records.is_a?(Array)
|
|
56
|
+
|
|
57
|
+
@records.each { |record| Endpoint.validate!(record) }
|
|
58
|
+
identities = @records.map { |record| Endpoint.identity(record) }
|
|
59
|
+
return @records if identities.uniq.length == identities.length
|
|
60
|
+
|
|
61
|
+
raise ConfigurationError, 'records: identities must be unique'
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Association history is prepared per model class across every selected root
|
|
65
|
+
# version, which is the work that would otherwise repeat for each record.
|
|
66
|
+
# The roots are preloaded first, because preparation reads their current
|
|
67
|
+
# association state as a fallback and would otherwise walk it one root at a
|
|
68
|
+
# time.
|
|
69
|
+
#: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
|
|
70
|
+
def prepare(records, selected)
|
|
71
|
+
loaded = @live_loader.call(records)
|
|
72
|
+
records.group_by(&:class).each_value do |grouped|
|
|
73
|
+
versions = grouped.flat_map do |record|
|
|
74
|
+
selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).versions
|
|
75
|
+
end
|
|
76
|
+
next if versions.empty?
|
|
77
|
+
|
|
78
|
+
roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
|
|
79
|
+
@history_preparer.call(roots, versions)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A root with no versions in range has nothing to report, which is an empty
|
|
84
|
+
# result rather than a failed request.
|
|
85
|
+
#: (untyped, RootVersionPlan) -> Analysis
|
|
86
|
+
def analysis_for(record, plan)
|
|
87
|
+
return Analysis.empty if plan.empty?
|
|
88
|
+
|
|
89
|
+
@analyzer.call(record, plan)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
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, RootVersionPlan) -> Analysis
|
|
18
|
+
def call(record, plan)
|
|
19
|
+
@preparer.call(record.class, historical: true)
|
|
20
|
+
return activity_analysis(record, plan) if @activity
|
|
21
|
+
|
|
22
|
+
TimelineBuilder.new(
|
|
23
|
+
record,
|
|
24
|
+
from: plan.versions.first,
|
|
25
|
+
to: plan.versions.last,
|
|
26
|
+
within: nil,
|
|
27
|
+
plan: plan,
|
|
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, RootVersionPlan) -> Analysis
|
|
41
|
+
def activity_analysis(record, plan)
|
|
42
|
+
range = TimelineRange.new(
|
|
43
|
+
record, from: plan.versions.first, to: plan.versions.last, within: nil, plan: plan
|
|
44
|
+
)
|
|
45
|
+
ActivityTimelineBuilder.new(
|
|
46
|
+
record, range: range, tree: @tree, snapshotter: @activity_snapshotter
|
|
47
|
+
).analyze
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|