paper_trail_diff 0.2.0 → 0.3.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/QUICKSTART.md +44 -2
  4. data/README.md +188 -14
  5. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +126 -0
  6. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
  7. data/lib/paper_trail_diff/activity_collection_event_applier.rb +137 -0
  8. data/lib/paper_trail_diff/activity_collection_record_updater.rb +67 -0
  9. data/lib/paper_trail_diff/activity_collection_route_change.rb +21 -0
  10. data/lib/paper_trail_diff/activity_collection_route_updater.rb +128 -0
  11. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +42 -0
  12. data/lib/paper_trail_diff/activity_event_record_resolver.rb +120 -0
  13. data/lib/paper_trail_diff/activity_event_route_finder.rb +112 -0
  14. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +59 -437
  15. data/lib/paper_trail_diff/activity_history.rb +136 -0
  16. data/lib/paper_trail_diff/activity_range.rb +28 -12
  17. data/lib/paper_trail_diff/activity_relationship.rb +55 -0
  18. data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
  19. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
  20. data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
  21. data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
  22. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -3
  23. data/lib/paper_trail_diff/collection_comparator.rb +155 -0
  24. data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
  25. data/lib/paper_trail_diff/collection_transition.rb +25 -0
  26. data/lib/paper_trail_diff/comparison_batch.rb +151 -0
  27. data/lib/paper_trail_diff/engine.rb +5 -45
  28. data/lib/paper_trail_diff/errors.rb +9 -0
  29. data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
  30. data/lib/paper_trail_diff/instrumentation.rb +30 -0
  31. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
  32. data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
  33. data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
  34. data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
  35. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
  36. data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
  37. data/lib/paper_trail_diff/prepared_history.rb +14 -4
  38. data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
  39. data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
  40. data/lib/paper_trail_diff/snapshot.rb +66 -3
  41. data/lib/paper_trail_diff/support.rb +18 -0
  42. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
  43. data/lib/paper_trail_diff/time_range.rb +85 -0
  44. data/lib/paper_trail_diff/time_version_range.rb +53 -0
  45. data/lib/paper_trail_diff/timeline_builder.rb +13 -7
  46. data/lib/paper_trail_diff/timeline_range.rb +72 -0
  47. data/lib/paper_trail_diff/version.rb +1 -1
  48. data/lib/paper_trail_diff/version_association_candidate_scope.rb +73 -0
  49. data/lib/paper_trail_diff.rb +83 -12
  50. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +41 -0
  51. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
  52. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +46 -0
  53. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +25 -0
  54. data/sig/generated/paper_trail_diff/activity_collection_route_change.rbs +17 -0
  55. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +44 -0
  56. data/sig/generated/paper_trail_diff/activity_event_record_normalizer.rbs +19 -0
  57. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +44 -0
  58. data/sig/generated/paper_trail_diff/activity_event_route_finder.rbs +54 -0
  59. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +20 -71
  60. data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
  61. data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
  62. data/sig/generated/paper_trail_diff/activity_relationship.rbs +23 -0
  63. data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
  64. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
  65. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
  66. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
  67. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  68. data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
  69. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
  70. data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
  71. data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
  72. data/sig/generated/paper_trail_diff/engine.rbs +0 -12
  73. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  74. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
  75. data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
  76. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
  77. data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
  78. data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
  79. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
  80. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
  81. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
  82. data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
  83. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
  84. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
  85. data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
  86. data/sig/generated/paper_trail_diff/support.rbs +6 -0
  87. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
  88. data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
  89. data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
  90. data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
  91. data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -0
  92. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +32 -0
  93. data/sig/generated/paper_trail_diff.rbs +16 -8
  94. metadata +56 -4
@@ -42,6 +42,21 @@ module PaperTrailDiff
42
42
  SUPPORTED_KINDS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
43
43
  private_constant :COLLECTION_KINDS, :SUPPORTED_KINDS
44
44
 
