paper_trail_diff 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +144 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1367
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +146 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +181 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +59 -24
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +25 -2
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +9 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +22 -2
- data/lib/paper_trail_diff/time_version_range.rb +1 -15
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +25 -43
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/value_objects.rb +66 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +33 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +81 -43
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +31 -18
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +37 -27
- metadata +31 -5
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Selects both populations and checks their shared ceiling before analysis.
|
|
6
|
+
class ScopedAnalysisBuilder
|
|
7
|
+
#: (untyped, limit: Integer?, within: untyped, historical_filter: untyped, max_filter_versions: Integer, include_deleted: bool, analyzer: untyped, deleted_analyzer: untyped) -> void
|
|
8
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
|
+
scope, limit:, within:, historical_filter:, max_filter_versions:, include_deleted:,
|
|
10
|
+
analyzer:, deleted_analyzer:
|
|
11
|
+
)
|
|
12
|
+
raise ConfigurationError, 'limit: is required when selecting roots by scope' if limit.nil?
|
|
13
|
+
unless [true, false].include?(include_deleted)
|
|
14
|
+
raise ConfigurationError, 'include_deleted: must be true or false'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@selection = ScopedRootSelection.new(
|
|
18
|
+
scope, limit: limit, time_range: within.nil? ? nil : TimeRange.new(within),
|
|
19
|
+
historical_filter: historical_filter, max_filter_versions: max_filter_versions
|
|
20
|
+
)
|
|
21
|
+
@limit = limit
|
|
22
|
+
@include_deleted = include_deleted
|
|
23
|
+
@analyzer = analyzer
|
|
24
|
+
@deleted_analyzer = deleted_analyzer
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#: () -> ScopedAnalysis
|
|
28
|
+
def call
|
|
29
|
+
selection = @selection.call
|
|
30
|
+
if @include_deleted && selection.records.length + selection.unreachable.length > @limit
|
|
31
|
+
raise BatchLimitExceededError, 'include_deleted: live and missing roots exceed limit:'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
ScopedAnalysis.new(
|
|
35
|
+
analyses: @analyzer.call(selection.records), roots: selection.records,
|
|
36
|
+
unreachable: selection.unreachable,
|
|
37
|
+
deleted: @include_deleted ? @deleted_analyzer.call(selection.unreachable) : {}
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @rbs @selection: ScopedRootSelection
|
|
42
|
+
# @rbs @limit: Integer
|
|
43
|
+
# @rbs @include_deleted: bool
|
|
44
|
+
# @rbs @analyzer: untyped
|
|
45
|
+
# @rbs @deleted_analyzer: untyped
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -26,6 +26,9 @@ module PaperTrailDiff
|
|
|
26
26
|
# window. `where(status: 'published')` selects what is published now, which is
|
|
27
27
|
# not the same set as what was published while the window was open.
|
|
28
28
|
class ScopedRootSelection
|
|
29
|
+
PAGE_SIZE = 250
|
|
30
|
+
private_constant :PAGE_SIZE
|
|
31
|
+
|
|
29
32
|
# A plain class rather than Data.define, which arrived in Ruby 3.2 while this
|
|
30
33
|
# gem supports 3.1.
|
|
31
34
|
class Result
|
|
@@ -40,16 +43,25 @@ module PaperTrailDiff
|
|
|
40
43
|
end
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
#: (untyped, time_range: TimeRange?, limit: Integer) -> void
|
|
44
|
-
def initialize(scope, time_range:, limit:
|
|
46
|
+
#: (untyped, time_range: TimeRange?, limit: Integer, ?historical_filter: untyped, ?max_filter_versions: Integer) -> void
|
|
47
|
+
def initialize(scope, time_range:, limit:, historical_filter: nil,
|
|
48
|
+
max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT)
|
|
45
49
|
@scope = normalize_scope(scope)
|
|
46
50
|
@time_range = time_range
|
|
47
51
|
@limit = validate_limit(limit)
|
|
52
|
+
@historical_filter = unless historical_filter.nil?
|
|
53
|
+
HistoricalFilter.new(base_class, predicate: historical_filter,
|
|
54
|
+
time_range: time_range, limit: @limit,
|
|
55
|
+
max_versions: max_filter_versions)
|
|
56
|
+
end
|
|
48
57
|
end
|
|
49
58
|
|
|
50
59
|
#: () -> Result
|
|
51
60
|
def call
|
|
61
|
+
return database_selection if @historical_filter.nil? && comparable_keys?
|
|
62
|
+
|
|
52
63
|
candidates = versioned_ids
|
|
64
|
+
candidates = @historical_filter.call(candidates) if @historical_filter && candidates.any?
|
|
53
65
|
return Result.new(records: [], unreachable: []) if candidates.empty?
|
|
54
66
|
|
|
55
67
|
live = live_ids(candidates)
|
|
@@ -64,6 +76,47 @@ module PaperTrailDiff
|
|
|
64
76
|
# @rbs @scope: untyped
|
|
65
77
|
# @rbs @time_range: TimeRange?
|
|
66
78
|
# @rbs @limit: Integer
|
|
79
|
+
# @rbs @historical_filter: untyped
|
|
80
|
+
|
|
81
|
+
# Leaves the candidate set in the database. Plucking every root changed in
|
|
82
|
+
# the window would cost memory and a statement proportional to all activity
|
|
83
|
+
# in the window -- not to the relation or `limit:` -- so a report on one
|
|
84
|
+
# record could read every id in a busy table. A subquery keeps the work
|
|
85
|
+
# bounded by what the relation and the deletions actually contain.
|
|
86
|
+
#: () -> Result
|
|
87
|
+
def database_selection
|
|
88
|
+
candidates = window_versions.select(:item_id)
|
|
89
|
+
Result.new(
|
|
90
|
+
records: selected_records(candidates),
|
|
91
|
+
unreachable: missing_ids.map { |id| identity(id) }.freeze
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Comparing the two columns in SQL needs them to share a type: PostgreSQL
|
|
96
|
+
# rejects an integer key against a string item_id outright.
|
|
97
|
+
#: () -> bool
|
|
98
|
+
def comparable_keys?
|
|
99
|
+
return false unless primary_key.is_a?(String)
|
|
100
|
+
|
|
101
|
+
item_id = version_class.columns_hash['item_id']&.type
|
|
102
|
+
key = base_class.columns_hash[primary_key]&.type
|
|
103
|
+
!item_id.nil? && item_id == key
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Changed in the window but without a row, found with an anti-join instead
|
|
107
|
+
# of comparing two plucked lists.
|
|
108
|
+
#: () -> Array[String]
|
|
109
|
+
def missing_ids
|
|
110
|
+
live = base_class.unscoped.select(primary_key)
|
|
111
|
+
window_versions.where.not(item_id: live).distinct.pluck(:item_id).map(&:to_s).uniq.sort
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
#: () -> untyped
|
|
115
|
+
def window_versions
|
|
116
|
+
relation = version_class.where(item_type: item_type)
|
|
117
|
+
range = @time_range
|
|
118
|
+
range ? range.scope(relation) : relation
|
|
119
|
+
end
|
|
67
120
|
|
|
68
121
|
# Accepts a model class as readily as a relation: `Article` and
|
|
69
122
|
# `Article.where(...)` both name a population, and `all` is what makes them
|
|
@@ -93,27 +146,67 @@ module PaperTrailDiff
|
|
|
93
146
|
# version table alone so that it does not depend on rows still existing.
|
|
94
147
|
#: () -> Array[String]
|
|
95
148
|
def versioned_ids
|
|
96
|
-
|
|
97
|
-
range = @time_range
|
|
98
|
-
relation = range.scope(relation) if range
|
|
99
|
-
relation.distinct.pluck(:item_id).map(&:to_s).uniq
|
|
149
|
+
window_versions.distinct.pluck(:item_id).map(&:to_s).uniq
|
|
100
150
|
end
|
|
101
151
|
|
|
102
152
|
# Which of those candidates still have a row, asked without the relation's
|
|
103
153
|
# conditions so that "filtered out" and "no longer exists" stay separable.
|
|
104
154
|
#: (Array[String]) -> Array[String]
|
|
105
155
|
def live_ids(candidates)
|
|
106
|
-
|
|
156
|
+
candidates.each_slice(PAGE_SIZE).flat_map do |slice|
|
|
157
|
+
base_class.unscoped.where(primary_key => slice).pluck(primary_key).map(&:to_s)
|
|
158
|
+
end
|
|
107
159
|
end
|
|
108
160
|
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
|
|
161
|
+
# Deduplicate records rather than adding SQL DISTINCT: an otherwise valid
|
|
162
|
+
# relation may order by joined columns or select a PostgreSQL json column.
|
|
163
|
+
# Read bounded pages until the relation ends or too many unique roots appear.
|
|
164
|
+
#: (untyped) -> Array[untyped]
|
|
112
165
|
def selected_records(live)
|
|
113
|
-
return [] if live.empty?
|
|
166
|
+
return [] if live.is_a?(Array) && live.empty?
|
|
167
|
+
|
|
168
|
+
records = {} #: Hash[untyped, untyped]
|
|
169
|
+
consumed = 0
|
|
170
|
+
loop do
|
|
171
|
+
page = record_page(live, consumed)
|
|
172
|
+
page.each { |record| records[record.id] ||= record }
|
|
173
|
+
reject_oversized!(records.length)
|
|
174
|
+
break if page.length < PAGE_SIZE
|
|
175
|
+
|
|
176
|
+
consumed += page.length
|
|
177
|
+
end
|
|
178
|
+
records.values.freeze
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
#: (untyped, Integer) -> Array[untyped]
|
|
182
|
+
def record_page(live, consumed)
|
|
183
|
+
count = page_limit(consumed)
|
|
184
|
+
return [] if count.zero?
|
|
185
|
+
|
|
186
|
+
# An id list or a candidate subquery; both read the same way in SQL.
|
|
187
|
+
conditions = { primary_key => live } #: Hash[untyped, untyped]
|
|
188
|
+
ordered_scope.where(conditions)
|
|
189
|
+
.offset((@scope.offset_value || 0) + consumed).limit(count).to_a
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Preserve explicit ordering: SQL Server rejects duplicate ORDER BY columns,
|
|
193
|
+
# even when the extra ordering would only have served as a tie-breaker.
|
|
194
|
+
#: () -> untyped
|
|
195
|
+
def ordered_scope
|
|
196
|
+
return @scope unless @scope.order_values.empty?
|
|
197
|
+
|
|
198
|
+
@scope.order(primary_key => :asc)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
#: (Integer) -> Integer
|
|
202
|
+
def page_limit(consumed)
|
|
203
|
+
remaining = @scope.limit_value
|
|
204
|
+
[remaining ? remaining - consumed : PAGE_SIZE, PAGE_SIZE].min
|
|
205
|
+
end
|
|
114
206
|
|
|
115
|
-
|
|
116
|
-
|
|
207
|
+
#: (Integer) -> void
|
|
208
|
+
def reject_oversized!(count)
|
|
209
|
+
return unless count > @limit
|
|
117
210
|
|
|
118
211
|
raise BatchLimitExceededError,
|
|
119
212
|
"scope: selected more than #{@limit} roots; narrow the window or the " \
|
|
@@ -184,6 +184,15 @@ module PaperTrailDiff
|
|
|
184
184
|
RecordReference.new(type: type, id: id)
|
|
185
185
|
end
|
|
186
186
|
|
|
187
|
+
# A copy holding a different state for one association.
|
|
188
|
+
#: (String, AssociationSnapshot) -> RecordSnapshot
|
|
189
|
+
def with_association(name, association)
|
|
190
|
+
self.class.new(
|
|
191
|
+
type: type, id: id, attributes: attributes,
|
|
192
|
+
associations: associations.merge(name => association)
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
187
196
|
#: () -> Hash[Symbol, untyped]
|
|
188
197
|
def to_h
|
|
189
198
|
value = {
|
|
@@ -76,12 +76,37 @@ module PaperTrailDiff
|
|
|
76
76
|
# Normalizes scalar attributes without traversing or loading associations.
|
|
77
77
|
#: (untyped, tree: AssociationTree, path: String) -> snapshot_attributes
|
|
78
78
|
def attributes_for(record, tree:, path:)
|
|
79
|
+
if record.is_a?(PreparedRecordState)
|
|
80
|
+
return from_state(record, tree: tree,
|
|
81
|
+
path: path).attributes
|
|
82
|
+
end
|
|
83
|
+
|
|
79
84
|
reflections = reflections_for(record, tree, path)
|
|
80
85
|
normalized_attributes(record, reflections, path)
|
|
81
86
|
end
|
|
82
87
|
|
|
88
|
+
# Same exclusions as AR normalization, applied to an already typed state.
|
|
89
|
+
#: (PreparedRecordState, tree: AssociationTree, path: String, ?incoming: untyped, ?associations: snapshot_associations) -> RecordSnapshot
|
|
90
|
+
def from_state(state, tree:, path:, incoming: nil, associations: {})
|
|
91
|
+
model = state.model_class
|
|
92
|
+
@structural_columns[path] ||= @traversal.incoming_relationship_columns(incoming) if incoming
|
|
93
|
+
reflections = @traversal.reflections_for(model, tree, path: path)
|
|
94
|
+
excluded = @excluded_attributes[[model, path]] ||=
|
|
95
|
+
build_excluded_attributes(model, reflections, path)
|
|
96
|
+
snapshot = RecordSnapshot.new(
|
|
97
|
+
type: model.name, id: state.attributes[model.primary_key],
|
|
98
|
+
attributes: state.attributes.except(*excluded), associations: associations
|
|
99
|
+
)
|
|
100
|
+
pool_state(path, snapshot)
|
|
101
|
+
end
|
|
102
|
+
|
|
83
103
|
private
|
|
84
104
|
|
|
105
|
+
#: (String, RecordSnapshot) -> RecordSnapshot
|
|
106
|
+
def pool_state(path, snapshot)
|
|
107
|
+
path.empty? ? snapshot : @pool.record(path, snapshot)
|
|
108
|
+
end
|
|
109
|
+
|
|
85
110
|
# @rbs @tree: AssociationTree
|
|
86
111
|
# @rbs @ignore_policy: IgnorePolicy
|
|
87
112
|
# @rbs @traversal: AssociationTraversal
|
|
@@ -106,7 +131,7 @@ module PaperTrailDiff
|
|
|
106
131
|
def normalized_attributes(record, reflections, path)
|
|
107
132
|
attributes = record.attributes.dup
|
|
108
133
|
excluded_attributes(record, reflections, path).each { |name| attributes.delete(name) }
|
|
109
|
-
attributes
|
|
134
|
+
StoredTimePrecision.apply(attributes, StoredTimePrecision.digits(record.class))
|
|
110
135
|
end
|
|
111
136
|
|
|
112
137
|
# Every input is fixed for one model class at one selected path, and the
|
|
@@ -127,7 +152,21 @@ module PaperTrailDiff
|
|
|
127
152
|
primary_keys = primary_keys.map { |key| key.to_s } # rubocop:disable Style/SymbolProc
|
|
128
153
|
ignored = @ignore_policy.attributes_for(path)
|
|
129
154
|
structural = @structural_columns.fetch(path, [])
|
|
130
|
-
(primary_keys + ignored +
|
|
155
|
+
(primary_keys + ignored + skipped_columns(model_class) +
|
|
156
|
+
relationship_columns(reflections) + structural).uniq.freeze
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# `has_paper_trail skip:` columns are never written to a version, so history
|
|
160
|
+
# holds only their defaults. Compared with a live row they would report a
|
|
161
|
+
# change nobody recorded.
|
|
162
|
+
#: (untyped) -> Array[String]
|
|
163
|
+
def skipped_columns(model_class)
|
|
164
|
+
return [] unless Support.versioned?(model_class)
|
|
165
|
+
|
|
166
|
+
entries = Array(model_class.paper_trail_options[:skip]) #: Array[untyped]
|
|
167
|
+
entries.flat_map do |entry|
|
|
168
|
+
entry.is_a?(Hash) ? entry.keys.map(&:to_s) : [entry.to_s]
|
|
169
|
+
end
|
|
131
170
|
end
|
|
132
171
|
|
|
133
172
|
#: (Array[untyped]) -> Array[String]
|
|
@@ -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,34 @@ 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 = trailing_scope(relation).where(first_after)
|
|
57
|
+
.reorder(created_at: :asc, id: :asc).to_a
|
|
58
|
+
first = {} #: Hash[String, untyped]
|
|
59
|
+
versions.each { |version| first[version.item_id.to_s] ||= version }
|
|
60
|
+
first
|
|
46
61
|
end
|
|
47
62
|
|
|
48
63
|
private
|
|
49
64
|
|
|
50
65
|
# @rbs @exclude_end: bool
|
|
51
66
|
|
|
67
|
+
#: (untyped) -> untyped
|
|
68
|
+
def after_end(column)
|
|
69
|
+
exclude_end? ? column.gteq(end_time) : column.gt(end_time)
|
|
70
|
+
end
|
|
71
|
+
|
|
52
72
|
#: () -> Range[Time]
|
|
53
73
|
def query_range
|
|
54
74
|
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,15 +35,6 @@ 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
40
|
@time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
|
|
@@ -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]
|