paper_trail_diff 0.1.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 (77) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +45 -0
  3. data/LICENSE +22 -0
  4. data/README.md +473 -0
  5. data/lib/paper_trail_diff/activity_boundary.rb +82 -0
  6. data/lib/paper_trail_diff/activity_event.rb +128 -0
  7. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +493 -0
  8. data/lib/paper_trail_diff/activity_range.rb +58 -0
  9. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +156 -0
  10. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +161 -0
  11. data/lib/paper_trail_diff/activity_timeline_builder.rb +177 -0
  12. data/lib/paper_trail_diff/activity_version_collector.rb +196 -0
  13. data/lib/paper_trail_diff/analysis.rb +26 -0
  14. data/lib/paper_trail_diff/association_discovery.rb +132 -0
  15. data/lib/paper_trail_diff/association_traversal.rb +131 -0
  16. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +191 -0
  17. data/lib/paper_trail_diff/configuration.rb +170 -0
  18. data/lib/paper_trail_diff/diagnostics.rb +242 -0
  19. data/lib/paper_trail_diff/endpoint.rb +87 -0
  20. data/lib/paper_trail_diff/engine.rb +180 -0
  21. data/lib/paper_trail_diff/errors.rb +34 -0
  22. data/lib/paper_trail_diff/historical_association_reifier.rb +103 -0
  23. data/lib/paper_trail_diff/historical_snapshot_store.rb +144 -0
  24. data/lib/paper_trail_diff/live_association_reader.rb +10 -0
  25. data/lib/paper_trail_diff/paper_trail_adapter.rb +182 -0
  26. data/lib/paper_trail_diff/prepared_association_reifier.rb +48 -0
  27. data/lib/paper_trail_diff/prepared_edge_loader.rb +155 -0
  28. data/lib/paper_trail_diff/prepared_history.rb +217 -0
  29. data/lib/paper_trail_diff/prepared_history_loader.rb +136 -0
  30. data/lib/paper_trail_diff/prepared_record_index.rb +154 -0
  31. data/lib/paper_trail_diff/snapshot.rb +120 -0
  32. data/lib/paper_trail_diff/snapshot_normalizer.rb +182 -0
  33. data/lib/paper_trail_diff/step.rb +28 -0
  34. data/lib/paper_trail_diff/support.rb +80 -0
  35. data/lib/paper_trail_diff/timeline_builder.rb +60 -0
  36. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -0
  37. data/lib/paper_trail_diff/value_objects.rb +134 -0
  38. data/lib/paper_trail_diff/version.rb +6 -0
  39. data/lib/paper_trail_diff/version_range.rb +81 -0
  40. data/lib/paper_trail_diff.rb +131 -0
  41. data/sig/generated/paper_trail_diff/activity_boundary.rbs +43 -0
  42. data/sig/generated/paper_trail_diff/activity_event.rbs +64 -0
  43. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +98 -0
  44. data/sig/generated/paper_trail_diff/activity_range.rbs +36 -0
  45. data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +54 -0
  46. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +71 -0
  47. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +61 -0
  48. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +71 -0
  49. data/sig/generated/paper_trail_diff/analysis.rbs +18 -0
  50. data/sig/generated/paper_trail_diff/association_discovery.rbs +63 -0
  51. data/sig/generated/paper_trail_diff/association_traversal.rbs +54 -0
  52. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +56 -0
  53. data/sig/generated/paper_trail_diff/configuration.rbs +67 -0
  54. data/sig/generated/paper_trail_diff/diagnostics.rbs +109 -0
  55. data/sig/generated/paper_trail_diff/endpoint.rbs +30 -0
  56. data/sig/generated/paper_trail_diff/engine.rbs +54 -0
  57. data/sig/generated/paper_trail_diff/errors.rbs +43 -0
  58. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +37 -0
  59. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +50 -0
  60. data/sig/generated/paper_trail_diff/live_association_reader.rbs +9 -0
  61. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +72 -0
  62. data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +27 -0
  63. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +65 -0
  64. data/sig/generated/paper_trail_diff/prepared_history.rbs +93 -0
  65. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +67 -0
  66. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +84 -0
  67. data/sig/generated/paper_trail_diff/snapshot.rbs +69 -0
  68. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +76 -0
  69. data/sig/generated/paper_trail_diff/step.rbs +18 -0
  70. data/sig/generated/paper_trail_diff/support.rbs +30 -0
  71. data/sig/generated/paper_trail_diff/timeline_builder.rbs +31 -0
  72. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -0
  73. data/sig/generated/paper_trail_diff/value_objects.rbs +87 -0
  74. data/sig/generated/paper_trail_diff/version.rbs +5 -0
  75. data/sig/generated/paper_trail_diff/version_range.rbs +35 -0
  76. data/sig/generated/paper_trail_diff.rbs +56 -0
  77. metadata +144 -0
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Reuses immutable nodes whose normalized state did not change at a selected path.
6
+ class SnapshotPool
7
+ def initialize
8
+ @records = {} #: Hash[Array[untyped], RecordSnapshot]
9
+ @associations = {} #: Hash[String, AssociationSnapshot]
10
+ end
11
+
12
+ #: (String, RecordSnapshot) -> RecordSnapshot
13
+ def record(path, snapshot)
14
+ key = [path, snapshot.type, snapshot.id]
15
+ previous = @records[key]
16
+ return previous if previous && equivalent_record?(previous, snapshot)
17
+
18
+ @records[key] = snapshot
19
+ end
20
+
21
+ #: (String, AssociationSnapshot) -> AssociationSnapshot
22
+ def association(path, snapshot)
23
+ previous = @associations[path]
24
+ if previous && previous.kind == snapshot.kind && previous.records == snapshot.records
25
+ return previous
26
+ end
27
+
28
+ @associations[path] = snapshot
29
+ end
30
+
31
+ private
32
+
33
+ # @rbs @records: Hash[Array[untyped], RecordSnapshot]
34
+ # @rbs @associations: Hash[String, AssociationSnapshot]
35
+
36
+ #: (RecordSnapshot, RecordSnapshot) -> bool
37
+ def equivalent_record?(left, right)
38
+ left.attributes == right.attributes &&
39
+ left.associations.keys == right.associations.keys &&
40
+ left.associations.all? do |name, association|
41
+ other = right.associations[name]
42
+ other && association.kind == other.kind && association.records == other.records
43
+ end
44
+ end
45
+ end
46
+
47
+ # Converts an ActiveRecord graph into immutable, persistence-independent snapshots.
48
+ class SnapshotNormalizer
49
+ #: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, ?pool: SnapshotPool) -> void
50
+ def initialize(tree:, ignore_policy:, traversal:, pool: SnapshotPool.new)
51
+ @tree = tree
52
+ @ignore_policy = ignore_policy
53
+ @traversal = traversal
54
+ @pool = pool
55
+ @structural_columns = {} #: Hash[String, Array[String]]
56
+ end
57
+
58
+ #: (untyped, reifier: untyped) -> RecordSnapshot?
59
+ def call(record, reifier:)
60
+ reflections = reflections_for(record, @tree, '')
61
+ normalize_record(record, @tree, '', reifier, reflections)
62
+ end
63
+
64
+ # Normalizes one member of an already selected association branch.
65
+ #: (untyped, tree: AssociationTree, path: String, incoming: untyped, reifier: untyped) -> RecordSnapshot?
66
+ def call_child(record, tree:, path:, incoming:, reifier:)
67
+ return unless record
68
+
69
+ @structural_columns[path] ||= @traversal.incoming_relationship_columns(incoming)
70
+ reflections = reflections_for(record, tree, path)
71
+ reifier.reify(record, reflections) unless reflections.empty?
72
+ normalize_record(record, tree, path, reifier, reflections)
73
+ end
74
+
75
+ # Normalizes scalar attributes without traversing or loading associations.
76
+ #: (untyped, tree: AssociationTree, path: String) -> snapshot_attributes
77
+ def attributes_for(record, tree:, path:)
78
+ reflections = reflections_for(record, tree, path)
79
+ normalized_attributes(record, reflections, path)
80
+ end
81
+
82
+ private
83
+
84
+ # @rbs @tree: AssociationTree
85
+ # @rbs @ignore_policy: IgnorePolicy
86
+ # @rbs @traversal: AssociationTraversal
87
+ # @rbs @pool: SnapshotPool
88
+ # @rbs @structural_columns: Hash[String, Array[String]]
89
+
90
+ #: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot?
91
+ def normalize_record(record, tree, path, reifier, reflections)
92
+ return unless record
93
+
94
+ snapshot = RecordSnapshot.new(
95
+ type: record.class.name,
96
+ id: record.id,
97
+ attributes: normalized_attributes(record, reflections, path),
98
+ associations: normalize_associations(record, tree, path, reifier, reflections)
99
+ )
100
+ path.empty? ? snapshot : @pool.record(path, snapshot)
101
+ end
102
+
103
+ #: (untyped, Array[untyped], String) -> snapshot_attributes
104
+ def normalized_attributes(record, reflections, path)
105
+ attributes = record.attributes.dup
106
+ excluded_attributes(record, reflections, path).each { |name| attributes.delete(name) }
107
+ attributes
108
+ end
109
+
110
+ #: (untyped, Array[untyped], String) -> Array[String]
111
+ def excluded_attributes(record, reflections, path)
112
+ primary_key = record.class.primary_key #: untyped
113
+ primary_keys = primary_key.is_a?(Array) ? primary_key : [primary_key]
114
+ # @type var primary_keys: Array[untyped]
115
+ primary_keys = primary_keys.map { |key| key.to_s } # rubocop:disable Style/SymbolProc
116
+ ignored = @ignore_policy.attributes_for(path)
117
+ structural = @structural_columns.fetch(path, [])
118
+ (primary_keys + ignored + relationship_columns(reflections) + structural).uniq
119
+ end
120
+
121
+ #: (Array[untyped]) -> Array[String]
122
+ def relationship_columns(reflections)
123
+ reflections.select { |reflection| reflection.macro == :belongs_to }.flat_map do |reflection|
124
+ columns = [reflection.foreign_key.to_s]
125
+ columns << reflection.foreign_type.to_s if reflection.polymorphic?
126
+ columns
127
+ end
128
+ end
129
+
130
+ #: (untyped, AssociationTree, String, untyped, Array[untyped]) -> Hash[String, AssociationSnapshot]
131
+ def normalize_associations(record, tree, path, reifier, reflections)
132
+ reflections.to_h do |reflection|
133
+ name = reflection.name.to_s
134
+ subtree = tree.child(name)
135
+ raise ConfigurationError, "missing traversal subtree: #{name}" unless subtree
136
+
137
+ [name, association_snapshot(record, reflection, subtree, path, reifier)]
138
+ end
139
+ end
140
+
141
+ #: (untyped, untyped, AssociationTree, String, untyped) -> AssociationSnapshot
142
+ def association_snapshot( # rubocop:disable Metrics/AbcSize
143
+ record,
144
+ reflection,
145
+ subtree,
146
+ path,
147
+ reifier
148
+ )
149
+ associated = record.public_send(reflection.name)
150
+ records = if AssociationSnapshot.collection_kind?(reflection.macro)
151
+ associated.to_a
152
+ else
153
+ Array(associated).compact
154
+ end
155
+ child_path = Support.association_path(path, reflection.name.to_s)
156
+ @structural_columns[child_path] ||= @traversal.incoming_relationship_columns(reflection)
157
+ snapshot = AssociationSnapshot.new(
158
+ kind: reflection.macro,
159
+ records: normalize_children(records, subtree, child_path, reifier)
160
+ )
161
+ @pool.association(child_path, snapshot)
162
+ end
163
+
164
+ #: (Array[untyped], AssociationTree, String, untyped) -> Array[RecordSnapshot]
165
+ def normalize_children(records, tree, path, reifier)
166
+ reflections_by_class = {} #: Hash[String, Array[untyped]]
167
+ records.filter_map do |child|
168
+ type = child.class.name
169
+ reflections = reflections_by_class[type] ||= reflections_for(child, tree, path)
170
+ reifier.reify(child, reflections) unless reflections.empty?
171
+ normalize_record(child, tree, path, reifier, reflections)
172
+ end
173
+ end
174
+
175
+ #: (untyped, AssociationTree, String) -> Array[untyped]
176
+ def reflections_for(record, tree, path)
177
+ return [] unless record
178
+
179
+ @traversal.reflections_for(record.class, tree, path: path)
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # One adjacent transition in a PaperTrail timeline.
6
+ class Step
7
+ attr_reader :from_version #: untyped
8
+ attr_reader :to_version #: untyped
9
+ attr_reader :diff #: Diff
10
+
11
+ #: (from_version: untyped, to_version: untyped, diff: Diff) -> void
12
+ def initialize(from_version:, to_version:, diff:)
13
+ @from_version = from_version
14
+ @to_version = to_version
15
+ @diff = diff
16
+ freeze
17
+ end
18
+
19
+ #: () -> Hash[Symbol, untyped]
20
+ def to_h
21
+ {
22
+ from_version_id: from_version.id,
23
+ to_version_id: to_version.id,
24
+ diff: diff.to_h
25
+ }
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Internal helpers for isolating and serializing values held by result objects.
6
+ module Support
7
+ module_function
8
+
9
+ #: (untyped) -> untyped
10
+ def immutable_copy(value)
11
+ return value if value.frozen?
12
+
13
+ case value
14
+ when Hash
15
+ immutable_hash(value)
16
+ when Array
17
+ value.map { |item| immutable_copy(item) }.freeze
18
+ else
19
+ duplicate_and_freeze(value)
20
+ end
21
+ end
22
+
23
+ #: (Hash[untyped, untyped]) -> Hash[untyped, untyped]
24
+ def immutable_hash(value)
25
+ copy = {} #: Hash[untyped, untyped]
26
+ value.each do |key, item|
27
+ copy[immutable_copy(key)] = immutable_copy(item)
28
+ end
29
+ copy.freeze
30
+ end
31
+ private_class_method :immutable_hash
32
+
33
+ #: (untyped) -> untyped
34
+ def serialize(value)
35
+ case value
36
+ when Hash
37
+ serialized = {} #: Hash[untyped, untyped]
38
+ value.each do |key, item|
39
+ serialized[key] = serialize(item)
40
+ end
41
+ serialized
42
+ when Array
43
+ value.map { |item| serialize(item) }
44
+ else
45
+ paper_trail_diff_value?(value) ? value.to_h : value
46
+ end
47
+ end
48
+
49
+ #: (untyped) -> Array[untyped]
50
+ def chronological_version_key(version)
51
+ [version.created_at, version.id.to_s.rjust(32, '0')]
52
+ end
53
+
54
+ #: (untyped, untyped) -> Integer
55
+ def compare_versions(left, right)
56
+ chronological_version_key(left) <=> chronological_version_key(right) ||
57
+ raise(ConfigurationError, 'versions have incomparable timestamps')
58
+ end
59
+
60
+ #: (String, String) -> String
61
+ def association_path(parent, name)
62
+ parent.empty? ? name : "#{parent}.#{name}"
63
+ end
64
+
65
+ #: (untyped) -> untyped
66
+ def duplicate_and_freeze(value)
67
+ value.dup.freeze
68
+ rescue TypeError
69
+ value
70
+ end
71
+ private_class_method :duplicate_and_freeze
72
+
73
+ #: (untyped) -> bool
74
+ def paper_trail_diff_value?(value)
75
+ class_name = value.class.name
76
+ class_name&.start_with?('PaperTrailDiff::') && value.respond_to?(:to_h)
77
+ end
78
+ private_class_method :paper_trail_diff_value?
79
+ end
80
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Selects and compares a chronological slice of a record's version history.
6
+ class TimelineBuilder
7
+ #: (untyped, from: untyped, to: untyped, snapshotter: untyped) -> void
8
+ def initialize(record, from:, to:, snapshotter:)
9
+ @record = record
10
+ @from = from
11
+ @to = to
12
+ @snapshotter = snapshotter
13
+ end
14
+
15
+ #: () -> Array[Step]
16
+ def build
17
+ steps, = compare_history(selected_versions)
18
+ steps
19
+ end
20
+
21
+ #: () -> Analysis
22
+ def analyze
23
+ steps, first_snapshot, last_snapshot = compare_history(selected_versions)
24
+ Analysis.new(
25
+ diff: Engine.compare(first_snapshot, last_snapshot),
26
+ timeline: steps
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ # @rbs @record: untyped
33
+ # @rbs @from: untyped
34
+ # @rbs @to: untyped
35
+ # @rbs @snapshotter: untyped
36
+
37
+ #: (Array[untyped]) -> [Array[Step], RecordSnapshot?, RecordSnapshot?]
38
+ def compare_history(versions)
39
+ @snapshotter.prepare(@record, versions) if @snapshotter.respond_to?(:prepare)
40
+ first_snapshot = @snapshotter.call(versions.first)
41
+ previous_snapshot = first_snapshot
42
+ steps = versions.each_cons(2).map do |from_version, to_version|
43
+ current_snapshot = @snapshotter.call(to_version)
44
+ step = Step.new(
45
+ from_version: from_version,
46
+ to_version: to_version,
47
+ diff: Engine.compare(previous_snapshot, current_snapshot)
48
+ )
49
+ previous_snapshot = current_snapshot
50
+ step
51
+ end.freeze
52
+ [steps, first_snapshot, previous_snapshot]
53
+ end
54
+
55
+ #: () -> Array[untyped]
56
+ def selected_versions
57
+ VersionRange.new(@record, from: @from, to: @to).select
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Gives TimelineBuilder one-argument access to a range-prepared snapshot store.
6
+ class TimelineSnapshotProvider
7
+ #: (HistoricalSnapshotStore) -> void
8
+ def initialize(store)
9
+ @store = store
10
+ end
11
+
12
+ #: (untyped, Array[untyped]) -> void
13
+ def prepare(record, versions)
14
+ @store.prepare(record, versions)
15
+ end
16
+
17
+ #: (untyped) -> RecordSnapshot?
18
+ def call(version)
19
+ @store.uncached(version, version)
20
+ end
21
+
22
+ # @rbs @store: HistoricalSnapshotStore
23
+ end
24
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # A directional change between two structured values.
6
+ class ValueChange
7
+ attr_reader :from #: untyped
8
+ attr_reader :to #: untyped
9
+
10
+ #: (from: untyped, to: untyped) -> void
11
+ def initialize(from:, to:)
12
+ @from = Support.immutable_copy(from)
13
+ @to = Support.immutable_copy(to)
14
+ freeze
15
+ end
16
+
17
+ #: () -> Hash[Symbol, untyped]
18
+ def to_h
19
+ { from: Support.serialize(from), to: Support.serialize(to) }
20
+ end
21
+ end
22
+
23
+ # Attribute and nested-association changes for a record whose identity did not change.
24
+ class RecordChange
25
+ attr_reader :record #: RecordReference
26
+ attr_reader :attributes #: Hash[String, ValueChange]
27
+ attr_reader :associations #: Hash[String, ToOneAssociationDiff | CollectionAssociationDiff]
28
+
29
+ #: (record: RecordSnapshot, attributes: attribute_changes, ?associations: association_diffs) -> void
30
+ def initialize(record:, attributes:, associations: {})
31
+ @record = record.reference
32
+ @attributes = Support.immutable_copy(attributes)
33
+ @associations = Support.immutable_copy(associations)
34
+ freeze
35
+ end
36
+
37
+ #: () -> Hash[Symbol, untyped]
38
+ def to_h
39
+ value = { record: Support.serialize(record), attributes: Support.serialize(attributes) }
40
+ value[:associations] = Support.serialize(associations) unless associations.empty?
41
+ value
42
+ end
43
+ end
44
+
45
+ # Relationship and associated-record changes for belongs_to or has_one.
46
+ class ToOneAssociationDiff
47
+ attr_reader :kind #: Symbol
48
+ attr_reader :relationship #: ValueChange?
49
+ attr_reader :changed #: RecordChange?
50
+
51
+ #: (kind: Symbol, relationship: ValueChange?, changed: RecordChange?) -> void
52
+ def initialize(kind:, relationship:, changed:)
53
+ @kind = kind
54
+ @relationship = relationship
55
+ @changed = changed
56
+ freeze
57
+ end
58
+
59
+ #: () -> bool
60
+ def empty?
61
+ relationship.nil? && changed.nil?
62
+ end
63
+
64
+ #: () -> Hash[Symbol, untyped]
65
+ def to_h
66
+ {
67
+ kind: kind,
68
+ relationship: Support.serialize(relationship),
69
+ changed: Support.serialize(changed)
70
+ }
71
+ end
72
+ end
73
+
74
+ # Membership and associated-record changes for has_many.
75
+ class CollectionAssociationDiff
76
+ attr_reader :kind #: Symbol
77
+ attr_reader :added #: Array[RecordSnapshot]
78
+ attr_reader :removed #: Array[RecordSnapshot]
79
+ attr_reader :changed #: Array[RecordChange]
80
+
81
+ #: (kind: Symbol, added: Array[RecordSnapshot], removed: Array[RecordSnapshot], changed: Array[RecordChange]) -> void
82
+ def initialize(kind:, added:, removed:, changed:)
83
+ @kind = kind
84
+ @added = added.dup.freeze
85
+ @removed = removed.dup.freeze
86
+ @changed = changed.dup.freeze
87
+ freeze
88
+ end
89
+
90
+ #: () -> bool
91
+ def empty?
92
+ added.empty? && removed.empty? && changed.empty?
93
+ end
94
+
95
+ #: () -> Hash[Symbol, untyped]
96
+ def to_h
97
+ {
98
+ kind: kind,
99
+ added: Support.serialize(added),
100
+ removed: Support.serialize(removed),
101
+ changed: Support.serialize(changed)
102
+ }
103
+ end
104
+ end
105
+
106
+ # The complete structured difference between two normalized endpoints.
107
+ class Diff
108
+ attr_reader :record_presence_change #: ValueChange?
109
+ attr_reader :attributes #: Hash[String, ValueChange]
110
+ attr_reader :associations #: Hash[String, ToOneAssociationDiff | CollectionAssociationDiff]
111
+
112
+ #: (?record_presence_change: ValueChange?, ?attributes: attribute_changes, ?associations: association_diffs) -> void
113
+ def initialize(record_presence_change: nil, attributes: {}, associations: {})
114
+ @record_presence_change = record_presence_change
115
+ @attributes = Support.immutable_copy(attributes)
116
+ @associations = Support.immutable_copy(associations)
117
+ freeze
118
+ end
119
+
120
+ #: () -> bool
121
+ def empty?
122
+ record_presence_change.nil? && attributes.empty? && associations.empty?
123
+ end
124
+
125
+ #: () -> Hash[Symbol, untyped]
126
+ def to_h
127
+ {
128
+ record_presence_change: Support.serialize(record_presence_change),
129
+ attributes: Support.serialize(attributes),
130
+ associations: Support.serialize(associations)
131
+ }
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Selects an inclusive, chronological range from a record's root versions.
6
+ class VersionRange
7
+ #: (untyped, from: untyped, to: untyped) -> void
8
+ def initialize(record, from:, to:)
9
+ @record = record
10
+ @from = from
11
+ @to = to
12
+ end
13
+
14
+ #: () -> Array[untyped]
15
+ def select
16
+ relation = versions_relation
17
+ validate_boundary!(@from, relation, boundary: :from)
18
+ validate_boundary!(@to, relation, boundary: :to)
19
+ if Support.compare_versions(@from, @to).positive?
20
+ raise InvalidTimelineRangeError, '`from` version must not follow `to` version'
21
+ end
22
+
23
+ select_relation(
24
+ relation.where(created_at: @from.created_at..@to.created_at),
25
+ through: @to
26
+ )
27
+ end
28
+
29
+ #: () -> Array[untyped]
30
+ def select_through_latest
31
+ relation = versions_relation
32
+ validate_boundary!(@from, relation, boundary: :from)
33
+ select_relation(relation.where(created_at: @from.created_at..))
34
+ end
35
+
36
+ private
37
+
38
+ # @rbs @record: untyped
39
+ # @rbs @from: untyped
40
+ # @rbs @to: untyped
41
+
42
+ #: () -> untyped
43
+ def versions_relation
44
+ association_name = @record.class.versions_association_name
45
+ @record.public_send(association_name)
46
+ rescue NoMethodError => e
47
+ message = 'record does not expose a PaperTrail version history'
48
+ raise InvalidTimelineRangeError, message, cause: e
49
+ end
50
+
51
+ #: (untyped, ?through: untyped) -> Array[untyped]
52
+ def select_relation(relation, through: nil)
53
+ selected = relation.to_a.select do |version|
54
+ next false if Support.compare_versions(@from, version).positive?
55
+ next true unless through
56
+
57
+ Support.compare_versions(version, through) <= 0
58
+ end
59
+ selected.sort_by { |version| Support.chronological_version_key(version) }
60
+ end
61
+
62
+ #: (untyped, untyped, boundary: Symbol) -> void
63
+ def validate_boundary!(requested, relation, boundary:)
64
+ Endpoint.validate!(requested)
65
+ return if valid_boundary?(requested, relation)
66
+
67
+ raise InvalidTimelineRangeError, "`#{boundary}` version is not in the record history"
68
+ rescue InvalidEndpointError, NoMethodError
69
+ raise InvalidTimelineRangeError, "`#{boundary}` version is not in the record history"
70
+ end
71
+
72
+ #: (untyped, untyped) -> bool
73
+ def valid_boundary?(requested, relation)
74
+ expected_identity = [@record.class.base_class.name.to_s, @record.id.to_s]
75
+ Endpoint.version?(requested) &&
76
+ requested.instance_of?(relation.klass) &&
77
+ Endpoint.identity(requested) == expected_identity &&
78
+ relation.where(id: requested.id).exists?
79
+ end
80
+ end
81
+ end