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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +158 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1388
  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 +169 -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 +10 -38
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/collection_comparator.rb +18 -20
  33. data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
  34. data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
  35. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  36. data/lib/paper_trail_diff/endpoint.rb +10 -0
  37. data/lib/paper_trail_diff/engine.rb +4 -2
  38. data/lib/paper_trail_diff/errors.rb +9 -0
  39. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  40. data/lib/paper_trail_diff/historical_filter.rb +176 -0
  41. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  42. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  43. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  44. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  45. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  46. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  47. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  48. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  49. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  50. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  51. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  52. data/lib/paper_trail_diff/root_version_plan.rb +35 -3
  53. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  54. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  55. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  56. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  57. data/lib/paper_trail_diff/snapshot.rb +14 -1
  58. data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
  59. data/lib/paper_trail_diff/step.rb +10 -5
  60. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  61. data/lib/paper_trail_diff/support.rb +13 -3
  62. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  63. data/lib/paper_trail_diff/time_range.rb +21 -2
  64. data/lib/paper_trail_diff/time_version_range.rb +2 -16
  65. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  66. data/lib/paper_trail_diff/timeline_range.rb +29 -46
  67. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  68. data/lib/paper_trail_diff/traversal.rb +1 -1
  69. data/lib/paper_trail_diff/version.rb +1 -1
  70. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  71. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  72. data/lib/paper_trail_diff/version_query.rb +41 -0
  73. data/lib/paper_trail_diff/version_range.rb +10 -38
  74. data/lib/paper_trail_diff.rb +78 -42
  75. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  76. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  77. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  78. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  79. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  80. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  81. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  82. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  83. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  84. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  85. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  86. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  87. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  88. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  90. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  91. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  92. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  93. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  94. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  95. data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
  96. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
  97. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  98. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  99. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  100. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  102. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  105. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  106. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  107. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  108. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  110. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  111. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  112. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  113. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  114. data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
  115. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  116. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  117. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  118. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  119. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  120. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
  121. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  122. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  123. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  124. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  125. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  126. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  127. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  128. data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
  129. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  130. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  131. data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
  132. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  133. data/sig/generated/paper_trail_diff.rbs +31 -25
  134. metadata +31 -5
@@ -4,7 +4,7 @@
4
4
  module PaperTrailDiff
5
5
  # Reconstructs selected root branches and merges them into a prior immutable snapshot.
6
6
  class BranchSnapshotRefresher
7
- #: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, pool: SnapshotPool, normalizer: SnapshotNormalizer, full_snapshotter: untyped, partial_snapshotter: untyped, association_reader: untyped, record_transition: untyped) -> void
7
+ #: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, pool: SnapshotPool, normalizer: SnapshotNormalizer, full_snapshotter: untyped, partial_snapshotter: untyped, association_reader: untyped, record_transition: untyped, ?prepared_record_after: untyped) -> void
8
8
  def initialize( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
9
9
  tree:,
10
10
  ignore_policy:,
@@ -14,7 +14,8 @@ module PaperTrailDiff
14
14
  full_snapshotter:,
15
15
  partial_snapshotter:,
16
16
  association_reader:,
17
- record_transition:
17
+ record_transition:,
18
+ prepared_record_after: nil
18
19
  )
19
20
  @tree = tree
20
21
  @ignore_policy = ignore_policy
@@ -24,16 +25,14 @@ module PaperTrailDiff
24
25
  @partial_snapshotter = partial_snapshotter
25
26
  @components = {} #: Hash[Array[String], [AssociationTree, SnapshotNormalizer]]
26
27
  @event_refresher = ActivityEventSnapshotRefresher.new(
27
- traversal: traversal,
28
- pool: pool,
28
+ traversal: traversal, pool: pool,
29
29
  components: method(:components),
30
30
  association_reader: association_reader,
31
- record_transition: record_transition
31
+ record_transition: record_transition,
32
+ prepared_record_after: prepared_record_after
32
33
  )