45
+ # Non-semantic memoization shared by structurally adjacent snapshots. The
46
+ # cached value is derived only from frozen records and is itself immutable.
47
+ class IdentityIndexCache
48
+ #: () -> void
49
+ def initialize
50
+ @index = nil #: CollectionIdentityIndex?
51
+ end
52
+
53
+ #: (Array[RecordSnapshot]) -> CollectionIdentityIndex
54
+ def index(records)
55
+ @index ||= CollectionIdentityIndex.new(records)
56
+ end
57
+ end
58
+ private_constant :IdentityIndexCache
59
+
45
60
  class << self
46
61
  #: (Symbol) -> bool
47
62
  def collection_kind?(kind)
@@ -52,8 +67,8 @@ module PaperTrailDiff
52
67
  attr_reader :kind #: Symbol
53
68
  attr_reader :records #: Array[RecordSnapshot]
54
69
 
55
- #: (kind: Symbol, records: Array[RecordSnapshot]) -> void
56
- def initialize(kind:, records:)
70
+ #: (kind: Symbol, records: Array[RecordSnapshot], ?identity_index_cache: untyped, ?transition: CollectionTransition?) -> void
71
+ def initialize(kind:, records:, identity_index_cache: nil, transition: nil)
57
72
  unless SUPPORTED_KINDS.include?(kind)
58
73
  raise ArgumentError, "unsupported association kind: #{kind.inspect}"
59
74
  end
@@ -62,14 +77,62 @@ module PaperTrailDiff
62
77
  end
63
78
 
64
79
  @kind = kind
65
- @records = records.dup.freeze
80
+ @records = records.frozen? ? records : records.dup.freeze
81
+ @identity_index_cache = identity_index_cache || IdentityIndexCache.new
82
+ @transition = transition
66
83
  freeze
67
84
  end
68
85
 
86
+ # Builds a structurally adjacent collection snapshot for internal activity carry-forward.
87
+ #: (Array[RecordSnapshot], before: RecordSnapshot?, after: RecordSnapshot?, membership_preserved: bool) -> AssociationSnapshot
88
+ def transition_to(records, before:, after:, membership_preserved:)
89
+ cache = membership_preserved ? @identity_index_cache : nil
90
+ transition = if before || after
91
+ CollectionTransition.new(from: self, before: before, after: after)
92
+ end
93
+ self.class.new(
94
+ kind: kind,
95
+ records: records,
96
+ identity_index_cache: cache,
97
+ transition: transition
98
+ )
99
+ end
100
+
101
+ #: (AssociationSnapshot) -> CollectionTransition?
102
+ def transition_from(association)
103
+ @transition if @transition&.from?(association)
104
+ end
105
+
106
+ #: (untyped, untyped) -> Integer?
107
+ def position(type, id)
108
+ identity_index.position(type, id)
109
+ end
110
+
111
+ #: (untyped) -> Integer?
112
+ def position_for_id(id)
113
+ identity_index.position_for_id(id)
114
+ end
115
+
116
+ #: () -> void
117
+ def validate_unique_identities!
118
+ identity_index
119
+ nil
120
+ end
121
+
69
122
  #: () -> Hash[Symbol, untyped]
70
123
  def to_h
71
124
  { kind: kind, records: Support.serialize(records) }
72
125
  end
126
+
127
+ private
128
+
129
+ # @rbs @identity_index_cache: untyped
130
+ # @rbs @transition: CollectionTransition?
131
+
132
+ #: () -> CollectionIdentityIndex
133
+ def identity_index
134
+ @identity_index_cache.index(records)
135
+ end
73
136
  end
74
137
 
75
138
  # An immutable, ActiveRecord-independent representation of a reified record.
@@ -62,6 +62,15 @@ module PaperTrailDiff
62
62
  parent.empty? ? name : "#{parent}.#{name}"
63
63
  end
64
64
 
65
+ #: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
66
+ def merge_record_groups(existing, incoming)
67
+ existing.merge(incoming) do |_name, left, right|
68
+ merged = left.merge(right, ids: (left.fetch(:ids) | right.fetch(:ids)))
69
+ owners = merge_record_owners(left[:owners], right[:owners])
70
+ owners ? merged.merge(owners: owners) : merged
71
+ end
72
+ end
73
+
65
74
  #: (untyped) -> untyped
