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
@@ -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)
@@ -0,0 +1,93 @@
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
+ versions = @model.paper_trail.version_class.where(
68
+ item_type: @model.base_class.name, item_id: @identities.map(&:last), event: 'destroy'
69
+ ).reorder(created_at: :asc, id: :asc)
70
+ versions.group_by { |version| version.item_id.to_s }.values.map do |history|
71
+ Support.chronological_sort(history).last
72
+ end
73
+ end
74
+
75
+ #: (Array[untyped]) -> Hash[Symbol, untyped]
76
+ def preparation_range(versions)
77
+ { start_at: @time_range&.begin_time,
78
+ end_at: [@time_range&.end_time, versions.map(&:created_at).max].compact.max }
79
+ end
80
+
81
+ #: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
82
+ def prepare(records, selected)
83
+ records.group_by(&:class).each_value do |group|
84
+ versions = group.flat_map do |record|
85
+ selected.fetch(Endpoint.identity(record)).reconstruction_versions
86
+ end
87
+ next if versions.empty?
88
+
89
+ @history_preparer.call(group, versions, **preparation_range(versions))
90
+ end
91
+ end
92
+ end
93
+ 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,181 @@
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.is_a?(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
+ versions = relation.reorder(created_at: :asc, id: :asc)
84
+ .limit(@max_versions - @loaded + 1).to_a
85
+ account_versions!(versions.length)
86
+ versions.group_by { |version| version.item_id.to_s }
87
+ end
88
+
89
+ #: (Array[untyped], untyped) -> bool
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.to_s == 'destroy' }
101
+ return {} if open.empty?
102
+
103
+ trailing = trailing_versions(open)
104
+ trailing.merge(live_records(open - trailing.keys))
105
+ end
106
+
107
+ #: (Array[String]) -> Hash[String, untyped]
108
+ def trailing_versions(ids)
109
+ range = @time_range
110
+ return {} unless range
111
+
112
+ relation = @model.paper_trail.version_class.where(item_type: @model.base_class.name,
113
+ item_id: ids)
114
+ trailing = range.first_trailing_versions(relation)
115
+ account_versions!(trailing.length)
116
+ trailing
117
+ end
118
+
119
+ #: (Array[String]) -> Hash[String, untyped]
120
+ def live_records(ids)
121
+ return {} if ids.empty?
122
+
123
+ @model.base_class.unscoped.where(@model.primary_key => ids).to_a
124
+ .to_h { |record| [record.id.to_s, record] }
125
+ end
126
+
127
+ #: (untyped) -> bool
128
+ def record_matches?(record)
129
+ return !!@predicate.call(record) unless @predicate.is_a?(Hash)
130
+
131
+ @predicate.all? { |name, expected| record.attributes.fetch(name) == expected }
132
+ end
133
+
134
+ #: (Integer) -> void
135
+ def account_versions!(count)
136
+ @loaded += count
137
+ return unless @loaded > @max_versions
138
+
139
+ raise FilterLimitExceededError,
140
+ "historical_filter: exceeds max_filter_versions: #{@max_versions}; " \
141
+ 'narrow the window or explicitly increase the version budget'
142
+ end
143
+
144
+ #: (untyped) -> bool
145
+ def matches?(version)
146
+ if @predicate.is_a?(Hash)
147
+ return false unless version.object
148
+
149
+ return attributes_match?(version)
150
+ end
151
+
152
+ state = version.reify(dup: true)
153
+ !!(state && @predicate.call(state))
154
+ end
155
+
156
+ #: (untyped) -> bool
157
+ def attributes_match?(version)
158
+ state = @state_loader.call(version)
159
+ unless state
160
+ raise ConfigurationError,
161
+ 'historical_filter: cannot read recorded attributes directly; use a bounded callable'
162
+ end
163
+
164
+ @predicate.all? do |name, expected|
165
+ unless state.attributes.key?(name)
166
+ raise ConfigurationError,
167
+ "historical_filter: #{name} was not recorded in version #{version.id}"
168
+ end
169
+
170
+ state.model_class.type_for_attribute(name).cast(state.attributes.fetch(name)) == expected
171
+ end
172
+ end
173
+
174
+ #: () -> void
175
+ def reject_root_limit!
176
+ raise BatchLimitExceededError,
177
+ "historical_filter: matched more than #{@limit} roots; narrow the window " \
178
+ 'or the filter, or raise limit: to the population you intend to analyze'
179
+ end
180
+ end
181
+ end
@@ -10,6 +10,7 @@ module PaperTrailDiff
10
10
  @traversal = traversal
