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
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Reads plain direct edges without creating AR records for their members.
6
+ class PreparedStateAssociationReader
7
+ #: (PreparedRecordIndex, untyped) -> void
8
+ def initialize(records, edges)
9
+ @records = records
10
+ @edges = edges
11
+ end
12
+
13
+ #: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
14
+ def call(owner, reflection, boundary)
15
+ return [false, []] unless supported?(reflection)
16
+
17
+ if reflection.belongs_to?
18
+ id = owner.attributes[reflection.foreign_key.to_s]
19
+ return [true, []] if id.nil?
20
+
21
+ handled, state = @records.snapshot_state_before(reflection.klass, id, boundary)
22
+ return [handled, state ? [state] : []]
23
+ end
24
+ children(owner, reflection, boundary)
25
+ end
26
+
27
+ private
28
+
29
+ # @rbs @records: PreparedRecordIndex
30
+ # @rbs @edges: untyped
31
+
32
+ #: (untyped) -> bool
33
+ def supported?(reflection)
34
+ !reflection.options[:through] && !reflection.polymorphic? && reflection.scope.nil? &&
35
+ !reflection.klass.default_scopes? && !reflection.foreign_key.is_a?(Array) &&
36
+ %i[belongs_to has_one has_many].include?(reflection.macro)
37
+ end
38
+
39
+ #: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
40
+ def children(owner, reflection, boundary)
41
+ selected = [] #: Array[PreparedRecordState]
42
+ @edges.call(owner.model_class, reflection).each_value do |group|
43
+ ids_for(group, owner).each do |id|
44
+ handled, child = @records.snapshot_state_before(group.fetch(:model), id, boundary)
45
+ return [false, []] unless handled
46
+
47
+ selected << child if child && member?(child, owner, reflection)
48
+ end
49
+ end
50
+ [true, reflection.collection? ? selected : selected.first(1)]
51
+ end
52
+
53
+ #: (untyped, PreparedRecordState) -> Array[untyped]
54
+ def ids_for(group, owner)
55
+ owners = group[:owners]
56
+ id = owner.attributes[owner.model_class.primary_key].to_s
57
+ empty = [] #: Array[untyped]
58
+ owners ? owners.fetch(id, empty) : group.fetch(:ids)
59
+ end
60
+
61
+ #: (PreparedRecordState, PreparedRecordState, untyped) -> bool
62
+ def member?(child, owner, reflection)
63
+ owner_id = owner.attributes[owner.model_class.primary_key].to_s
64
+ return false unless child.attributes[reflection.foreign_key.to_s].to_s == owner_id
65
+ return true unless reflection.options[:as]
66
+
67
+ matching_type?(child, owner.model_class, reflection)
68
+ end
69
+
70
+ #: (PreparedRecordState, untyped, untyped) -> bool
71
+ def matching_type?(child, model, reflection)
72
+ types = [model.name, model.base_class.name,
73
+ reflection.active_record.name]
74
+ types.include?(child.attributes[reflection.type.to_s].to_s)
75
+ end
76
+ end
77
+ end
@@ -27,6 +27,10 @@ module PaperTrailDiff
27
27
  # The root versions this plan reports as mutations, which excludes any
28
28
  # version present only to reveal what the last of them produced.
29
29
  attr_reader :mutations #: Array[untyped]
30
+ # Whether a wall-clock window closes on the root's own destruction. Its
31
+ # final state is then the absence the destroy left, just as a window
32
+ # closing on current state ends at the live record.
33
+ attr_reader :closes_on_destroy #: bool
30
34
 
31
35
  class << self
32
36
  # Adjacent boundaries, which is what an unfiltered range reports.
@@ -45,20 +49,30 @@ module PaperTrailDiff
45
49
  steps = [] #: Array[[untyped, untyped]]
46
50
  new(versions: versions, steps: steps)
47
51
  end