66
75
  def duplicate_and_freeze(value)
67
76
  value.dup.freeze
@@ -70,6 +79,15 @@ module PaperTrailDiff
70
79
  end
71
80
  private_class_method :duplicate_and_freeze
72
81
 
82
+ #: (Hash[String, Array[untyped]]?, Hash[String, Array[untyped]]?) -> Hash[String, Array[untyped]]?
83
+ def merge_record_owners(left, right)
84
+ return right unless left
85
+ return left unless right
86
+
87
+ left.merge(right) { |_owner, first, second| first | second }
88
+ end
89
+ private_class_method :merge_record_owners
90
+
73
91
  #: (untyped) -> bool
74
92
  def paper_trail_diff_value?(value)
75
93
  class_name = value.class.name
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Builds activity views for mutations selected by a wall-clock range.
6
+ class TimeActivityTimelineBuilder
7
+ #: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped) -> void
8
+ def initialize(record, range:, tree:, snapshotter:)
9
+ @record = record
10
+ @range = range
11
+ @tree = tree
12
+ @snapshotter = snapshotter
13
+ end
14
+
15
+ #: () -> Array[ActivityStep]
16
+ def build
17
+ history, = history_and_versions
18
+ history.steps
19
+ end
20
+
21
+ #: () -> Analysis
22
+ def analyze
23
+ history, root_versions = history_and_versions
24
+ Analysis.new(
25
+ diff: Engine.compare(history.first_snapshot, history.last_snapshot),
26
+ timeline: root_steps(root_versions, history.root_snapshots),
27
+ activity_timeline: history.steps
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ # @rbs @record: untyped
34
+ # @rbs @range: TimelineRange
35
+ # @rbs @tree: AssociationTree
36
+ # @rbs @snapshotter: untyped
37
+
38
+ #: () -> [ActivityHistory, Array[untyped]]
39
+ def history_and_versions
40
+ root_versions = @range.select(context_required: !@tree.empty?)
41
+ return [ActivityHistory.empty, root_versions] if root_versions.empty?
42
+
43
+ prepare_history(root_versions)
44
+ events = collect_events(root_versions)
45
+ return [ActivityHistory.empty, root_versions] unless time_events?(events)
46
+
47
+ history = ActivityHistoryBuilder.new(
48
+ root_versions,
49
+ events,
50
+ @snapshotter,
51
+ include_step: ->(event) { @range.include?(event.version) }
52
+ ).call
53
+ [history, root_versions]
54
+ end
55
+
56
+ #: (Array[untyped]) -> void
57
+ def prepare_history(root_versions)
58
+ return unless @snapshotter.respond_to?(:prepare)
59
+
60
+ @snapshotter.prepare(@record, root_versions, start_at: @range.begin_time)
61
+ end
62
+
63
+ #: (Array[untyped]) -> Array[ActivityEvent]
64
+ def collect_events(root_versions)
65
+ ActivityVersionCollector.new(
66
+ @record,
67
+ root_versions: root_versions,
68
+ tree: @tree,
69
+ traversal: AssociationTraversal.new(@tree),
70
+ range_start: @range.begin_time,
71
+ range_end: root_versions.last
72
+ ).call
73
+ end
74
+
75
+ #: (Array[ActivityEvent]) -> bool
76
+ def time_events?(events)
77
+ selected = events.select { |event| @range.include?(event.version) }
78
+ return false if selected.empty?
79
+ return true if later_event?(selected.last, events)
80
+
81
+ message = 'time range requires a later activity boundary to reconstruct its final change'
82
+ raise IncompleteTimeRangeError, message
83
+ end
84
+
85
+ #: (ActivityEvent, Array[ActivityEvent]) -> bool
86
+ def later_event?(selected, events)
87
+ events.any? do |event|
88
+ Support.compare_versions(selected.version, event.version).negative?
89
+ end
90
+ end
91
+
92
+ #: (Array[untyped], Hash[Array[untyped], RecordSnapshot?]) -> Array[Step]
93
+ def root_steps(root_versions, root_snapshots)
94
+ snapshots = root_versions.map do |version|
95
+ root_snapshots.fetch(version_key(version))
96
+ end
97
+ root_versions.each_cons(2).with_index.map do |versions, index|
98
+ Step.new(
99
+ from_version: versions.fetch(0),
100
+ to_version: versions.fetch(1),
101
+ diff: Engine.compare(snapshots.fetch(index), snapshots.fetch(index + 1))
102
+ )
103
+ end.freeze
104
+ end
105
+
106
+ #: (untyped) -> Array[untyped]
107
+ def version_key(version)
108
+ [version.class.name, version.id]
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Validates and applies a finite wall-clock range without loading model state.
6
+ class TimeRange
7
+ attr_reader :begin_time #: Time
8
+ attr_reader :end_time #: Time
9
+
10
+ #: (untyped) -> void
11
+ def initialize(value)
12
+ unless value.is_a?(Range) && value.begin && value.end
13
+ raise InvalidTimeRangeError, '`within` must be a finite Range'
14
+ end
15
+
16
+ @begin_time = coerce_time(value.begin, boundary: :begin)
17
+ @end_time = coerce_time(value.end, boundary: :end)
18
+ @exclude_end = value.exclude_end?
19
+ validate_order!
20
+ freeze
21
+ end
22
+
23
+ #: () -> bool
24
+ def exclude_end?
25
+ @exclude_end
26
+ end
27
+
28
+ #: (untyped) -> bool
29
+ def include?(value)
30
+ timestamp = coerce_time(value, boundary: :timestamp)
31
+ return false if timestamp < begin_time
32
+
33
+ exclude_end? ? timestamp < end_time : timestamp <= end_time
34
+ end
35
+
36
+ #: (untyped) -> untyped
37
+ def scope(relation)
38
+ relation.where(created_at: query_range)
39
+ end
40
+
41
+ #: (untyped) -> untyped
42
+ def trailing_scope(relation)
43
+ column = relation.klass.arel_table[:created_at]
44
+ predicate = exclude_end? ? column.gteq(end_time) : column.gt(end_time)
45
+ relation.where(predicate)
46
+ end
47
+
48
+ private
49
+
50
+ # @rbs @exclude_end: bool
51
+
52
+ #: () -> Range[Time]
53
+ def query_range
54
+ exclude_end? ? (begin_time...end_time) : (begin_time..end_time)
55
+ end
56
+
57
+ #: (untyped, boundary: Symbol) -> Time
58
+ def coerce_time(value, boundary:)
59
+ unless value.respond_to?(:to_time)
60
+ raise InvalidTimeRangeError, "`within` #{boundary} must be time-like"
61
+ end
62
+
63
+ time_value(value).getutc.freeze
64
+ rescue ArgumentError, NoMethodError, TypeError => e
65
+ raise InvalidTimeRangeError,
66
+ "`within` #{boundary} must be time-like",
67
+ cause: e
68
+ end
69
+
70
+ #: (untyped) -> Time
71
+ def time_value(value)
72
+ return value if value.is_a?(Time)
73
+
74
+ utc = value.utc if value.respond_to?(:utc)
75
+ utc.is_a?(Time) ? utc : value.to_time
76
+ end
77
+
78
+ #: () -> void
79
+ def validate_order!
80
+ return unless begin_time > end_time
81
+
82
+ raise InvalidTimeRangeError, '`within` beginning must not follow its end'
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Selects in-range root versions plus one later reconstruction boundary.
6
+ class TimeVersionRange
7
+ #: (untyped, time_range: TimeRange) -> void
8
+ def initialize(record, time_range:)
9
+ @record = record
10
+ @time_range = time_range
11
+ end
12
+
13
+ #: (?context_required: bool) -> Array[untyped]
14
+ def select(context_required: false)
15
+ relation = versions_relation
16
+ selected = ordered(@time_range.scope(relation).to_a)
17
+ empty = [] #: Array[untyped]
18
+ return empty.freeze if selected.empty? && !context_required
19
+
20
+ trailing = trailing_version(relation)
21
+ unless trailing
22
+ message = 'time range requires a later root version to reconstruct its final change'
23
+ raise IncompleteTimeRangeError, message
24
+ end
25
+
26
+ (selected + [trailing]).freeze
27
+ end
28
+
29
+ private
30
+
31
+ # @rbs @record: untyped
32
+ # @rbs @time_range: TimeRange
33
+
34
+ #: () -> untyped
35
+ def versions_relation
36
+ association_name = @record.class.versions_association_name
37
+ @record.public_send(association_name)
38
+ rescue NoMethodError => e
39
+ message = 'record does not expose a PaperTrail version history'
40
+ raise InvalidTimelineRangeError, message, cause: e
41
+ end
42
+
43
+ #: (untyped) -> untyped
44
+ def trailing_version(relation)
45
+ @time_range.trailing_scope(relation).reorder(created_at: :asc, id: :asc).first
46
+ end
47
+
48
+ #: (Array[untyped]) -> Array[untyped]
49
+ def ordered(versions)
50
+ versions.sort_by { |version| Support.chronological_version_key(version) }
51
+ end
52
+ end
53
+ end
@@ -4,11 +4,10 @@
4
4
  module PaperTrailDiff
5
5
  # Selects and compares a chronological slice of a record's version history.
6
6
  class TimelineBuilder
7
- #: (untyped, from: untyped, to: untyped, snapshotter: untyped) -> void
8
- def initialize(record, from:, to:, snapshotter:)
7
+ #: (untyped, from: untyped, to: untyped, snapshotter: untyped, ?within: untyped) -> void
8
+ def initialize(record, from:, to:, snapshotter:, within: nil)
9
9
  @record = record
10
- @from = from
11
- @to = to
10
+ @range = TimelineRange.new(record, from: from, to: to, within: within)
12
11
  @snapshotter = snapshotter
13
12
  end
14
13
 
@@ -30,12 +29,13 @@ module PaperTrailDiff
30
29
  private
31
30
 
32
31
  # @rbs @record: untyped
33
- # @rbs @from: untyped
34
- # @rbs @to: untyped
32
+ # @rbs @range: TimelineRange
35
33
  # @rbs @snapshotter: untyped
36
34
 
37
35
  #: (Array[untyped]) -> [Array[Step], RecordSnapshot?, RecordSnapshot?]
38
36
  def compare_history(versions)
37
+ return empty_history if versions.empty?
38
+
39
39
  @snapshotter.prepare(@record, versions) if @snapshotter.respond_to?(:prepare)
40
40
  first_snapshot = @snapshotter.call(versions.first)
41
41
  previous_snapshot = first_snapshot
@@ -52,9 +52,15 @@ module PaperTrailDiff
52
52
  [steps, first_snapshot, previous_snapshot]
53
53
  end
54
54
 
55
+ #: () -> [Array[Step], nil, nil]
56
+ def empty_history
57
+ steps = [] #: Array[Step]
58
+ [steps.freeze, nil, nil]
59
+ end
60
+
55
61
  #: () -> Array[untyped]
56
62
  def selected_versions
57
- VersionRange.new(@record, from: @from, to: @to).select
63
+ @range.select
58
64
  end
59
65
  end
60
66
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Chooses explicit-version or wall-clock selection for one timeline request.
6
+ class TimelineRange
7
+ attr_reader :from #: untyped
8
+ attr_reader :to #: untyped
9
+ attr_reader :time_range #: TimeRange?
10
+
11
+ #: (untyped, from: untyped, to: untyped, within: untyped) -> void
12
+ def initialize(record, from:, to:, within:)
13
+ @record = record
14
+ @from = from
15
+ @to = to
16
+ @time_range = build_time_range(within)
17
+ validate_mode!
18
+ freeze
19
+ end
20
+
21
+ #: (?context_required: bool) -> Array[untyped]
22
+ def select(context_required: false)
23
+ range = time_range
24
+ if range
25
+ return TimeVersionRange.new(@record, time_range: range).select(
26
+ context_required: context_required
27
+ )
28
+ end
29
+
30
+ VersionRange.new(@record, from: @from, to: @to).select
31
+ end
32
+
33
+ #: () -> bool
34
+ def time?
35
+ !time_range.nil?
36
+ end
37
+
38
+ #: () -> Time?
39
+ def begin_time
40
+ time_range&.begin_time
41
+ end
42
+
43
+ #: (untyped) -> bool
44
+ def include?(version)
45
+ range = time_range
46
+ !range || range.include?(version.created_at)
47
+ end
48
+
49
+ private
50
+
51
+ # @rbs @record: untyped
52
+ # @rbs @from: untyped
53
+ # @rbs @to: untyped
54
+
55
+ #: (untyped) -> TimeRange?
56
+ def build_time_range(within)
57
+ TimeRange.new(within) unless within.nil?
58
+ end
59
+
60
+ #: () -> void
61
+ def validate_mode!
62
+ if time?
63
+ return if @from.nil? && @to.nil?
64
+
65
+ raise InvalidTimelineRangeError, '`within` cannot be combined with `from` or `to`'
66
+ end
67
+ return unless @from.nil? || @to.nil?
68
+
69
+ raise InvalidTimelineRangeError, 'provide both `from` and `to`, or provide `within`'
70
+ end
71
+ end
72
+ end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.2.0'
5
+ VERSION = '0.3.1'
6
6
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Selects association activity from a boundary forward plus the state at that boundary.
6
+ class VersionAssociationCandidateScope
7
+ #: (untyped, untyped, ?end_at: untyped) -> void
8
+ def initialize(version_class, start_at, end_at: nil)
9
+ @version_class = version_class
10
+ @start_at = start_at
11
+ @end_at = end_at
12
+ end
13
+
14
+ #: (untyped) -> untyped
15
+ def call(relation)
16
+ return relation unless @start_at
17
+
18
+ relation.where(candidate_condition)
19
+ end
20
+
21
+ private
22
+
23
+ # @rbs @version_class: untyped
24
+ # @rbs @start_at: untyped
25
+ # @rbs @end_at: untyped
26
+
27
+ #: () -> String
28
+ def candidate_condition # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
29
+ connection = @version_class.connection
30
+ outer = connection.quote_table_name(@version_class.table_name)
31
+ later = connection.quote_table_name('paper_trail_diff_later_versions')
32
+ start_at = connection.quote(@start_at)
33
+ destroyed = connection.quote('destroy')
34
+ activity = activity_condition(outer, start_at)
35
+ compact_sql(<<~SQL)
36
+ (
37
+ #{activity}
38
+ OR (
39
+ #{column(outer, 'created_at')} < #{start_at}
40
+ AND #{column(outer, 'event')} != #{destroyed}
41
+ AND NOT EXISTS (
42
+ SELECT 1
43
+ FROM #{outer} #{later}
44
+ WHERE #{column(later, 'item_type')} = #{column(outer, 'item_type')}
45
+ AND #{column(later, 'item_id')} = #{column(outer, 'item_id')}
46
+ AND #{column(later, 'created_at')} < #{start_at}
47
+ AND #{column(later, 'created_at')} > #{column(outer, 'created_at')}
48
+ )
49
+ )
50
+ )
51
+ SQL
52
+ end
53
+
54
+ #: (String, String) -> String
55
+ def activity_condition(table, start_at)
56
+ condition = "#{column(table, 'created_at')} >= #{start_at}"
57
+ return condition unless @end_at
58
+
59
+ end_at = @version_class.connection.quote(@end_at)
60
+ "(#{condition} AND #{column(table, 'created_at')} <= #{end_at})"
61
+ end
62
+
63
+ #: (String, String) -> String
64
+ def column(table, name)
65
+ "#{table}.#{@version_class.connection.quote_column_name(name)}"
66
+ end
67
+
68
+ #: (String) -> String
69
+ def compact_sql(sql)
70
+ sql.gsub(/\s+/, ' ').strip
71
+ end
72
+ end
73
+ end