11
11
  @normalizer = normalizer
12
12
  @preparer = preparer
13
+ @prepared_snapshotter = PreparedSnapshotBuilder.new(traversal)
13
14
  @live_graph_collector = LiveGraphCollector.new(tree: tree, traversal: traversal)
14
15
  @snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
15
16
  @prepared_history = nil #: PreparedHistory?
@@ -24,8 +25,13 @@ module PaperTrailDiff
24
25
  end_at: root_versions.last.created_at
25
26
  )
26
27
  return if @tree.empty?
28
+
27
29
  # A batched preparation already covers these roots.
28
- return if root_versions.any? { |v| @prepared_histories.key?(context_key(v)) }
30
+ batched = root_versions.find { |version| @prepared_histories.key?(context_key(version)) }
31
+ if batched
32
+ @prepared_history = @prepared_histories.fetch(context_key(batched))
33
+ return
34
+ end
29
35
 
30
36
  @prepared_history = PreparedHistoryLoader.new(
31
37
  record,
@@ -37,15 +43,24 @@ module PaperTrailDiff
37
43
  ).call
38
44
  end
39
45
 
40
- #: (Array[untyped], Array[untyped]) -> void
41
- def prepare_batch(records, root_versions)
46
+ #: (Array[untyped], Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
47
+ def prepare_batch(records, root_versions, start_at: nil, end_at: nil)
42
48
  return if @tree.empty? || records.empty? || root_versions.empty?
43
49
 
44
50
  Instrumentation.instrument('prepare_history', batch_payload(records, root_versions)) do
45
- register_batch(records, root_versions)
51
+ register_batch(records, root_versions, start_at: start_at, end_at: end_at)
46
52
  end
47
53
  end
48
54
 
55
+ # Missing roots have no live graph to collect. Reading their associations
56
+ # here would issue a query per root and seed nonexistent rows as live state.
57
+ #: (Array[untyped], Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
58
+ def prepare_deleted_batch(records, root_versions, start_at: nil, end_at: nil)
59
+ return if @tree.empty? || records.empty? || root_versions.empty?
60
+
61
+ register_batch(records, root_versions, live_records: [], start_at: start_at, end_at: end_at)
62
+ end
63
+
49
64
  #: (untyped, untyped) -> RecordSnapshot?
50
65
  def call(root_endpoint, context_endpoint)
51
66
  key = snapshot_key(root_endpoint, context_endpoint)
@@ -100,6 +115,11 @@ module PaperTrailDiff
100
115
  )
101
116
  end
102
117
 
118
+ #: () -> PreparedActivityIndex?
119
+ def activity_index
120
+ @prepared_history&.activity_index
121
+ end
122
+
103
123
  #: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
104
124
  def record_transition(version)
105
125
  history = @prepared_history
@@ -108,12 +128,19 @@ module PaperTrailDiff
108
128
  history.record_transition(Endpoint.model_class(version), version.item_id, version)
109
129
  end
110
130
 
131
+ #: (untyped, ?direct: bool) -> [bool, untyped]
132
+ def record_after(version, direct: false)
133
+ @prepared_history&.record_after(Endpoint.model_class(version), version.item_id, version,
134
+ direct: direct) || [false, nil]
135
+ end
136
+
111
137
  private
112
138
 
113
139
  # @rbs @tree: AssociationTree
114
140
  # @rbs @traversal: AssociationTraversal
115
141
  # @rbs @normalizer: SnapshotNormalizer
116
142
  # @rbs @preparer: untyped
143
+ # @rbs @prepared_snapshotter: PreparedSnapshotBuilder
117
144
  # @rbs @live_graph_collector: LiveGraphCollector
118
145
  # @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
119
146
  # @rbs @prepared_history: PreparedHistory?
@@ -126,25 +153,22 @@ module PaperTrailDiff
126
153
 
