paper_trail_diff 0.5.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 +24 -0
- data/README.md +64 -1
- 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 +28 -10
- data/lib/paper_trail_diff/batched_root_analyzer.rb +9 -9
- data/lib/paper_trail_diff/batched_root_versions.rb +48 -30
- data/lib/paper_trail_diff/paper_trail_adapter.rb +25 -18
- 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 +31 -7
- 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 +24 -13
- 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 +13 -6
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +4 -4
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +21 -14
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +10 -10
- 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 +11 -2
- 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 +8 -8
- metadata +10 -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,30 @@
|
|
|
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
|
+
|
|
6
30
|
## [0.5.0] - 2026-08-11
|
|
7
31
|
|
|
8
32
|
### Added
|
data/README.md
CHANGED
|
@@ -324,6 +324,52 @@ value is the same `Analysis` that `analyze` returns for one record. A root with
|
|
|
324
324
|
no versions inside the window gets an empty `Analysis` rather than raising, so a
|
|
325
325
|
listing page needs no special case. Root identities must be unique.
|
|
326
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
|
+
|
|
327
373
|
Omit `within:` to analyze each root's whole recorded history instead, which is
|
|
328
374
|
the batched equivalent of `analyze(record, from: :first, to: :last)`. Explicit
|
|
329
375
|
version endpoints are not accepted, because a single pair cannot mean the same
|
|
@@ -922,10 +968,27 @@ The public result types are:
|
|
|
922
968
|
- `PaperTrailDiff::DiagnosticReport`
|
|
923
969
|
- `PaperTrailDiff::DiagnosticIssue`
|
|
924
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
|
+
|
|
925
986
|
They expose readers, are frozen after construction, and provide deterministic
|
|
926
987
|
`to_h` output. Collection results are ordered by record identity: by type, then
|
|
927
988
|
naturally within one id type, so numeric ids sort `2` before `10`. Mixed or
|
|
928
|
-
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
|
|
929
992
|
names are strings. Attribute values retain their Ruby types. `RecordChange#record`
|
|
930
993
|
is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
|
|
931
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
|
|
@@ -6,10 +6,13 @@ module PaperTrailDiff
|
|
|
6
6
|
# versions and preparing their association history once for the whole batch
|
|
7
7
|
# rather than once per root.
|
|
8
8
|
class AnalysisBatch
|
|
9
|
-
#: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped) -> void
|
|
10
|
-
def initialize(
|
|
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
|
+
)
|
|
11
13
|
@records = records
|
|
12
14
|
@time_range = time_range
|
|
15
|
+
@version_scope = validated_scope(version_scope)
|
|
13
16
|
@live_loader = live_loader
|
|
14
17
|
@history_preparer = history_preparer
|
|
15
18
|
@analyzer = analyzer
|
|
@@ -18,11 +21,14 @@ module PaperTrailDiff
|
|
|
18
21
|
#: () -> Hash[identity, Analysis]
|
|
19
22
|
def call
|
|
20
23
|
records = validated_records
|
|
21
|
-
selected = BatchedRootVersions.new(
|
|
24
|
+
selected = BatchedRootVersions.new(
|
|
25
|
+
records, time_range: @time_range, version_scope: @version_scope
|
|
26
|
+
).call
|
|
22
27
|
prepare(records, selected)
|
|
23
28
|
records.to_h do |record|
|
|
24
29
|
key = Endpoint.identity(record)
|
|
25
|
-
|
|
30
|
+
plan = selected.fetch(key, RootVersionPlan.empty)
|
|
31
|
+
[Support.immutable_copy(key), analysis_for(record, plan)]
|
|
26
32
|
end.freeze
|
|
27
33
|
end
|
|
28
34
|
|
|
@@ -30,10 +36,20 @@ module PaperTrailDiff
|
|
|
30
36
|
|
|
31
37
|
# @rbs @records: Array[untyped]
|
|
32
38
|
# @rbs @time_range: TimeRange?
|
|
39
|
+
# @rbs @version_scope: untyped
|
|
33
40
|
# @rbs @live_loader: untyped
|
|
34
41
|
# @rbs @history_preparer: untyped
|
|
35
42
|
# @rbs @analyzer: untyped
|
|
36
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
|
+
|
|
37
53
|
#: () -> Array[untyped]
|
|
38
54
|
def validated_records
|
|
39
55
|
raise ConfigurationError, 'records: must be an array' unless @records.is_a?(Array)
|
|
@@ -50,11 +66,13 @@ module PaperTrailDiff
|
|
|
50
66
|
# The roots are preloaded first, because preparation reads their current
|
|
51
67
|
# association state as a fallback and would otherwise walk it one root at a
|
|
52
68
|
# time.
|
|
53
|
-
#: (Array[untyped], Hash[Array[String],
|
|
69
|
+
#: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
|
|
54
70
|
def prepare(records, selected)
|
|
55
71
|
loaded = @live_loader.call(records)
|
|
56
72
|
records.group_by(&:class).each_value do |grouped|
|
|
57
|
-
versions = grouped.flat_map
|
|
73
|
+
versions = grouped.flat_map do |record|
|
|
74
|
+
selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).versions
|
|
75
|
+
end
|
|
58
76
|
next if versions.empty?
|
|
59
77
|
|
|
60
78
|
roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
|
|
@@ -64,11 +82,11 @@ module PaperTrailDiff
|
|
|
64
82
|
|
|
65
83
|
# A root with no versions in range has nothing to report, which is an empty
|
|
66
84
|
# result rather than a failed request.
|
|
67
|
-
#: (untyped,
|
|
68
|
-
def analysis_for(record,
|
|
69
|
-
return Analysis.empty if
|
|
85
|
+
#: (untyped, RootVersionPlan) -> Analysis
|
|
86
|
+
def analysis_for(record, plan)
|
|
87
|
+
return Analysis.empty if plan.empty?
|
|
70
88
|
|
|
71
|
-
@analyzer.call(record,
|
|
89
|
+
@analyzer.call(record, plan)
|
|
72
90
|
end
|
|
73
91
|
end
|
|
74
92
|
end
|
|
@@ -14,17 +14,17 @@ module PaperTrailDiff
|
|
|
14
14
|
@activity = activity
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
#: (untyped,
|
|
18
|
-
def call(record,
|
|
17
|
+
#: (untyped, RootVersionPlan) -> Analysis
|
|
18
|
+
def call(record, plan)
|
|
19
19
|
@preparer.call(record.class, historical: true)
|
|
20
|
-
return activity_analysis(record,
|
|
20
|
+
return activity_analysis(record, plan) if @activity
|
|
21
21
|
|
|
22
22
|
TimelineBuilder.new(
|
|
23
23
|
record,
|
|
24
|
-
from: versions.first,
|
|
25
|
-
to: versions.last,
|
|
24
|
+
from: plan.versions.first,
|
|
25
|
+
to: plan.versions.last,
|
|
26
26
|
within: nil,
|
|
27
|
-
|
|
27
|
+
plan: plan,
|
|
28
28
|
snapshotter: @timeline_snapshotter
|
|
29
29
|
).analyze
|
|
30
30
|
end
|
|
@@ -37,10 +37,10 @@ module PaperTrailDiff
|
|
|
37
37
|
# @rbs @preparer: untyped
|
|
38
38
|
# @rbs @activity: bool
|
|
39
39
|
|
|
40
|
-
#: (untyped,
|
|
41
|
-
def activity_analysis(record,
|
|
40
|
+
#: (untyped, RootVersionPlan) -> Analysis
|
|
41
|
+
def activity_analysis(record, plan)
|
|
42
42
|
range = TimelineRange.new(
|
|
43
|
-
record, from: versions.first, to: versions.last, within: nil,
|
|
43
|
+
record, from: plan.versions.first, to: plan.versions.last, within: nil, plan: plan
|
|
44
44
|
)
|
|
45
45
|
ActivityTimelineBuilder.new(
|
|
46
46
|
record, range: range, tree: @tree, snapshotter: @activity_snapshotter
|
|
@@ -6,18 +6,19 @@ module PaperTrailDiff
|
|
|
6
6
|
# queries. Only the range forms that mean the same thing for every root are
|
|
7
7
|
# supported: a shared wall-clock window, or each root's own whole history.
|
|
8
8
|
class BatchedRootVersions
|
|
9
|
-
#: (Array[untyped], time_range: TimeRange?) -> void
|
|
10
|
-
def initialize(records, time_range:)
|
|
9
|
+
#: (Array[untyped], time_range: TimeRange?, ?version_scope: untyped) -> void
|
|
10
|
+
def initialize(records, time_range:, version_scope: nil)
|
|
11
11
|
@records = records
|
|
12
12
|
@time_range = time_range
|
|
13
|
+
@version_scope = version_scope
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
# Returns
|
|
16
|
-
#: () -> Hash[Array[String],
|
|
16
|
+
# Returns a plan per record identity.
|
|
17
|
+
#: () -> Hash[Array[String], RootVersionPlan]
|
|
17
18
|
def call
|
|
18
19
|
return {} if @records.empty?
|
|
19
20
|
|
|
20
|
-
selected = {} #: Hash[Array[String],
|
|
21
|
+
selected = {} #: Hash[Array[String], RootVersionPlan]
|
|
21
22
|
@records.group_by(&:class).each do |model_class, records|
|
|
22
23
|
select_model(model_class, records, selected)
|
|
23
24
|
end
|
|
@@ -28,47 +29,64 @@ module PaperTrailDiff
|
|
|
28
29
|
|
|
29
30
|
# @rbs @records: Array[untyped]
|
|
30
31
|
# @rbs @time_range: TimeRange?
|
|
32
|
+
# @rbs @version_scope: untyped
|
|
31
33
|
|
|
32
|
-
#: (untyped, Array[untyped], Hash[Array[String],
|
|
34
|
+
#: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
|
|
33
35
|
def select_model(model_class, records, selected)
|
|
34
36
|
ids = records.map(&:id)
|
|
35
|
-
|
|
36
|
-
grouped = grouped_versions(model_class, ids, range)
|
|
37
|
-
empty = {} #: Hash[String, untyped]
|
|
38
|
-
trailing = range ? trailing_versions(model_class, ids, range) : empty
|
|
37
|
+
in_range, chosen, trailing = model_versions(model_class, ids)
|
|
39
38
|
records.each do |record|
|
|
40
39
|
key = identity(model_class, record.id)
|
|
41
|
-
selected[key] = versions_for(
|
|
40
|
+
selected[key] = versions_for(
|
|
41
|
+
in_range.fetch(record.id.to_s, []), chosen, trailing[record.id.to_s]
|
|
42
|
+
)
|
|
42
43
|
end
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
raise IncompleteTimeRangeError,
|
|
56
|
-
'time range requires a later root version to reconstruct its final change'
|
|
57
|
-
end
|
|
46
|
+
#: (untyped, Array[untyped]) -> [Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped]]
|
|
47
|
+
def model_versions(model_class, ids)
|
|
48
|
+
range = @time_range
|
|
49
|
+
empty = {} #: Hash[String, untyped]
|
|
50
|
+
[
|
|
51
|
+
grouped_versions(model_class, ids, range),
|
|
52
|
+
chosen_version_ids(model_class, ids, range),
|
|
53
|
+
range ? trailing_versions(model_class, ids, range) : empty
|
|
54
|
+
]
|
|
55
|
+
end
|
|
58
56
|
|
|
59
|
-
|
|
57
|
+
#: (Array[untyped], Set[untyped]?, untyped) -> RootVersionPlan
|
|
58
|
+
def versions_for(in_range, chosen, after_range)
|
|
59
|
+
RootVersionSelection.new(
|
|
60
|
+
in_range: in_range,
|
|
61
|
+
selected: chosen ? in_range.select { |version| chosen.include?(version.id) } : in_range,
|
|
62
|
+
after_range: after_range,
|
|
63
|
+
windowed: !@time_range.nil?,
|
|
64
|
+
filtered: !@version_scope.nil?
|
|
65
|
+
).call
|
|
60
66
|
end
|
|
61
67
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
# One extra query names the selected mutations without discarding the
|
|
69
|
+
# unfiltered versions the successor lookup still needs.
|
|
70
|
+
#: (untyped, Array[untyped], TimeRange?) -> Set[untyped]?
|
|
71
|
+
def chosen_version_ids(model_class, ids, range)
|
|
72
|
+
scope = @version_scope
|
|
73
|
+
return unless scope
|
|
74
|
+
|
|
75
|
+
# A narrowed relation is the expected return. Active Support also gives
|
|
76
|
+
# `pluck` to plain enumerables, so an array of versions works too.
|
|
77
|
+
filtered = scope.call(range_scope(model_class, ids, range))
|
|
78
|
+
Set.new(filtered.pluck(:id))
|
|
65
79
|
end
|
|
66
80
|
|
|
67
81
|
#: (untyped, Array[untyped], TimeRange?) -> Hash[String, Array[untyped]]
|
|
68
82
|
def grouped_versions(model_class, ids, range)
|
|
83
|
+
ordered(range_scope(model_class, ids, range)).group_by { |version| version.item_id.to_s }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
#: (untyped, Array[untyped], TimeRange?) -> untyped
|
|
87
|
+
def range_scope(model_class, ids, range)
|
|
69
88
|
scope = base_scope(model_class, ids)
|
|
70
|
-
|
|
71
|
-
ordered(scope).group_by { |version| version.item_id.to_s }
|
|
89
|
+
range ? range.scope(scope) : scope
|
|
72
90
|
end
|
|
73
91
|
|
|
74
92
|
# One row per root: the earliest version after the window, found without a
|
|
@@ -50,57 +50,62 @@ module PaperTrailDiff
|
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
#: (untyped, from: untyped, to: untyped, within: untyped) -> Array[Step]
|
|
54
|
-
def timeline(record, from:, to:, within:)
|
|
53
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[Step]
|
|
54
|
+
def timeline(record, from:, to:, within:, version_scope: nil)
|
|
55
55
|
@traversal_preparer.call(record.class, historical: true)
|
|
56
|
-
|
|
56
|
+
TimelineBuilder.new(
|
|
57
57
|
record,
|
|
58
58
|
from: from,
|
|
59
59
|
to: to,
|
|
60
60
|
within: within,
|
|
61
|
+
version_scope: version_scope,
|
|
61
62
|
snapshotter: @timeline_snapshotter
|
|
62
|
-
)
|
|
63
|
-
builder.build
|
|
63
|
+
).build
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
#: (untyped, from: untyped, to: untyped, within: untyped) -> Array[ActivityStep]
|
|
67
|
-
def activity_timeline(record, from:, to:, within:)
|
|
66
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> Array[ActivityStep]
|
|
67
|
+
def activity_timeline(record, from:, to:, within:, version_scope: nil)
|
|
68
68
|
payload = @instrumentation_payload.merge(model_type: record.class.base_class.name.to_s)
|
|
69
69
|
Instrumentation.instrument('activity_timeline', payload) do
|
|
70
70
|
@traversal_preparer.call(record.class, historical: true)
|
|
71
71
|
reject_live_habtm_activity!(record.class) if Endpoint.record?(to)
|
|
72
|
-
steps = activity_builder(
|
|
72
|
+
steps = activity_builder(
|
|
73
|
+
record, from: from, to: to, within: within, version_scope: version_scope
|
|
74
|
+
).build
|
|
73
75
|
payload[:step_count] = steps.length
|
|
74
76
|
steps
|
|
75
77
|
end
|
|
76
78
|
end
|
|
77
79
|
|
|
78
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool) -> Analysis
|
|
79
|
-
def analyze(record, from:, to:, within:, activity: false)
|
|
80
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped) -> Analysis
|
|
81
|
+
def analyze(record, from:, to:, within:, activity: false, version_scope: nil) # rubocop:disable Metrics/ParameterLists
|
|
82
|
+
@traversal_preparer.call(record.class, historical: true)
|
|
80
83
|
if activity
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
return activity_builder(
|
|
85
|
+
record, from: from, to: to, within: within, version_scope: version_scope
|
|
86
|
+
).analyze
|
|
83
87
|
end
|
|
84
88
|
|
|
85
|
-
@traversal_preparer.call(record.class, historical: true)
|
|
86
89
|
TimelineBuilder.new(
|
|
87
90
|
record,
|
|
88
91
|
from: from,
|
|
89
92
|
to: to,
|
|
90
93
|
within: within,
|
|
94
|
+
version_scope: version_scope,
|
|
91
95
|
snapshotter: @timeline_snapshotter
|
|
92
96
|
).analyze
|
|
93
97
|
end
|
|
94
98
|
|
|
95
99
|
# Analyzes many roots over one shared range, preparing their history once.
|
|
96
|
-
#: (Array[untyped], within: untyped, ?activity: bool) -> Hash[identity, Analysis]
|
|
97
|
-
def analyze_many(records, within:, activity: false)
|
|
100
|
+
#: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped) -> Hash[identity, Analysis]
|
|
101
|
+
def analyze_many(records, within:, activity: false, version_scope: nil)
|
|
98
102
|
count = records.is_a?(Array) ? records.length : 0
|
|
99
103
|
payload = @instrumentation_payload.merge(comparison_count: count)
|
|
100
104
|
Instrumentation.instrument('analyze_many', payload) do
|
|
101
105
|
AnalysisBatch.new(
|
|
102
106
|
records,
|
|
103
107
|
time_range: within.nil? ? nil : TimeRange.new(within),
|
|
108
|
+
version_scope: version_scope,
|
|
104
109
|
live_loader: @live_endpoints.method(:call),
|
|
105
110
|
history_preparer: @historical_store.method(:prepare_batch),
|
|
106
111
|
analyzer: batched_root_analyzer(activity)
|
|
@@ -168,11 +173,13 @@ module PaperTrailDiff
|
|
|
168
173
|
)
|
|
169
174
|
end
|
|
170
175
|
|
|
171
|
-
#: (untyped, from: untyped, to: untyped, within: untyped) -> ActivityTimelineBuilder
|
|
172
|
-
def activity_builder(record, from:, to:, within:)
|
|
176
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped) -> ActivityTimelineBuilder
|
|
177
|
+
def activity_builder(record, from:, to:, within:, version_scope: nil)
|
|
173
178
|
ActivityTimelineBuilder.new(
|
|
174
179
|
record,
|
|
175
|
-
range: TimelineRange.new(
|
|
180
|
+
range: TimelineRange.new(
|
|
181
|
+
record, from: from, to: to, within: within, version_scope: version_scope
|
|
182
|
+
),
|
|
176
183
|
tree: @association_tree,
|
|
177
184
|
snapshotter: @activity_snapshotter
|
|
178
185
|
)
|