33
34
  @root_refresher = ActivityRootSnapshotRefresher.new(
34
- tree: tree,
35
- traversal: traversal,
36
- normalizer: normalizer,
35
+ tree: tree, traversal: traversal, normalizer: normalizer,
37
36
  record_after: @event_refresher.method(:record_after),
38
37
  changeset: @event_refresher.method(:deserialized_changeset),
39
38
  partial_snapshotter: method(:partial_snapshot)
@@ -28,25 +28,21 @@ module PaperTrailDiff
28
28
  # @rbs @to: AssociationSnapshot
29
29
  # @rbs @record_comparer: untyped
30
30
 
31
+ # A transition names the one record that changed between adjacent
32
+ # snapshots. With matching identities it is an update; otherwise the
33
+ # before record left and the after record joined.
31
34
  #: (CollectionTransition) -> CollectionAssociationDiff
32
35
  def transition_difference(transition)
33
36
  @from.validate_unique_identities!
34
37
  @to.validate_unique_identities!
35
38
  before = transition.before
36
39
  after = transition.after
37
- changed = compare_transition_records(before, after)
38
- difference(
39
- added: before.nil? && after ? [after] : [],
40
- removed: after.nil? && before ? [before] : [],
41
- changed: changed ? [changed] : []
42
- )
43
- end
44
-
45
- #: (RecordSnapshot?, RecordSnapshot?) -> RecordChange?
46
- def compare_transition_records(before, after)
47
- return unless before && after && before.identity == after.identity
40
+ if before && after && before.identity == after.identity
41
+ change = @record_comparer.call(before, after)
42
+ return difference(added: [], removed: [], changed: change ? [change] : [])
43
+ end
48
44
 
49
- @record_comparer.call(before, after)
45
+ difference(added: [after].compact, removed: [before].compact, changed: [])
50
46
  end
51
47
 
52
48
  #: () -> Array[RecordChange]?
@@ -70,17 +66,16 @@ module PaperTrailDiff
70
66
 
71
67
  #: (Array[RecordSnapshot], Array[RecordSnapshot]) -> Array[[RecordSnapshot, RecordSnapshot]]?
72
68
  def collect_aligned_pairs(from_records, to_records)
73
- seen = {} #: Hash[identity, bool]
69
+ seen = Set.new #: Set[identity]
74
70
  changed = [] #: Array[[RecordSnapshot, RecordSnapshot]]
75
71
  index = 0
76
72
  while index < from_records.length
77
73
  from_record = from_records.fetch(index)
78
74
  to_record = to_records.fetch(index)
79
75
  identity = from_record.identity
80
- raise_duplicate_identity!(identity) if seen.key?(identity)
76
+ raise_duplicate_identity!(identity) unless seen.add?(identity)
81
77
  return unless identity == to_record.identity
82
78
 
83
- seen[identity] = true
84
79
  changed << [from_record, to_record] unless from_record.equal?(to_record)
85
80
  index += 1
86
81
  end
@@ -148,21 +143,24 @@ module PaperTrailDiff
148
143
  # result is rendered.
149
144
  #: (identity) -> Array[untyped]
150
145
  def sortable_identity(identity)
151
- [identity.fetch(0), *sortable_id(identity.fetch(1))]
146
+ [identity.fetch(0), sortable_id(identity.fetch(1))]
152
147
  end
153
148
 
149
+ # Only the order of the group numbers matters, so moving the lowest one
150
+ # down or the highest one up changes nothing; the spec checks the order.
151
+ # mutant:disable
154
152
  #: (untyped) -> Array[untyped]
155
153
  def sortable_id(id)
156
154
  case id
157
- when Numeric then [0, id, '']
158
- when String, Symbol then [1, 0, id.to_s]
159
- else [2, 0, id.inspect]
155
+ when Numeric then [0, id]
156
+ when String, Symbol then [1, id.to_s]
157
+ else [2, id.inspect]
160
158
  end
161
159
  end
162
160
 
163
161
  #: (identity) -> void
164
162
  def raise_duplicate_identity!(identity)
165
- raise ArgumentError, "duplicate record identity: #{identity.inspect}"
163
+ raise ArgumentError, "duplicate record identity: #{identity}"
166
164
  end
167
165
  end
168
166
  end
@@ -6,19 +6,16 @@ module PaperTrailDiff
6
6
  class CollectionIdentityIndex
7
7
  #: (Array[untyped]) -> void
8
8
  def initialize(records)
9
- @positions = {} #: Hash[Array[untyped], Integer]
9
+ @identities = Set.new #: Set[Array[untyped]]
10
10
  @serialized_positions = {} #: Hash[Array[String], Integer]
11
11
  @id_positions = {} #: Hash[String, Integer?]
12
12
  records.each_with_index { |record, index| register(record, index) }
13
- @positions.freeze
14
- @serialized_positions.freeze
15
- @id_positions.freeze
16
13
  freeze
17
14
  end
18
15
 
19
- #: (untyped, untyped) -> Integer?
16
+ #: (String, untyped) -> Integer?
20
17
  def position(type, id)
21
- @serialized_positions[[type.to_s, id.to_s]]
18
+ @serialized_positions[[type, id.to_s]]
22
19
  end
23
20
 
24
21
  #: (untyped) -> Integer?
@@ -28,26 +25,25 @@ module PaperTrailDiff
28
25
 
29
26
  private
30
27
 
31
- # @rbs @positions: Hash[Array[untyped], Integer]
28
+ # @rbs @identities: Set[Array[untyped]]
32
29
  # @rbs @serialized_positions: Hash[Array[String], Integer]
33
30
  # @rbs @id_positions: Hash[String, Integer?]
34
31
 
35
32
  #: (untyped, Integer) -> void
36
33
  def register(record, index)
37
34
  identity = record.identity
38
- if @positions.key?(identity)
39
- raise ArgumentError, "duplicate record identity: #{identity.inspect}"
35
+ unless @identities.add?(identity)
36
+ raise ArgumentError, "duplicate record identity: #{identity}"
40
37
  end
41
38
 
42
- @positions[identity] = index
43
- @serialized_positions[[record.type.to_s, record.id.to_s]] = index
39
+ @serialized_positions[[record.type, record.id.to_s]] = index
44
40
  register_id(record.id, index)
45
41
  end
46
42
 
47
43
  #: (untyped, Integer) -> void
48
44
  def register_id(id, index)
49
45
  key = id.to_s
50
- @id_positions[key] = @id_positions.key?(key) ? nil : index
46
+ @id_positions[key] = (index unless @id_positions.key?(key))
51
47
  end
52
48
  end
53
49
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Analyzes missing roots together without pretending that they have live rows.
6
+ class DeletedAnalysisBatch
7
+ #: (untyped, identities: Array[Array[String]], time_range: TimeRange?, analyzer: untyped, history_preparer: untyped, ?version_scope: untyped) -> void
8
+ def initialize( # rubocop:disable Metrics/ParameterLists
9
+ model, identities:, time_range:, analyzer:, history_preparer:, version_scope: nil
10
+ )
11
+ @model = model
12
+ @identities = identities
13
+ @time_range = time_range
14
+ @analyzer = analyzer
15
+ @history_preparer = history_preparer
16
+ @version_scope = version_scope
17
+ end
18
+
19
+ #: () -> Hash[Array[String], Analysis?]
20
+ def call
21
+ empty = {} #: Hash[Array[String], Analysis?]
22
+ return empty.freeze if @identities.empty?
23
+
24
+ records = historical_roots
25
+ selected = BatchedRootVersions.new(
26
+ records, time_range: @time_range, version_scope: @version_scope
27
+ ).call
28
+ prepare(records, selected)
29
+ analyses = records.to_h do |record|
30
+ key = Endpoint.identity(record)
31
+ plan = selected.fetch(key)
32
+ [key, plan.empty? ? Analysis.empty : @analyzer.call(record, plan)]
33
+ end
34
+ with_unavailable(analyses)
35
+ end
36
+
37
+ private
38
+
39
+ #: (Hash[Array[String], Analysis]) -> Hash[Array[String], Analysis?]
40
+ def with_unavailable(analyses)
41
+ @identities.to_h { |key| [Support.immutable_copy(key), analyses[key]] }.freeze
42
+ end
43
+
44
+ # @rbs @model: untyped
45
+ # @rbs @identities: Array[Array[String]]
46
+ # @rbs @time_range: TimeRange?
47
+ # @rbs @analyzer: untyped
48
+ # @rbs @history_preparer: untyped
49
+ # @rbs @version_scope: untyped
50
+
51
+ # These records supply identity and model metadata only. Every reported
52
+ # value comes from the batched historical plan; no per-root reify or live
53
+ # lookup is needed to get into the comparison engine.
54
+ #: () -> Array[untyped]
55
+ def historical_roots
56
+ loader = PreparedVersionStateLoader.new
57
+ destroy_versions.map do |version|
58
+ model = loader.call(version)&.model_class || Endpoint.model_class(version)
59
+ record = model.new
60
+ record[model.primary_key] = version.item_id
61
+ record
62
+ end
63
+ end
64
+
65
+ #: () -> Array[untyped]
66
+ def destroy_versions
67
+ destroys = @model.paper_trail.version_class.where(
68
+ item_type: @model.base_class.name, item_id: @identities.map(&:last), event: 'destroy'
69
+ )
70
+ versions = VersionQuery.chronological(destroys)
71
+ versions.group_by { |version| version.item_id.to_s }.values.map do |history|
72
+ Support.chronological_sort(history).last
73
+ end
74
+ end
75
+
76
+ #: (Array[untyped]) -> Hash[Symbol, untyped]
77
+ def preparation_range(versions)
78
+ { start_at: @time_range&.begin_time,
79
+ end_at: [@time_range&.end_time, versions.map(&:created_at).max].compact.max }
80
+ end
81
+
82
+ #: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
83
+ def prepare(records, selected)
84
+ records.group_by(&:class).each_value do |group|
85
+ versions = group.flat_map do |record|
86
+ selected.fetch(Endpoint.identity(record)).reconstruction_versions
87
+ end
88
+ next if versions.empty?
89
+
90
+ @history_preparer.call(group, versions, **preparation_range(versions))
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Completes plain recorded state without invoking model initialization or writers.
6
+ # Decline model behavior whose values require an actual ActiveRecord instance.
7
+ class DirectRecordStateLoader
8
+ #: () -> void
9
+ def initialize
10
+ @loader = PreparedVersionStateLoader.new
11
+ @supported = {} #: Hash[untyped, bool]
12
+ @defaults = {} #: Hash[untyped, Hash[untyped, untyped]]
13
+ end
14
+
15
+ #: (untyped) -> PreparedRecordState?
16
+ def call(version)
17
+ state = @loader.call(version)
18
+ complete(state) if state
19
+ end
20
+
21
+ #: (PreparedRecordState) -> PreparedRecordState?
22
+ def complete(state)
23
+ model = state.model_class
24
+ return unless supported?(model)
25
+
26
+ defaults = @defaults[model] ||= model.column_defaults
27
+ attributes = defaults.merge(state.attributes).to_h do |name, value|
28
+ [name, model.type_for_attribute(name).cast(value)]
29
+ end
30
+ PreparedRecordState.from_attributes(model, attributes)
31
+ end
32
+
33
+ private
34
+
35
+ # @rbs @loader: PreparedVersionStateLoader
36
+ # @rbs @supported: Hash[untyped, bool]
37
+ # @rbs @defaults: Hash[untyped, Hash[untyped, untyped]]
38
+
39
+ #: (untyped) -> bool
40
+ def supported?(model)
41
+ return @supported[model] if @supported.key?(model)
42
+
43
+ @supported[model] = plain_model?(model) && plain_attributes?(model)
44
+ end
45
+
46
+ #: (untyped) -> bool
47
+ def plain_model?(model)
48
+ !model.primary_key.is_a?(Array) && !model.default_scopes? &&
49
+ model._initialize_callbacks.empty? &&
50
+ %i[initialize attributes []= write_attribute id].all? do |name|
51
+ framework_class?(model.instance_method(name).owner)
52
+ end
53
+ end
54
+
55
+ #: (untyped) -> bool
56
+ def plain_attributes?(model)
57
+ return false if encrypted?(model)
58
+ return false unless model.attribute_types.keys.sort == model.column_names.sort
59
+ return false if application_defaults?(model)
60
+
61
+ model.attribute_types.values.all? { |type| framework_class?(type.class) }
62
+ end
63
+
64
+ #: (untyped) -> bool
65
+ def application_defaults?(model)
66
+ model._default_attributes.each_value.any? do |attribute|
67
+ attribute.class.name.to_s.end_with?('UserProvidedDefault')
68
+ end
69
+ end
70
+
71
+ #: (untyped) -> bool
72
+ def encrypted?(model)
73
+ !!(model.respond_to?(:encrypted_attributes) && model.encrypted_attributes&.any?)
74
+ end
75
+
76
+ #: (untyped) -> bool
77
+ def framework_class?(value)
78
+ value.name.to_s.start_with?('ActiveRecord::', 'ActiveModel::')
79
+ end
80
+ end
81
+ end
@@ -82,6 +82,16 @@ module PaperTrailDiff
82
82
  raise InvalidEndpointError, "cannot resolve endpoint model: #{model_type}", cause: e
83
83
  end
84
84
 
85
+ # The record's own PaperTrail versions, under whatever name the model gave
86
+ # the association.
87
+ #: (untyped) -> untyped
88
+ def versions(record)
89
+ record.public_send(record.class.versions_association_name)
90
+ rescue NoMethodError => e
91
+ message = 'record does not expose a PaperTrail version history'
92
+ raise InvalidTimelineRangeError, message, cause: e
93
+ end
94
+
85
95
  #: (untyped) -> untyped
86
96
  def reload_record(record)
87
97
  validate_record!(record)
@@ -71,6 +71,8 @@ module PaperTrailDiff
71
71
  raise ArgumentError, 'association kind changed between snapshots'
72
72
  end
73
73
 
74
+ # Kinds are symbols, so `!=`, `equal?`, and `eql?` all agree here.
75
+ # mutant:disable
74
76
  #: (AssociationSnapshot?, AssociationSnapshot?) -> bool
75
77
  def kinds_differ?(from_association, to_association)
76
78
  return false unless from_association && to_association
@@ -131,9 +133,9 @@ module PaperTrailDiff
131
133
  ).call