52
+
53
+ # No root version to report or replay: only descendants moved, and the
54
+ # live record is the root's state throughout.
55
+ #: (untyped) -> RootVersionPlan
56
+ def closing_on(record)
57
+ versions = [] #: Array[untyped]
58
+ steps = [] #: Array[[untyped, untyped]]
59
+ new(versions: versions, steps: steps, closing_record: record)
60
+ end
48
61
  end
49
62
 
50
- #: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped) -> void
63
+ #: (versions: Array[untyped], steps: Array[[untyped, untyped]], ?context_version: untyped, ?reconstruction_versions: Array[untyped]?, ?mutations: Array[untyped]?, ?closing_record: untyped, ?closes_on_destroy: bool) -> void
51
64
  def initialize( # rubocop:disable Metrics/ParameterLists
52
65
  versions:, steps:, context_version: nil, reconstruction_versions: nil, mutations: nil,
53
- closing_record: nil
66
+ closing_record: nil, closes_on_destroy: false
54
67
  )
55
68
  @closing_record = closing_record
69
+ @closes_on_destroy = closes_on_destroy
56
70
  @versions = versions.freeze
57
71
  @reconstruction_versions = (reconstruction_versions || versions).freeze
58
72
  @steps = steps.freeze
59
73
  @context_version = context_version
60
74
  @mutations = (mutations || default_mutations).freeze
61
- @mutation_keys = Set.new(@mutations.map { |version| key(version) }).freeze
75
+ @mutation_keys = key_set(@mutations)
62
76
  freeze
63
77
  end
64
78
 
@@ -67,6 +81,15 @@ module PaperTrailDiff
67
81
  versions.empty?
68
82
  end
69
83
 
84
+ #: () -> RootVersionPlan
85
+ def closing_on_destroy
86
+ self.class.new(
87
+ versions: versions, steps: steps, context_version: context_version,
88
+ reconstruction_versions: reconstruction_versions, mutations: mutations,
89
+ closing_record: closing_record, closes_on_destroy: true
90
+ )
91
+ end
92
+
70
93
  # What the range's final state is read from, which is the live record when
71
94
  # the window closes on current state and the last version otherwise.
72
95
  #: () -> untyped
@@ -91,6 +114,15 @@ module PaperTrailDiff
91
114
  @versions.reject { |version| key(version) == context }
92
115
  end
93
116
 
117
+ # A Set only makes the lookup in `mutation?` fast; an Array would answer the
118
+ # same, which is why mutant leaves this alone.
119
+ # mutant:disable
120
+ #: (Array[untyped]) -> Set[Array[untyped]]
121
+ def key_set(versions)
122
+ Set.new(versions.map { |version| key(version) })
123
+ end
124
+
125
+ # By class name, so a version still matches after its class is reloaded.
94
126
  #: (untyped) -> Array[untyped]
95
127
  def key(version)
96
128
  [version.class.name, version.id]
@@ -37,10 +37,8 @@ module PaperTrailDiff
37
37
  return without_selection if @selected.empty?
38
38
 
39
39
  closing = revealing_version || @live_endpoint
40
- raise IncompleteTimeRangeError, INCOMPLETE if !closing && @windowed && !terminal_destroy?
41
- return filtered_plan(closing) if @filtered
42
-
43
- contiguous_plan(closing)
40
+ plan = @filtered ? filtered_plan(closing) : contiguous_plan(closing)
41
+ closes_on_destroy?(closing) ? plan.closing_on_destroy : plan
44
42
  end
45
43
 
46
44
  private
@@ -69,13 +67,16 @@ module PaperTrailDiff
69
67
  end
70
68
 
71
69
  # An activity view still needs a root to reconstruct from even when no root
72
- # version falls inside the window, because descendants may have moved.
70
+ # version falls inside the window, because descendants may have moved. With
71
+ # no later version, a window closing on current state reads the root from
72
+ # the live record, which is then its state throughout the window.
73
73
  #: () -> RootVersionPlan
74
74
  def without_selection
75
75
  return RootVersionPlan.empty unless @context_required
76
76
  if @after_range
77
77
  return RootVersionPlan.contiguous([@after_range], context_version: @after_range)
78
78
  end