127
154
  #: (Array[untyped], Array[untyped]) -> Hash[Symbol, untyped]
128
155
  def batch_payload(records, root_versions)
129
- {
130
- root_count: records.length,
131
- version_count: root_versions.length,
132
- model_type: records.first.class.base_class.name.to_s,
133
- batched: true
134
- }
156
+ { root_count: records.length, version_count: root_versions.length,
157
+ model_type: records.first.class.base_class.name.to_s, batched: true }
135
158
  end
136
159
 
137
- #: (Array[untyped], Array[untyped]) -> void
138
- def register_batch(records, root_versions)
160
+ #: (Array[untyped], Array[untyped], ?live_records: Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
161
+ def register_batch(records, root_versions, live_records: @live_graph_collector.call(records),
162
+ start_at: nil, end_at: nil)
139
163
  history = PreparedHistoryLoader.new(
140
164
  records.first,
141
165
  root_ids: records.map(&:id),
142
166
  root_versions: root_versions,
143
- start_at: root_versions.map(&:created_at).min,
144
- end_at: root_versions.map(&:created_at).max,
167
+ start_at: start_at || root_versions.map(&:created_at).min,
168
+ end_at: end_at || root_versions.map(&:created_at).max,
145
169
  tree: @tree,
146
170
  traversal: @traversal,
147
- live_records: @live_graph_collector.call(records)
171
+ live_records: live_records
148
172
  ).call
149
173
  root_versions.each { |version| @prepared_histories[context_key(version)] = history }
150
174
  end
@@ -154,27 +178,24 @@ module PaperTrailDiff
154
178
  model_class = Endpoint.model_class(root_version)
155
179
  @preparer.call(model_class, historical: true)
156
180
  @traversal.ensure_habtm_history!(model_class, root_version)
157
- record = root_version.reify(dup: true)
158
- normalize(
159
- record,
160
- context_version,
161
- tree: tree,
162
- normalizer: normalizer,
163
- habtm_version: root_version
181
+ handled, snapshot = @prepared_snapshotter.call(
182
+ root_version, context_version, tree: tree, normalizer: normalizer,
183
+ history: @prepared_histories[context_key(context_version)] ||
184
+ @prepared_history
164
185
  )
186
+ return snapshot if handled
187
+
188
+ record = root_version.reify(dup: true)
189
+ normalize(record, context_version, tree: tree, normalizer: normalizer,
190
+ habtm_version: root_version)
165
191
  end
166
192
 
167
193
  #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
168
194
  def snapshot_from_live_root(root_record, context_version, tree:, normalizer:)
169
195
  record = Endpoint.reload_record(root_record)
170
196
  @preparer.call(record.class, historical: true)
171
- normalize(
172
- record,
173
- context_version,
174
- tree: tree,
175
- normalizer: normalizer,
176
- habtm_version: context_version
177
- )
197
+ normalize(record, context_version, tree: tree, normalizer: normalizer,
198
+ habtm_version: context_version)
178
199
  end
179
200
 
180
201
  #: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, habtm_version: untyped) -> RecordSnapshot?
@@ -188,12 +209,7 @@ module PaperTrailDiff
188
209
 
189
210
  #: (untyped, untyped) -> Array[untyped]
190
211
  def snapshot_key(root_endpoint, context_endpoint)
191
- [
192
- root_endpoint.class.name,
193
- root_endpoint.id,
194
- context_endpoint.class.name,
195
- context_endpoint.id
196
- ]
212
+ context_key(root_endpoint) + context_key(context_endpoint)
197
213
  end
198
214
  end
199
215
  end
@@ -9,13 +9,13 @@ module PaperTrailDiff
9
9
  module_function
10
10
 
11
11
  #: (String | Symbol, Hash[Symbol, untyped]) { () -> untyped } -> untyped
12
- def instrument(event, payload, &)
12
+ def instrument(event, payload, &block)
13
13
  active_support = Object.const_get(:ActiveSupport) #: untyped
14
14
  notifications = active_support.const_get(:Notifications) #: untyped
15
15
  notifications.instrument(
16
16
  "#{event}.paper_trail_diff",
17
17
  payload,
18
- &
18
+ &block
19
19
  )
20
20
  end
21
21