132
134
  end
133
135
 
134
- #: (RecordSnapshot?, RecordSnapshot?) -> bool
136
+ #: (RecordSnapshot?, RecordSnapshot?) -> boolish
135
137
  def same_identity?(from_record, to_record)
136
- from_record && to_record ? from_record.identity == to_record.identity : false
138
+ from_record && to_record && from_record.identity == to_record.identity
137
139
  end
138
140
  end
139
141
  end
@@ -8,6 +8,9 @@ module PaperTrailDiff
8
8
  # Raised when a public option has an invalid type, key, value, or path.
9
9
  class ConfigurationError < Error; end
10
10
 
11
+ # Raised when nested data keys cannot be represented by unique string paths.
12
+ class AmbiguousNestedKeyError < Error; end
13
+
11
14
  # Raised when compare endpoints do not belong to the same PaperTrail item.
12
15
  class VersionMismatchError < Error; end
13
16
 
@@ -36,6 +39,12 @@ module PaperTrailDiff
36
39
  # Truncating instead would hand back a report that is short without saying so.
37
40
  class BatchLimitExceededError < Error; end
38
41
 
42
+ # Historical candidate loading exceeded its explicitly bounded work budget.
43
+ class FilterLimitExceededError < Error; end
44
+
45
+ # A batch selected a known expensive association without explicitly opting in.
46
+ class UnsupportedBatchPathError < ConfigurationError; end
47
+
39
48
  # Raised when a requested ActiveRecord association does not exist.
