paper_trail_diff 0.12.0 → 0.13.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 +127 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1388
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +146 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +181 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +39 -15
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +25 -2
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +9 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +22 -2
- data/lib/paper_trail_diff/time_version_range.rb +1 -15
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +25 -43
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +33 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +78 -42
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +31 -25
- metadata +31 -5
|
@@ -4,18 +4,21 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Chooses explicit-version or wall-clock selection for one timeline request.
|
|
6
6
|
class TimelineRange
|
|
7
|
-
BOUNDARY_SYMBOLS = %i[first
|
|
7
|
+
BOUNDARY_SYMBOLS = %i[first].freeze
|
|
8
|
+
# `:last` once named the last version, whose recorded state is the one
|
|
9
|
+
# *before* the latest change, so a range ending there silently dropped it.
|
|
10
|
+
LAST_REMOVED = '`:last` was removed because it left out the latest change: omit `to:` ' \
|
|
11
|
+
'to end at current state, or pass a version'
|
|
8
12
|
|
|
9
13
|
attr_reader :from #: untyped
|
|
10
14
|
attr_reader :to #: untyped
|
|
11
15
|
attr_reader :time_range #: TimeRange?
|
|
12
16
|
|
|
13
|
-
#: (untyped, from: untyped, to: untyped, within: untyped, ?
|
|
14
|
-
def initialize(record, from:, to:, within:,
|
|
17
|
+
#: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
|
|
18
|
+
def initialize(record, from:, to:, within:, version_scope: nil, plan: nil, live_endpoint: nil) # rubocop:disable Metrics/ParameterLists
|
|
15
19
|
@record = record
|
|
16
20
|
@plan = plan
|
|
17
21
|
@live_endpoint = live_endpoint
|
|
18
|
-
@versions = (plan ? plan.versions : versions)&.freeze
|
|
19
22
|
@version_scope = version_scope
|
|
20
23
|
@requested_from = from
|
|
21
24
|
@requested_to = to
|
|
@@ -26,7 +29,7 @@ module PaperTrailDiff
|
|
|
26
29
|
freeze
|
|
27
30
|
end
|
|
28
31
|
|
|
29
|
-
# A record with no versions has no first
|
|
32
|
+
# A record with no versions has no first boundary to resolve, which
|
|
30
33
|
# is an empty history rather than a bad request.
|
|
31
34
|
#: () -> bool
|
|
32
35
|
def unresolved?
|
|
@@ -49,27 +52,7 @@ module PaperTrailDiff
|
|
|
49
52
|
live_endpoint: @live_endpoint
|
|
50
53
|
).select_plan(context_required: context_required)
|
|
51
54
|
end
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
VersionRange.new(
|
|
55
|
-
@record, from: @from, to: @to, version_scope: @version_scope
|
|
56
|
-
).select_plan_for_range
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
#: (?context_required: bool) -> Array[untyped]
|
|
60
|
-
def select(context_required: false)
|
|
61
|
-
preselected = @versions
|
|
62
|
-
return preselected if preselected
|
|
63
|
-
|
|
64
|
-
range = time_range
|
|
65
|
-
if range
|
|
66
|
-
return TimeVersionRange.new(
|
|
67
|
-
@record, time_range: range, version_scope: @version_scope
|
|
68
|
-
).select(context_required: context_required)
|
|
69
|
-
end
|
|
70
|
-
return empty_versions if unresolved?
|
|
71
|
-
|
|
72
|
-
VersionRange.new(@record, from: @from, to: @to, version_scope: @version_scope).select
|
|
55
|
+
explicit_plan
|
|
73
56
|
end
|
|
74
57
|
|
|
75
58
|
#: () -> bool
|
|
@@ -91,7 +74,6 @@ module PaperTrailDiff
|
|
|
91
74
|
private
|
|
92
75
|
|
|
93
76
|
# @rbs @record: untyped
|
|
94
|
-
# @rbs @versions: Array[untyped]?
|
|
95
77
|
# @rbs @plan: RootVersionPlan?
|
|
96
78
|
# @rbs @version_scope: untyped
|
|
97
79
|
# @rbs @live_endpoint: untyped
|
|
@@ -100,10 +82,17 @@ module PaperTrailDiff
|
|
|
100
82
|
# @rbs @from: untyped
|
|
101
83
|
# @rbs @to: untyped
|
|
102
84
|
|
|
103
|
-
#: () ->
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
85
|
+
#: () -> RootVersionPlan
|
|
86
|
+
def explicit_plan
|
|
87
|
+
return RootVersionPlan.empty if unresolved?
|
|
88
|
+
if Endpoint.record?(@to)
|
|
89
|
+
return VersionRange.new(@record, from: @from, to: @from, version_scope: @version_scope)
|
|
90
|
+
.select_plan_through_current(@to)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
VersionRange.new(
|
|
94
|
+
@record, from: @from, to: @to, version_scope: @version_scope
|
|
95
|
+
).select_plan_for_range
|
|
107
96
|
end
|
|
108
97
|
|
|
109
98
|
#: (untyped) -> bool
|
|
@@ -111,32 +100,25 @@ module PaperTrailDiff
|
|
|
111
100
|
boundary.is_a?(Symbol)
|
|
112
101
|
end
|
|
113
102
|
|
|
114
|
-
# Resolving `:first`
|
|
103
|
+
# Resolving `:first` here keeps callers from depending on the
|
|
115
104
|
# order PaperTrail happens to give its versions association, which a caller
|
|
116
105
|
# is also free to reorder.
|
|
117
106
|
#: (untyped) -> untyped
|
|
118
107
|
def resolve(boundary)
|
|
119
108
|
return boundary unless symbolic?(boundary)
|
|
109
|
+
raise InvalidTimelineRangeError, LAST_REMOVED if boundary == :last
|
|
120
110
|
|
|
121
111
|
unless BOUNDARY_SYMBOLS.include?(boundary)
|
|
122
112
|
raise InvalidTimelineRangeError,
|
|
123
|
-
"unsupported boundary: #{boundary.inspect}; use :first,
|
|
113
|
+
"unsupported boundary: #{boundary.inspect}; use :first, a version, or a record"
|
|
124
114
|
end
|
|
125
115
|
|
|
126
|
-
|
|
116
|
+
ordered_versions.first
|
|
127
117
|
end
|
|
128
118
|
|
|
129
119
|
#: () -> untyped
|
|
130
120
|
def ordered_versions
|
|
131
|
-
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
#: () -> untyped
|
|
135
|
-
def versions_relation
|
|
136
|
-
@record.public_send(@record.class.versions_association_name)
|
|
137
|
-
rescue NoMethodError => e
|
|
138
|
-
message = 'record does not expose a PaperTrail version history'
|
|
139
|
-
raise InvalidTimelineRangeError, message, cause: e
|
|
121
|
+
Endpoint.versions(@record).reorder(created_at: :asc, id: :asc)
|
|
140
122
|
end
|
|
141
123
|
|
|
142
124
|
#: (untyped) -> TimeRange?
|
|
@@ -8,10 +8,15 @@ module PaperTrailDiff
|
|
|
8
8
|
def initialize(store, live_snapshotter:)
|
|
9
9
|
@store = store
|
|
10
10
|
@live_snapshotter = live_snapshotter
|
|
11
|
+
@snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
#: (untyped, Array[untyped]) -> void
|
|
14
15
|
def prepare(record, versions)
|
|
16
|
+
# Adjacent steps share endpoints. Keep their immutable snapshots for this
|
|
17
|
+
# root only, including nil create states, and release them before the next
|
|
18
|
+
# root so batch memory does not retain every reconstructed graph.
|
|
19
|
+
@snapshots.clear
|
|
15
20
|
@store.prepare(record, versions)
|
|
16
21
|
end
|
|
17
22
|
|
|
@@ -19,9 +24,14 @@ module PaperTrailDiff
|
|
|
19
24
|
# version, which is the one endpoint no version history can reconstruct.
|
|
20
25
|
#: (untyped) -> RecordSnapshot?
|
|
21
26
|
def call(endpoint)
|
|
22
|
-
|
|
27
|
+
key = [endpoint.class.name, endpoint.id]
|
|
28
|
+
return @snapshots[key] if @snapshots.key?(key)
|
|
23
29
|
|
|
24
|
-
@
|
|
30
|
+
@snapshots[key] = if Endpoint.record?(endpoint)
|
|
31
|
+
live_snapshot(endpoint)
|
|
32
|
+
else
|
|
33
|
+
@store.uncached(endpoint, endpoint)
|
|
34
|
+
end
|
|
25
35
|
end
|
|
26
36
|
|
|
27
37
|
private
|
|
@@ -33,5 +43,6 @@ module PaperTrailDiff
|
|
|
33
43
|
|
|
34
44
|
# @rbs @store: HistoricalSnapshotStore
|
|
35
45
|
# @rbs @live_snapshotter: untyped
|
|
46
|
+
# @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
|
|
36
47
|
end
|
|
37
48
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Places one version relative to another on the recorded timeline.
|
|
6
|
+
module VersionBoundary
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Whether `candidate` was recorded at or after `boundary`. Versions sharing
|
|
10
|
+
# a timestamp are ordered by id, which reflects insertion order only for
|
|
11
|
+
# sequential ids in one version table; any other tie cannot be ordered, and
|
|
12
|
+
# guessing would place a change on an arbitrary side of the boundary. A
|
|
13
|
+
# bare instant has no position among same-time versions, so it keeps them.
|
|
14
|
+
#: (untyped, untyped) -> bool
|
|
15
|
+
def at_or_after?(candidate, boundary)
|
|
16
|
+
tied = candidate.created_at == boundary.created_at
|
|
17
|
+
return candidate.created_at > boundary.created_at unless tied
|
|
18
|
+
return true unless version_record?(boundary)
|
|
19
|
+
return true if same_version?(candidate, boundary)
|
|
20
|
+
|
|
21
|
+
unless orderable_tie?(candidate, boundary)
|
|
22
|
+
raise AmbiguousVersionOrderError, Support.ambiguous_message([boundary, candidate])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Support.compare_versions(candidate, boundary) >= 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: (untyped) -> bool
|
|
29
|
+
def version_record?(value)
|
|
30
|
+
value.respond_to?(:id) && value.respond_to?(:item_type) &&
|
|
31
|
+
value.class.respond_to?(:table_name)
|
|
32
|
+
end
|
|
33
|
+
private_class_method :version_record?
|
|
34
|
+
|
|
35
|
+
#: (untyped, untyped) -> bool
|
|
36
|
+
def same_version?(left, right)
|
|
37
|
+
left.class.table_name == right.class.table_name && left.id.to_s == right.id.to_s
|
|
38
|
+
end
|
|
39
|
+
private_class_method :same_version?
|
|
40
|
+
|
|
41
|
+
#: (untyped, untyped) -> bool
|
|
42
|
+
def orderable_tie?(left, right)
|
|
43
|
+
left.class.table_name == right.class.table_name &&
|
|
44
|
+
Support.sequential_id?(left.id) && Support.sequential_id?(right.id)
|
|
45
|
+
end
|
|
46
|
+
private_class_method :orderable_tie?
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Arel conditions shared by the version queries that need one row per item.
|
|
6
|
+
module VersionQuery
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Keeps each item's earliest version among those the block also accepts:
|
|
10
|
+
# no other version of the same item that the block accepts is earlier. A
|
|
11
|
+
# correlated NOT EXISTS stays portable where window functions are not.
|
|
12
|
+
#: (untyped) ?{ (untyped) -> untyped } -> untyped
|
|
13
|
+
def no_earlier_version(table, &block)
|
|
14
|
+
arel = Object.const_get(:Arel) #: untyped
|
|
15
|
+
earlier = table.alias('paper_trail_diff_earlier_versions')
|
|
16
|
+
arel.const_get(:SelectManager).new
|
|
17
|
+
.from(earlier)
|
|
18
|
+
.project(arel.sql('1'))
|
|
19
|
+
.where(earlier_version(table, earlier, &block))
|
|
20
|
+
.exists
|
|
21
|
+
.not
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
#: (untyped, untyped) ?{ (untyped) -> untyped } -> untyped
|
|
25
|
+
def earlier_version(table, earlier)
|
|
26
|
+
same_item = earlier[:item_type].eq(table[:item_type])
|
|
27
|
+
.and(earlier[:item_id].eq(table[:item_id]))
|
|
28
|
+
.and(earlier[:created_at].lt(table[:created_at]))
|
|
29
|
+
block_given? ? same_item.and(yield(earlier)) : same_item
|
|
30
|
+
end
|
|
31
|
+
private_class_method :earlier_version
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -12,7 +12,7 @@ module PaperTrailDiff
|
|
|
12
12
|
@version_scope = version_scope
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
#
|
|
15
|
+
# Keep both reconstruction boundaries and the step pairs a filter implies.
|
|
16
16
|
#: () -> RootVersionPlan
|
|
17
17
|
def select_plan_for_range
|
|
18
18
|
relation = validated_relation
|
|
@@ -24,7 +24,7 @@ module PaperTrailDiff
|
|
|
24
24
|
|
|
25
25
|
#: () -> untyped
|
|
26
26
|
def validated_relation
|
|
27
|
-
relation =
|
|
27
|
+
relation = Endpoint.versions(@record)
|
|
28
28
|
validate_boundary!(@from, relation, boundary: :from)
|
|
29
29
|
validate_boundary!(@to, relation, boundary: :to)
|
|
30
30
|
if Support.compare_versions(@from, @to).positive?
|
|
@@ -34,26 +34,12 @@ module PaperTrailDiff
|
|
|
34
34
|
relation
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
#: () ->
|
|
38
|
-
def
|
|
39
|
-
|
|
37
|
+
#: (untyped) -> RootVersionPlan
|
|
38
|
+
def select_plan_through_current(current)
|
|
39
|
+
Endpoint.validate_pair!(@record, current)
|
|
40
|
+
relation = Endpoint.versions(@record)
|
|
40
41
|
validate_boundary!(@from, relation, boundary: :from)
|
|
41
|
-
|
|
42
|
-
if Support.compare_versions(@from, @to).positive?
|
|
43
|
-
raise InvalidTimelineRangeError, '`from` version must not follow `to` version'
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
select_relation(
|
|
47
|
-
relation.where(created_at: @from.created_at..@to.created_at),
|
|
48
|
-
through: @to
|
|
49
|
-
)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
#: () -> Array[untyped]
|
|
53
|
-
def select_through_latest
|
|
54
|
-
relation = versions_relation
|
|
55
|
-
validate_boundary!(@from, relation, boundary: :from)
|
|
56
|
-
select_relation(relation.where(created_at: @from.created_at..))
|
|
42
|
+
select_plan(relation.where(created_at: @from.created_at..), live_endpoint: current)
|
|
57
43
|
end
|
|
58
44
|
|
|
59
45
|
private
|
|
@@ -63,22 +49,8 @@ module PaperTrailDiff
|
|
|
63
49
|
# @rbs @to: untyped
|
|
64
50
|
# @rbs @version_scope: untyped
|
|
65
51
|
|
|
66
|
-
#: () ->
|
|
67
|
-
def
|
|
68
|
-
association_name = @record.class.versions_association_name
|
|
69
|
-
@record.public_send(association_name)
|
|
70
|
-
rescue NoMethodError => e
|
|
71
|
-
message = 'record does not expose a PaperTrail version history'
|
|
72
|
-
raise InvalidTimelineRangeError, message, cause: e
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
#: (untyped, ?through: untyped) -> Array[untyped]
|
|
76
|
-
def select_relation(relation, through: nil)
|
|
77
|
-
select_plan(relation, through: through).versions
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
#: (untyped, ?through: untyped) -> RootVersionPlan
|
|
81
|
-
def select_plan(relation, through: nil)
|
|
52
|
+
#: (untyped, ?through: untyped, ?live_endpoint: untyped) -> RootVersionPlan
|
|
53
|
+
def select_plan(relation, through: nil, live_endpoint: nil)
|
|
82
54
|
in_range = ordered(relation.to_a.select do |version|
|
|
83
55
|
next false if Support.compare_versions(@from, version).positive?
|
|
84
56
|
next true unless through
|
|
@@ -90,7 +62,7 @@ module PaperTrailDiff
|
|
|
90
62
|
selected: VersionScopeFilter.new(@version_scope).call(relation, in_range),
|
|
91
63
|
after_range: nil,
|
|
92
64
|
windowed: false,
|
|
93
|
-
filtered: !@version_scope.nil
|
|
65
|
+
filtered: !@version_scope.nil?, live_endpoint: live_endpoint
|
|
94
66
|
).call
|
|
95
67
|
end
|
|
96
68
|
|
data/lib/paper_trail_diff.rb
CHANGED
|
@@ -5,11 +5,15 @@ require 'paper_trail'
|
|
|
5
5
|
|
|
6
6
|
require_relative 'paper_trail_diff/version'
|
|
7
7
|
require_relative 'paper_trail_diff/support'
|
|
8
|
+
require_relative 'paper_trail_diff/version_query'
|
|
9
|
+
require_relative 'paper_trail_diff/stored_time_precision'
|
|
10
|
+
require_relative 'paper_trail_diff/version_boundary'
|
|
8
11
|
require_relative 'paper_trail_diff/instrumentation'
|
|
9
12
|
require_relative 'paper_trail_diff/errors'
|
|
10
13
|
require_relative 'paper_trail_diff/configuration'
|
|
11
14
|
require_relative 'paper_trail_diff/endpoint'
|
|
12
15
|
require_relative 'paper_trail_diff/association_traversal'
|
|
16
|
+
require_relative 'paper_trail_diff/batch_safety'
|
|
13
17
|
require_relative 'paper_trail_diff/traversal_preparer'
|
|
14
18
|
require_relative 'paper_trail_diff/association_discovery'
|
|
15
19
|
require_relative 'paper_trail_diff/diagnostics'
|
|
@@ -28,6 +32,10 @@ require_relative 'paper_trail_diff/nested_comparator'
|
|
|
28
32
|
require_relative 'paper_trail_diff/engine'
|
|
29
33
|
require_relative 'paper_trail_diff/historical_association_reifier'
|
|
30
34
|
require_relative 'paper_trail_diff/prepared_record_index'
|
|
35
|
+
require_relative 'paper_trail_diff/direct_record_state_loader'
|
|
36
|
+
require_relative 'paper_trail_diff/prepared_state_association_reader'
|
|
37
|
+
require_relative 'paper_trail_diff/prepared_snapshot_builder'
|
|
38
|
+
require_relative 'paper_trail_diff/prepared_activity_index'
|
|
31
39
|
require_relative 'paper_trail_diff/prepared_history'
|
|
32
40
|
require_relative 'paper_trail_diff/version_association_candidate_scope'
|
|
33
41
|
require_relative 'paper_trail_diff/prepared_edge_loader'
|
|
@@ -66,8 +74,11 @@ require_relative 'paper_trail_diff/step'
|
|
|
66
74
|
require_relative 'paper_trail_diff/analysis'
|
|
67
75
|
require_relative 'paper_trail_diff/activity_root_steps'
|
|
68
76
|
require_relative 'paper_trail_diff/analysis_batch'
|
|
77
|
+
require_relative 'paper_trail_diff/deleted_analysis_batch'
|
|
69
78
|
require_relative 'paper_trail_diff/scoped_analysis'
|
|
79
|
+
require_relative 'paper_trail_diff/historical_filter'
|
|
70
80
|
require_relative 'paper_trail_diff/scoped_root_selection'
|
|
81
|
+
require_relative 'paper_trail_diff/scoped_analysis_builder'
|
|
71
82
|
require_relative 'paper_trail_diff/batched_root_analyzer'
|
|
72
83
|
require_relative 'paper_trail_diff/version_range'
|
|
73
84
|
require_relative 'paper_trail_diff/version_sequence_diagnostics'
|
|
@@ -98,6 +109,10 @@ require_relative 'paper_trail_diff/paper_trail_adapter'
|
|
|
98
109
|
# type identity = Array[untyped]
|
|
99
110
|
# type comparison_input = Hash[String | Symbol, untyped]
|
|
100
111
|
# type comparison_results = Hash[identity, Diff]
|
|
112
|
+
# type report_options = {
|
|
113
|
+
# within: untyped, activity: bool, version_scope: untyped,
|
|
114
|
+
# close_on: Symbol?, snapshots: bool, group: Symbol?
|
|
115
|
+
# }
|
|
101
116
|
# type ignore_option = Array[String | Symbol] | Hash[String | Symbol, untyped]
|
|
102
117
|
# type reference_key = :type | :id | "type" | "id"
|
|
103
118
|
# type traversal_context = :change | :included_state
|
|
@@ -117,7 +132,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
117
132
|
has_and_belongs_to_many
|
|
118
133
|
].freeze
|
|
119
134
|
|
|
120
|
-
class << self
|
|
135
|
+
class << self # rubocop:disable Metrics/ClassLength
|
|
121
136
|
# Compares net state between explicit PaperTrail-version or current-record endpoints.
|
|
122
137
|
#: (untyped, untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> Diff
|
|
123
138
|
def compare(
|
|
@@ -138,21 +153,23 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
138
153
|
end
|
|
139
154
|
|
|
140
155
|
# Compares many independent endpoint pairs while batching current-record loading.
|
|
141
|
-
#: (Array[comparison_input], ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> comparison_results
|
|
156
|
+
#: (Array[comparison_input], ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?allow_expensive: bool) -> comparison_results
|
|
142
157
|
def compare_many(
|
|
143
158
|
comparisons,
|
|
144
159
|
associations: [],
|
|
145
160
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
146
|
-
reload_live_endpoints: true
|
|
161
|
+
reload_live_endpoints: true,
|
|
162
|
+
allow_expensive: false
|
|
147
163
|
)
|
|
148
164
|
PaperTrailAdapter.new(
|
|
149
165
|
associations: associations,
|
|
150
166
|
ignore: ignore,
|
|
151
|
-
reload_live_endpoints: reload_live_endpoints
|
|
167
|
+
reload_live_endpoints: reload_live_endpoints, allow_expensive: allow_expensive
|
|
152
168
|
).compare_many(comparisons)
|
|
153
169
|
end
|
|
154
170
|
|
|
155
|
-
# Compares every adjacent reconstructed state in an inclusive version range
|
|
171
|
+
# Compares every adjacent reconstructed state in an inclusive version range,
|
|
172
|
+
# by default the record's whole history through its current state.
|
|
156
173
|
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
|
|
157
174
|
def timeline( # rubocop:disable Metrics/ParameterLists
|
|
158
175
|
record,
|
|
@@ -175,9 +192,10 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
175
192
|
end
|
|
176
193
|
|
|
177
194
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
178
|
-
# `reload_live_endpoints:` applies
|
|
179
|
-
#
|
|
180
|
-
|
|
195
|
+
# `reload_live_endpoints:` applies whenever the range ends at the current
|
|
196
|
+
# record: an explicit `to:` record, the default range, or a window with no
|
|
197
|
+
# later version.
|
|
198
|
+
#: (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, ?group: Symbol?) -> Array[ActivityStep]
|
|
181
199
|
def activity_timeline( # rubocop:disable Metrics/ParameterLists
|
|
182
200
|
record,
|
|
183
201
|
from: nil,
|
|
@@ -235,47 +253,36 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
235
253
|
)
|
|
236
254
|
end
|
|
237
255
|
|
|
238
|
-
# Analyzes
|
|
239
|
-
# history once for the batch instead of once per record
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
|
|
244
|
-
# you from a relation, which returns a `ScopedAnalysis` -- the same Hash,
|
|
245
|
-
# plus the roots the relation could not reach. See `analyze_scope` for why
|
|
246
|
-
# that second collection exists.
|
|
247
|
-
#: (?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)
|
|
256
|
+
# Analyzes an array of records over one shared range, preparing their
|
|
257
|
+
# selected history once for the batch instead of once per record, and
|
|
258
|
+
# returns a frozen Hash keyed by identity. Roots with nothing to report in
|
|
259
|
+
# the window return an empty `Analysis`. To let a relation choose the
|
|
260
|
+
# records, use `analyze_scope`.
|
|
261
|
+
#: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?allow_expensive: bool) -> Hash[identity, Analysis]
|
|
248
262
|
def analyze_many( # rubocop:disable Metrics/ParameterLists
|
|
249
|
-
records
|
|
250
|
-
scope: nil,
|
|
251
|
-
limit: nil,
|
|
263
|
+
records,
|
|
252
264
|
within: nil,
|
|
253
265
|
associations: [],
|
|
254
266
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
255
267
|
activity: false,
|
|
256
268
|
version_scope: nil,
|
|
257
|
-
close_on: nil
|
|
269
|
+
close_on: nil,
|
|
270
|
+
snapshots: false,
|
|
271
|
+
group: nil,
|
|
272
|
+
allow_expensive: false
|
|
258
273
|
)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
return adapter.analyze_scope(scope, limit: limit, within: within, activity: activity,
|
|
264
|
-
version_scope: version_scope, close_on: close_on)
|
|
265
|
-
end
|
|
266
|
-
raise ConfigurationError, 'pass records or scope:' if records.nil?
|
|
267
|
-
|
|
268
|
-
adapter.analyze_many(records, within: within, activity: activity,
|
|
269
|
-
version_scope: version_scope, close_on: close_on)
|
|
274
|
+
PaperTrailAdapter.new(
|
|
275
|
+
associations: associations, ignore: ignore, allow_expensive: allow_expensive
|
|
276
|
+
).analyze_many(records, within: within, activity: activity, version_scope: version_scope,
|
|
277
|
+
close_on: close_on, snapshots: snapshots, group: group)
|
|
270
278
|
end
|
|
271
279
|
|
|
272
280
|
# Analyzes every root the relation reaches whose history moved inside the
|
|
273
|
-
# window
|
|
274
|
-
# caller rediscover them.
|
|
281
|
+
# window. Joined duplicates are read in bounded pages and analyzed once.
|
|
275
282
|
#
|
|
276
283
|
# `limit:` is required and exceeding it raises. Selection moves into the gem
|
|
277
|
-
# here
|
|
278
|
-
#
|
|
284
|
+
# here. The relation's own limit/offset select the live page; this separate
|
|
285
|
+
# ceiling rejects too many unique roots rather than truncating that page.
|
|
279
286
|
#
|
|
280
287
|
# Returns a `ScopedAnalysis`, which destructures:
|
|
281
288
|
#
|
|
@@ -284,7 +291,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
284
291
|
# )
|
|
285
292
|
#
|
|
286
293
|
# `unreachable` names roots that changed in the window but have no live row
|
|
287
|
-
# left
|
|
294
|
+
# left, independently of the live page's pagination and safety ceiling.
|
|
295
|
+
# A relation's conditions are evaluated against the live table, so a
|
|
288
296
|
# destroyed root cannot be tested against them at all -- its history is
|
|
289
297
|
# intact and the state it held at destruction may well have matched. Those
|
|
290
298
|
# roots are reported rather than dropped so that a page auditing deletions
|
|
@@ -293,7 +301,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
293
301
|
# Note also that a relation selects on current state, not on state during
|
|
294
302
|
# the window: `where(status: 'published')` means published *now*, which is a
|
|
295
303
|
# different set from what was published while the window was open.
|
|
296
|
-
#: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
304
|
+
#: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool, ?allow_expensive: bool) -> ScopedAnalysis
|
|
297
305
|
def analyze_scope( # rubocop:disable Metrics/ParameterLists
|
|
298
306
|
scope,
|
|
299
307
|
limit:,
|
|
@@ -302,11 +310,39 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
302
310
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
303
311
|
activity: false,
|
|
304
312
|
version_scope: nil,
|
|
305
|
-
close_on: nil
|
|
313
|
+
close_on: nil,
|
|
314
|
+
snapshots: false,
|
|
315
|
+
group: nil,
|
|
316
|
+
historical_filter: nil,
|
|
317
|
+
max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT,
|
|
318
|
+
include_deleted: false,
|
|
319
|
+
allow_expensive: false
|
|
306
320
|
)
|
|
307
|
-
PaperTrailAdapter.new(associations: associations, ignore: ignore
|
|
321
|
+
adapter = PaperTrailAdapter.new(associations: associations, ignore: ignore,
|
|
322
|
+
allow_expensive: allow_expensive)
|
|
323
|
+
adapter.analyze_scope(
|
|
308
324
|
scope, limit: limit, within: within, activity: activity,
|
|
309
|
-
version_scope: version_scope, close_on: close_on
|
|
325
|
+
version_scope: version_scope, close_on: close_on, snapshots: snapshots, group: group,
|
|
326
|
+
historical_filter: historical_filter, max_filter_versions: max_filter_versions,
|
|
327
|
+
include_deleted: include_deleted
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Recent root and selected-child activity through the current saved state.
|
|
332
|
+
# Historical windows remain explicit on analyze_scope; this entry point is
|
|
333
|
+
# deliberately named for a report through now rather than an as-of query.
|
|
334
|
+
#: (untyped, since: untyped, limit: Integer, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?snapshots: bool, ?group: Symbol?, ?include_deleted: bool, ?historical_filter: untyped, ?max_filter_versions: Integer, ?allow_expensive: bool) -> ScopedAnalysis
|
|
335
|
+
def report( # rubocop:disable Metrics/ParameterLists
|
|
336
|
+
scope, since:, limit:, associations: [], ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
337
|
+
snapshots: false, group: nil, include_deleted: false, historical_filter: nil,
|
|
338
|
+
max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, allow_expensive: false
|
|
339
|
+
)
|
|
340
|
+
analyze_scope(
|
|
341
|
+
scope,
|
|
342
|
+
within: since..Time.now.utc, limit: limit, associations: associations,
|
|
343
|
+
ignore: ignore, activity: true, close_on: :current, snapshots: snapshots, group: group,
|
|
344
|
+
include_deleted: include_deleted, historical_filter: historical_filter,
|
|
345
|
+
max_filter_versions: max_filter_versions, allow_expensive: allow_expensive
|
|
310
346
|
)
|
|
311
347
|
end
|
|
312
348
|
|
|
@@ -29,9 +29,6 @@ module PaperTrailDiff
|
|
|
29
29
|
# : (Array[RecordSnapshot], untyped, RecordSnapshot?) -> [Array[RecordSnapshot], bool]
|
|
30
30
|
def replace_existing_target: (Array[RecordSnapshot], untyped, RecordSnapshot?) -> [ Array[RecordSnapshot], bool ]
|
|
31
31
|
|
|
32
|
-
# : (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
|
|
33
|
-
def replace_association: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
|
|
34
|
-
|
|
35
32
|
# : (untyped, RecordSnapshot?) -> String
|
|
36
33
|
def replacement_type: (untyped, RecordSnapshot?) -> String
|
|
37
34
|
|
|
@@ -54,8 +54,8 @@ module PaperTrailDiff
|
|
|
54
54
|
# : () -> bool
|
|
55
55
|
def destroyed?: () -> bool
|
|
56
56
|
|
|
57
|
-
# : () -> Hash[Symbol, untyped]
|
|
58
|
-
def to_h: () -> Hash[Symbol, untyped]
|
|
57
|
+
# : (?metadata: bool) -> Hash[Symbol, untyped]
|
|
58
|
+
def to_h: (?metadata: bool) -> Hash[Symbol, untyped]
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
# One adjacent transition in a descendant-aware activity timeline.
|
|
@@ -87,7 +87,12 @@ module PaperTrailDiff
|
|
|
87
87
|
# : () -> bool
|
|
88
88
|
def empty?: () -> bool
|
|
89
89
|
|
|
90
|
-
#
|
|
91
|
-
|
|
90
|
+
# The event whose change this step reports. For a transaction group, this
|
|
91
|
+
# is the first event in the group, not necessarily its only actor or record.
|
|
92
|
+
# : () -> ActivityBoundary
|
|
93
|
+
def source_boundary: () -> ActivityBoundary
|
|
94
|
+
|
|
95
|
+
# : (?metadata: bool, ?snapshots: bool) -> Hash[Symbol, untyped]
|
|
96
|
+
def to_h: (?metadata: bool, ?snapshots: bool) -> Hash[Symbol, untyped]
|
|
92
97
|
end
|
|
93
98
|
end
|
|
@@ -9,6 +9,14 @@ module PaperTrailDiff
|
|
|
9
9
|
# : () -> Array[untyped]
|
|
10
10
|
def call: () -> Array[untyped]
|
|
11
11
|
|
|
12
|
+
# One shared query retains the owner and timestamp needed to partition a
|
|
13
|
+
# batch's candidates back into each root's exact activity range.
|
|
14
|
+
# : () -> Array[Array[untyped]]
|
|
15
|
+
def historical_rows: () -> Array[Array[untyped]]
|
|
16
|
+
|
|
17
|
+
# : () -> Array[Array[untyped]]
|
|
18
|
+
def current_pairs: () -> Array[Array[untyped]]
|
|
19
|
+
|
|
12
20
|
private
|
|
13
21
|
|
|
14
22
|
@parent_class: untyped
|
|
@@ -19,6 +27,9 @@ module PaperTrailDiff
|
|
|
19
27
|
|
|
20
28
|
@reflection: untyped
|
|
21
29
|
|
|
30
|
+
# : (Array[Array[untyped]]) -> Array[Array[untyped]]
|
|
31
|
+
def typed_rows: (Array[Array[untyped]]) -> Array[Array[untyped]]
|
|
32
|
+
|
|
22
33
|
# : () -> Array[untyped]
|
|
23
34
|
def historical_ids: () -> Array[untyped]
|
|
24
35
|
|