paper_trail_diff 0.2.0 → 0.3.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 +47 -0
- data/QUICKSTART.md +44 -2
- data/README.md +179 -14
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +261 -45
- data/lib/paper_trail_diff/activity_history.rb +136 -0
- data/lib/paper_trail_diff/activity_range.rb +28 -12
- data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
- data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
- data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +5 -3
- data/lib/paper_trail_diff/collection_comparator.rb +155 -0
- data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
- data/lib/paper_trail_diff/collection_transition.rb +25 -0
- data/lib/paper_trail_diff/comparison_batch.rb +151 -0
- data/lib/paper_trail_diff/engine.rb +5 -45
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
- data/lib/paper_trail_diff/instrumentation.rb +30 -0
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
- data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
- data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
- data/lib/paper_trail_diff/prepared_history.rb +14 -4
- data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
- data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
- data/lib/paper_trail_diff/snapshot.rb +66 -3
- data/lib/paper_trail_diff/support.rb +18 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
- data/lib/paper_trail_diff/time_range.rb +85 -0
- data/lib/paper_trail_diff/time_version_range.rb +53 -0
- data/lib/paper_trail_diff/timeline_builder.rb +13 -7
- data/lib/paper_trail_diff/timeline_range.rb +72 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
- data/lib/paper_trail_diff.rb +74 -12
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +49 -8
- data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
- data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
- data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
- data/sig/generated/paper_trail_diff/engine.rbs +0 -12
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
- data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
- data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
- data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
- data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
- data/sig/generated/paper_trail_diff/support.rbs +6 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
- data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
- data/sig/generated/paper_trail_diff.rbs +16 -8
- metadata +38 -4
|
@@ -42,6 +42,21 @@ module PaperTrailDiff
|
|
|
42
42
|
SUPPORTED_KINDS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
|
|
43
43
|
private_constant :COLLECTION_KINDS, :SUPPORTED_KINDS
|
|
44
44
|
|
|
45
|
+
# Non-semantic memoization shared by structurally adjacent snapshots. The
|
|
46
|
+
# cached value is derived only from frozen records and is itself immutable.
|
|
47
|
+
class IdentityIndexCache
|
|
48
|
+
#: () -> void
|
|
49
|
+
def initialize
|
|
50
|
+
@index = nil #: CollectionIdentityIndex?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
#: (Array[RecordSnapshot]) -> CollectionIdentityIndex
|
|
54
|
+
def index(records)
|
|
55
|
+
@index ||= CollectionIdentityIndex.new(records)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
private_constant :IdentityIndexCache
|
|
59
|
+
|
|
45
60
|
class << self
|
|
46
61
|
#: (Symbol) -> bool
|
|
47
62
|
def collection_kind?(kind)
|
|
@@ -52,8 +67,8 @@ module PaperTrailDiff
|
|
|
52
67
|
attr_reader :kind #: Symbol
|
|
53
68
|
attr_reader :records #: Array[RecordSnapshot]
|
|
54
69
|
|
|
55
|
-
#: (kind: Symbol, records: Array[RecordSnapshot]) -> void
|
|
56
|
-
def initialize(kind:, records:)
|
|
70
|
+
#: (kind: Symbol, records: Array[RecordSnapshot], ?identity_index_cache: untyped, ?transition: CollectionTransition?) -> void
|
|
71
|
+
def initialize(kind:, records:, identity_index_cache: nil, transition: nil)
|
|
57
72
|
unless SUPPORTED_KINDS.include?(kind)
|
|
58
73
|
raise ArgumentError, "unsupported association kind: #{kind.inspect}"
|
|
59
74
|
end
|
|
@@ -62,14 +77,62 @@ module PaperTrailDiff
|
|
|
62
77
|
end
|
|
63
78
|
|
|
64
79
|
@kind = kind
|
|
65
|
-
@records = records.dup.freeze
|
|
80
|
+
@records = records.frozen? ? records : records.dup.freeze
|
|
81
|
+
@identity_index_cache = identity_index_cache || IdentityIndexCache.new
|
|
82
|
+
@transition = transition
|
|
66
83
|
freeze
|
|
67
84
|
end
|
|
68
85
|
|
|
86
|
+
# Builds a structurally adjacent collection snapshot for internal activity carry-forward.
|
|
87
|
+
#: (Array[RecordSnapshot], before: RecordSnapshot?, after: RecordSnapshot?, membership_preserved: bool) -> AssociationSnapshot
|
|
88
|
+
def transition_to(records, before:, after:, membership_preserved:)
|
|
89
|
+
cache = membership_preserved ? @identity_index_cache : nil
|
|
90
|
+
transition = if before || after
|
|
91
|
+
CollectionTransition.new(from: self, before: before, after: after)
|
|
92
|
+
end
|
|
93
|
+
self.class.new(
|
|
94
|
+
kind: kind,
|
|
95
|
+
records: records,
|
|
96
|
+
identity_index_cache: cache,
|
|
97
|
+
transition: transition
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (AssociationSnapshot) -> CollectionTransition?
|
|
102
|
+
def transition_from(association)
|
|
103
|
+
@transition if @transition&.from?(association)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: (untyped, untyped) -> Integer?
|
|
107
|
+
def position(type, id)
|
|
108
|
+
identity_index.position(type, id)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
#: (untyped) -> Integer?
|
|
112
|
+
def position_for_id(id)
|
|
113
|
+
identity_index.position_for_id(id)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
#: () -> void
|
|
117
|
+
def validate_unique_identities!
|
|
118
|
+
identity_index
|
|
119
|
+
nil
|
|
120
|
+
end
|
|
121
|
+
|
|
69
122
|
#: () -> Hash[Symbol, untyped]
|
|
70
123
|
def to_h
|
|
71
124
|
{ kind: kind, records: Support.serialize(records) }
|
|
72
125
|
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
# @rbs @identity_index_cache: untyped
|
|
130
|
+
# @rbs @transition: CollectionTransition?
|
|
131
|
+
|
|
132
|
+
#: () -> CollectionIdentityIndex
|
|
133
|
+
def identity_index
|
|
134
|
+
@identity_index_cache.index(records)
|
|
135
|
+
end
|
|
73
136
|
end
|
|
74
137
|
|
|
75
138
|
# An immutable, ActiveRecord-independent representation of a reified record.
|
|
@@ -62,6 +62,15 @@ module PaperTrailDiff
|
|
|
62
62
|
parent.empty? ? name : "#{parent}.#{name}"
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
#: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
|
|
66
|
+
def merge_record_groups(existing, incoming)
|
|
67
|
+
existing.merge(incoming) do |_name, left, right|
|
|
68
|
+
merged = left.merge(right, ids: (left.fetch(:ids) | right.fetch(:ids)))
|
|
69
|
+
owners = merge_record_owners(left[:owners], right[:owners])
|
|
70
|
+
owners ? merged.merge(owners: owners) : merged
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
65
74
|
#: (untyped) -> untyped
|
|
66
75
|
def duplicate_and_freeze(value)
|
|
67
76
|
value.dup.freeze
|
|
@@ -70,6 +79,15 @@ module PaperTrailDiff
|
|
|
70
79
|
end
|
|
71
80
|
private_class_method :duplicate_and_freeze
|
|
72
81
|
|
|
82
|
+
#: (Hash[String, Array[untyped]]?, Hash[String, Array[untyped]]?) -> Hash[String, Array[untyped]]?
|
|
83
|
+
def merge_record_owners(left, right)
|
|
84
|
+
return right unless left
|
|
85
|
+
return left unless right
|
|
86
|
+
|
|
87
|
+
left.merge(right) { |_owner, first, second| first | second }
|
|
88
|
+
end
|
|
89
|
+
private_class_method :merge_record_owners
|
|
90
|
+
|
|
73
91
|
#: (untyped) -> bool
|
|
74
92
|
def paper_trail_diff_value?(value)
|
|
75
93
|
class_name = value.class.name
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Builds activity views for mutations selected by a wall-clock range.
|
|
6
|
+
class TimeActivityTimelineBuilder
|
|
7
|
+
#: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
|
|
8
|
+
def initialize(record, range:, tree:, snapshotter:)
|
|
9
|
+
@record = record
|
|
10
|
+
@range = range
|
|
11
|
+
@tree = tree
|
|
12
|
+
@snapshotter = snapshotter
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
#: () -> Array[ActivityStep]
|
|
16
|
+
def build
|
|
17
|
+
history, = history_and_versions
|
|
18
|
+
history.steps
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: () -> Analysis
|
|
22
|
+
def analyze
|
|
23
|
+
history, root_versions = history_and_versions
|
|
24
|
+
Analysis.new(
|
|
25
|
+
diff: Engine.compare(history.first_snapshot, history.last_snapshot),
|
|
26
|
+
timeline: root_steps(root_versions, history.root_snapshots),
|
|
27
|
+
activity_timeline: history.steps
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# @rbs @record: untyped
|
|
34
|
+
# @rbs @range: TimelineRange
|
|
35
|
+
# @rbs @tree: AssociationTree
|
|
36
|
+
# @rbs @snapshotter: untyped
|
|
37
|
+
|
|
38
|
+
#: () -> [ActivityHistory, Array[untyped]]
|
|
39
|
+
def history_and_versions
|
|
40
|
+
root_versions = @range.select(context_required: !@tree.empty?)
|
|
41
|
+
return [ActivityHistory.empty, root_versions] if root_versions.empty?
|
|
42
|
+
|
|
43
|
+
prepare_history(root_versions)
|
|
44
|
+
events = collect_events(root_versions)
|
|
45
|
+
return [ActivityHistory.empty, root_versions] unless time_events?(events)
|
|
46
|
+
|
|
47
|
+
history = ActivityHistoryBuilder.new(
|
|
48
|
+
root_versions,
|
|
49
|
+
events,
|
|
50
|
+
@snapshotter,
|
|
51
|
+
include_step: ->(event) { @range.include?(event.version) }
|
|
52
|
+
).call
|
|
53
|
+
[history, root_versions]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
#: (Array[untyped]) -> void
|
|
57
|
+
def prepare_history(root_versions)
|
|
58
|
+
return unless @snapshotter.respond_to?(:prepare)
|
|
59
|
+
|
|
60
|
+
@snapshotter.prepare(@record, root_versions, start_at: @range.begin_time)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: (Array[untyped]) -> Array[ActivityEvent]
|
|
64
|
+
def collect_events(root_versions)
|
|
65
|
+
ActivityVersionCollector.new(
|
|
66
|
+
@record,
|
|
67
|
+
root_versions: root_versions,
|
|
68
|
+
tree: @tree,
|
|
69
|
+
traversal: AssociationTraversal.new(@tree),
|
|
70
|
+
range_start: @range.begin_time,
|
|
71
|
+
range_end: root_versions.last
|
|
72
|
+
).call
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
#: (Array[ActivityEvent]) -> bool
|
|
76
|
+
def time_events?(events)
|
|
77
|
+
selected = events.select { |event| @range.include?(event.version) }
|
|
78
|
+
return false if selected.empty?
|
|
79
|
+
return true if later_event?(selected.last, events)
|
|
80
|
+
|
|
81
|
+
message = 'time range requires a later activity boundary to reconstruct its final change'
|
|
82
|
+
raise IncompleteTimeRangeError, message
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#: (ActivityEvent, Array[ActivityEvent]) -> bool
|
|
86
|
+
def later_event?(selected, events)
|
|
87
|
+
events.any? do |event|
|
|
88
|
+
Support.compare_versions(selected.version, event.version).negative?
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
#: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
|
|
93
|
+
def root_steps(root_versions, root_snapshots)
|
|
94
|
+
snapshots = root_versions.map do |version|
|
|
95
|
+
root_snapshots.fetch(version_key(version))
|
|
96
|
+
end
|
|
97
|
+
root_versions.each_cons(2).with_index.map do |versions, index|
|
|
98
|
+
Step.new(
|
|
99
|
+
from_version: versions.fetch(0),
|
|
100
|
+
to_version: versions.fetch(1),
|
|
101
|
+
diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
|
|
102
|
+
)
|
|
103
|
+
end.freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: (untyped) -> Array[untyped]
|
|
107
|
+
def version_key(version)
|
|
108
|
+
[version.class.name, version.id]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Validates and applies a finite wall-clock range without loading model state.
|
|
6
|
+
class TimeRange
|
|
7
|
+
attr_reader :begin_time #: Time
|
|
8
|
+
attr_reader :end_time #: Time
|
|
9
|
+
|
|
10
|
+
#: (untyped) -> void
|
|
11
|
+
def initialize(value)
|
|
12
|
+
unless value.is_a?(Range) && value.begin && value.end
|
|
13
|
+
raise InvalidTimeRangeError, '`within` must be a finite Range'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@begin_time = coerce_time(value.begin, boundary: :begin)
|
|
17
|
+
@end_time = coerce_time(value.end, boundary: :end)
|
|
18
|
+
@exclude_end = value.exclude_end?
|
|
19
|
+
validate_order!
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
#: () -> bool
|
|
24
|
+
def exclude_end?
|
|
25
|
+
@exclude_end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: (untyped) -> bool
|
|
29
|
+
def include?(value)
|
|
30
|
+
timestamp = coerce_time(value, boundary: :timestamp)
|
|
31
|
+
return false if timestamp < begin_time
|
|
32
|
+
|
|
33
|
+
exclude_end? ? timestamp < end_time : timestamp <= end_time
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
#: (untyped) -> untyped
|
|
37
|
+
def scope(relation)
|
|
38
|
+
relation.where(created_at: query_range)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#: (untyped) -> untyped
|
|
42
|
+
def trailing_scope(relation)
|
|
43
|
+
column = relation.klass.arel_table[:created_at]
|
|
44
|
+
predicate = exclude_end? ? column.gteq(end_time) : column.gt(end_time)
|
|
45
|
+
relation.where(predicate)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# @rbs @exclude_end: bool
|
|
51
|
+
|
|
52
|
+
#: () -> Range[Time]
|
|
53
|
+
def query_range
|
|
54
|
+
exclude_end? ? (begin_time...end_time) : (begin_time..end_time)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
#: (untyped, boundary: Symbol) -> Time
|
|
58
|
+
def coerce_time(value, boundary:)
|
|
59
|
+
unless value.respond_to?(:to_time)
|
|
60
|
+
raise InvalidTimeRangeError, "`within` #{boundary} must be time-like"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
time_value(value).getutc.freeze
|
|
64
|
+
rescue ArgumentError, NoMethodError, TypeError => e
|
|
65
|
+
raise InvalidTimeRangeError,
|
|
66
|
+
"`within` #{boundary} must be time-like",
|
|
67
|
+
cause: e
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: (untyped) -> Time
|
|
71
|
+
def time_value(value)
|
|
72
|
+
return value if value.is_a?(Time)
|
|
73
|
+
|
|
74
|
+
utc = value.utc if value.respond_to?(:utc)
|
|
75
|
+
utc.is_a?(Time) ? utc : value.to_time
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
#: () -> void
|
|
79
|
+
def validate_order!
|
|
80
|
+
return unless begin_time > end_time
|
|
81
|
+
|
|
82
|
+
raise InvalidTimeRangeError, '`within` beginning must not follow its end'
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Selects in-range root versions plus one later reconstruction boundary.
|
|
6
|
+
class TimeVersionRange
|
|
7
|
+
#: (untyped, time_range: TimeRange) -> void
|
|
8
|
+
def initialize(record, time_range:)
|
|
9
|
+
@record = record
|
|
10
|
+
@time_range = time_range
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
#: (?context_required: bool) -> Array[untyped]
|
|
14
|
+
def select(context_required: false)
|
|
15
|
+
relation = versions_relation
|
|
16
|
+
selected = ordered(@time_range.scope(relation).to_a)
|
|
17
|
+
empty = [] #: Array[untyped]
|
|
18
|
+
return empty.freeze if selected.empty? && !context_required
|
|
19
|
+
|
|
20
|
+
trailing = trailing_version(relation)
|
|
21
|
+
unless trailing
|
|
22
|
+
message = 'time range requires a later root version to reconstruct its final change'
|
|
23
|
+
raise IncompleteTimeRangeError, message
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
(selected + [trailing]).freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# @rbs @record: untyped
|
|
32
|
+
# @rbs @time_range: TimeRange
|
|
33
|
+
|
|
34
|
+
#: () -> untyped
|
|
35
|
+
def versions_relation
|
|
36
|
+
association_name = @record.class.versions_association_name
|
|
37
|
+
@record.public_send(association_name)
|
|
38
|
+
rescue NoMethodError => e
|
|
39
|
+
message = 'record does not expose a PaperTrail version history'
|
|
40
|
+
raise InvalidTimelineRangeError, message, cause: e
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped) -> untyped
|
|
44
|
+
def trailing_version(relation)
|
|
45
|
+
@time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
49
|
+
def ordered(versions)
|
|
50
|
+
versions.sort_by { |version| Support.chronological_version_key(version) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -4,11 +4,10 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Selects and compares a chronological slice of a record's version history.
|
|
6
6
|
class TimelineBuilder
|
|
7
|
-
#: (untyped, from: untyped, to: untyped, snapshotter: untyped) -> void
|
|
8
|
-
def initialize(record, from:, to:, snapshotter:)
|
|
7
|
+
#: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped) -> void
|
|
8
|
+
def initialize(record, from:, to:, snapshotter:, within: nil)
|
|
9
9
|
@record = record
|
|
10
|
-
@
|
|
11
|
-
@to = to
|
|
10
|
+
@range = TimelineRange.new(record, from: from, to: to, within: within)
|
|
12
11
|
@snapshotter = snapshotter
|
|
13
12
|
end
|
|
14
13
|
|
|
@@ -30,12 +29,13 @@ module PaperTrailDiff
|
|
|
30
29
|
private
|
|
31
30
|
|
|
32
31
|
# @rbs @record: untyped
|
|
33
|
-
# @rbs @
|
|
34
|
-
# @rbs @to: untyped
|
|
32
|
+
# @rbs @range: TimelineRange
|
|
35
33
|
# @rbs @snapshotter: untyped
|
|
36
34
|
|
|
37
35
|
#: (Array[untyped]) -> [Array[Step], RecordSnapshot?, RecordSnapshot?]
|
|
38
36
|
def compare_history(versions)
|
|
37
|
+
return empty_history if versions.empty?
|
|
38
|
+
|
|
39
39
|
@snapshotter.prepare(@record, versions) if @snapshotter.respond_to?(:prepare)
|
|
40
40
|
first_snapshot = @snapshotter.call(versions.first)
|
|
41
41
|
previous_snapshot = first_snapshot
|
|
@@ -52,9 +52,15 @@ module PaperTrailDiff
|
|
|
52
52
|
[steps, first_snapshot, previous_snapshot]
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
#: () -> [Array[Step], nil, nil]
|
|
56
|
+
def empty_history
|
|
57
|
+
steps = [] #: Array[Step]
|
|
58
|
+
[steps.freeze, nil, nil]
|
|
59
|
+
end
|
|
60
|
+
|
|
55
61
|
#: () -> Array[untyped]
|
|
56
62
|
def selected_versions
|
|
57
|
-
|
|
63
|
+
@range.select
|
|
58
64
|
end
|
|
59
65
|
end
|
|
60
66
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Chooses explicit-version or wall-clock selection for one timeline request.
|
|
6
|
+
class TimelineRange
|
|
7
|
+
attr_reader :from #: untyped
|
|
8
|
+
attr_reader :to #: untyped
|
|
9
|
+
attr_reader :time_range #: TimeRange?
|
|
10
|
+
|
|
11
|
+
#: (untyped, from: untyped, to: untyped, within: untyped) -> void
|
|
12
|
+
def initialize(record, from:, to:, within:)
|
|
13
|
+
@record = record
|
|
14
|
+
@from = from
|
|
15
|
+
@to = to
|
|
16
|
+
@time_range = build_time_range(within)
|
|
17
|
+
validate_mode!
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: (?context_required: bool) -> Array[untyped]
|
|
22
|
+
def select(context_required: false)
|
|
23
|
+
range = time_range
|
|
24
|
+
if range
|
|
25
|
+
return TimeVersionRange.new(@record, time_range: range).select(
|
|
26
|
+
context_required: context_required
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
VersionRange.new(@record, from: @from, to: @to).select
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
#: () -> bool
|
|
34
|
+
def time?
|
|
35
|
+
!time_range.nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#: () -> Time?
|
|
39
|
+
def begin_time
|
|
40
|
+
time_range&.begin_time
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped) -> bool
|
|
44
|
+
def include?(version)
|
|
45
|
+
range = time_range
|
|
46
|
+
!range || range.include?(version.created_at)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# @rbs @record: untyped
|
|
52
|
+
# @rbs @from: untyped
|
|
53
|
+
# @rbs @to: untyped
|
|
54
|
+
|
|
55
|
+
#: (untyped) -> TimeRange?
|
|
56
|
+
def build_time_range(within)
|
|
57
|
+
TimeRange.new(within) unless within.nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: () -> void
|
|
61
|
+
def validate_mode!
|
|
62
|
+
if time?
|
|
63
|
+
return if @from.nil? && @to.nil?
|
|
64
|
+
|
|
65
|
+
raise InvalidTimelineRangeError, '`within` cannot be combined with `from` or `to`'
|
|
66
|
+
end
|
|
67
|
+
return unless @from.nil? || @to.nil?
|
|
68
|
+
|
|
69
|
+
raise InvalidTimelineRangeError, 'provide both `from` and `to`, or provide `within`'
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Selects association activity from a boundary forward plus the state at that boundary.
|
|
6
|
+
class VersionAssociationCandidateScope
|
|
7
|
+
#: (untyped, untyped, ?end_at: untyped) -> void
|
|
8
|
+
def initialize(version_class, start_at, end_at: nil)
|
|
9
|
+
@version_class = version_class
|
|
10
|
+
@start_at = start_at
|
|
11
|
+
@end_at = end_at
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
#: (untyped) -> untyped
|
|
15
|
+
def call(relation)
|
|
16
|
+
return relation unless @start_at
|
|
17
|
+
|
|
18
|
+
relation.where(candidate_condition)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# @rbs @version_class: untyped
|
|
24
|
+
# @rbs @start_at: untyped
|
|
25
|
+
# @rbs @end_at: untyped
|
|
26
|
+
|
|
27
|
+
#: () -> String
|
|
28
|
+
def candidate_condition # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
29
|
+
connection = @version_class.connection
|
|
30
|
+
outer = connection.quote_table_name(@version_class.table_name)
|
|
31
|
+
later = connection.quote_table_name('paper_trail_diff_later_versions')
|
|
32
|
+
start_at = connection.quote(@start_at)
|
|
33
|
+
destroyed = connection.quote('destroy')
|
|
34
|
+
activity = activity_condition(outer, start_at)
|
|
35
|
+
compact_sql(<<~SQL)
|
|
36
|
+
(
|
|
37
|
+
#{activity}
|
|
38
|
+
OR (
|
|
39
|
+
#{column(outer, 'created_at')} < #{start_at}
|
|
40
|
+
AND #{column(outer, 'event')} != #{destroyed}
|
|
41
|
+
AND NOT EXISTS (
|
|
42
|
+
SELECT 1
|
|
43
|
+
FROM #{outer} #{later}
|
|
44
|
+
WHERE #{column(later, 'item_type')} = #{column(outer, 'item_type')}
|
|
45
|
+
AND #{column(later, 'item_id')} = #{column(outer, 'item_id')}
|
|
46
|
+
AND #{column(later, 'created_at')} < #{start_at}
|
|
47
|
+
AND #{column(later, 'created_at')} > #{column(outer, 'created_at')}
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
SQL
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#: (String, String) -> String
|
|
55
|
+
def activity_condition(table, start_at)
|
|
56
|
+
condition = "#{column(table, 'created_at')} >= #{start_at}"
|
|
57
|
+
return condition unless @end_at
|
|
58
|
+
|
|
59
|
+
end_at = @version_class.connection.quote(@end_at)
|
|
60
|
+
"(#{condition} AND #{column(table, 'created_at')} <= #{end_at})"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: (String, String) -> String
|
|
64
|
+
def column(table, name)
|
|
65
|
+
"#{table}.#{@version_class.connection.quote_column_name(name)}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: (String) -> String
|
|
69
|
+
def compact_sql(sql)
|
|
70
|
+
sql.gsub(/\s+/, ' ').strip
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|