40
49
  class UnknownAssociationError < Error; end
41
50
 
@@ -51,7 +51,7 @@ module PaperTrailDiff
51
51
  def reify_has_many(record, reflection)
52
52
  if reflection.options[:through]
53
53
  reify_association(record, reflection.through_reflection)
54
- reifier(:HasManyThrough).reify(reflection, record, options, @transaction_id)
54
+ reify_through(record, reflection)
55
55
  return
56
56
  end
57
57
 
@@ -65,6 +65,31 @@ module PaperTrailDiff
65
65
  )
66
66
  end
67
67
 
68
+ # PT-AT's collection-source helper reifies every has_many on each join
69
+ # record. Walk only the requested source instead, including nested throughs.
70
+ #: (untyped, untyped) -> void
71
+ def reify_through(record, reflection)
72
+ source = reflection.source_reflection
73
+ if source.belongs_to?
74
+ reifier(:HasManyThrough).reify(reflection, record, options, @transaction_id)
75
+ return
76
+ end
77
+
78
+ members = through_members(record, reflection, source)
79
+ association = record.association(reflection.name)
80
+ association.target = members.uniq { |member| [member.class.base_class.name, member.id] }
81
+ association.loaded!
82
+ end
83
+
84
+ #: (untyped, untyped, untyped) -> Array[untyped]
85
+ def through_members(record, reflection, source)
86
+ joins = Array(record.public_send(reflection.through_reflection.name)) #: Array[untyped]
87
+ joins.flat_map do |join|
88
+ reify_association(join, source)
89
+ Array(join.public_send(source.name))
90
+ end
91
+ end
92
+
68
93
  #: (untyped, untyped) -> void
