paper_trail_diff 0.12.0 → 0.13.1
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 +158 -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 +169 -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 +10 -38
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/collection_comparator.rb +18 -20
- data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -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 +176 -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 +35 -3
- 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 +14 -1
- data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
- 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 +21 -2
- data/lib/paper_trail_diff/time_version_range.rb +2 -16
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +29 -46
- 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 +41 -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/collection_comparator.rbs +6 -3
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
- 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 +22 -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 +34 -11
- 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 +12 -15
- 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 +21 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +31 -25
- metadata +31 -5
|
@@ -21,9 +21,7 @@ module PaperTrailDiff
|
|
|
21
21
|
#: (String, AssociationSnapshot) -> AssociationSnapshot
|
|
22
22
|
def association(path, snapshot)
|
|
23
23
|
previous = @associations[path]
|
|
24
|
-
|
|
25
|
-
return previous
|
|
26
|
-
end
|
|
24
|
+
return previous if previous && same_association?(previous, snapshot)
|
|
27
25
|
|
|
28
26
|
@associations[path] = snapshot
|
|
29
27
|
end
|
|
@@ -38,10 +36,14 @@ module PaperTrailDiff
|
|
|
38
36
|
left.attributes == right.attributes &&
|
|
39
37
|
left.associations.keys == right.associations.keys &&
|
|
40
38
|
left.associations.all? do |name, association|
|
|
41
|
-
|
|
42
|
-
other && association.kind == other.kind && association.records == other.records
|
|
39
|
+
same_association?(association, right.associations.fetch(name))
|
|
43
40
|
end
|
|
44
41
|
end
|
|
42
|
+
|
|
43
|
+
#: (AssociationSnapshot, AssociationSnapshot) -> bool
|
|
44
|
+
def same_association?(left, right)
|
|
45
|
+
left.kind == right.kind && left.records == right.records
|
|
46
|
+
end
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
# Converts an ActiveRecord graph into immutable, persistence-independent snapshots.
|
|
@@ -53,11 +55,13 @@ module PaperTrailDiff
|
|
|
53
55
|
@traversal = traversal
|
|
54
56
|
@pool = pool
|
|
55
57
|
@structural_columns = {} #: Hash[String, Array[String]]
|
|
56
|
-
@excluded_attributes = {} #: Hash[Array[untyped], Array[String]]
|
|
58
|
+
@excluded_attributes = {} #: Hash[Array[untyped], Array[String?]]
|
|
57
59
|
end
|
|
58
60
|
|
|
59
61
|
#: (untyped, reifier: untyped) -> RecordSnapshot?
|
|
60
62
|
def call(record, reifier:)
|
|
63
|
+
return unless record
|
|
64
|
+
|
|
61
65
|
reflections = reflections_for(record, @tree, '')
|
|
62
66
|
normalize_record(record, @tree, '', reifier, reflections)
|
|
63
67
|
end
|
|
@@ -76,23 +80,54 @@ module PaperTrailDiff
|
|
|
76
80
|
# Normalizes scalar attributes without traversing or loading associations.
|
|
77
81
|
#: (untyped, tree: AssociationTree, path: String) -> snapshot_attributes
|
|
78
82
|
def attributes_for(record, tree:, path:)
|
|
83
|
+
if record.instance_of?(PreparedRecordState)
|
|
84
|
+
return from_state(record, tree: tree,
|
|
85
|
+
path: path).attributes
|
|
86
|
+
end
|
|
87
|
+
|
|
79
88
|
reflections = reflections_for(record, tree, path)
|
|
80
89
|
normalized_attributes(record, reflections, path)
|
|
81
90
|
end
|
|
82
91
|
|
|
92
|
+
# Same exclusions as AR normalization, applied to an already typed state.
|
|
93
|
+
#: (PreparedRecordState, tree: AssociationTree, path: String, ?incoming: untyped, ?associations: snapshot_associations) -> RecordSnapshot
|
|
94
|
+
def from_state(state, tree:, path:, incoming: nil, associations: {})
|
|
95
|
+
model = state.model_class
|
|
96
|
+
@structural_columns[path] ||= @traversal.incoming_relationship_columns(incoming) if incoming
|
|
97
|
+
reflections = @traversal.reflections_for(model, tree, path: path)
|
|
98
|
+
excluded = @excluded_attributes[[model, path]] ||=
|
|
99
|
+
build_excluded_attributes(model, reflections, path)
|
|
100
|
+
snapshot = RecordSnapshot.new(
|
|
101
|
+
type: model.name, id: state_id(model, state),
|
|
102
|
+
attributes: state.attributes.except(*excluded), associations: associations
|
|
103
|
+
)
|
|
104
|
+
pool_state(path, snapshot)
|
|
105
|
+
end
|
|
106
|
+
|
|
83
107
|
private
|
|
84
108
|
|
|
109
|
+
# The id a live record of the same row reports: one value, or one per
|
|
110
|
+
# column of a composite key.
|
|
111
|
+
#: (untyped, PreparedRecordState) -> untyped
|
|
112
|
+
def state_id(model, state)
|
|
113
|
+
key = model.primary_key
|
|
114
|
+
key.instance_of?(Array) ? state.attributes.fetch_values(*key) : state.attributes.fetch(key)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
#: (String, RecordSnapshot) -> RecordSnapshot
|
|
118
|
+
def pool_state(path, snapshot)
|
|
119
|
+
path.empty? ? snapshot : @pool.record(path, snapshot)
|
|
120
|
+
end
|
|
121
|
+
|
|
85
122
|
# @rbs @tree: AssociationTree
|
|
86
123
|
# @rbs @ignore_policy: IgnorePolicy
|
|
87
124
|
# @rbs @traversal: AssociationTraversal
|
|
88
125
|
# @rbs @pool: SnapshotPool
|
|
89
126
|
# @rbs @structural_columns: Hash[String, Array[String]]
|
|
90
|
-
# @rbs @excluded_attributes: Hash[Array[untyped], Array[String]]
|
|
127
|
+
# @rbs @excluded_attributes: Hash[Array[untyped], Array[String?]]
|
|
91
128
|
|
|
92
|
-
#: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot
|
|
129
|
+
#: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot
|
|
93
130
|
def normalize_record(record, tree, path, reifier, reflections)
|
|
94
|
-
return unless record
|
|
95
|
-
|
|
96
131
|
snapshot = RecordSnapshot.new(
|
|
97
132
|
type: record.class.name,
|
|
98
133
|
id: record.id,
|
|
@@ -104,38 +139,48 @@ module PaperTrailDiff
|
|
|
104
139
|
|
|
105
140
|
#: (untyped, Array[untyped], String) -> snapshot_attributes
|
|
106
141
|
def normalized_attributes(record, reflections, path)
|
|
107
|
-
attributes = record.attributes
|
|
142
|
+
attributes = record.attributes
|
|
108
143
|
excluded_attributes(record, reflections, path).each { |name| attributes.delete(name) }
|
|
109
|
-
attributes
|
|
144
|
+
StoredTimePrecision.apply(attributes, StoredTimePrecision.digits(record.class))
|
|
110
145
|
end
|
|
111
146
|
|
|
112
147
|
# Every input is fixed for one model class at one selected path, and the
|
|
113
148
|
# path's structural columns are recorded before its records normalize, so
|
|
114
149
|
# this is resolved once instead of per record per boundary.
|
|
115
|
-
#: (untyped, Array[untyped], String) -> Array[String]
|
|
150
|
+
#: (untyped, Array[untyped], String) -> Array[String?]
|
|
116
151
|
def excluded_attributes(record, reflections, path)
|
|
117
152
|
model_class = record.class
|
|
118
153
|
@excluded_attributes[[model_class, path]] ||=
|
|
119
154
|
build_excluded_attributes(model_class, reflections, path)
|
|
120
155
|
end
|
|
121
156
|
|
|
122
|
-
#: (untyped, Array[untyped], String) -> Array[String]
|
|
157
|
+
#: (untyped, Array[untyped], String) -> Array[String?]
|
|
123
158
|
def build_excluded_attributes(model_class, reflections, path)
|
|
124
|
-
|
|
125
|
-
primary_keys = primary_key.is_a?(Array) ? primary_key : [primary_key]
|
|
126
|
-
# @type var primary_keys: Array[untyped]
|
|
127
|
-
primary_keys = primary_keys.map { |key| key.to_s } # rubocop:disable Style/SymbolProc
|
|
159
|
+
primary_keys = Array(model_class.primary_key) #: Array[String]
|
|
128
160
|
ignored = @ignore_policy.attributes_for(path)
|
|
129
161
|
structural = @structural_columns.fetch(path, [])
|
|
130
|
-
|
|
162
|
+
primary_keys + ignored + skipped_columns(model_class) +
|
|
163
|
+
relationship_columns(reflections) + structural
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# `has_paper_trail skip:` columns are never written to a version, so history
|
|
167
|
+
# holds only their defaults. Compared with a live row they would report a
|
|
168
|
+
# change nobody recorded.
|
|
169
|
+
#: (untyped) -> Array[String]
|
|
170
|
+
def skipped_columns(model_class)
|
|
171
|
+
return [] unless Support.versioned?(model_class)
|
|
172
|
+
|
|
173
|
+
# PaperTrail stores these as strings, or hashes keyed by strings.
|
|
174
|
+
entries = model_class.paper_trail_options.fetch(:skip) #: Array[untyped]
|
|
175
|
+
entries.flat_map { |entry| entry.instance_of?(Hash) ? entry.keys : entry }
|
|
131
176
|
end
|
|
132
177
|
|
|
133
|
-
|
|
178
|
+
# A non-polymorphic reflection has no foreign type, so it adds nil, which
|
|
179
|
+
# excludes nothing.
|
|
180
|
+
#: (Array[untyped]) -> Array[String?]
|
|
134
181
|
def relationship_columns(reflections)
|
|
135
182
|
reflections.select { |reflection| reflection.macro == :belongs_to }.flat_map do |reflection|
|
|
136
|
-
|
|
137
|
-
columns << reflection.foreign_type.to_s if reflection.polymorphic?
|
|
138
|
-
columns
|
|
183
|
+
[reflection.foreign_key, reflection.foreign_type]
|
|
139
184
|
end
|
|
140
185
|
end
|
|
141
186
|
|
|
@@ -143,27 +188,20 @@ module PaperTrailDiff
|
|
|
143
188
|
def normalize_associations(record, tree, path, reifier, reflections)
|
|
144
189
|
reflections.to_h do |reflection|
|
|
145
190
|
name = reflection.name.to_s
|
|
146
|
-
|
|
147
|
-
raise ConfigurationError, "missing traversal subtree: #{name}" unless subtree
|
|
148
|
-
|
|
149
|
-
[name, association_snapshot(record, reflection, subtree, path, reifier)]
|
|
191
|
+
[name, association_snapshot(record, reflection, tree.children.fetch(name), path, reifier)]
|
|
150
192
|
end
|
|
151
193
|
end
|
|
152
194
|
|
|
153
195
|
#: (untyped, untyped, AssociationTree, String, untyped) -> AssociationSnapshot
|
|
154
|
-
def association_snapshot(
|
|
196
|
+
def association_snapshot(
|
|
155
197
|
record,
|
|
156
198
|
reflection,
|
|
157
199
|
subtree,
|
|
158
200
|
path,
|
|
159
201
|
reifier
|
|
160
202
|
)
|
|
161
|
-
|
|
162
|
-
records =
|
|
163
|
-
associated.to_a
|
|
164
|
-
else
|
|
165
|
-
Array(associated).compact
|
|
166
|
-
end
|
|
203
|
+
# A reified history can hold a member PaperTrail could not rebuild.
|
|
204
|
+
records = Array(record.public_send(reflection.name)).compact
|
|
167
205
|
child_path = Support.association_path(path, reflection.name.to_s)
|
|
168
206
|
@structural_columns[child_path] ||= @traversal.incoming_relationship_columns(reflection)
|
|
169
207
|
snapshot = AssociationSnapshot.new(
|
|
@@ -175,10 +213,9 @@ module PaperTrailDiff
|
|
|
175
213
|
|
|
176
214
|
#: (Array[untyped], AssociationTree, String, untyped) -> Array[RecordSnapshot]
|
|
177
215
|
def normalize_children(records, tree, path, reifier)
|
|
178
|
-
reflections_by_class = {} #: Hash[
|
|
179
|
-
records.
|
|
180
|
-
|
|
181
|
-
reflections = reflections_by_class[type] ||= reflections_for(child, tree, path)
|
|
216
|
+
reflections_by_class = {} #: Hash[untyped, Array[untyped]]
|
|
217
|
+
records.map do |child|
|
|
218
|
+
reflections = reflections_by_class[child.class] ||= reflections_for(child, tree, path)
|
|
182
219
|
reifier.reify(child, reflections) unless reflections.empty?
|
|
183
220
|
normalize_record(child, tree, path, reifier, reflections)
|
|
184
221
|
end
|
|
@@ -186,8 +223,6 @@ module PaperTrailDiff
|
|
|
186
223
|
|
|
187
224
|
#: (untyped, AssociationTree, String) -> Array[untyped]
|
|
188
225
|
def reflections_for(record, tree, path)
|
|
189
|
-
return [] unless record
|
|
190
|
-
|
|
191
226
|
@traversal.reflections_for(record.class, tree, path: path)
|
|
192
227
|
end
|
|
193
228
|
end
|
|
@@ -33,12 +33,17 @@ module PaperTrailDiff
|
|
|
33
33
|
diff.empty?
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
# The root event whose change this step reports.
|
|
37
|
+
#: () -> ActivityBoundary
|
|
38
|
+
def source_boundary = from_boundary
|
|
39
|
+
|
|
40
|
+
# The same shape as `ActivityStep#to_h`, so a consumer reads both step
|
|
41
|
+
# types alike; each boundary carries its own `version_id`.
|
|
42
|
+
#: (?metadata: bool) -> Hash[Symbol, untyped]
|
|
43
|
+
def to_h(metadata: false)
|
|
38
44
|
{
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
to_boundary: to_boundary.to_h,
|
|
45
|
+
from: from_boundary.to_h(metadata: metadata),
|
|
46
|
+
to: to_boundary.to_h(metadata: metadata),
|
|
42
47
|
diff: diff.to_h
|
|
43
48
|
}
|
|
44
49
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Matches time values to the precision PaperTrail actually stored.
|
|
6
|
+
#
|
|
7
|
+
# PaperTrail's JSON serializer, and a `json` or `jsonb` object column, write
|
|
8
|
+
# times through ActiveSupport's JSON encoder, which keeps only
|
|
9
|
+
# `ActiveSupport::JSON::Encoding.time_precision` fractional digits
|
|
10
|
+
# (milliseconds by default). The live row keeps the database's precision, so
|
|
11
|
+
# comparing the two would report every timestamp as changed even though
|
|
12
|
+
# nothing was edited. Truncating every side to the stored precision is
|
|
13
|
+
# idempotent, so values that came from history are unaffected and only the
|
|
14
|
+
# digits history could never have held are dropped.
|
|
15
|
+
module StoredTimePrecision
|
|
16
|
+
NANOSECOND_DIGITS = 9
|
|
17
|
+
JSON_COLUMN_TYPES = %i[json jsonb].freeze
|
|
18
|
+
private_constant :NANOSECOND_DIGITS, :JSON_COLUMN_TYPES
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# Fractional digits a model's history keeps for times, or nil when the
|
|
23
|
+
# history is stored losslessly.
|
|
24
|
+
#: (untyped) -> Integer?
|
|
25
|
+
def digits(model_class)
|
|
26
|
+
return unless Support.versioned?(model_class)
|
|
27
|
+
return unless json_history?(model_class.paper_trail.version_class)
|
|
28
|
+
|
|
29
|
+
active_support = Object.const_get(:ActiveSupport) #: untyped
|
|
30
|
+
active_support.const_get(:JSON).const_get(:Encoding).time_precision
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
#: (Hash[untyped, untyped], Integer?) -> Hash[untyped, untyped]
|
|
34
|
+
def apply(attributes, digits)
|
|
35
|
+
return attributes unless digits
|
|
36
|
+
|
|
37
|
+
attributes.transform_values { |value| truncate(value, digits) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (untyped, Integer) -> untyped
|
|
41
|
+
def truncate(value, digits)
|
|
42
|
+
return value unless value.is_a?(Time) && digits < NANOSECOND_DIGITS
|
|
43
|
+
|
|
44
|
+
# ActiveSupport's Time#change, which also serves TimeWithZone.
|
|
45
|
+
time = value #: untyped
|
|
46
|
+
step = 10**(NANOSECOND_DIGITS - [digits, 0].max) #: Integer
|
|
47
|
+
remainder = time.nsec % step
|
|
48
|
+
remainder.zero? ? time : time.change(nsec: time.nsec - remainder)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#: (untyped) -> bool
|
|
52
|
+
def json_history?(version_class)
|
|
53
|
+
columns = version_class.columns_hash
|
|
54
|
+
json_column = %w[object object_changes].any? do |name|
|
|
55
|
+
JSON_COLUMN_TYPES.include?(columns[name]&.type)
|
|
56
|
+
end
|
|
57
|
+
json_column || json_serializer?
|
|
58
|
+
end
|
|
59
|
+
private_class_method :json_history?
|
|
60
|
+
|
|
61
|
+
#: () -> bool
|
|
62
|
+
def json_serializer?
|
|
63
|
+
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
64
|
+
paper_trail.serializer.equal?(paper_trail.const_get(:Serializers).const_get(:JSON))
|
|
65
|
+
end
|
|
66
|
+
private_class_method :json_serializer?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -8,15 +8,13 @@ module PaperTrailDiff
|
|
|
8
8
|
|
|
9
9
|
#: (untyped) -> untyped
|
|
10
10
|
def immutable_copy(value)
|
|
11
|
-
return value if value.frozen?
|
|
12
|
-
|
|
13
11
|
case value
|
|
14
12
|
when Hash
|
|
15
13
|
immutable_hash(value)
|
|
16
14
|
when Array
|
|
17
15
|
value.map { |item| immutable_copy(item) }.freeze
|
|
18
16
|
else
|
|
19
|
-
duplicate_and_freeze(value)
|
|
17
|
+
value.frozen? ? value : duplicate_and_freeze(value)
|
|
20
18
|
end
|
|
21
19
|
end
|
|
22
20
|
|
|
@@ -94,6 +92,18 @@ module PaperTrailDiff
|
|
|
94
92
|
model_class.respond_to?(:paper_trail_options) && !model_class.paper_trail_options.nil?
|
|
95
93
|
end
|
|
96
94
|
|
|
95
|
+
# `versions.item_id` is often a string column -- PaperTrail's setup for UUID
|
|
96
|
+
# keys -- while the model's own key is an integer. An id read from history
|
|
97
|
+
# is cast to the key's type, or the same record read once from history and
|
|
98
|
+
# once from its live row would count as two.
|
|
99
|
+
#: (untyped, untyped) -> untyped
|
|
100
|
+
def record_id(model_class, id)
|
|
101
|
+
key = model_class.primary_key
|
|
102
|
+
return id if id.nil? || !key.is_a?(String)
|
|
103
|
+
|
|
104
|
+
model_class.type_for_attribute(key).cast(id)
|
|
105
|
+
end
|
|
106
|
+
|
|
97
107
|
#: (untyped) -> bool
|
|
98
108
|
def sequential_id?(id)
|
|
99
109
|
id.is_a?(Integer) || id.to_s.match?(/\A\d+\z/)
|
|
@@ -28,7 +28,7 @@ module PaperTrailDiff
|
|
|
28
28
|
#: () -> Analysis
|
|
29
29
|
def analyze
|
|
30
30
|
history, plan, closing, closing_snapshot, captured_at = history_and_versions
|
|
31
|
-
final = closing_snapshot || history.last_snapshot
|
|
31
|
+
final = plan.closes_on_destroy ? nil : closing_snapshot || history.last_snapshot
|
|
32
32
|
Analysis.new(
|
|
33
33
|
diff: Engine.compare(history.first_snapshot, final),
|
|
34
34
|
timeline: ActivityRootSteps.call(
|
|
@@ -52,7 +52,10 @@ module PaperTrailDiff
|
|
|
52
52
|
#: () -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
|
|
53
53
|
def history_and_versions
|
|
54
54
|
plan = @range.select_plan(context_required: !@tree.empty?)
|
|
55
|
-
|
|
55
|
+
reject_live_habtm! if plan.closing_record
|
|
56
|
+
if plan.reconstruction_versions.empty? && !plan.closing_record
|
|
57
|
+
return [ActivityHistory.empty, plan, nil, nil, nil]
|
|
58
|
+
end
|
|
56
59
|
|
|
57
60
|
# A window closing on current state runs to the instant it is captured,
|
|
58
61
|
# not to the last root version, or descendants that moved after that
|
|
@@ -60,6 +63,11 @@ module PaperTrailDiff
|
|
|
60
63
|
history_for(plan, plan.closing_record ? Time.now.utc : nil)
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
#: () -> void
|
|
67
|
+
def reject_live_habtm!
|
|
68
|
+
AssociationTraversal.new(@tree).reject_live_habtm_activity!(@record.class)
|
|
69
|
+
end
|
|
70
|
+
|
|
63
71
|
#: (RootVersionPlan, untyped) -> [ActivityHistory, RootVersionPlan, ActivityStep?, RecordSnapshot?, untyped]
|
|
64
72
|
def history_for(plan, captured_at)
|
|
65
73
|
root_versions = plan.reconstruction_versions
|
|
@@ -69,7 +77,7 @@ module PaperTrailDiff
|
|
|
69
77
|
return [ActivityHistory.empty, plan, nil, nil, nil]
|
|
70
78
|
end
|
|
71
79
|
|
|
72
|
-
history = build_history(root_versions, events)
|
|
80
|
+
history = build_history(root_versions, events, current: plan.closing_record)
|
|
73
81
|
snapshot = current_snapshot(plan)
|
|
74
82
|
closing = closing_step(history, selected.last, plan, captured_at, snapshot)
|
|
75
83
|
[history, plan, closing, snapshot, captured_at]
|
|
@@ -91,12 +99,15 @@ module PaperTrailDiff
|
|
|
91
99
|
collect_events(root_versions, range_end: captured_at)
|
|
92
100
|
end
|
|
93
101
|
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
# Events after the last root version read the root from the live record
|
|
103
|
+
# when the window closes on it, as an explicit `to: record` range does.
|
|
104
|
+
#: (Array[untyped], Array[ActivityEvent], ?current: untyped) -> ActivityHistory
|
|
105
|
+
def build_history(root_versions, events, current: nil)
|
|
96
106
|
ActivityHistoryBuilder.new(
|
|
97
107
|
root_versions,
|
|
98
108
|
events,
|
|
99
109
|
@snapshotter,
|
|
110
|
+
current: current,
|
|
100
111
|
include_step: ->(event) { @range.include?(event.version) },
|
|
101
112
|
snapshots: @retain
|
|
102
113
|
).call
|
|
@@ -150,13 +161,13 @@ module PaperTrailDiff
|
|
|
150
161
|
# have moved since it.
|
|
151
162
|
#: (ActivityHistory, untyped, untyped, RecordSnapshot?) -> ActivityStep?
|
|
152
163
|
def current_step(history, record, captured_at, snapshot)
|
|
153
|
-
previous = history.
|
|
164
|
+
previous = history.last_boundary
|
|
154
165
|
return unless previous
|
|
155
166
|
|
|
156
167
|
ActivityStep.between(
|
|
157
168
|
from_boundary: previous,
|
|
158
169
|
to_boundary: ActivityBoundary.current(record, captured_at: captured_at),
|
|
159
|
-
from_snapshot: history.
|
|
170
|
+
from_snapshot: history.last_event_snapshot, to_snapshot: snapshot, retain: @retain
|
|
160
171
|
)
|
|
161
172
|
end
|
|
162
173
|
|
|
@@ -184,6 +195,7 @@ module PaperTrailDiff
|
|
|
184
195
|
root_versions: root_versions,
|
|
185
196
|
tree: @tree,
|
|
186
197
|
traversal: AssociationTraversal.new(@tree),
|
|
198
|
+
prepared: (@snapshotter.activity_index if @snapshotter.respond_to?(:activity_index)),
|
|
187
199
|
range_start: @range.begin_time,
|
|
188
200
|
range_end: range_end || root_versions.last
|
|
189
201
|
).call
|
|
@@ -41,14 +41,33 @@ module PaperTrailDiff
|
|
|
41
41
|
#: (untyped) -> untyped
|
|
42
42
|
def trailing_scope(relation)
|
|
43
43
|
column = relation.klass.arel_table[:created_at]
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
relation.where(after_end(column))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Each item's earliest version after the range, keyed by item id. One row
|
|
48
|
+
# per item comes back from a NOT EXISTS rather than a window function, so
|
|
49
|
+
# the query stays portable; a timestamp tie keeps the lower id.
|
|
50
|
+
#: (untyped) -> Hash[String, untyped]
|
|
51
|
+
def first_trailing_versions(relation)
|
|
52
|
+
table = relation.klass.arel_table
|
|
53
|
+
first_after = VersionQuery.no_earlier_version(table) do |earlier|
|
|
54
|
+
after_end(earlier[:created_at])
|
|
55
|
+
end
|
|
56
|
+
versions = VersionQuery.chronological(trailing_scope(relation).where(first_after)).to_a
|
|
57
|
+
first = {} #: Hash[String, untyped]
|
|
58
|
+
versions.each { |version| first[version.item_id.to_s] ||= version }
|
|
59
|
+
first
|
|
46
60
|
end
|
|
47
61
|
|
|
48
62
|
private
|
|
49
63
|
|
|
50
64
|
# @rbs @exclude_end: bool
|
|
51
65
|
|
|
66
|
+
#: (untyped) -> untyped
|
|
67
|
+
def after_end(column)
|
|
68
|
+
exclude_end? ? column.gteq(end_time) : column.gt(end_time)
|
|
69
|
+
end
|
|
70
|
+
|
|
52
71
|
#: () -> Range[Time]
|
|
53
72
|
def query_range
|
|
54
73
|
exclude_end? ? (begin_time...end_time) : (begin_time..end_time)
|
|
@@ -12,14 +12,9 @@ module PaperTrailDiff
|
|
|
12
12
|
@live_endpoint = live_endpoint
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
#: (?context_required: bool) -> Array[untyped]
|
|
16
|
-
def select(context_required: false)
|
|
17
|
-
select_plan(context_required: context_required).versions
|
|
18
|
-
end
|
|
19
|
-
|
|
20
15
|
#: (?context_required: bool) -> RootVersionPlan
|
|
21
16
|
def select_plan(context_required: false)
|
|
22
|
-
relation =
|
|
17
|
+
relation = Endpoint.versions(@record)
|
|
23
18
|
in_range = ordered(@time_range.scope(relation).to_a)
|
|
24
19
|
RootVersionSelection.new(
|
|
25
20
|
in_range: in_range,
|
|
@@ -40,18 +35,9 @@ module PaperTrailDiff
|
|
|
40
35
|
# @rbs @version_scope: untyped
|
|
41
36
|
# @rbs @live_endpoint: untyped
|
|
42
37
|
|
|
43
|
-
#: () -> untyped
|
|
44
|
-
def versions_relation
|
|
45
|
-
association_name = @record.class.versions_association_name
|
|
46
|
-
@record.public_send(association_name)
|
|
47
|
-
rescue NoMethodError => e
|
|
48
|
-
message = 'record does not expose a PaperTrail version history'
|
|
49
|
-
raise InvalidTimelineRangeError, message, cause: e
|
|
50
|
-
end
|
|
51
|
-
|
|
52
38
|
#: (untyped) -> untyped
|
|
53
39
|
def trailing_version(relation)
|
|
54
|
-
@time_range.trailing_scope(relation)
|
|
40
|
+
VersionQuery.chronological(@time_range.trailing_scope(relation)).first
|
|
55
41
|
end
|
|
56
42
|
|
|
57
43
|
#: (Array[untyped]) -> Array[untyped]
|
|
@@ -4,15 +4,15 @@
|
|
|
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, ?within: untyped, ?
|
|
7
|
+
#: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
|
|
8
8
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
|
-
record, from:, to:, snapshotter:, within: nil,
|
|
9
|
+
record, from:, to:, snapshotter:, within: nil, version_scope: nil, plan: nil,
|
|
10
10
|
live_endpoint: nil
|
|
11
11
|
)
|
|
12
12
|
@record = record
|
|
13
13
|
@range = TimelineRange.new(
|
|
14
14
|
record, from: from, to: to, within: within,
|
|
15
|
-
|
|
15
|
+
version_scope: version_scope, plan: plan,
|
|
16
16
|
live_endpoint: live_endpoint
|
|
17
17
|
)
|
|
18
18
|
@snapshotter = snapshotter
|
|
@@ -48,7 +48,8 @@ module PaperTrailDiff
|
|
|
48
48
|
versions = plan.versions
|
|
49
49
|
@snapshotter.prepare(@record, versions) if @snapshotter.respond_to?(:prepare)
|
|
50
50
|
steps = build_steps(plan)
|
|
51
|
-
|
|
51
|
+
final = plan.closes_on_destroy ? nil : @snapshotter.call(plan.final_endpoint)
|
|
52
|
+
[steps, @snapshotter.call(versions.first), final]
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
#: (RootVersionPlan) -> Array[Step]
|
|
@@ -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,11 +29,12 @@ module PaperTrailDiff
|
|
|
26
29
|
freeze
|
|
27
30
|
end
|
|
28
31
|
|
|
29
|
-
# A record with no versions has no first
|
|
30
|
-
# is an empty history rather than a bad request.
|
|
32
|
+
# A record with no versions has no first boundary to resolve, which
|
|
33
|
+
# is an empty history rather than a bad request. Both endpoints are
|
|
34
|
+
# required, so only `:first` can resolve to nothing; a window has none.
|
|
31
35
|
#: () -> bool
|
|
32
36
|
def unresolved?
|
|
33
|
-
|
|
37
|
+
!time? && (@from.nil? || @to.nil?)
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
# A batch may have selected these versions already, in which case reselecting
|
|
@@ -49,27 +53,7 @@ module PaperTrailDiff
|
|
|
49
53
|
live_endpoint: @live_endpoint
|
|
50
54
|
).select_plan(context_required: context_required)
|
|
51
55
|
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
|
|
56
|
+
explicit_plan
|
|
73
57
|
end
|
|
74
58
|
|
|
75
59
|
#: () -> bool
|
|
@@ -91,7 +75,6 @@ module PaperTrailDiff
|
|
|
91
75
|
private
|
|
92
76
|
|
|
93
77
|
# @rbs @record: untyped
|
|
94
|
-
# @rbs @versions: Array[untyped]?
|
|
95
78
|
# @rbs @plan: RootVersionPlan?
|
|
96
79
|
# @rbs @version_scope: untyped
|
|
97
80
|
# @rbs @live_endpoint: untyped
|
|
@@ -100,43 +83,43 @@ module PaperTrailDiff
|
|
|
100
83
|
# @rbs @from: untyped
|
|
101
84
|
# @rbs @to: untyped
|
|
102
85
|
|
|
103
|
-
#: () ->
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
86
|
+
#: () -> RootVersionPlan
|
|
87
|
+
def explicit_plan
|
|
88
|
+
return RootVersionPlan.empty if unresolved?
|
|
89
|
+
if Endpoint.record?(@to)
|
|
90
|
+
return VersionRange.new(@record, from: @from, to: @from, version_scope: @version_scope)
|
|
91
|
+
.select_plan_through_current(@to)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
VersionRange.new(
|
|
95
|
+
@record, from: @from, to: @to, version_scope: @version_scope
|
|
96
|
+
).select_plan_for_range
|
|
107
97
|
end
|
|
108
98
|
|
|
109
99
|
#: (untyped) -> bool
|
|
110
100
|
def symbolic?(boundary)
|
|
111
|
-
boundary.
|
|
101
|
+
boundary.instance_of?(Symbol)
|
|
112
102
|
end
|
|
113
103
|
|
|
114
|
-
# Resolving `:first`
|
|
104
|
+
# Resolving `:first` here keeps callers from depending on the
|
|
115
105
|
# order PaperTrail happens to give its versions association, which a caller
|
|
116
106
|
# is also free to reorder.
|
|
117
107
|
#: (untyped) -> untyped
|
|
118
108
|
def resolve(boundary)
|
|
119
109
|
return boundary unless symbolic?(boundary)
|
|
110
|
+
raise InvalidTimelineRangeError, LAST_REMOVED if boundary == :last
|
|
120
111
|
|
|
121
112
|
unless BOUNDARY_SYMBOLS.include?(boundary)
|
|
122
113
|
raise InvalidTimelineRangeError,
|
|
123
|
-
"unsupported boundary: #{boundary.inspect}; use :first,
|
|
114
|
+
"unsupported boundary: #{boundary.inspect}; use :first, a version, or a record"
|
|
124
115
|
end
|
|
125
116
|
|
|
126
|
-
|
|
117
|
+
ordered_versions.first
|
|
127
118
|
end
|
|
128
119
|
|
|
129
120
|
#: () -> untyped
|
|
130
121
|
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
|
|
122
|
+
VersionQuery.chronological(Endpoint.versions(@record))
|
|
140
123
|
end
|
|
141
124
|
|
|
142
125
|
#: (untyped) -> TimeRange?
|