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,242 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # One immutable historical-reconstruction setup finding.
6
+ class DiagnosticIssue
7
+ attr_reader :severity #: Symbol
8
+ attr_reader :code #: Symbol
9
+ attr_reader :message #: String
10
+ attr_reader :path #: String?
11
+ attr_reader :version_id #: untyped
12
+
13
+ #: (severity: Symbol, code: Symbol, message: String, ?path: String?, ?version_id: untyped) -> void
14
+ def initialize(severity:, code:, message:, path: nil, version_id: nil)
15
+ @severity = severity
16
+ @code = code
17
+ @message = Support.immutable_copy(message)
18
+ @path = Support.immutable_copy(path)
19
+ @version_id = Support.immutable_copy(version_id)
20
+ freeze
21
+ end
22
+
23
+ #: () -> Hash[Symbol, untyped]
24
+ def to_h
25
+ {
26
+ severity: severity,
27
+ code: code,
28
+ message: message,
29
+ path: path,
30
+ version_id: version_id
31
+ }
32
+ end
33
+ end
34
+
35
+ # Immutable diagnostics for a requested historical association comparison.
36
+ class DiagnosticReport
37
+ attr_reader :issues #: Array[DiagnosticIssue]
38
+
39
+ #: (issues: Array[DiagnosticIssue]) -> void
40
+ def initialize(issues:)
41
+ @issues = issues.dup.freeze
42
+ freeze
43
+ end
44
+
45
+ #: () -> Array[DiagnosticIssue]
46
+ def errors
47
+ issues.select { |issue| issue.severity == :error }.freeze
48
+ end
49
+
50
+ #: () -> Array[DiagnosticIssue]
51
+ def warnings
52
+ issues.select { |issue| issue.severity == :warning }.freeze
53
+ end
54
+
55
+ #: () -> bool
56
+ def ok?
57
+ errors.empty?
58
+ end
59
+
60
+ #: () -> Hash[Symbol, untyped]
61
+ def to_h
62
+ { ok: ok?, issues: Support.serialize(issues) }
63
+ end
64
+ end
65
+
66
+ # Performs read-only checks for known PaperTrail/PT-AT reconstruction hazards.
67
+ class HistoryDiagnostics
68
+ #: (untyped, untyped, associations: Array[String | Symbol]) -> void
69
+ def initialize(from_version, to_version, associations:)
70
+ @from_version = from_version
71
+ @to_version = to_version
72
+ @tree = AssociationTree.build(associations)
73
+ @issues = [] #: Array[DiagnosticIssue]
74
+ end
75
+
76
+ #: () -> DiagnosticReport
77
+ def call
78
+ model_class = validated_model_class
79
+ return DiagnosticReport.new(issues: []) if @tree.empty?
80
+
81
+ unless association_tracking_available?
82
+ add_error(:association_tracking_unavailable, tracking_unavailable_message)
83
+ return DiagnosticReport.new(issues: @issues)
84
+ end
85
+
86
+ traversal = AssociationTraversal.new(@tree)
87
+ selected = traversal.selected_reflections(model_class)
88
+ inspect_targets(selected)
89
+ inspect_checkpoint_timestamp(model_class)
90
+ inspect_habtm(selected)
91
+ DiagnosticReport.new(issues: @issues)
92
+ end
93
+
94
+ private
95
+
96
+ # @rbs @from_version: untyped
97
+ # @rbs @to_version: untyped
98
+ # @rbs @tree: AssociationTree
99
+ # @rbs @issues: Array[DiagnosticIssue]
100
+
101
+ #: () -> untyped
102
+ def validated_model_class
103
+ unless version_identity(@from_version) == version_identity(@to_version)
104
+ raise VersionMismatchError, 'versions must belong to the same PaperTrail item'
105
+ end
106
+
107
+ Object.const_get(version_type(@from_version))
108
+ rescue NoMethodError => e
109
+ raise VersionMismatchError, 'expected PaperTrail version endpoints', cause: e
110
+ end
111
+
112
+ #: (Array[Array[untyped]]) -> void
113
+ def inspect_targets(selected)
114
+ selected.each do |path, reflection|
115
+ if reflection.polymorphic?
116
+ add_warning(:polymorphic_target_unverified, 'polymorphic target cannot be verified', path)
117
+ next
118
+ end
119
+
120
+ inspect_versioned_model(reflection.klass, path)
121
+ inspect_through_model(reflection, path) if reflection.options[:through]
122
+ end
123
+ end
124
+
125
+ #: (Array[Array[untyped]]) -> void
126
+ def inspect_habtm(selected)
127
+ paths = selected.filter_map do |path, reflection|
128
+ path if reflection.macro == :has_and_belongs_to_many
129
+ end
130
+ return if paths.empty?
131
+
132
+ inspect_transaction_metadata(paths)
133
+ end
134
+
135
+ #: (untyped) -> void
136
+ def inspect_checkpoint_timestamp(model_class)
137
+ return if synchronized_timestamp_disabled?(model_class)
138
+
139
+ message = 'manual checkpoints should set synchronize_version_creation_timestamp: false'
140
+ add_warning(:synchronized_version_timestamp, message)
141
+ end
142
+
143
+ #: (Array[String]) -> void
144
+ def inspect_transaction_metadata(paths)
145
+ [@from_version, @to_version].each do |version|
146
+ if !version.respond_to?(:transaction_id)
147
+ add_error(:transaction_id_column_missing, 'version has no transaction_id column')
148
+ elsif version.transaction_id.nil?
149
+ paths.each do |path|
150
+ add_error(
151
+ :transaction_id_missing,
152
+ 'HABTM endpoint has no transaction-backed association snapshot',
153
+ path,
154
+ version.id
155
+ )
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ #: (untyped, String) -> void
162
+ def inspect_versioned_model(model_class, path)
163
+ return if model_versioned?(model_class)
164
+
165
+ add_warning(
166
+ :unversioned_association_target,
167
+ "#{model_class.name} does not appear to have PaperTrail enabled",
168
+ path
169
+ )
170
+ end
171
+
172
+ #: (untyped, String) -> void
173
+ def inspect_through_model(reflection, path)
174
+ model_class = reflection.through_reflection.klass
175
+ return if model_versioned?(model_class)
176
+
177
+ add_warning(
178
+ :unversioned_through_model,
179
+ "#{model_class.name} does not appear to have PaperTrail enabled",
180
+ path
181
+ )
182
+ end
183
+
184
+ #: (untyped) -> bool
185
+ def model_versioned?(model_class)
186
+ model_class.respond_to?(:paper_trail_options) && !model_class.paper_trail_options.nil?
187
+ end
188
+
189
+ #: (untyped) -> bool
190
+ def synchronized_timestamp_disabled?(model_class)
191
+ options = model_class.paper_trail_options
192
+ options[:synchronize_version_creation_timestamp] == false
193
+ rescue NoMethodError
194
+ false
195
+ end
196
+
197
+ #: () -> bool
198
+ def association_tracking_available?
199
+ paper_trail = Object.const_get(:PaperTrail) #: untyped
200
+ config = paper_trail.config #: untyped
201
+ !!(defined?(::PaperTrailAssociationTracking) &&
202
+ config.respond_to?(:track_associations?) && config.track_associations?)
203
+ end
204
+
205
+ #: (untyped) -> Array[String]
206
+ def version_identity(version)
207
+ [version.item_type.to_s, version.item_id.to_s]
208
+ end
209
+
210
+ #: (untyped) -> String
211
+ def version_type(version)
212
+ subtype = version.item_subtype if version.respond_to?(:item_subtype)
213
+ subtype.to_s.empty? ? version.item_type.to_s : subtype.to_s
214
+ end
215
+
216
+ #: () -> String
217
+ def tracking_unavailable_message
218
+ 'association tracking must be loaded and enabled to diagnose associations'
219
+ end
220
+
221
+ #: (Symbol, String, ?String?, ?untyped) -> void
222
+ def add_error(code, message, path = nil, version_id = nil)
223
+ add_issue(:error, code, message, path, version_id)
224
+ end
225
+
226
+ #: (Symbol, String, ?String?) -> void
227
+ def add_warning(code, message, path = nil)
228
+ add_issue(:warning, code, message, path, nil)
229
+ end
230
+
231
+ #: (Symbol, Symbol, String, String?, untyped) -> void
232
+ def add_issue(severity, code, message, path, version_id)
233
+ @issues << DiagnosticIssue.new(
234
+ severity: severity,
235
+ code: code,
236
+ message: message,
237
+ path: path,
238
+ version_id: version_id
239
+ )
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Validates and describes explicit PaperTrail-version and live-record endpoints.
6
+ module Endpoint
7
+ module_function
8
+
9
+ #: (untyped, untyped) -> void
10
+ def validate_pair!(from_endpoint, to_endpoint)
11
+ validate!(from_endpoint)
12
+ validate!(to_endpoint)
13
+ return if identity(from_endpoint) == identity(to_endpoint)
14
+
15
+ raise VersionMismatchError, 'endpoints must belong to the same PaperTrail item'
16
+ end
17
+
18
+ #: (untyped) -> void
19
+ def validate!(endpoint)
20
+ return if version?(endpoint)
21
+
22
+ validate_record!(endpoint)
23
+ end
24
+
25
+ #: (untyped) -> bool
26
+ def version?(endpoint)
27
+ endpoint.respond_to?(:reify) &&
28
+ endpoint.respond_to?(:item_type) &&
29
+ endpoint.respond_to?(:item_id)
30
+ end
31
+
32
+ #: (untyped) -> bool
33
+ def record?(endpoint)
34
+ !version?(endpoint) &&
35
+ endpoint.respond_to?(:persisted?) &&
36
+ endpoint.respond_to?(:destroyed?) &&
37
+ endpoint.class.respond_to?(:base_class)
38
+ end
39
+
40
+ #: (untyped) -> Array[String]
41
+ def identity(endpoint)
42
+ if version?(endpoint)
43
+ [endpoint.item_type.to_s, endpoint.item_id.to_s]
44
+ elsif record?(endpoint)
45
+ [endpoint.class.base_class.name.to_s, endpoint.id.to_s]
46
+ else
47
+ raise InvalidEndpointError, 'expected a PaperTrail version or persisted record endpoint'
48
+ end
49
+ end
50
+
51
+ #: (untyped) -> untyped
52
+ def model_class(version)
53
+ subtype = version.item_subtype if version.respond_to?(:item_subtype)
54
+ model_type = subtype.to_s.empty? ? version.item_type.to_s : subtype.to_s
55
+ Object.const_get(model_type)
56
+ rescue NameError => e
57
+ raise InvalidEndpointError, "cannot resolve endpoint model: #{model_type}", cause: e
58
+ end
59
+
60
+ #: (untyped) -> untyped
61
+ def reload_record(record)
62
+ validate_record!(record)
63
+ record.class.unscoped.find(record.id)
64
+ rescue StandardError => e
65
+ active_record = Object.const_get(:ActiveRecord) #: untyped
66
+ record_not_found = active_record.const_get(:RecordNotFound) #: untyped
67
+ raise unless e.is_a?(record_not_found)
68
+
69
+ message = 'current record endpoint could not be reloaded from the database'
70
+ raise InvalidEndpointError, message, cause: e
71
+ end
72
+
73
+ #: (untyped) -> void
74
+ def validate_record!(record)
75
+ unless record?(record)
76
+ raise InvalidEndpointError, 'expected a PaperTrail version or persisted record endpoint'
77
+ end
78
+ unless record.persisted? && !record.destroyed?
79
+ raise InvalidEndpointError, 'current record endpoint must be persisted and not destroyed'
80
+ end
81
+ return unless record.respond_to?(:has_changes_to_save?) && record.has_changes_to_save?
82
+
83
+ raise InvalidEndpointError, 'current record endpoint must not have unsaved changes'
84
+ end
85
+ private_class_method :validate_record!
86
+ end
87
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Pure snapshot comparison. This class has no ActiveRecord or PaperTrail dependencies.
6
+ class Engine
7
+ class << self
8
+ #: (RecordSnapshot?, RecordSnapshot?) -> Diff
9
+ def compare(from_snapshot, to_snapshot)
10
+ return Diff.new if from_snapshot.equal?(to_snapshot)
11
+
12
+ if from_snapshot.nil? || to_snapshot.nil?
13
+ change = ValueChange.new(from: from_snapshot, to: to_snapshot)
14
+ return Diff.new(record_presence_change: change)
15
+ end
16
+
17
+ Diff.new(
18
+ attributes: compare_attributes(from_snapshot.attributes, to_snapshot.attributes),
19
+ associations: compare_associations(from_snapshot.associations, to_snapshot.associations)
20
+ )
21
+ end
22
+
23
+ private
24
+
25
+ #: (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, ValueChange]
26
+ def compare_attributes(from_attributes, to_attributes)
27
+ changes = {} #: attribute_changes
28
+ (from_attributes.keys | to_attributes.keys).sort.each do |name|
29
+ from_value = from_attributes[name]
30
+ to_value = to_attributes[name]
31
+ next if from_value == to_value
32
+
33
+ changes[name] = ValueChange.new(from: from_value, to: to_value)
34
+ end
35
+ changes
36
+ end
37
+
38
+ #: (association_snapshots, association_snapshots) -> association_diffs
39
+ def compare_associations(from_associations, to_associations)
40
+ changes = {} #: association_diffs
41
+ (from_associations.keys | to_associations.keys).sort.each do |name|
42
+ next if from_associations[name].equal?(to_associations[name])
43
+
44
+ from_association, to_association = association_pair(
45
+ from_associations[name],
46
+ to_associations[name]
47
+ )
48
+ difference = compare_association(from_association, to_association)
49
+ changes[name] = difference unless difference.empty?
50
+ end
51
+ changes
52
+ end
53
+
54
+ #: (AssociationSnapshot?, AssociationSnapshot?) -> [AssociationSnapshot, AssociationSnapshot]
55
+ def association_pair(from_association, to_association)
56
+ validate_association_kinds!(from_association, to_association)
57
+ association = from_association || to_association
58
+ # @type var association: AssociationSnapshot
59
+ kind = association.kind
60
+
61
+ [
62
+ from_association || AssociationSnapshot.new(kind: kind, records: []),
63
+ to_association || AssociationSnapshot.new(kind: kind, records: [])
64
+ ]
65
+ end
66
+
67
+ #: (AssociationSnapshot?, AssociationSnapshot?) -> void
68
+ def validate_association_kinds!(from_association, to_association)
69
+ return unless kinds_differ?(from_association, to_association)
70
+
71
+ raise ArgumentError, 'association kind changed between snapshots'
72
+ end
73
+
74
+ #: (AssociationSnapshot?, AssociationSnapshot?) -> bool
75
+ def kinds_differ?(from_association, to_association)
76
+ return false unless from_association && to_association
77
+
78
+ from_association.kind != to_association.kind
79
+ end
80
+
81
+ #: (AssociationSnapshot, AssociationSnapshot) -> association_diff
82
+ def compare_association(from_association, to_association)
83
+ if AssociationSnapshot.collection_kind?(from_association.kind)
84
+ return compare_collection(from_association, to_association)
85
+ end
86
+
87
+ compare_to_one(from_association, to_association)
88
+ end
89
+
90
+ #: (AssociationSnapshot, AssociationSnapshot) -> ToOneAssociationDiff
91
+ def compare_to_one(from_association, to_association)
92
+ from_record = from_association.records.first
93
+ to_record = to_association.records.first
94
+ if same_identity?(from_record, to_record)
95
+ return ToOneAssociationDiff.new(
96
+ kind: from_association.kind,
97
+ relationship: nil,
98
+ changed: compare_record(from_record, to_record)
99
+ )
100
+ end
101
+
102
+ ToOneAssociationDiff.new(
103
+ kind: from_association.kind,
104
+ relationship: relationship_change(from_record, to_record),
105
+ changed: nil
106
+ )
107
+ end
108
+
109
+ #: (RecordSnapshot?, RecordSnapshot?) -> ValueChange?
110
+ def relationship_change(from_record, to_record)
111
+ return unless from_record || to_record
112
+
113
+ ValueChange.new(from: from_record, to: to_record)
114
+ end
115
+
116
+ #: (RecordSnapshot, RecordSnapshot) -> RecordChange?
117
+ def compare_record(from_record, to_record)
118
+ attributes = compare_attributes(from_record.attributes, to_record.attributes)
119
+ associations = compare_associations(from_record.associations, to_record.associations)
120
+ return if attributes.empty? && associations.empty?
121
+
122
+ RecordChange.new(record: to_record, attributes: attributes, associations: associations)
123
+ end
124
+
125
+ #: (AssociationSnapshot, AssociationSnapshot) -> CollectionAssociationDiff
126
+ def compare_collection(from_association, to_association)
127
+ from_records = index_records(from_association.records)
128
+ to_records = index_records(to_association.records)
129
+
130
+ CollectionAssociationDiff.new(
131
+ kind: from_association.kind,
132
+ added: records_missing_from(to_records, from_records),
133
+ removed: records_missing_from(from_records, to_records),
134
+ changed: changed_records(from_records, to_records)
135
+ )
136
+ end
137
+
138
+ #: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordSnapshot]
139
+ def records_missing_from(records, other_records)
140
+ identities = sorted_identities(records.keys - other_records.keys)
141
+ identities.map { |identity| records.fetch(identity) }
142
+ end
143
+
144
+ #: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordChange]
145
+ def changed_records(from_records, to_records)
146
+ sorted_identities(from_records.keys & to_records.keys).filter_map do |identity|
147
+ from_record = from_records.fetch(identity)
148
+ to_record = to_records.fetch(identity)
149
+ next if from_record.equal?(to_record)
150
+
151
+ compare_record(from_record, to_record)
152
+ end
153
+ end
154
+
155
+ #: (Array[RecordSnapshot]) -> Hash[Array[untyped], RecordSnapshot]
156
+ def index_records(records)
157
+ index = {} #: Hash[identity, RecordSnapshot]
158
+ records.each do |record|
159
+ identity = record.identity
160
+ if index.key?(identity)
161
+ raise ArgumentError, "duplicate record identity: #{identity.inspect}"
162
+ end
163
+
164
+ index[identity] = record
165
+ end
166
+ index
167
+ end
168
+
169
+ #: (Array[Array[untyped]]) -> Array[Array[untyped]]
170
+ def sorted_identities(identities)
171
+ identities.sort_by { |type, id| [type, id.inspect] }
172
+ end
173
+
174
+ #: (RecordSnapshot?, RecordSnapshot?) -> bool
175
+ def same_identity?(from_record, to_record)
176
+ from_record && to_record ? from_record.identity == to_record.identity : false
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Base error for failures detected by paper_trail_diff.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when a public option has an invalid type, key, value, or path.
9
+ class ConfigurationError < Error; end
10
+
11
+ # Raised when compare endpoints do not belong to the same PaperTrail item.
12
+ class VersionMismatchError < Error; end
13
+
14
+ # Raised when a live endpoint is not a clean, persisted ActiveRecord record.
15
+ class InvalidEndpointError < Error; end
16
+
17
+ # Raised when a timeline boundary is absent or the requested range is reversed.
18
+ class InvalidTimelineRangeError < Error; end
19
+
20
+ # Raised when a requested ActiveRecord association does not exist.
21
+ class UnknownAssociationError < Error; end
22
+
23
+ # Raised when a requested association macro is not supported.
24
+ class UnsupportedAssociationError < Error; end
25
+
26
+ # Raised when association comparison is requested without an available PT-AT setup.
27
+ class AssociationTrackingUnavailableError < Error; end
28
+
29
+ # Raised when recorded PT-AT metadata cannot reliably reconstruct a selected association.
30
+ class IncompleteAssociationHistoryError < Error; end
31
+
32
+ # Raised when current-ended activity cannot be reconstructed for a selected association.
33
+ class UnsupportedLiveActivityError < Error; end
34
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Reconstructs selected nested edges at one root PaperTrail endpoint.
6
+ class HistoricalAssociationReifier
7
+ #: (untyped, ?habtm_version: untyped) -> void
8
+ def initialize(version, habtm_version: version)
9
+ @transaction_id = version.transaction_id if version.respond_to?(:transaction_id)
10
+ if habtm_version.respond_to?(:transaction_id)
11
+ @habtm_transaction_id = habtm_version.transaction_id
12
+ end
13
+ @version_at = version.created_at
14
+ @reified_associations = {} #: Hash[Array[untyped], bool]
15
+ end
16
+
17
+ #: (untyped, Array[untyped]) -> void
18
+ def reify(record, reflections)
19
+ reflections.each { |reflection| reify_association(record, reflection) }
20
+ end
21
+
22
+ private
23
+
24
+ # @rbs @transaction_id: untyped
25
+ # @rbs @habtm_transaction_id: untyped
26
+ # @rbs @version_at: untyped
27
+ # @rbs @reified_associations: Hash[Array[untyped], bool]
28
+
29
+ #: (untyped, untyped) -> void
30
+ def reify_association(record, reflection)
31
+ key = [record.object_id, reflection.name]
32
+ return if @reified_associations[key]
33
+
34
+ @reified_associations[key] = true
35
+ case reflection.macro
36
+ when :belongs_to
37
+ reifier(:BelongsTo).reify(reflection, record, options, @transaction_id)
38
+ when :has_one
39
+ reifier(:HasOne).reify(reflection, record, options, @transaction_id)
40
+ when :has_many
41
+ reify_has_many(record, reflection)
42
+ when :has_and_belongs_to_many
43
+ reify_habtm(record, reflection)
44
+ end
45
+ end
46
+
47
+ #: (untyped, untyped) -> void
48
+ def reify_has_many(record, reflection)
49
+ if reflection.options[:through]
50
+ reify_association(record, reflection.through_reflection)
51
+ reifier(:HasManyThrough).reify(reflection, record, options, @transaction_id)
52
+ return
53
+ end
54
+
55
+ version_table = record.class.paper_trail.version_class.table_name
56
+ reifier(:HasMany).reify(
57
+ reflection,
58
+ record,
59
+ options,
60
+ @transaction_id,
61
+ version_table
62
+ )
63
+ end
64
+
65
+ #: (untyped, untyped) -> void
66
+ def reify_habtm(record, reflection)
67
+ unless @habtm_transaction_id
68
+ message = 'HABTM reconstruction requires a transaction-backed endpoint version'
69
+ raise IncompleteAssociationHistoryError, message
70
+ end
71
+
72
+ paper_trail = Object.const_get(:PaperTrail) #: untyped
73
+ target_versioned = paper_trail.request.enabled_for_model?(reflection.klass)
74
+ reifier(:HasAndBelongsToMany).reify(
75
+ target_versioned,
76
+ reflection,
77
+ record,
78
+ options,
79
+ @habtm_transaction_id
80
+ )
81
+ end
82
+
83
+ #: () -> Hash[Symbol, untyped]
84
+ def options
85
+ {
86
+ dup: true,
87
+ mark_for_destruction: false,
88
+ unversioned_attributes: :nil,
89
+ version_at: @version_at
90
+ }
91
+ end
92
+
93
+ #: (Symbol) -> untyped
94
+ def reifier(name)
95
+ tracking = Object.const_get(:PaperTrailAssociationTracking) #: untyped
96
+ reifiers = tracking.const_get(:Reifiers) #: untyped
97
+ reifiers.const_get(name)
98
+ rescue NameError => e
99
+ message = 'loaded association tracking does not support nested reconstruction'
100
+ raise AssociationTrackingUnavailableError, message, cause: e
101
+ end
102
+ end
103
+ end