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,18 +4,21 @@
4
4
  module PaperTrailDiff
5
5
  # Chooses explicit-version or wall-clock selection for one timeline request.
6
6
  class TimelineRange
7
- BOUNDARY_SYMBOLS = %i[first last].freeze
7
+ BOUNDARY_SYMBOLS = %i[first].freeze
8
+ # `:last` once named the last version, whose recorded state is the one
9
+ # *before* the latest change, so a range ending there silently dropped it.
10
+ LAST_REMOVED = '`:last` was removed because it left out the latest change: omit `to:` ' \
11
+ 'to end at current state, or pass a version'
8
12
 
9
13
  attr_reader :from #: untyped
10
14
  attr_reader :to #: untyped
11
15
  attr_reader :time_range #: TimeRange?
12
16
 
13
- #: (untyped, from: untyped, to: untyped, within: untyped, ?versions: Array[untyped]?, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
14
- def initialize(record, from:, to:, within:, versions: nil, version_scope: nil, plan: nil, live_endpoint: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
17
+ #: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
18
+ def initialize(record, from:, to:, within:, version_scope: nil, plan: nil, live_endpoint: nil) # rubocop:disable Metrics/ParameterLists
15
19
  @record = record
16
20
  @plan = plan
17
21
  @live_endpoint = live_endpoint
18
- @versions = (plan ? plan.versions : versions)&.freeze
19
22
  @version_scope = version_scope
20
23
  @requested_from = from
21
24
  @requested_to = to
@@ -26,7 +29,7 @@ module PaperTrailDiff
26
29
  freeze
27
30
  end
28
31
 
29
- # A record with no versions has no first or last boundary to resolve, which
32
+ # A record with no versions has no first boundary to resolve, which
30
33
  # is an empty history rather than a bad request.
31
34
  #: () -> bool
32
35
  def unresolved?
@@ -49,27 +52,7 @@ module PaperTrailDiff
49
52
  live_endpoint: @live_endpoint
50
53
  ).select_plan(context_required: context_required)
51
54
  end
52
- return RootVersionPlan.empty if unresolved?
53
-
54
- VersionRange.new(
55
- @record, from: @from, to: @to, version_scope: @version_scope
56
- ).select_plan_for_range
57
- end
58
-
59
- #: (?context_required: bool) -> Array[untyped]
60
- def select(context_required: false)
61
- preselected = @versions
62
- return preselected if preselected
63
-
64
- range = time_range
65
- if range
66
- return TimeVersionRange.new(
67
- @record, time_range: range, version_scope: @version_scope
68
- ).select(context_required: context_required)
69
- end
70
- return empty_versions if unresolved?
71
-
72
- VersionRange.new(@record, from: @from, to: @to, version_scope: @version_scope).select
55
+ explicit_plan
73
56
  end
74
57
 
75
58
  #: () -> bool
@@ -91,7 +74,6 @@ module PaperTrailDiff
91
74
  private
92
75
 
93
76
  # @rbs @record: untyped
94
- # @rbs @versions: Array[untyped]?
95
77
  # @rbs @plan: RootVersionPlan?
96
78
  # @rbs @version_scope: untyped
97
79
  # @rbs @live_endpoint: untyped
@@ -100,10 +82,17 @@ module PaperTrailDiff
100
82
  # @rbs @from: untyped
101
83
  # @rbs @to: untyped
102
84
 
103
- #: () -> Array[untyped]
104
- def empty_versions
105
- versions = [] #: Array[untyped]
106
- versions.freeze
85
+ #: () -> RootVersionPlan
86
+ def explicit_plan
87
+ return RootVersionPlan.empty if unresolved?
88
+ if Endpoint.record?(@to)
89
+ return VersionRange.new(@record, from: @from, to: @from, version_scope: @version_scope)
90
+ .select_plan_through_current(@to)
91
+ end
92
+
93
+ VersionRange.new(
94
+ @record, from: @from, to: @to, version_scope: @version_scope
95
+ ).select_plan_for_range
107
96
  end
108
97
 
109
98
  #: (untyped) -> bool
@@ -111,32 +100,25 @@ module PaperTrailDiff
111
100
  boundary.is_a?(Symbol)
112
101
  end
113
102
 
114
- # Resolving `:first` and `:last` here keeps callers from depending on the
103
+ # Resolving `:first` here keeps callers from depending on the
115
104
  # order PaperTrail happens to give its versions association, which a caller
116
105
  # is also free to reorder.
117
106
  #: (untyped) -> untyped
118
107
  def resolve(boundary)
