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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/README.md +120 -3
  4. data/lib/paper_trail_diff/activity_root_steps.rb +9 -7
  5. data/lib/paper_trail_diff/activity_timeline_builder.rb +15 -8
  6. data/lib/paper_trail_diff/analysis.rb +19 -5
  7. data/lib/paper_trail_diff/analysis_batch.rb +92 -0
  8. data/lib/paper_trail_diff/batch_boundary_resolver.rb +137 -0
  9. data/lib/paper_trail_diff/batched_root_analyzer.rb +50 -0
  10. data/lib/paper_trail_diff/batched_root_versions.rb +149 -0
  11. data/lib/paper_trail_diff/comparison_batch.rb +37 -13
  12. data/lib/paper_trail_diff/historical_snapshot_store.rb +3 -7
  13. data/lib/paper_trail_diff/paper_trail_adapter.rb +70 -47
  14. data/lib/paper_trail_diff/root_version_plan.rb +86 -0
  15. data/lib/paper_trail_diff/root_version_selection.rb +131 -0
  16. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +25 -13
  17. data/lib/paper_trail_diff/time_version_range.rb +19 -24
  18. data/lib/paper_trail_diff/timeline_builder.rb +21 -18
  19. data/lib/paper_trail_diff/timeline_range.rb +37 -6
  20. data/lib/paper_trail_diff/traversal_preparer.rb +40 -0
  21. data/lib/paper_trail_diff/version.rb +1 -1
  22. data/lib/paper_trail_diff/version_range.rb +45 -5
  23. data/lib/paper_trail_diff/version_scope_filter.rb +37 -0
  24. data/lib/paper_trail_diff.rb +54 -12
  25. data/sig/generated/paper_trail_diff/activity_root_steps.rbs +2 -2
  26. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +6 -2
  27. data/sig/generated/paper_trail_diff/analysis.rbs +13 -4
  28. data/sig/generated/paper_trail_diff/analysis_batch.rbs +49 -0
  29. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +62 -0
  30. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +28 -0
  31. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +69 -0
  32. data/sig/generated/paper_trail_diff/comparison_batch.rbs +15 -0
  33. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +0 -1
  34. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +24 -15
  35. data/sig/generated/paper_trail_diff/root_version_plan.rbs +56 -0
  36. data/sig/generated/paper_trail_diff/root_version_selection.rbs +77 -0
  37. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +9 -4
  38. data/sig/generated/paper_trail_diff/time_version_range.rbs +7 -9
  39. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -6
  40. data/sig/generated/paper_trail_diff/timeline_range.rbs +15 -2
  41. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +22 -0
  42. data/sig/generated/paper_trail_diff/version_range.rbs +17 -2
  43. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +22 -0
  44. data/sig/generated/paper_trail_diff.rbs +17 -7
  45. metadata +20 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fac7dbbad6368383d2b73f4c5acbdcabb1599da6c096a5c8363327a8602a1298
4
- data.tar.gz: 7657cc9ae4b3e8f7d9f1026ac06fb93b6571ba7258bca66183b84053bb8469c0
3
+ metadata.gz: 88e9b952c5cfe214974d5eab1a9d6e5a30dcfeb34ebc4193110f478d0cd9884a
4
+ data.tar.gz: 3a66b3ae3a77b65097ece227010c423beb05ec4ebd981edee396b824638c801c
5
5
  SHA512:
6
- metadata.gz: adadf85c15151889247d0791ffc944731c781c6dc3f4d23b4a04fa046e489b74f2a6501a16fb53386e6c80c3fae4d6c583d4b91db198bfbe8919a3a19eaf1a4e
7
- data.tar.gz: 2a3bf4422b73c513981b519d04f0c556574f75ebc08963fab606f872c2428acac2455869675318ae75d8f71bddeb208e952642eeafedae7131b4745b5f36f534
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 `compare_many` reload current endpoints by default. A caller that
229
- already owns a consistent, fully preloaded graph may opt out:
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. Structural hash keys are symbols; attribute and association
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
- #: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
11
- def call(root_versions, root_snapshots)
12
- snapshots = root_versions.map { |version| root_snapshots.fetch(version_key(version)) }
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: versions.fetch(0),
16
- to_version: versions.fetch(1),
17
- diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
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
- root_versions = @range.select
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(root_versions, events, event_history(root_versions, events))
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.select
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
- #: (Array[untyped], Array[ActivityEvent], ActivityHistory) -> Analysis
181
- def build_analysis(root_versions, events, history)
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(root_versions, history.root_snapshots),
189
+ timeline: ActivityRootSteps.call(plan, history.root_snapshots),
185
190
  activity_timeline: activity_steps(
186
- history, events, destroyed_boundary(root_versions), nil
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
- #: (diff: Diff, timeline: Array[Step], ?activity_timeline: Array[ActivityStep]?) -> void
21
- def initialize(diff:, timeline:, activity_timeline: nil)
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
- #: () -> Hash[Symbol, untyped]
29
- def to_h
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