69
94
  def reify_habtm(record, reflection)
70
95
  unless @habtm_transaction_id
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Selects roots by the values they held at any point in the window, with an
6
+ # explicit ceiling on version loading.
7
+ #
8
+ # A version records the state *before* its own change, so the versions in
9
+ # the window supply the state at its start and after every change but the
10
+ # last. The state after the last change lives in the first version after
11
+ # the window, or in the live row when nothing follows; a destroy leaves none.
12
+ class HistoricalFilter
13
+ DEFAULT_VERSION_LIMIT = 10_000
14
+ CANDIDATE_BATCH = 250
15
+
16
+ #: (untyped, predicate: untyped, time_range: TimeRange?, limit: Integer, max_versions: Integer) -> void
17
+ def initialize(model, predicate:, time_range:, limit:, max_versions:)
18
+ @model = model
19
+ @predicate = validate_predicate(predicate)
20
+ @time_range = time_range
21
+ @limit = limit
22
+ @max_versions = validate_budget(max_versions)
23
+ @loaded = 0
24
+ @state_loader = PreparedVersionStateLoader.new
25
+ end
26
+
27
+ #: (Array[String]) -> Array[String]
28
+ def call(candidates)
29
+ matched = [] #: Array[String]
30
+ candidates.each_slice(CANDIDATE_BATCH) do |slice|
31
+ versions = candidate_versions(slice)
32
+ closing = closing_states(slice, versions)
33
+ slice.each do |id|
34
+ next unless held?(versions.fetch(id, []), closing[id])
35
+
36
+ matched << id
37
+ reject_root_limit! if matched.length > @limit
38
+ end
39
+ end
40
+ matched
41
+ end
42
+
43
+ private
44
+
45
+ # @rbs @model: untyped
46
+ # @rbs @predicate: untyped
47
+ # @rbs @time_range: TimeRange?
48
+ # @rbs @limit: Integer
49
+ # @rbs @max_versions: Integer
50
+ # @rbs @loaded: Integer
51
+ # @rbs @state_loader: PreparedVersionStateLoader
52
+
53
+ #: (untyped) -> untyped
54
+ def validate_predicate(predicate)
55
+ return predicate if predicate.respond_to?(:call)
56
+ unless predicate.is_a?(Hash)
57
+ raise ConfigurationError, 'historical_filter: must respond to call or be an attribute hash'
58
+ end
59
+
60
+ attributes = predicate.transform_keys(&:to_s)
61
+ unknown = attributes.keys - @model.attribute_names
62
+ if unknown.any?
63
+ raise ConfigurationError,
64
+ "unknown historical attributes: #{unknown.join(', ')}"
65
+ end
66
+
67
+ Support.immutable_copy(attributes)
68
+ end
69
+
70
+ #: (untyped) -> Integer
71
+ def validate_budget(value)
72
+ return value if value.instance_of?(Integer) && value.positive?
73
+
74
+ raise ConfigurationError, 'max_filter_versions: must be a positive Integer'
75
+ end
76
+
77
+ #: (Array[String]) -> Hash[String, Array[untyped]]
78
+ def candidate_versions(ids)
79
+ relation = @model.paper_trail.version_class.where(item_type: @model.base_class.name,
80
+ item_id: ids)
81
+ range = @time_range
82
+ relation = range.scope(relation) if range
83
+ # One past the remaining budget is enough to know it was exceeded.
84
+ versions = VersionQuery.chronological(relation).limit(@max_versions - @loaded + 1)
85
+ account_versions!(versions.length)
86
+ versions.group_by { |version| version.item_id.to_s }
87
+ end
88
+
89
+ #: (Array[untyped], untyped) -> boolish
90
+ def held?(versions, closing)
91
+ return true if versions.any? { |version| matches?(version) }
92
+ return false unless closing
93
+
94
+ Endpoint.version?(closing) ? matches?(closing) : record_matches?(closing)
95
+ end
96
+
97
+ # What each candidate held after its last change in the window.
98
+ #: (Array[String], Hash[String, Array[untyped]]) -> Hash[String, untyped]
99
+ def closing_states(ids, versions)
100
+ open = ids.reject { |id| versions.fetch(id, []).last&.event == 'destroy' }
101
+ trailing = trailing_versions(open)
102
+ trailing.merge(live_records(open - trailing.keys))
103
+ end
104
+
105
+ #: (Array[String]) -> Hash[String, untyped]
106
+ def trailing_versions(ids)
107
+ range = @time_range
108
+ return {} unless range
109
+
110
+ relation = @model.paper_trail.version_class.where(item_type: @model.base_class.name,
111
+ item_id: ids)
112
+ trailing = range.first_trailing_versions(relation)
113
+ account_versions!(trailing.length)
114
+ trailing
115
+ end
116
+
117
+ #: (Array[String]) -> Hash[String, untyped]
118
+ def live_records(ids)
119
+ @model.unscoped.where(@model.primary_key => ids).to_h { |record| [record.id.to_s, record] }
120
+ end
121
+
122
+ #: (untyped) -> boolish
123
+ def record_matches?(record)
124
+ return @predicate.call(record) unless @predicate.instance_of?(Hash)
125
+
126
+ @predicate.all? { |name, expected| record.attributes.fetch(name) == expected }
127
+ end
128
+
129
+ #: (Integer) -> void
130
+ def account_versions!(count)
131
+ @loaded += count
132
+ return unless @loaded > @max_versions
133
+
134
+ raise FilterLimitExceededError,
135
+ "historical_filter: exceeds max_filter_versions: #{@max_versions}; " \
136
+ 'narrow the window or explicitly increase the version budget'
137
+ end
138
+
139
+ #: (untyped) -> boolish
140
+ def matches?(version)
141
+ if @predicate.instance_of?(Hash)
142
+ return false unless version.object
143
+
144
+ return attributes_match?(version)
145
+ end
146
+
147
+ state = version.reify(dup: true)
148
+ state && @predicate.call(state)
149
+ end
150
+
151
+ #: (untyped) -> bool
152
+ def attributes_match?(version)
153
+ state = @state_loader.call(version)
154
+ unless state
155
+ raise ConfigurationError,
156
+ 'historical_filter: cannot read recorded attributes directly; use a bounded callable'
157
+ end
158
+
159
+ @predicate.all? do |name, expected|
160
+ unless state.attributes.key?(name)
161
+ raise ConfigurationError,
162
+ "historical_filter: #{name} was not recorded in version #{version.id}"
163
+ end
164
+
165
+ state.attributes.fetch(name) == expected
166
+ end
167
+ end
168
+
169
+ #: () -> void
170
+ def reject_root_limit!
171
+ raise BatchLimitExceededError,
172
+ "historical_filter: matched more than #{@limit} roots; narrow the window " \
173
+ 'or the filter, or raise limit: to the population you intend to analyze'
174
+ end
175
+ end
176
+ end