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.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1367
  5. data/docs/architecture.md +88 -0
  6. data/docs/performance.md +309 -0
  7. data/docs/reference.md +977 -0
  8. data/docs/testing.md +146 -0
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
  10. data/lib/paper_trail_diff/activity_boundary.rb +20 -8
  11. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
  12. data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
  13. data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
  14. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
  15. data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
  16. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
  17. data/lib/paper_trail_diff/activity_grouping.rb +12 -0
  18. data/lib/paper_trail_diff/activity_history.rb +9 -3
  19. data/lib/paper_trail_diff/activity_relationship.rb +2 -0
  20. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
  21. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
  22. data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
  23. data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
  24. data/lib/paper_trail_diff/analysis.rb +9 -4
  25. data/lib/paper_trail_diff/analysis_batch.rb +28 -7
  26. data/lib/paper_trail_diff/association_traversal.rb +23 -0
  27. data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
  28. data/lib/paper_trail_diff/batch_safety.rb +55 -0
  29. data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
  30. data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
  33. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  34. data/lib/paper_trail_diff/endpoint.rb +10 -0
  35. data/lib/paper_trail_diff/engine.rb +4 -2
  36. data/lib/paper_trail_diff/errors.rb +9 -0
  37. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  38. data/lib/paper_trail_diff/historical_filter.rb +181 -0
  39. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  40. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  41. data/lib/paper_trail_diff/nested_comparator.rb +59 -24
  42. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  43. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  44. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  45. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  46. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  47. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  48. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  49. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  50. data/lib/paper_trail_diff/root_version_plan.rb +25 -2
  51. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  52. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  53. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  54. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  55. data/lib/paper_trail_diff/snapshot.rb +9 -0
  56. data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
  57. data/lib/paper_trail_diff/step.rb +10 -5
  58. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  59. data/lib/paper_trail_diff/support.rb +13 -3
  60. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  61. data/lib/paper_trail_diff/time_range.rb +22 -2
  62. data/lib/paper_trail_diff/time_version_range.rb +1 -15
  63. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  64. data/lib/paper_trail_diff/timeline_range.rb +25 -43
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  66. data/lib/paper_trail_diff/traversal.rb +1 -1
  67. data/lib/paper_trail_diff/value_objects.rb +66 -0
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  70. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  71. data/lib/paper_trail_diff/version_query.rb +33 -0
  72. data/lib/paper_trail_diff/version_range.rb +10 -38
  73. data/lib/paper_trail_diff.rb +81 -43
  74. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  76. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  77. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  78. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  79. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  80. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  81. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  82. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  83. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  84. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  85. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  86. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  87. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  88. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  89. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  90. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  93. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  94. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  95. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  96. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  97. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  98. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  99. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  100. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  101. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  102. data/sig/generated/paper_trail_diff/nested_comparator.rbs +31 -18
  103. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  104. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  105. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  106. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  107. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  108. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  109. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  110. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  111. data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
  112. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  113. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  114. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  115. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  116. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  117. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
  118. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  119. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  120. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  121. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  122. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  123. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  124. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  125. data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
  126. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  127. data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
  128. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  129. data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
  130. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  131. data/sig/generated/paper_trail_diff.rbs +37 -27
  132. 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
- relation = version_class.where(item_type: item_type)
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
- base_class.unscoped.where(primary_key => candidates).pluck(primary_key).map(&:to_s)
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
- # Loading one past the limit is what turns an oversized page into an error
110
- # rather than a silently truncated report.
111
- #: (Array[String]) -> Array[untyped]
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
- records = @scope.where(primary_key => live).limit(@limit + 1).to_a
116
- return records.freeze unless records.length > @limit
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 + relationship_columns(reflections) + structural).uniq.freeze
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
- #: () -> Hash[Symbol, untyped]
37
- def to_h
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
- from_version_id: from_version.id,
40
- to_version_id: to_version&.id,
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
- return [ActivityHistory.empty, plan, nil, nil, nil] if plan.reconstruction_versions.empty?
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
- #: (Array[untyped], Array[ActivityEvent]) -> ActivityHistory
95
- def build_history(root_versions, events)
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.steps.last&.to_boundary
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.last_snapshot, to_snapshot: snapshot, retain: @retain
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
- predicate = exclude_end? ? column.gteq(end_time) : column.gt(end_time)
45
- relation.where(predicate)
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 = versions_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, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
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, versions: nil, version_scope: nil, plan: 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
- versions: versions, version_scope: version_scope, plan: plan,
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
- [steps, @snapshotter.call(versions.first), @snapshotter.call(plan.final_endpoint)]
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]