paper_trail_diff 0.10.0 → 0.12.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 +89 -0
- data/README.md +153 -0
- data/lib/paper_trail_diff/activity_boundary.rb +20 -4
- data/lib/paper_trail_diff/activity_grouping.rb +26 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +13 -6
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +83 -0
- data/lib/paper_trail_diff/diagnostics.rb +1 -1
- data/lib/paper_trail_diff/errors.rb +4 -0
- data/lib/paper_trail_diff/nested_comparator.rb +119 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +31 -12
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -1
- data/lib/paper_trail_diff/scoped_analysis.rb +31 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +148 -0
- data/lib/paper_trail_diff/support.rb +13 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +13 -8
- data/lib/paper_trail_diff/traversal_preparer.rb +1 -1
- data/lib/paper_trail_diff/value_objects.rb +66 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff.rb +103 -12
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +13 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +19 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
- data/sig/generated/paper_trail_diff/activity_transaction_grouper.rbs +54 -0
- data/sig/generated/paper_trail_diff/errors.rbs +5 -0
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +73 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +15 -8
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +25 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +93 -0
- data/sig/generated/paper_trail_diff/support.rbs +11 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
- data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
- data/sig/generated/paper_trail_diff.rbs +62 -4
- metadata +14 -4
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/activity_grouping.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Shared by the two activity builders. They differ in how they choose
|
|
5
|
+
# boundaries -- one from an explicit version range, one from a wall-clock
|
|
6
|
+
# window -- but a transaction has to collapse the same way in both, or the
|
|
7
|
+
# same history would read differently depending on how it was asked for.
|
|
8
|
+
module ActivityGrouping
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# : () -> bool
|
|
12
|
+
def grouping?: () -> bool
|
|
13
|
+
|
|
14
|
+
# Applied last, to finished steps, so grouping sees the same timeline the
|
|
15
|
+
# caller would otherwise have received.
|
|
16
|
+
# : (Array[ActivityStep]) -> Array[ActivityStep]
|
|
17
|
+
def group_steps: (Array[ActivityStep]) -> Array[ActivityStep]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
module PaperTrailDiff
|
|
4
4
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
5
5
|
class ActivityTimelineBuilder
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
include ActivityGrouping
|
|
7
|
+
|
|
8
|
+
# : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
9
|
+
def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
8
10
|
|
|
9
11
|
# : () -> Array[ActivityStep]
|
|
10
12
|
def build: () -> Array[ActivityStep]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/activity_transaction_grouper.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Reports one saved transaction as one activity step.
|
|
5
|
+
#
|
|
6
|
+
# A parent and its children saved together produce a version each, and the
|
|
7
|
+
# timeline reports the gap between every pair of them. One deliberate action
|
|
8
|
+
# therefore arrives as several steps, none of which is the thing the person
|
|
9
|
+
# did.
|
|
10
|
+
#
|
|
11
|
+
# A version records the state before its own event. Two things follow.
|
|
12
|
+
#
|
|
13
|
+
# The change a step reports was made by the event that *opens* it, so a step
|
|
14
|
+
# belongs to the transaction of its `from_boundary`, and consecutive steps
|
|
15
|
+
# sharing one are the parts of a single save. Grouping on the closing boundary
|
|
16
|
+
# instead would credit each change to whoever made the next one.
|
|
17
|
+
#
|
|
18
|
+
# And a child's new value is revealed only by something later -- a further
|
|
19
|
+
# version of that child, its destroy version, or the live row. Inside a
|
|
20
|
+
# transaction there is usually none of those yet, so a step can read as empty
|
|
21
|
+
# while a change was in fact made at that boundary, and the change surfaces
|
|
22
|
+
# later, folded in with whatever that step carried. Grouping puts it back
|
|
23
|
+
# together with the save it belongs to.
|
|
24
|
+
#
|
|
25
|
+
# Merging compares the group's outer snapshots rather than combining the
|
|
26
|
+
# diffs between them. A field set and then restored inside one transaction
|
|
27
|
+
# has not changed, and only a comparison of the endpoints can say so.
|
|
28
|
+
#
|
|
29
|
+
# A boundary with no transaction groups with nothing. PaperTrail leaves the
|
|
30
|
+
# column nil outside a transaction, and a custom version class need not carry
|
|
31
|
+
# it at all; treating those as one shared transaction would merge unrelated
|
|
32
|
+
# history into a single step.
|
|
33
|
+
class ActivityTransactionGrouper
|
|
34
|
+
# : (Array[ActivityStep], retain: bool) -> void
|
|
35
|
+
def initialize: (Array[ActivityStep], retain: bool) -> void
|
|
36
|
+
|
|
37
|
+
# : () -> Array[ActivityStep]
|
|
38
|
+
def call: () -> Array[ActivityStep]
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
@retain: bool
|
|
43
|
+
|
|
44
|
+
@steps: Array[ActivityStep]
|
|
45
|
+
|
|
46
|
+
# : (Array[ActivityStep], ActivityStep) -> bool
|
|
47
|
+
def continues?: (Array[ActivityStep], ActivityStep) -> bool
|
|
48
|
+
|
|
49
|
+
# Rebuilt rather than reused even when a group holds one step, so that every
|
|
50
|
+
# returned step retains its snapshots on the same terms.
|
|
51
|
+
# : (Array[ActivityStep]) -> ActivityStep
|
|
52
|
+
def merge: (Array[ActivityStep]) -> ActivityStep
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -41,6 +41,11 @@ module PaperTrailDiff
|
|
|
41
41
|
class AmbiguousVersionOrderError < Error
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Raised when a relation selects more roots than the batch was told to analyze.
|
|
45
|
+
# Truncating instead would hand back a report that is short without saying so.
|
|
46
|
+
class BatchLimitExceededError < Error
|
|
47
|
+
end
|
|
48
|
+
|
|
44
49
|
# Raised when a requested ActiveRecord association does not exist.
|
|
45
50
|
class UnknownAssociationError < Error
|
|
46
51
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/nested_comparator.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Compares the inside of a value a database column holds whole.
|
|
5
|
+
#
|
|
6
|
+
# A JSON or jsonb column reifies to one Hash, so an ordinary attribute diff
|
|
7
|
+
# can only say that the blob changed. This says which keys changed, leaving
|
|
8
|
+
# the surrounding diff untouched.
|
|
9
|
+
#
|
|
10
|
+
# Three decisions worth knowing about.
|
|
11
|
+
#
|
|
12
|
+
# Paths are arrays, not dotted strings. A JSON key may contain a dot -- host
|
|
13
|
+
# names and locales routinely do -- and joining would make `a.b` ambiguous
|
|
14
|
+
# between one key and two.
|
|
15
|
+
#
|
|
16
|
+
# Arrays are reported by membership, not by position -- see ArrayChange. Their
|
|
17
|
+
# elements carry no identity, so an insertion at the front makes every later
|
|
18
|
+
# index look changed, and one insertion reads as several edits. What is added
|
|
19
|
+
# and removed can be answered without claiming any pairing; what cannot is the
|
|
20
|
+
# same elements in a new order, so that is named rather than passed over.
|
|
21
|
+
#
|
|
22
|
+
# An absent key is not a null one. `{"a": null}` and `{}` mean different
|
|
23
|
+
# things in JSON and an audit trail that conflated them would be lying about
|
|
24
|
+
# one of them, so absence is its own value rather than nil.
|
|
25
|
+
class NestedComparator
|
|
26
|
+
# Stands in for a key that was not there at all.
|
|
27
|
+
ABSENT: untyped
|
|
28
|
+
|
|
29
|
+
def inspect: () -> untyped
|
|
30
|
+
|
|
31
|
+
def to_s: () -> untyped
|
|
32
|
+
|
|
33
|
+
# : (untyped, untyped) -> nested_changes
|
|
34
|
+
def self.call: (untyped, untyped) -> nested_changes
|
|
35
|
+
|
|
36
|
+
# : (untyped, untyped) -> void
|
|
37
|
+
def initialize: (untyped, untyped) -> void
|
|
38
|
+
|
|
39
|
+
# Returns the changed paths, or an empty hash when the pair is not two
|
|
40
|
+
# structures this can look inside. An empty result therefore means "nothing
|
|
41
|
+
# to report at this depth", and the caller still has the whole-value change.
|
|
42
|
+
# : () -> nested_changes
|
|
43
|
+
def call: () -> nested_changes
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
@from_value: untyped
|
|
48
|
+
|
|
49
|
+
@to_value: untyped
|
|
50
|
+
|
|
51
|
+
# Both sides have to be readable as a Hash for a nested answer to mean
|
|
52
|
+
# anything. A column that held text on one side and JSON on the other
|
|
53
|
+
# changed wholesale, and saying so is the accurate report.
|
|
54
|
+
# : () -> [Hash[untyped, untyped]?, Hash[untyped, untyped]?]
|
|
55
|
+
def structures: () -> [ Hash[untyped, untyped]?, Hash[untyped, untyped]? ]
|
|
56
|
+
|
|
57
|
+
# : (untyped) -> Hash[untyped, untyped]?
|
|
58
|
+
def structure: (untyped) -> Hash[untyped, untyped]?
|
|
59
|
+
|
|
60
|
+
# : (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], nested_changes) -> void
|
|
61
|
+
def walk: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], nested_changes) -> void
|
|
62
|
+
|
|
63
|
+
# Two arrays are a membership change; anything else is a change to the
|
|
64
|
+
# value as a whole, including an array facing something that is not one.
|
|
65
|
+
# : (untyped, untyped) -> nested_change
|
|
66
|
+
def change_for: (untyped, untyped) -> nested_change
|
|
67
|
+
|
|
68
|
+
# Sorted so a report reads the same twice, and stringified because a hash
|
|
69
|
+
# loaded from JSON and one built in Ruby can key the same field differently.
|
|
70
|
+
# : (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
|
|
71
|
+
def keys: (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -22,16 +22,23 @@ module PaperTrailDiff
|
|
|
22
22
|
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
|
|
23
23
|
def timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
|
|
24
24
|
|
|
25
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
26
|
-
def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
25
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
|
|
26
|
+
def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
|
|
27
27
|
|
|
28
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
29
|
-
def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
28
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
29
|
+
def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
30
30
|
|
|
31
31
|
# Analyzes many roots over one shared range, preparing their history once.
|
|
32
32
|
# : (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
|
|
33
33
|
def analyze_many: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
|
|
34
34
|
|
|
35
|
+
# Selects the roots from a relation before running the ordinary batch, so a
|
|
36
|
+
# caller reporting on a population does not have to rediscover which of its
|
|
37
|
+
# members changed. The roots the relation could not reach come back named
|
|
38
|
+
# rather than dropped -- see `PaperTrailDiff.analyze_scope`.
|
|
39
|
+
# : (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
40
|
+
def analyze_scope: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
41
|
+
|
|
35
42
|
private
|
|
36
43
|
|
|
37
44
|
@activity_snapshotter: ActivitySnapshotProvider
|
|
@@ -56,8 +63,8 @@ module PaperTrailDiff
|
|
|
56
63
|
|
|
57
64
|
@traversal_preparer: TraversalPreparer
|
|
58
65
|
|
|
59
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
|
|
60
|
-
def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
|
|
66
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
67
|
+
def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
61
68
|
|
|
62
69
|
# Association membership is resolved by timestamp, so endpoints sharing one
|
|
63
70
|
# cannot be told apart and any association change between them is invisible.
|
|
@@ -89,8 +96,8 @@ module PaperTrailDiff
|
|
|
89
96
|
# : () -> ActivitySnapshotProvider
|
|
90
97
|
def build_activity_snapshotter: () -> ActivitySnapshotProvider
|
|
91
98
|
|
|
92
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
93
|
-
def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
99
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> ActivityTimelineBuilder
|
|
100
|
+
def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> ActivityTimelineBuilder
|
|
94
101
|
|
|
95
102
|
# : (untyped) -> void
|
|
96
103
|
def reject_live_habtm_activity!: (untyped) -> void
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/scoped_analysis.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# What the relation form of `analyze_many` returns: the analyses, plus the
|
|
5
|
+
# roots the relation could not reach.
|
|
6
|
+
#
|
|
7
|
+
# `to_ary` is defined so the pair destructures, which keeps the common case
|
|
8
|
+
# reading like the plain Hash the record form returns:
|
|
9
|
+
#
|
|
10
|
+
# analyses, unreachable = PaperTrailDiff.analyze_many(scope: ..., limit: 500)
|
|
11
|
+
#
|
|
12
|
+
# It deliberately does not pretend to be a Hash beyond that. Somebody
|
|
13
|
+
# iterating this object should have to decide which half they meant.
|
|
14
|
+
class ScopedAnalysis
|
|
15
|
+
attr_reader analyses: Hash[Array[String], Analysis]
|
|
16
|
+
|
|
17
|
+
attr_reader unreachable: Array[Array[String]]
|
|
18
|
+
|
|
19
|
+
# : (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
|
|
20
|
+
def initialize: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
|
|
21
|
+
|
|
22
|
+
# : () -> [Hash[Array[String], Analysis], Array[Array[String]]]
|
|
23
|
+
def to_ary: () -> [ Hash[Array[String], Analysis], Array[Array[String]] ]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/scoped_root_selection.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Chooses the roots a batch will analyze from a relation instead of from an
|
|
5
|
+
# array the caller assembled, so a reporting page does not reimplement the
|
|
6
|
+
# root selection this gem already performs.
|
|
7
|
+
#
|
|
8
|
+
# Two populations are deliberately kept apart, because conflating them would
|
|
9
|
+
# make the report wrong in a way nothing announces:
|
|
10
|
+
#
|
|
11
|
+
# - A root whose live row does not satisfy the relation is filtered out. That
|
|
12
|
+
# is what the caller asked for, and it needs no reporting.
|
|
13
|
+
# - A root whose live row is gone cannot be filtered at all. The relation's
|
|
14
|
+
# conditions read the live table, and a destroyed root has nothing there to
|
|
15
|
+
# read -- even though its history is intact, and the state it held when it
|
|
16
|
+
# was destroyed may well have satisfied those conditions. Reifying every
|
|
17
|
+
# candidate to find out would cost the batched query plan this class exists
|
|
18
|
+
# to provide.
|
|
19
|
+
#
|
|
20
|
+
# So the second population is returned by name rather than dropped. A caller
|
|
21
|
+
# that does not care can ignore it; one auditing deletions is told where to
|
|
22
|
+
# look instead of silently coming up short.
|
|
23
|
+
#
|
|
24
|
+
# Note that a relation filters on current state, not on state during the
|
|
25
|
+
# window. `where(status: 'published')` selects what is published now, which is
|
|
26
|
+
# not the same set as what was published while the window was open.
|
|
27
|
+
class ScopedRootSelection
|
|
28
|
+
# A plain class rather than Data.define, which arrived in Ruby 3.2 while this
|
|
29
|
+
# gem supports 3.1.
|
|
30
|
+
class Result
|
|
31
|
+
attr_reader records: Array[untyped]
|
|
32
|
+
|
|
33
|
+
attr_reader unreachable: Array[Array[String]]
|
|
34
|
+
|
|
35
|
+
# : (records: Array[untyped], unreachable: Array[Array[String]]) -> void
|
|
36
|
+
def initialize: (records: Array[untyped], unreachable: Array[Array[String]]) -> void
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# : (untyped, time_range: TimeRange?, limit: Integer) -> void
|
|
40
|
+
def initialize: (untyped, time_range: TimeRange?, limit: Integer) -> void
|
|
41
|
+
|
|
42
|
+
# : () -> Result
|
|
43
|
+
def call: () -> Result
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
@limit: Integer
|
|
48
|
+
|
|
49
|
+
@scope: untyped
|
|
50
|
+
|
|
51
|
+
@time_range: TimeRange?
|
|
52
|
+
|
|
53
|
+
# Accepts a model class as readily as a relation: `Article` and
|
|
54
|
+
# `Article.where(...)` both name a population, and `all` is what makes them
|
|
55
|
+
# the same kind of thing.
|
|
56
|
+
# : (untyped) -> untyped
|
|
57
|
+
def normalize_scope: (untyped) -> untyped
|
|
58
|
+
|
|
59
|
+
# : (Integer) -> Integer
|
|
60
|
+
def validate_limit: (Integer) -> Integer
|
|
61
|
+
|
|
62
|
+
# Every root whose history moved inside the window, destroyed ones included.
|
|
63
|
+
# This is the gem's own notion of "which roots changed", answered from the
|
|
64
|
+
# version table alone so that it does not depend on rows still existing.
|
|
65
|
+
# : () -> Array[String]
|
|
66
|
+
def versioned_ids: () -> Array[String]
|
|
67
|
+
|
|
68
|
+
# Which of those candidates still have a row, asked without the relation's
|
|
69
|
+
# conditions so that "filtered out" and "no longer exists" stay separable.
|
|
70
|
+
# : (Array[String]) -> Array[String]
|
|
71
|
+
def live_ids: (Array[String]) -> Array[String]
|
|
72
|
+
|
|
73
|
+
# Loading one past the limit is what turns an oversized page into an error
|
|
74
|
+
# rather than a silently truncated report.
|
|
75
|
+
# : (Array[String]) -> Array[untyped]
|
|
76
|
+
def selected_records: (Array[String]) -> Array[untyped]
|
|
77
|
+
|
|
78
|
+
# : (String) -> Array[String]
|
|
79
|
+
def identity: (String) -> Array[String]
|
|
80
|
+
|
|
81
|
+
# : () -> String
|
|
82
|
+
def item_type: () -> String
|
|
83
|
+
|
|
84
|
+
# : () -> untyped
|
|
85
|
+
def base_class: () -> untyped
|
|
86
|
+
|
|
87
|
+
# : () -> untyped
|
|
88
|
+
def primary_key: () -> untyped
|
|
89
|
+
|
|
90
|
+
# : () -> untyped
|
|
91
|
+
def version_class: () -> untyped
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -32,6 +32,17 @@ module PaperTrailDiff
|
|
|
32
32
|
# : (Array[untyped]) -> Array[untyped]?
|
|
33
33
|
def self?.tied_timestamp_pair: (Array[untyped]) -> Array[untyped]?
|
|
34
34
|
|
|
35
|
+
# Whether a model records history at all.
|
|
36
|
+
#
|
|
37
|
+
# PaperTrail defines `paper_trail` on every ActiveRecord model, so asking
|
|
38
|
+
# whether a class responds to it says nothing -- it is true for models that
|
|
39
|
+
# never called `has_paper_trail`, and reading history from one of those fails
|
|
40
|
+
# at the version class rather than at the question. Only configured options
|
|
41
|
+
# distinguish the two, which is why this lives in one place: the predicate is
|
|
42
|
+
# easy to write in a form that looks right and always answers true.
|
|
43
|
+
# : (untyped) -> bool
|
|
44
|
+
def self?.versioned?: (untyped) -> bool
|
|
45
|
+
|
|
35
46
|
# : (untyped) -> bool
|
|
36
47
|
def self?.sequential_id?: (untyped) -> bool
|
|
37
48
|
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
module PaperTrailDiff
|
|
4
4
|
# Builds activity views for mutations selected by a wall-clock range.
|
|
5
5
|
class TimeActivityTimelineBuilder
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
include ActivityGrouping
|
|
7
|
+
|
|
8
|
+
# : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
9
|
+
def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
8
10
|
|
|
9
11
|
# : () -> Array[ActivityStep]
|
|
10
12
|
def build: () -> Array[ActivityStep]
|
|
@@ -14,6 +14,51 @@ module PaperTrailDiff
|
|
|
14
14
|
def to_h: () -> Hash[Symbol, untyped]
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# A change to an array inside a value the database stores whole, reported by
|
|
18
|
+
# membership rather than by position.
|
|
19
|
+
#
|
|
20
|
+
# Position would be the obvious thing to report and the wrong one: elements
|
|
21
|
+
# here carry no identity, so inserting at the front makes every later index
|
|
22
|
+
# look changed, and one insertion is described as several edits. Membership is
|
|
23
|
+
# answerable without claiming any pairing -- "gained \"saturn v\"" is true
|
|
24
|
+
# whether the array is a set, a queue, or a ranked list.
|
|
25
|
+
#
|
|
26
|
+
# That leaves one case membership cannot see, so it is named rather than
|
|
27
|
+
# hidden: the same elements in a different order. `reordered?` says so.
|
|
28
|
+
#
|
|
29
|
+
# An element that is itself a Hash or Array is reported whole. Saying which
|
|
30
|
+
# field of which object changed would require pairing before-elements with
|
|
31
|
+
# after-elements, and nothing in the value licenses that pairing.
|
|
32
|
+
class ArrayChange
|
|
33
|
+
attr_reader from: Array[untyped]
|
|
34
|
+
|
|
35
|
+
attr_reader to: Array[untyped]
|
|
36
|
+
|
|
37
|
+
attr_reader added: Array[untyped]
|
|
38
|
+
|
|
39
|
+
attr_reader removed: Array[untyped]
|
|
40
|
+
|
|
41
|
+
# : (from: Array[untyped], to: Array[untyped]) -> void
|
|
42
|
+
def initialize: (from: Array[untyped], to: Array[untyped]) -> void
|
|
43
|
+
|
|
44
|
+
# Elements of `left` with no counterpart left in `right`, matching by value
|
|
45
|
+
# and respecting duplicates, so ["a", "a"] -> ["a"] reports one removal
|
|
46
|
+
# rather than none.
|
|
47
|
+
# : (Array[untyped], Array[untyped]) -> Array[untyped]
|
|
48
|
+
def self.surplus: (Array[untyped], Array[untyped]) -> Array[untyped]
|
|
49
|
+
|
|
50
|
+
# Same elements, different sequence. Only meaningful for a change that was
|
|
51
|
+
# recorded at all, which this only is when the two sides differ.
|
|
52
|
+
# : () -> bool
|
|
53
|
+
def reordered?: () -> bool
|
|
54
|
+
|
|
55
|
+
# : () -> bool
|
|
56
|
+
def empty?: () -> bool
|
|
57
|
+
|
|
58
|
+
# : () -> Hash[Symbol, untyped]
|
|
59
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
60
|
+
end
|
|
61
|
+
|
|
17
62
|
# Attribute and nested-association changes for a record whose identity did not change.
|
|
18
63
|
class RecordChange
|
|
19
64
|
attr_reader record: RecordReference
|
|
@@ -27,14 +27,47 @@ module PaperTrailDiff
|
|
|
27
27
|
def self.activity_timeline: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
28
28
|
|
|
29
29
|
# Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
|
|
30
|
-
# : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
31
|
-
def self.analyze: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
30
|
+
# : (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
31
|
+
def self.analyze: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
32
32
|
|
|
33
33
|
# Analyzes many roots over one shared time window, preparing their selected
|
|
34
34
|
# history once for the batch instead of once per record. Roots with no
|
|
35
35
|
# versions in the window return an empty `Analysis`.
|
|
36
|
-
#
|
|
37
|
-
|
|
36
|
+
#
|
|
37
|
+
# Pass `records` to analyze a list you assembled, which returns a Hash keyed
|
|
38
|
+
# by identity. Pass `scope:` with a `limit:` to have the roots selected for
|
|
39
|
+
# you from a relation, which returns a `ScopedAnalysis` -- the same Hash,
|
|
40
|
+
# plus the roots the relation could not reach. See `analyze_scope` for why
|
|
41
|
+
# that second collection exists.
|
|
42
|
+
# : (?Array[untyped]?, ?scope: untyped, ?limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> (Hash[identity, Analysis] | ScopedAnalysis)
|
|
43
|
+
def self.analyze_many: (?Array[untyped]?, ?scope: untyped, ?limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> (Hash[identity, Analysis] | ScopedAnalysis)
|
|
44
|
+
|
|
45
|
+
# Analyzes every root the relation reaches whose history moved inside the
|
|
46
|
+
# window, selecting them in a fixed number of queries rather than making the
|
|
47
|
+
# caller rediscover them.
|
|
48
|
+
#
|
|
49
|
+
# `limit:` is required and exceeding it raises. Selection moves into the gem
|
|
50
|
+
# here, so the bound on how much work a page can ask for has to move with
|
|
51
|
+
# it, and a truncated audit report is worse than a refused one.
|
|
52
|
+
#
|
|
53
|
+
# Returns a `ScopedAnalysis`, which destructures:
|
|
54
|
+
#
|
|
55
|
+
# analyses, unreachable = PaperTrailDiff.analyze_scope(
|
|
56
|
+
# Article.where(status: 'published'), within: july, limit: 500
|
|
57
|
+
# )
|
|
58
|
+
#
|
|
59
|
+
# `unreachable` names roots that changed in the window but have no live row
|
|
60
|
+
# left. A relation's conditions are evaluated against the live table, so a
|
|
61
|
+
# destroyed root cannot be tested against them at all -- its history is
|
|
62
|
+
# intact and the state it held at destruction may well have matched. Those
|
|
63
|
+
# roots are reported rather than dropped so that a page auditing deletions
|
|
64
|
+
# is told where to look instead of quietly coming up short.
|
|
65
|
+
#
|
|
66
|
+
# Note also that a relation selects on current state, not on state during
|
|
67
|
+
# the window: `where(status: 'published')` means published *now*, which is a
|
|
68
|
+
# different set from what was published while the window was open.
|
|
69
|
+
# : (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
70
|
+
def self.analyze_scope: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
38
71
|
|
|
39
72
|
# Returns the association macros this release can normalize.
|
|
40
73
|
# : () -> Array[Symbol]
|
|
@@ -47,6 +80,27 @@ module PaperTrailDiff
|
|
|
47
80
|
# Reports known reconstruction hazards without mutating application state.
|
|
48
81
|
# : (untyped, untyped, ?associations: Array[String | Symbol]) -> DiagnosticReport
|
|
49
82
|
def self.diagnose: (untyped, untyped, ?associations: Array[String | Symbol]) -> DiagnosticReport
|
|
83
|
+
|
|
84
|
+
# Looks inside an attribute the database stores whole, such as a JSON or
|
|
85
|
+
# jsonb column, and reports which keys changed.
|
|
86
|
+
#
|
|
87
|
+
# change = diff.attributes.fetch('config')
|
|
88
|
+
# PaperTrailDiff.nested_changes(change)
|
|
89
|
+
# # => { ['theme'] => <from "dark" to "light">,
|
|
90
|
+
# # ['limits', 'max'] => <from 10 to 20> }
|
|
91
|
+
#
|
|
92
|
+
# Accepts the `ValueChange` an attribute diff already produced, or a bare
|
|
93
|
+
# pair. Returns an empty hash when the pair is not two readable structures,
|
|
94
|
+
# which is the honest answer: a column that held text on one side and JSON
|
|
95
|
+
# on the other changed wholesale, and the caller still has that change.
|
|
96
|
+
#
|
|
97
|
+
# Paths are arrays because a JSON key may contain a dot. Arrays are reported
|
|
98
|
+
# whole rather than by index, since their elements carry no identity and a
|
|
99
|
+
# list that merely shifted would otherwise look changed throughout. A key
|
|
100
|
+
# that was absent reads as `NestedComparator::ABSENT` rather than nil, which
|
|
101
|
+
# JSON uses for a present null.
|
|
102
|
+
# : (untyped, ?untyped) -> nested_changes
|
|
103
|
+
def self.nested_changes: (untyped, ?untyped) -> nested_changes
|
|
50
104
|
end
|
|
51
105
|
|
|
52
106
|
module PaperTrailDiff
|
|
@@ -60,6 +114,10 @@ module PaperTrailDiff
|
|
|
60
114
|
|
|
61
115
|
type association_diffs = Hash[String, association_diff]
|
|
62
116
|
|
|
117
|
+
type nested_change = ValueChange | ArrayChange
|
|
118
|
+
|
|
119
|
+
type nested_changes = Hash[Array[String], nested_change]
|
|
120
|
+
|
|
63
121
|
type association_snapshots = Hash[String, AssociationSnapshot]
|
|
64
122
|
|
|
65
123
|
type identity = Array[untyped]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: paper_trail_diff
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Williams
|
|
@@ -55,6 +55,7 @@ files:
|
|
|
55
55
|
- lib/paper_trail_diff/activity_event_record_resolver.rb
|
|
56
56
|
- lib/paper_trail_diff/activity_event_route_finder.rb
|
|
57
57
|
- lib/paper_trail_diff/activity_event_snapshot_refresher.rb
|
|
58
|
+
- lib/paper_trail_diff/activity_grouping.rb
|
|
58
59
|
- lib/paper_trail_diff/activity_history.rb
|
|
59
60
|
- lib/paper_trail_diff/activity_range.rb
|
|
60
61
|
- lib/paper_trail_diff/activity_relationship.rb
|
|
@@ -63,6 +64,7 @@ files:
|
|
|
63
64
|
- lib/paper_trail_diff/activity_snapshot_delta.rb
|
|
64
65
|
- lib/paper_trail_diff/activity_snapshot_sequence.rb
|
|
65
66
|
- lib/paper_trail_diff/activity_timeline_builder.rb
|
|
67
|
+
- lib/paper_trail_diff/activity_transaction_grouper.rb
|
|
66
68
|
- lib/paper_trail_diff/activity_version_collector.rb
|
|
67
69
|
- lib/paper_trail_diff/analysis.rb
|
|
68
70
|
- lib/paper_trail_diff/analysis_batch.rb
|
|
@@ -89,6 +91,7 @@ files:
|
|
|
89
91
|
- lib/paper_trail_diff/live_endpoint_batch_loader.rb
|
|
90
92
|
- lib/paper_trail_diff/live_endpoint_provider.rb
|
|
91
93
|
- lib/paper_trail_diff/live_graph_collector.rb
|
|
94
|
+
- lib/paper_trail_diff/nested_comparator.rb
|
|
92
95
|
- lib/paper_trail_diff/paper_trail_adapter.rb
|
|
93
96
|
- lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb
|
|
94
97
|
- lib/paper_trail_diff/prepared_association_reifier.rb
|
|
@@ -98,6 +101,8 @@ files:
|
|
|
98
101
|
- lib/paper_trail_diff/prepared_record_index.rb
|
|
99
102
|
- lib/paper_trail_diff/root_version_plan.rb
|
|
100
103
|
- lib/paper_trail_diff/root_version_selection.rb
|
|
104
|
+
- lib/paper_trail_diff/scoped_analysis.rb
|
|
105
|
+
- lib/paper_trail_diff/scoped_root_selection.rb
|
|
101
106
|
- lib/paper_trail_diff/snapshot.rb
|
|
102
107
|
- lib/paper_trail_diff/snapshot_normalizer.rb
|
|
103
108
|
- lib/paper_trail_diff/snapshot_traversal.rb
|
|
@@ -132,6 +137,7 @@ files:
|
|
|
132
137
|
- sig/generated/paper_trail_diff/activity_event_record_resolver.rbs
|
|
133
138
|
- sig/generated/paper_trail_diff/activity_event_route_finder.rbs
|
|
134
139
|
- sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs
|
|
140
|
+
- sig/generated/paper_trail_diff/activity_grouping.rbs
|
|
135
141
|
- sig/generated/paper_trail_diff/activity_history.rbs
|
|
136
142
|
- sig/generated/paper_trail_diff/activity_range.rbs
|
|
137
143
|
- sig/generated/paper_trail_diff/activity_relationship.rbs
|
|
@@ -140,6 +146,7 @@ files:
|
|
|
140
146
|
- sig/generated/paper_trail_diff/activity_snapshot_delta.rbs
|
|
141
147
|
- sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs
|
|
142
148
|
- sig/generated/paper_trail_diff/activity_timeline_builder.rbs
|
|
149
|
+
- sig/generated/paper_trail_diff/activity_transaction_grouper.rbs
|
|
143
150
|
- sig/generated/paper_trail_diff/activity_version_collector.rbs
|
|
144
151
|
- sig/generated/paper_trail_diff/analysis.rbs
|
|
145
152
|
- sig/generated/paper_trail_diff/analysis_batch.rbs
|
|
@@ -166,6 +173,7 @@ files:
|
|
|
166
173
|
- sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs
|
|
167
174
|
- sig/generated/paper_trail_diff/live_endpoint_provider.rbs
|
|
168
175
|
- sig/generated/paper_trail_diff/live_graph_collector.rbs
|
|
176
|
+
- sig/generated/paper_trail_diff/nested_comparator.rbs
|
|
169
177
|
- sig/generated/paper_trail_diff/paper_trail_adapter.rbs
|
|
170
178
|
- sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs
|
|
171
179
|
- sig/generated/paper_trail_diff/prepared_association_reifier.rbs
|
|
@@ -175,6 +183,8 @@ files:
|
|
|
175
183
|
- sig/generated/paper_trail_diff/prepared_record_index.rbs
|
|
176
184
|
- sig/generated/paper_trail_diff/root_version_plan.rbs
|
|
177
185
|
- sig/generated/paper_trail_diff/root_version_selection.rbs
|
|
186
|
+
- sig/generated/paper_trail_diff/scoped_analysis.rbs
|
|
187
|
+
- sig/generated/paper_trail_diff/scoped_root_selection.rbs
|
|
178
188
|
- sig/generated/paper_trail_diff/snapshot.rbs
|
|
179
189
|
- sig/generated/paper_trail_diff/snapshot_normalizer.rbs
|
|
180
190
|
- sig/generated/paper_trail_diff/snapshot_traversal.rbs
|
|
@@ -202,11 +212,11 @@ licenses:
|
|
|
202
212
|
metadata:
|
|
203
213
|
allowed_push_host: https://rubygems.org
|
|
204
214
|
bug_tracker_uri: https://github.com/aheathwilliams/paper_trail_diff/issues
|
|
205
|
-
changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.
|
|
206
|
-
documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.
|
|
215
|
+
changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.12.0/CHANGELOG.md
|
|
216
|
+
documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.12.0/README.md
|
|
207
217
|
homepage_uri: https://github.com/aheathwilliams/paper_trail_diff
|
|
208
218
|
rubygems_mfa_required: 'true'
|
|
209
|
-
source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.
|
|
219
|
+
source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.12.0
|
|
210
220
|
rdoc_options: []
|
|
211
221
|
require_paths:
|
|
212
222
|
- lib
|