79
+ return RootVersionPlan.closing_on(@live_endpoint) if @live_endpoint && @windowed
79
80
 
80
81
  raise IncompleteTimeRangeError, INCOMPLETE
81
82
  end
@@ -149,6 +150,16 @@ module PaperTrailDiff
149
150
  immediate_successor(@selected.last) || @after_range
150
151
  end
151
152
 
153
+ # A window with nothing after its last selected mutation can only close on
154
+ # that mutation being the record's destruction.
155
+ #: (untyped) -> bool
156
+ def closes_on_destroy?(closing)
157
+ return false if closing || !@windowed
158
+ raise IncompleteTimeRangeError, INCOMPLETE unless terminal_destroy?
159
+
160
+ true
161
+ end
162
+
152
163
  # A range closing on the record's own destruction needs no later version:
153
164
  # nothing can follow it, so demanding one would reject the range forever.
154
165
  #: () -> bool
@@ -15,14 +15,29 @@ module PaperTrailDiff
15
15
  class ScopedAnalysis
16
16
  attr_reader :analyses #: Hash[Array[String], Analysis]
17
17
  attr_reader :unreachable #: Array[Array[String]]
18
+ # The live records the selection loaded, in the order it loaded them. The
19
+ # gem already has them; withholding them only makes a caller query the same
20
+ # rows again to attach its own presentation data. A destroyed root has no
21
+ # record to hand back, so `roots` and `unreachable` stay complementary.
22
+ attr_reader :roots #: Array[untyped]
23
+ # Requested missing-root analyses, kept separate because live SQL conditions
24
+ # cannot prove which deleted rows matched. nil means no destroy history.
25
+ attr_reader :deleted #: Hash[Array[String], Analysis?]
18
26
 
19
- #: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
20
- def initialize(analyses:, unreachable:)
27
+ #: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]], ?roots: Array[untyped], ?deleted: Hash[Array[String], Analysis?]) -> void
28
+ def initialize(analyses:, unreachable:, roots: [], deleted: {})
21
29
  @analyses = analyses
22
30
  @unreachable = Support.immutable_copy(unreachable)
31
+ # Not deep-copied: these are the caller's own model instances, and
32
+ # freezing them would make an ordinary `record.title` on a lazy attribute
33
+ # fail. The array itself is frozen so the collection cannot be reshaped.
34
+ @roots = roots.dup.freeze
35
+ @deleted = Support.immutable_copy(deleted)
23
36
  freeze
24
37
  end
25
38
 
39
+ # Two elements, not three: `roots` is reached by name so that existing
40
+ # destructuring keeps working.
26
41
  #: () -> [Hash[Array[String], Analysis], Array[Array[String]]]
27
42
  def to_ary
28
43
  [analyses, unreachable]
@@ -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 " \
@@ -171,7 +171,11 @@ module PaperTrailDiff
171
171
 
172
172
  #: (type: String, id: untyped, attributes: snapshot_attributes, ?associations: snapshot_associations) -> void
173
173
  def initialize(type:, id:, attributes:, associations: {})
174
- @type = Support.immutable_copy(type.to_s)
174
+ unless type.is_a?(String)
175
+ raise ArgumentError, "record type must be a String, not #{type.inspect}"
176
+ end
177
+
178
+ @type = Support.immutable_copy(type)
175
179
  @id = Support.immutable_copy(id)
176
180
  @identity = [@type, @id].freeze
177
181
  @attributes = normalize_hash(attributes)
@@ -184,6 +188,15 @@ module PaperTrailDiff
184
188
  RecordReference.new(type: type, id: id)
185
189
  end
186
190
 
191
+ # A copy holding a different state for one association.
192
+ #: (String, AssociationSnapshot) -> RecordSnapshot
193
+ def with_association(name, association)
194
+ self.class.new(
195
+ type: type, id: id, attributes: attributes,
196
+ associations: associations.merge(name => association)
197
+ )
198
+ end
199
+
187
200
  #: () -> Hash[Symbol, untyped]
188
201
  def to_h
189
202
  value = {