119
108
  return boundary unless symbolic?(boundary)
109
+ raise InvalidTimelineRangeError, LAST_REMOVED if boundary == :last
120
110
 
121
111
  unless BOUNDARY_SYMBOLS.include?(boundary)
122
112
  raise InvalidTimelineRangeError,
123
- "unsupported boundary: #{boundary.inspect}; use :first, :last, a version, or a record"
113
+ "unsupported boundary: #{boundary.inspect}; use :first, a version, or a record"
124
114
  end
125
115
 
126
- boundary == :first ? ordered_versions.first : ordered_versions.last
116
+ ordered_versions.first
127
117
  end
128
118
 
129
119
  #: () -> untyped
130
120
  def ordered_versions
131
- versions_relation.reorder(created_at: :asc, id: :asc)
132
- end
133
-
134
- #: () -> untyped
135
- def versions_relation
136
- @record.public_send(@record.class.versions_association_name)
137
- rescue NoMethodError => e
138
- message = 'record does not expose a PaperTrail version history'
139
- raise InvalidTimelineRangeError, message, cause: e
121
+ Endpoint.versions(@record).reorder(created_at: :asc, id: :asc)
140
122
  end
141
123
 
142
124
  #: (untyped) -> TimeRange?
@@ -8,10 +8,15 @@ module PaperTrailDiff
8
8
  def initialize(store, live_snapshotter:)
9
9
  @store = store
10
10
  @live_snapshotter = live_snapshotter
11
+ @snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
11
12
  end
12
13
 
13
14
  #: (untyped, Array[untyped]) -> void
14
15
  def prepare(record, versions)
16
+ # Adjacent steps share endpoints. Keep their immutable snapshots for this
17
+ # root only, including nil create states, and release them before the next
18
+ # root so batch memory does not retain every reconstructed graph.
19
+ @snapshots.clear
15
20
  @store.prepare(record, versions)
16
21
  end
17
22
 
@@ -19,9 +24,14 @@ module PaperTrailDiff
19
24
  # version, which is the one endpoint no version history can reconstruct.
20
25
  #: (untyped) -> RecordSnapshot?
21
26
  def call(endpoint)
22
- return live_snapshot(endpoint) if Endpoint.record?(endpoint)
27
+ key = [endpoint.class.name, endpoint.id]
28
+ return @snapshots[key] if @snapshots.key?(key)
23
29
 
24
- @store.uncached(endpoint, endpoint)
30
+ @snapshots[key] = if Endpoint.record?(endpoint)
31
+ live_snapshot(endpoint)
32
+ else
33
+ @store.uncached(endpoint, endpoint)
34
+ end
25
35
  end
26
36
 
27
37
  private
@@ -33,5 +43,6 @@ module PaperTrailDiff
33
43
 
34
44
  # @rbs @store: HistoricalSnapshotStore
35
45
  # @rbs @live_snapshotter: untyped
46
+ # @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
36
47
  end
37
48
  end
@@ -73,7 +73,7 @@ module PaperTrailDiff
73
73
  def each_change(&receiver)
74
74
  return enum_for(:each_change) unless receiver
75
75
 
76
- each_entry { |entry| receiver.call(entry) if entry.change? }
76
+ each_entry { |entry| yield(entry) if entry.change? }
77
77
  self
78
78
  end
79
79
  end
@@ -20,6 +20,72 @@ module PaperTrailDiff
20
20
  end
21
21
  end
22
22
 
