paper_trail_diff 0.1.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 +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE +22 -0
- data/README.md +473 -0
- data/lib/paper_trail_diff/activity_boundary.rb +82 -0
- data/lib/paper_trail_diff/activity_event.rb +128 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +493 -0
- data/lib/paper_trail_diff/activity_range.rb +58 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +156 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +161 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +177 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +196 -0
- data/lib/paper_trail_diff/analysis.rb +26 -0
- data/lib/paper_trail_diff/association_discovery.rb +132 -0
- data/lib/paper_trail_diff/association_traversal.rb +131 -0
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +191 -0
- data/lib/paper_trail_diff/configuration.rb +170 -0
- data/lib/paper_trail_diff/diagnostics.rb +242 -0
- data/lib/paper_trail_diff/endpoint.rb +87 -0
- data/lib/paper_trail_diff/engine.rb +180 -0
- data/lib/paper_trail_diff/errors.rb +34 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +103 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +144 -0
- data/lib/paper_trail_diff/live_association_reader.rb +10 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +182 -0
- data/lib/paper_trail_diff/prepared_association_reifier.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +155 -0
- data/lib/paper_trail_diff/prepared_history.rb +217 -0
- data/lib/paper_trail_diff/prepared_history_loader.rb +136 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +154 -0
- data/lib/paper_trail_diff/snapshot.rb +120 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +182 -0
- data/lib/paper_trail_diff/step.rb +28 -0
- data/lib/paper_trail_diff/support.rb +80 -0
- data/lib/paper_trail_diff/timeline_builder.rb +60 -0
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -0
- data/lib/paper_trail_diff/value_objects.rb +134 -0
- data/lib/paper_trail_diff/version.rb +6 -0
- data/lib/paper_trail_diff/version_range.rb +81 -0
- data/lib/paper_trail_diff.rb +131 -0
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +43 -0
- data/sig/generated/paper_trail_diff/activity_event.rbs +64 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +98 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +36 -0
- data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +54 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +71 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +61 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +71 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +18 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +63 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +54 -0
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +56 -0
- data/sig/generated/paper_trail_diff/configuration.rbs +67 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +109 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +30 -0
- data/sig/generated/paper_trail_diff/engine.rbs +54 -0
- data/sig/generated/paper_trail_diff/errors.rbs +43 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +37 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +50 -0
- data/sig/generated/paper_trail_diff/live_association_reader.rbs +9 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +72 -0
- data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +27 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +65 -0
- data/sig/generated/paper_trail_diff/prepared_history.rbs +93 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +67 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +84 -0
- data/sig/generated/paper_trail_diff/snapshot.rbs +69 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +76 -0
- data/sig/generated/paper_trail_diff/step.rbs +18 -0
- data/sig/generated/paper_trail_diff/support.rbs +30 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +31 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -0
- data/sig/generated/paper_trail_diff/value_objects.rbs +87 -0
- data/sig/generated/paper_trail_diff/version.rbs +5 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +35 -0
- data/sig/generated/paper_trail_diff.rbs +56 -0
- metadata +144 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Reuses immutable nodes whose normalized state did not change at a selected path.
|
|
6
|
+
class SnapshotPool
|
|
7
|
+
def initialize
|
|
8
|
+
@records = {} #: Hash[Array[untyped], RecordSnapshot]
|
|
9
|
+
@associations = {} #: Hash[String, AssociationSnapshot]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
#: (String, RecordSnapshot) -> RecordSnapshot
|
|
13
|
+
def record(path, snapshot)
|
|
14
|
+
key = [path, snapshot.type, snapshot.id]
|
|
15
|
+
previous = @records[key]
|
|
16
|
+
return previous if previous && equivalent_record?(previous, snapshot)
|
|
17
|
+
|
|
18
|
+
@records[key] = snapshot
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: (String, AssociationSnapshot) -> AssociationSnapshot
|
|
22
|
+
def association(path, snapshot)
|
|
23
|
+
previous = @associations[path]
|
|
24
|
+
if previous && previous.kind == snapshot.kind && previous.records == snapshot.records
|
|
25
|
+
return previous
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@associations[path] = snapshot
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# @rbs @records: Hash[Array[untyped], RecordSnapshot]
|
|
34
|
+
# @rbs @associations: Hash[String, AssociationSnapshot]
|
|
35
|
+
|
|
36
|
+
#: (RecordSnapshot, RecordSnapshot) -> bool
|
|
37
|
+
def equivalent_record?(left, right)
|
|
38
|
+
left.attributes == right.attributes &&
|
|
39
|
+
left.associations.keys == right.associations.keys &&
|
|
40
|
+
left.associations.all? do |name, association|
|
|
41
|
+
other = right.associations[name]
|
|
42
|
+
other && association.kind == other.kind && association.records == other.records
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Converts an ActiveRecord graph into immutable, persistence-independent snapshots.
|
|
48
|
+
class SnapshotNormalizer
|
|
49
|
+
#: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, ?pool: SnapshotPool) -> void
|
|
50
|
+
def initialize(tree:, ignore_policy:, traversal:, pool: SnapshotPool.new)
|
|
51
|
+
@tree = tree
|
|
52
|
+
@ignore_policy = ignore_policy
|
|
53
|
+
@traversal = traversal
|
|
54
|
+
@pool = pool
|
|
55
|
+
@structural_columns = {} #: Hash[String, Array[String]]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
#: (untyped, reifier: untyped) -> RecordSnapshot?
|
|
59
|
+
def call(record, reifier:)
|
|
60
|
+
reflections = reflections_for(record, @tree, '')
|
|
61
|
+
normalize_record(record, @tree, '', reifier, reflections)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Normalizes one member of an already selected association branch.
|
|
65
|
+
#: (untyped, tree: AssociationTree, path: String, incoming: untyped, reifier: untyped) -> RecordSnapshot?
|
|
66
|
+
def call_child(record, tree:, path:, incoming:, reifier:)
|
|
67
|
+
return unless record
|
|
68
|
+
|
|
69
|
+
@structural_columns[path] ||= @traversal.incoming_relationship_columns(incoming)
|
|
70
|
+
reflections = reflections_for(record, tree, path)
|
|
71
|
+
reifier.reify(record, reflections) unless reflections.empty?
|
|
72
|
+
normalize_record(record, tree, path, reifier, reflections)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Normalizes scalar attributes without traversing or loading associations.
|
|
76
|
+
#: (untyped, tree: AssociationTree, path: String) -> snapshot_attributes
|
|
77
|
+
def attributes_for(record, tree:, path:)
|
|
78
|
+
reflections = reflections_for(record, tree, path)
|
|
79
|
+
normalized_attributes(record, reflections, path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# @rbs @tree: AssociationTree
|
|
85
|
+
# @rbs @ignore_policy: IgnorePolicy
|
|
86
|
+
# @rbs @traversal: AssociationTraversal
|
|
87
|
+
# @rbs @pool: SnapshotPool
|
|
88
|
+
# @rbs @structural_columns: Hash[String, Array[String]]
|
|
89
|
+
|
|
90
|
+
#: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot?
|
|
91
|
+
def normalize_record(record, tree, path, reifier, reflections)
|
|
92
|
+
return unless record
|
|
93
|
+
|
|
94
|
+
snapshot = RecordSnapshot.new(
|
|
95
|
+
type: record.class.name,
|
|
96
|
+
id: record.id,
|
|
97
|
+
attributes: normalized_attributes(record, reflections, path),
|
|
98
|
+
associations: normalize_associations(record, tree, path, reifier, reflections)
|
|
99
|
+
)
|
|
100
|
+
path.empty? ? snapshot : @pool.record(path, snapshot)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
#: (untyped, Array[untyped], String) -> snapshot_attributes
|
|
104
|
+
def normalized_attributes(record, reflections, path)
|
|
105
|
+
attributes = record.attributes.dup
|
|
106
|
+
excluded_attributes(record, reflections, path).each { |name| attributes.delete(name) }
|
|
107
|
+
attributes
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
#: (untyped, Array[untyped], String) -> Array[String]
|
|
111
|
+
def excluded_attributes(record, reflections, path)
|
|
112
|
+
primary_key = record.class.primary_key #: untyped
|
|
113
|
+
primary_keys = primary_key.is_a?(Array) ? primary_key : [primary_key]
|
|
114
|
+
# @type var primary_keys: Array[untyped]
|
|
115
|
+
primary_keys = primary_keys.map { |key| key.to_s } # rubocop:disable Style/SymbolProc
|
|
116
|
+
ignored = @ignore_policy.attributes_for(path)
|
|
117
|
+
structural = @structural_columns.fetch(path, [])
|
|
118
|
+
(primary_keys + ignored + relationship_columns(reflections) + structural).uniq
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
#: (Array[untyped]) -> Array[String]
|
|
122
|
+
def relationship_columns(reflections)
|
|
123
|
+
reflections.select { |reflection| reflection.macro == :belongs_to }.flat_map do |reflection|
|
|
124
|
+
columns = [reflection.foreign_key.to_s]
|
|
125
|
+
columns << reflection.foreign_type.to_s if reflection.polymorphic?
|
|
126
|
+
columns
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
#: (untyped, AssociationTree, String, untyped, Array[untyped]) -> Hash[String, AssociationSnapshot]
|
|
131
|
+
def normalize_associations(record, tree, path, reifier, reflections)
|
|
132
|
+
reflections.to_h do |reflection|
|
|
133
|
+
name = reflection.name.to_s
|
|
134
|
+
subtree = tree.child(name)
|
|
135
|
+
raise ConfigurationError, "missing traversal subtree: #{name}" unless subtree
|
|
136
|
+
|
|
137
|
+
[name, association_snapshot(record, reflection, subtree, path, reifier)]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
#: (untyped, untyped, AssociationTree, String, untyped) -> AssociationSnapshot
|
|
142
|
+
def association_snapshot( # rubocop:disable Metrics/AbcSize
|
|
143
|
+
record,
|
|
144
|
+
reflection,
|
|
145
|
+
subtree,
|
|
146
|
+
path,
|
|
147
|
+
reifier
|
|
148
|
+
)
|
|
149
|
+
associated = record.public_send(reflection.name)
|
|
150
|
+
records = if AssociationSnapshot.collection_kind?(reflection.macro)
|
|
151
|
+
associated.to_a
|
|
152
|
+
else
|
|
153
|
+
Array(associated).compact
|
|
154
|
+
end
|
|
155
|
+
child_path = Support.association_path(path, reflection.name.to_s)
|
|
156
|
+
@structural_columns[child_path] ||= @traversal.incoming_relationship_columns(reflection)
|
|
157
|
+
snapshot = AssociationSnapshot.new(
|
|
158
|
+
kind: reflection.macro,
|
|
159
|
+
records: normalize_children(records, subtree, child_path, reifier)
|
|
160
|
+
)
|
|
161
|
+
@pool.association(child_path, snapshot)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
#: (Array[untyped], AssociationTree, String, untyped) -> Array[RecordSnapshot]
|
|
165
|
+
def normalize_children(records, tree, path, reifier)
|
|
166
|
+
reflections_by_class = {} #: Hash[String, Array[untyped]]
|
|
167
|
+
records.filter_map do |child|
|
|
168
|
+
type = child.class.name
|
|
169
|
+
reflections = reflections_by_class[type] ||= reflections_for(child, tree, path)
|
|
170
|
+
reifier.reify(child, reflections) unless reflections.empty?
|
|
171
|
+
normalize_record(child, tree, path, reifier, reflections)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
#: (untyped, AssociationTree, String) -> Array[untyped]
|
|
176
|
+
def reflections_for(record, tree, path)
|
|
177
|
+
return [] unless record
|
|
178
|
+
|
|
179
|
+
@traversal.reflections_for(record.class, tree, path: path)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# One adjacent transition in a PaperTrail timeline.
|
|
6
|
+
class Step
|
|
7
|
+
attr_reader :from_version #: untyped
|
|
8
|
+
attr_reader :to_version #: untyped
|
|
9
|
+
attr_reader :diff #: Diff
|
|
10
|
+
|
|
11
|
+
#: (from_version: untyped, to_version: untyped, diff: Diff) -> void
|
|
12
|
+
def initialize(from_version:, to_version:, diff:)
|
|
13
|
+
@from_version = from_version
|
|
14
|
+
@to_version = to_version
|
|
15
|
+
@diff = diff
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#: () -> Hash[Symbol, untyped]
|
|
20
|
+
def to_h
|
|
21
|
+
{
|
|
22
|
+
from_version_id: from_version.id,
|
|
23
|
+
to_version_id: to_version.id,
|
|
24
|
+
diff: diff.to_h
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Internal helpers for isolating and serializing values held by result objects.
|
|
6
|
+
module Support
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
#: (untyped) -> untyped
|
|
10
|
+
def immutable_copy(value)
|
|
11
|
+
return value if value.frozen?
|
|
12
|
+
|
|
13
|
+
case value
|
|
14
|
+
when Hash
|
|
15
|
+
immutable_hash(value)
|
|
16
|
+
when Array
|
|
17
|
+
value.map { |item| immutable_copy(item) }.freeze
|
|
18
|
+
else
|
|
19
|
+
duplicate_and_freeze(value)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
#: (Hash[untyped, untyped]) -> Hash[untyped, untyped]
|
|
24
|
+
def immutable_hash(value)
|
|
25
|
+
copy = {} #: Hash[untyped, untyped]
|
|
26
|
+
value.each do |key, item|
|
|
27
|
+
copy[immutable_copy(key)] = immutable_copy(item)
|
|
28
|
+
end
|
|
29
|
+
copy.freeze
|
|
30
|
+
end
|
|
31
|
+
private_class_method :immutable_hash
|
|
32
|
+
|
|
33
|
+
#: (untyped) -> untyped
|
|
34
|
+
def serialize(value)
|
|
35
|
+
case value
|
|
36
|
+
when Hash
|
|
37
|
+
serialized = {} #: Hash[untyped, untyped]
|
|
38
|
+
value.each do |key, item|
|
|
39
|
+
serialized[key] = serialize(item)
|
|
40
|
+
end
|
|
41
|
+
serialized
|
|
42
|
+
when Array
|
|
43
|
+
value.map { |item| serialize(item) }
|
|
44
|
+
else
|
|
45
|
+
paper_trail_diff_value?(value) ? value.to_h : value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
#: (untyped) -> Array[untyped]
|
|
50
|
+
def chronological_version_key(version)
|
|
51
|
+
[version.created_at, version.id.to_s.rjust(32, '0')]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#: (untyped, untyped) -> Integer
|
|
55
|
+
def compare_versions(left, right)
|
|
56
|
+
chronological_version_key(left) <=> chronological_version_key(right) ||
|
|
57
|
+
raise(ConfigurationError, 'versions have incomparable timestamps')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: (String, String) -> String
|
|
61
|
+
def association_path(parent, name)
|
|
62
|
+
parent.empty? ? name : "#{parent}.#{name}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: (untyped) -> untyped
|
|
66
|
+
def duplicate_and_freeze(value)
|
|
67
|
+
value.dup.freeze
|
|
68
|
+
rescue TypeError
|
|
69
|
+
value
|
|
70
|
+
end
|
|
71
|
+
private_class_method :duplicate_and_freeze
|
|
72
|
+
|
|
73
|
+
#: (untyped) -> bool
|
|
74
|
+
def paper_trail_diff_value?(value)
|
|
75
|
+
class_name = value.class.name
|
|
76
|
+
class_name&.start_with?('PaperTrailDiff::') && value.respond_to?(:to_h)
|
|
77
|
+
end
|
|
78
|
+
private_class_method :paper_trail_diff_value?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Selects and compares a chronological slice of a record's version history.
|
|
6
|
+
class TimelineBuilder
|
|
7
|
+
#: (untyped, from: untyped, to: untyped, snapshotter: untyped) -> void
|
|
8
|
+
def initialize(record, from:, to:, snapshotter:)
|
|
9
|
+
@record = record
|
|
10
|
+
@from = from
|
|
11
|
+
@to = to
|
|
12
|
+
@snapshotter = snapshotter
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
#: () -> Array[Step]
|
|
16
|
+
def build
|
|
17
|
+
steps, = compare_history(selected_versions)
|
|
18
|
+
steps
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: () -> Analysis
|
|
22
|
+
def analyze
|
|
23
|
+
steps, first_snapshot, last_snapshot = compare_history(selected_versions)
|
|
24
|
+
Analysis.new(
|
|
25
|
+
diff: Engine.compare(first_snapshot, last_snapshot),
|
|
26
|
+
timeline: steps
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# @rbs @record: untyped
|
|
33
|
+
# @rbs @from: untyped
|
|
34
|
+
# @rbs @to: untyped
|
|
35
|
+
# @rbs @snapshotter: untyped
|
|
36
|
+
|
|
37
|
+
#: (Array[untyped]) -> [Array[Step], RecordSnapshot?, RecordSnapshot?]
|
|
38
|
+
def compare_history(versions)
|
|
39
|
+
@snapshotter.prepare(@record, versions) if @snapshotter.respond_to?(:prepare)
|
|
40
|
+
first_snapshot = @snapshotter.call(versions.first)
|
|
41
|
+
previous_snapshot = first_snapshot
|
|
42
|
+
steps = versions.each_cons(2).map do |from_version, to_version|
|
|
43
|
+
current_snapshot = @snapshotter.call(to_version)
|
|
44
|
+
step = Step.new(
|
|
45
|
+
from_version: from_version,
|
|
46
|
+
to_version: to_version,
|
|
47
|
+
diff: Engine.compare(previous_snapshot, current_snapshot)
|
|
48
|
+
)
|
|
49
|
+
previous_snapshot = current_snapshot
|
|
50
|
+
step
|
|
51
|
+
end.freeze
|
|
52
|
+
[steps, first_snapshot, previous_snapshot]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: () -> Array[untyped]
|
|
56
|
+
def selected_versions
|
|
57
|
+
VersionRange.new(@record, from: @from, to: @to).select
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Gives TimelineBuilder one-argument access to a range-prepared snapshot store.
|
|
6
|
+
class TimelineSnapshotProvider
|
|
7
|
+
#: (HistoricalSnapshotStore) -> void
|
|
8
|
+
def initialize(store)
|
|
9
|
+
@store = store
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
#: (untyped, Array[untyped]) -> void
|
|
13
|
+
def prepare(record, versions)
|
|
14
|
+
@store.prepare(record, versions)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (untyped) -> RecordSnapshot?
|
|
18
|
+
def call(version)
|
|
19
|
+
@store.uncached(version, version)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @rbs @store: HistoricalSnapshotStore
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# A directional change between two structured values.
|
|
6
|
+
class ValueChange
|
|
7
|
+
attr_reader :from #: untyped
|
|
8
|
+
attr_reader :to #: untyped
|
|
9
|
+
|
|
10
|
+
#: (from: untyped, to: untyped) -> void
|
|
11
|
+
def initialize(from:, to:)
|
|
12
|
+
@from = Support.immutable_copy(from)
|
|
13
|
+
@to = Support.immutable_copy(to)
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: () -> Hash[Symbol, untyped]
|
|
18
|
+
def to_h
|
|
19
|
+
{ from: Support.serialize(from), to: Support.serialize(to) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Attribute and nested-association changes for a record whose identity did not change.
|
|
24
|
+
class RecordChange
|
|
25
|
+
attr_reader :record #: RecordReference
|
|
26
|
+
attr_reader :attributes #: Hash[String, ValueChange]
|
|
27
|
+
attr_reader :associations #: Hash[String, ToOneAssociationDiff | CollectionAssociationDiff]
|
|
28
|
+
|
|
29
|
+
#: (record: RecordSnapshot, attributes: attribute_changes, ?associations: association_diffs) -> void
|
|
30
|
+
def initialize(record:, attributes:, associations: {})
|
|
31
|
+
@record = record.reference
|
|
32
|
+
@attributes = Support.immutable_copy(attributes)
|
|
33
|
+
@associations = Support.immutable_copy(associations)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#: () -> Hash[Symbol, untyped]
|
|
38
|
+
def to_h
|
|
39
|
+
value = { record: Support.serialize(record), attributes: Support.serialize(attributes) }
|
|
40
|
+
value[:associations] = Support.serialize(associations) unless associations.empty?
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Relationship and associated-record changes for belongs_to or has_one.
|
|
46
|
+
class ToOneAssociationDiff
|
|
47
|
+
attr_reader :kind #: Symbol
|
|
48
|
+
attr_reader :relationship #: ValueChange?
|
|
49
|
+
attr_reader :changed #: RecordChange?
|
|
50
|
+
|
|
51
|
+
#: (kind: Symbol, relationship: ValueChange?, changed: RecordChange?) -> void
|
|
52
|
+
def initialize(kind:, relationship:, changed:)
|
|
53
|
+
@kind = kind
|
|
54
|
+
@relationship = relationship
|
|
55
|
+
@changed = changed
|
|
56
|
+
freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
#: () -> bool
|
|
60
|
+
def empty?
|
|
61
|
+
relationship.nil? && changed.nil?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
#: () -> Hash[Symbol, untyped]
|
|
65
|
+
def to_h
|
|
66
|
+
{
|
|
67
|
+
kind: kind,
|
|
68
|
+
relationship: Support.serialize(relationship),
|
|
69
|
+
changed: Support.serialize(changed)
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Membership and associated-record changes for has_many.
|
|
75
|
+
class CollectionAssociationDiff
|
|
76
|
+
attr_reader :kind #: Symbol
|
|
77
|
+
attr_reader :added #: Array[RecordSnapshot]
|
|
78
|
+
attr_reader :removed #: Array[RecordSnapshot]
|
|
79
|
+
attr_reader :changed #: Array[RecordChange]
|
|
80
|
+
|
|
81
|
+
#: (kind: Symbol, added: Array[RecordSnapshot], removed: Array[RecordSnapshot], changed: Array[RecordChange]) -> void
|
|
82
|
+
def initialize(kind:, added:, removed:, changed:)
|
|
83
|
+
@kind = kind
|
|
84
|
+
@added = added.dup.freeze
|
|
85
|
+
@removed = removed.dup.freeze
|
|
86
|
+
@changed = changed.dup.freeze
|
|
87
|
+
freeze
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
#: () -> bool
|
|
91
|
+
def empty?
|
|
92
|
+
added.empty? && removed.empty? && changed.empty?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
#: () -> Hash[Symbol, untyped]
|
|
96
|
+
def to_h
|
|
97
|
+
{
|
|
98
|
+
kind: kind,
|
|
99
|
+
added: Support.serialize(added),
|
|
100
|
+
removed: Support.serialize(removed),
|
|
101
|
+
changed: Support.serialize(changed)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The complete structured difference between two normalized endpoints.
|
|
107
|
+
class Diff
|
|
108
|
+
attr_reader :record_presence_change #: ValueChange?
|
|
109
|
+
attr_reader :attributes #: Hash[String, ValueChange]
|
|
110
|
+
attr_reader :associations #: Hash[String, ToOneAssociationDiff | CollectionAssociationDiff]
|
|
111
|
+
|
|
112
|
+
#: (?record_presence_change: ValueChange?, ?attributes: attribute_changes, ?associations: association_diffs) -> void
|
|
113
|
+
def initialize(record_presence_change: nil, attributes: {}, associations: {})
|
|
114
|
+
@record_presence_change = record_presence_change
|
|
115
|
+
@attributes = Support.immutable_copy(attributes)
|
|
116
|
+
@associations = Support.immutable_copy(associations)
|
|
117
|
+
freeze
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
#: () -> bool
|
|
121
|
+
def empty?
|
|
122
|
+
record_presence_change.nil? && attributes.empty? && associations.empty?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
#: () -> Hash[Symbol, untyped]
|
|
126
|
+
def to_h
|
|
127
|
+
{
|
|
128
|
+
record_presence_change: Support.serialize(record_presence_change),
|
|
129
|
+
attributes: Support.serialize(attributes),
|
|
130
|
+
associations: Support.serialize(associations)
|
|
131
|
+
}
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Selects an inclusive, chronological range from a record's root versions.
|
|
6
|
+
class VersionRange
|
|
7
|
+
#: (untyped, from: untyped, to: untyped) -> void
|
|
8
|
+
def initialize(record, from:, to:)
|
|
9
|
+
@record = record
|
|
10
|
+
@from = from
|
|
11
|
+
@to = to
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
#: () -> Array[untyped]
|
|
15
|
+
def select
|
|
16
|
+
relation = versions_relation
|
|
17
|
+
validate_boundary!(@from, relation, boundary: :from)
|
|
18
|
+
validate_boundary!(@to, relation, boundary: :to)
|
|
19
|
+
if Support.compare_versions(@from, @to).positive?
|
|
20
|
+
raise InvalidTimelineRangeError, '`from` version must not follow `to` version'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
select_relation(
|
|
24
|
+
relation.where(created_at: @from.created_at..@to.created_at),
|
|
25
|
+
through: @to
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
#: () -> Array[untyped]
|
|
30
|
+
def select_through_latest
|
|
31
|
+
relation = versions_relation
|
|
32
|
+
validate_boundary!(@from, relation, boundary: :from)
|
|
33
|
+
select_relation(relation.where(created_at: @from.created_at..))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# @rbs @record: untyped
|
|
39
|
+
# @rbs @from: untyped
|
|
40
|
+
# @rbs @to: untyped
|
|
41
|
+
|
|
42
|
+
#: () -> untyped
|
|
43
|
+
def versions_relation
|
|
44
|
+
association_name = @record.class.versions_association_name
|
|
45
|
+
@record.public_send(association_name)
|
|
46
|
+
rescue NoMethodError => e
|
|
47
|
+
message = 'record does not expose a PaperTrail version history'
|
|
48
|
+
raise InvalidTimelineRangeError, message, cause: e
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#: (untyped, ?through: untyped) -> Array[untyped]
|
|
52
|
+
def select_relation(relation, through: nil)
|
|
53
|
+
selected = relation.to_a.select do |version|
|
|
54
|
+
next false if Support.compare_versions(@from, version).positive?
|
|
55
|
+
next true unless through
|
|
56
|
+
|
|
57
|
+
Support.compare_versions(version, through) <= 0
|
|
58
|
+
end
|
|
59
|
+
selected.sort_by { |version| Support.chronological_version_key(version) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#: (untyped, untyped, boundary: Symbol) -> void
|
|
63
|
+
def validate_boundary!(requested, relation, boundary:)
|
|
64
|
+
Endpoint.validate!(requested)
|
|
65
|
+
return if valid_boundary?(requested, relation)
|
|
66
|
+
|
|
67
|
+
raise InvalidTimelineRangeError, "`#{boundary}` version is not in the record history"
|
|
68
|
+
rescue InvalidEndpointError, NoMethodError
|
|
69
|
+
raise InvalidTimelineRangeError, "`#{boundary}` version is not in the record history"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#: (untyped, untyped) -> bool
|
|
73
|
+
def valid_boundary?(requested, relation)
|
|
74
|
+
expected_identity = [@record.class.base_class.name.to_s, @record.id.to_s]
|
|
75
|
+
Endpoint.version?(requested) &&
|
|
76
|
+
requested.instance_of?(relation.klass) &&
|
|
77
|
+
Endpoint.identity(requested) == expected_identity &&
|
|
78
|
+
relation.where(id: requested.id).exists?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|