23
+ # A change to an array inside a value the database stores whole, reported by
24
+ # membership rather than by position.
25
+ #
26
+ # Position would be the obvious thing to report and the wrong one: elements
27
+ # here carry no identity, so inserting at the front makes every later index
28
+ # look changed, and one insertion is described as several edits. Membership is
29
+ # answerable without claiming any pairing -- "gained \"saturn v\"" is true
30
+ # whether the array is a set, a queue, or a ranked list.
31
+ #
32
+ # That leaves one case membership cannot see, so it is named rather than
33
+ # hidden: the same elements in a different order. `reordered?` says so.
34
+ #
35
+ # An element that is itself a Hash or Array is reported whole. Saying which
36
+ # field of which object changed would require pairing before-elements with
37
+ # after-elements, and nothing in the value licenses that pairing.
38
+ class ArrayChange
39
+ attr_reader :from #: Array[untyped]
40
+ attr_reader :to #: Array[untyped]
41
+ attr_reader :added #: Array[untyped]
42
+ attr_reader :removed #: Array[untyped]
43
+
44
+ #: (from: Array[untyped], to: Array[untyped]) -> void
45
+ def initialize(from:, to:)
46
+ @from = Support.immutable_copy(from)
47
+ @to = Support.immutable_copy(to)
48
+ @added = Support.immutable_copy(ArrayChange.surplus(to, from))
49
+ @removed = Support.immutable_copy(ArrayChange.surplus(from, to))
50
+ freeze
51
+ end
52
+
53
+ # Elements of `left` with no counterpart left in `right`, matching by value
54
+ # and respecting duplicates, so ["a", "a"] -> ["a"] reports one removal
55
+ # rather than none.
56
+ #: (Array[untyped], Array[untyped]) -> Array[untyped]
57
+ def self.surplus(left, right)
58
+ pool = right.dup
59
+ left.reject do |item|
60
+ index = pool.index(item)
61
+ index ? pool.delete_at(index) || true : false
62
+ end
63
+ end
64
+
65
+ # Same elements, different sequence. Only meaningful for a change that was
66
+ # recorded at all, which this only is when the two sides differ.
67
+ #: () -> bool
68
+ def reordered?
69
+ added.empty? && removed.empty?
70
+ end
71
+
72
+ #: () -> bool
73
+ def empty?
74
+ from == to
75
+ end
76
+
77
+ #: () -> Hash[Symbol, untyped]
78
+ def to_h
79
+ {
80
+ from: Support.serialize(from),
81
+ to: Support.serialize(to),
82
+ added: Support.serialize(added),
83
+ removed: Support.serialize(removed),
84
+ reordered: reordered?
85
+ }
86
+ end
87
+ end
88
+
23
89
  # Attribute and nested-association changes for a record whose identity did not change.
24
90
  class RecordChange
25
91
  attr_reader :record #: RecordReference
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.11.0'
5
+ VERSION = '0.13.0'
6
6
  end
@@ -32,7 +32,7 @@ module PaperTrailDiff
32
32
  def with_connection(&block)
33
33
  return @version_class.with_connection(&block) if @version_class.respond_to?(:with_connection)
34
34
 
35
- block.call(@version_class.connection)
35
+ yield(@version_class.connection)
36
36
  end
37
37
 
38
38
  #: () -> String
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Places one version relative to another on the recorded timeline.
6
+ module VersionBoundary
7
+ module_function
8
+
9
+ # Whether `candidate` was recorded at or after `boundary`. Versions sharing
10
+ # a timestamp are ordered by id, which reflects insertion order only for
11
+ # sequential ids in one version table; any other tie cannot be ordered, and
12
+ # guessing would place a change on an arbitrary side of the boundary. A
13
+ # bare instant has no position among same-time versions, so it keeps them.
14
+ #: (untyped, untyped) -> bool
15
+ def at_or_after?(candidate, boundary)
16
+ tied = candidate.created_at == boundary.created_at
17
+ return candidate.created_at > boundary.created_at unless tied
18
+ return true unless version_record?(boundary)
19
+ return true if same_version?(candidate, boundary)
20
+
21
+ unless orderable_tie?(candidate, boundary)
22
+ raise AmbiguousVersionOrderError, Support.ambiguous_message([boundary, candidate])
23
+ end
24
+
25
+ Support.compare_versions(candidate, boundary) >= 0
26
+ end
27
+
28
+ #: (untyped) -> bool
29
+ def version_record?(value)
30
+ value.respond_to?(:id) && value.respond_to?(:item_type) &&
31
+ value.class.respond_to?(:table_name)
32
+ end
33
+ private_class_method :version_record?
34
+
35
+ #: (untyped, untyped) -> bool
36
+ def same_version?(left, right)
37
+ left.class.table_name == right.class.table_name && left.id.to_s == right.id.to_s
38
+ end
39
+ private_class_method :same_version?
40
+
41
+ #: (untyped, untyped) -> bool
42
+ def orderable_tie?(left, right)
43
+ left.class.table_name == right.class.table_name &&
44
+ Support.sequential_id?(left.id) && Support.sequential_id?(right.id)
45
+ end
46
+ private_class_method :orderable_tie?
47
+ end
48
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Arel conditions shared by the version queries that need one row per item.
6
+ module VersionQuery
7
+ module_function
8
+
9
+ # Keeps each item's earliest version among those the block also accepts:
10
+ # no other version of the same item that the block accepts is earlier. A
11
+ # correlated NOT EXISTS stays portable where window functions are not.
12
+ #: (untyped) ?{ (untyped) -> untyped } -> untyped
13
+ def no_earlier_version(table, &block)
14
+ arel = Object.const_get(:Arel) #: untyped
15
+ earlier = table.alias('paper_trail_diff_earlier_versions')
16
+ arel.const_get(:SelectManager).new
17
+ .from(earlier)
18
+ .project(arel.sql('1'))
19
+ .where(earlier_version(table, earlier, &block))
20
+ .exists
21
+ .not
22
+ end
23
+
24
+ #: (untyped, untyped) ?{ (untyped) -> untyped } -> untyped
25
+ def earlier_version(table, earlier)
26
+ same_item = earlier[:item_type].eq(table[:item_type])
27
+ .and(earlier[:item_id].eq(table[:item_id]))
28
+ .and(earlier[:created_at].lt(table[:created_at]))
29
+ block_given? ? same_item.and(yield(earlier)) : same_item
30
+ end
31
+ private_class_method :earlier_version
32
+ end
33
+ end
@@ -12,7 +12,7 @@ module PaperTrailDiff
12
12
  @version_scope = version_scope
13
13
  end
14
14
 
15
- # Same selection as `select`, but keeping the step pairs a filter implies.
15
+ # Keep both reconstruction boundaries and the step pairs a filter implies.
16
16
  #: () -> RootVersionPlan
17
17
  def select_plan_for_range
18
18
  relation = validated_relation
@@ -24,7 +24,7 @@ module PaperTrailDiff
24
24
 
25
25
  #: () -> untyped
26
26
  def validated_relation
27
- relation = versions_relation
27
+ relation = Endpoint.versions(@record)
28
28
  validate_boundary!(@from, relation, boundary: :from)
29
29
  validate_boundary!(@to, relation, boundary: :to)
30
30
  if Support.compare_versions(@from, @to).positive?
@@ -34,26 +34,12 @@ module PaperTrailDiff
34
34
  relation
35
35
  end
36
36
 
37
- #: () -> Array[untyped]
38
- def select
39
- relation = versions_relation
37
+ #: (untyped) -> RootVersionPlan
38
+ def select_plan_through_current(current)
39
+ Endpoint.validate_pair!(@record, current)
40
+ relation = Endpoint.versions(@record)
40
41
  validate_boundary!(@from, relation, boundary: :from)
41
- validate_boundary!(@to, relation, boundary: :to)
42
- if Support.compare_versions(@from, @to).positive?
43
- raise InvalidTimelineRangeError, '`from` version must not follow `to` version'
44
- end
45
-
46
- select_relation(
47
- relation.where(created_at: @from.created_at..@to.created_at),
48
- through: @to
49
- )
50
- end
51
-
52
- #: () -> Array[untyped]
53
- def select_through_latest
54
- relation = versions_relation
55
- validate_boundary!(@from, relation, boundary: :from)
56
- select_relation(relation.where(created_at: @from.created_at..))
42
+ select_plan(relation.where(created_at: @from.created_at..), live_endpoint: current)
57
43
  end
58
44
 
59
45
  private
@@ -63,22 +49,8 @@ module PaperTrailDiff
63
49
  # @rbs @to: untyped
64
50
  # @rbs @version_scope: untyped
65
51
 
66
- #: () -> untyped
67
- def versions_relation
68
- association_name = @record.class.versions_association_name
69
- @record.public_send(association_name)
70
- rescue NoMethodError => e
71
- message = 'record does not expose a PaperTrail version history'
72
- raise InvalidTimelineRangeError, message, cause: e
73
- end
74
-
75
- #: (untyped, ?through: untyped) -> Array[untyped]
76
- def select_relation(relation, through: nil)
77
- select_plan(relation, through: through).versions
78
- end
79
-
80
- #: (untyped, ?through: untyped) -> RootVersionPlan
81
- def select_plan(relation, through: nil)
52
+ #: (untyped, ?through: untyped, ?live_endpoint: untyped) -> RootVersionPlan
53
+ def select_plan(relation, through: nil, live_endpoint: nil)
82
54
  in_range = ordered(relation.to_a.select do |version|
83
55
  next false if Support.compare_versions(@from, version).positive?
84
56
  next true unless through
@@ -90,7 +62,7 @@ module PaperTrailDiff
90
62
  selected: VersionScopeFilter.new(@version_scope).call(relation, in_range),
91
63
  after_range: nil,
92
64
  windowed: false,
93
- filtered: !@version_scope.nil?
65
+ filtered: !@version_scope.nil?, live_endpoint: live_endpoint
94
66
  ).call
95
67
  end
96
68