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,217 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Batched record states and relationship candidates for one activity range.
6
+ class PreparedHistory
7
+ DIRECT_RESOLVERS = {
8
+ belongs_to: :resolve_belongs_to,
9
+ has_one: :resolve_children,
10
+ has_many: :resolve_children,
11
+ has_and_belongs_to_many: :resolve_habtm
12
+ }.freeze
13
+ private_constant :DIRECT_RESOLVERS
14
+
15
+ #: (PreparedRecordIndex) -> void
16
+ def initialize(records)
17
+ @records = records
18
+ @edges = {} #: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
19
+ @unsupported = {} #: Hash[Array[String], bool]
20
+ @habtm = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
21
+ end
22
+
23
+ #: (untyped, untyped, Hash[String, Hash[Symbol, untyped]]) -> void
24
+ def merge_edge(owner_class, reflection, groups)
25
+ key = edge_key(owner_class, reflection)
26
+ empty = {} #: Hash[String, Hash[Symbol, untyped]]
27
+ @edges[key] = merge_groups(@edges.fetch(key, empty), groups)
28
+ end
29
+
30
+ #: (untyped, untyped) -> Hash[String, Hash[Symbol, untyped]]
31
+ def edge(owner_class, reflection)
32
+ empty = {} #: Hash[String, Hash[Symbol, untyped]]
33
+ @edges.fetch(edge_key(owner_class, reflection), empty)
34
+ end
35
+
36
+ #: (untyped, untyped) -> void
37
+ def mark_unsupported(owner_class, reflection)
38
+ @unsupported[edge_key(owner_class, reflection)] = true
39
+ end
40
+
41
+ #: (untyped, untyped, Hash[String, Array[untyped]]) -> void
42
+ def register_habtm(owner_class, reflection, memberships)
43
+ @habtm[edge_key(owner_class, reflection)] = memberships.transform_keys(&:to_s)
44
+ end
45
+
46
+ #: (untyped, untyped, untyped, habtm_boundary: untyped) -> [bool, Array[untyped]]
47
+ def resolve(record, reflection, boundary, habtm_boundary:)
48
+ return [false, []] unless supported?(record.class, reflection)
49
+
50
+ records = if reflection.options[:through]
51
+ through_records(record, reflection, boundary, habtm_boundary)
52
+ else
53
+ direct_records(record, reflection, boundary, habtm_boundary)
54
+ end
55
+ records ? [true, records] : [false, []]
56
+ end
57
+
58
+ private
59
+
60
+ # @rbs @records: PreparedRecordIndex
61
+ # @rbs @edges: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
62
+ # @rbs @unsupported: Hash[Array[String], bool]
63
+ # @rbs @habtm: Hash[Array[String], Hash[String, Array[untyped]]]
64
+
65
+ #: (untyped, untyped) -> bool
66
+ def supported?(owner_class, reflection)
67
+ !@unsupported[edge_key(owner_class, reflection)] &&
68
+ reflection.scope.nil? && !default_scoped?(reflection.klass)
69
+ end
70
+
71
+ #: (untyped) -> bool
72
+ def default_scoped?(model_class)
73
+ model_class.default_scopes?
74
+ end
75
+
76
+ #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
77
+ def direct_records(record, reflection, boundary, habtm_boundary)
78
+ resolver = DIRECT_RESOLVERS.fetch(reflection.macro)
79
+ send(resolver, record, reflection, boundary, habtm_boundary)
80
+ end
81
+
82
+ #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
83
+ def resolve_belongs_to(record, reflection, boundary, _habtm_boundary)
84
+ belongs_to_records(record, reflection, boundary)
85
+ end
86
+
87
+ #: (untyped, untyped, untyped, untyped) -> Array[untyped]
88
+ def resolve_children(record, reflection, boundary, _habtm_boundary)
89
+ child_records(record, reflection, boundary)
90
+ end
91
+
92
+ #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
93
+ def resolve_habtm(record, reflection, boundary, habtm_boundary)
94
+ habtm_records(record, reflection, boundary, habtm_boundary)
95
+ end
96
+
97
+ #: (untyped, untyped, untyped) -> Array[untyped]?
98
+ def belongs_to_records(record, reflection, boundary)
99
+ foreign_key = scalar_foreign_key(reflection)
100
+ return unless foreign_key
101
+
102
+ id = record.public_send(foreign_key)
103
+ return [] if id.nil?
104
+
105
+ model_class = belongs_to_class(record, reflection)
106
+ return unless model_class && @records.loaded?(model_class, id)
107
+
108
+ Array(@records.record_before(model_class, id, boundary)).compact
109
+ end
110
+
111
+ #: (untyped, untyped, untyped) -> Array[untyped]
112
+ def child_records(record, reflection, boundary)
113
+ edge(record.class, reflection).values.flat_map do |group|
114
+ model_class = group.fetch(:model)
115
+ group.fetch(:ids).filter_map do |id|
116
+ child = @records.record_before(model_class, id, boundary)
117
+ child if child && member_of?(child, reflection, record)
118
+ end
119
+ end
120
+ end
121
+
122
+ #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
123
+ def habtm_records(record, reflection, boundary, habtm_boundary)
124
+ transaction_id = transaction_id_for(habtm_boundary)
125
+ return unless transaction_id
126
+
127
+ empty = {} #: Hash[String, Array[untyped]]
128
+ ids = @habtm.fetch(edge_key(record.class, reflection), empty)[transaction_id.to_s]
129
+ return unless ids
130
+
131
+ ids.filter_map do |id|
132
+ @records.record_before(reflection.klass, id, boundary)
133
+ end
134
+ end
135
+
136
+ #: (untyped, untyped, untyped, untyped) -> Array[untyped]?
137
+ def through_records(record, reflection, boundary, habtm_boundary)
138
+ handled, intermediates = resolve(
139
+ record,
140
+ reflection.through_reflection,
141
+ boundary,
142
+ habtm_boundary: habtm_boundary
143
+ )
144
+ return unless handled
145
+
146
+ resolved = resolve_sources(intermediates, reflection, boundary, habtm_boundary)
147
+ return unless resolved.all?(&:first)
148
+
149
+ unique_records(resolved.flat_map(&:last))
150
+ end
151
+
152
+ #: (Array[untyped], untyped, untyped, untyped) -> Array[[bool, Array[untyped]]]
153
+ def resolve_sources(intermediates, reflection, boundary, habtm_boundary)
154
+ intermediates.map do |intermediate|
155
+ resolve(
156
+ intermediate,
157
+ reflection.source_reflection,
158
+ boundary,
159
+ habtm_boundary: habtm_boundary
160
+ )
161
+ end
162
+ end
163
+
164
+ #: (untyped, untyped, untyped) -> bool
165
+ def member_of?(child, reflection, owner)
166
+ foreign_key = scalar_foreign_key(reflection)
167
+ return false unless foreign_key
168
+ return false unless child.public_send(foreign_key).to_s == owner.id.to_s
169
+ return true unless reflection.options[:as]
170
+
171
+ owner_types(reflection, owner).include?(child.public_send(reflection.type).to_s)
172
+ end
173
+
174
+ #: (untyped) -> String?
175
+ def scalar_foreign_key(reflection)
176
+ key = reflection.foreign_key
177
+ key.to_s unless key.is_a?(Array)
178
+ end
179
+
180
+ #: (untyped, untyped) -> untyped
181
+ def belongs_to_class(record, reflection)
182
+ return reflection.klass unless reflection.polymorphic?
183
+
184
+ Object.const_get(record.public_send(reflection.foreign_type).to_s)
185
+ rescue NameError
186
+ nil
187
+ end
188
+
189
+ #: (untyped, untyped) -> Array[String]
190
+ def owner_types(reflection, owner)
191
+ types = [owner.class.name, owner.class.base_class.name, reflection.active_record.name]
192
+ types.map(&:to_s).uniq
193
+ end
194
+
195
+ #: (untyped) -> untyped
196
+ def transaction_id_for(boundary)
197
+ boundary.transaction_id
198
+ end
199
+
200
+ #: (Array[untyped]) -> Array[untyped]
201
+ def unique_records(records)
202
+ records.uniq { |record| [record.class.base_class.name, record.id.to_s] }
203
+ end
204
+
205
+ #: (untyped, untyped) -> Array[String]
206
+ def edge_key(owner_class, reflection)
207
+ [owner_class.base_class.name.to_s, reflection.name.to_s]
208
+ end
209
+
210
+ #: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
211
+ def merge_groups(existing, incoming)
212
+ existing.merge(incoming) do |_name, left, right|
213
+ left.merge(ids: (left.fetch(:ids) | right.fetch(:ids)))
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Expands the explicit association tree into a request-scoped PreparedHistory.
6
+ class PreparedHistoryLoader
7
+ #: (untyped, root_versions: Array[untyped], tree: AssociationTree, traversal: AssociationTraversal) -> void
8
+ def initialize(record, root_versions:, tree:, traversal:)
9
+ @record = record
10
+ @tree = tree
11
+ @traversal = traversal
12
+ @records = PreparedRecordIndex.new(root_versions.first)
13
+ @history = PreparedHistory.new(@records)
14
+ @edges = PreparedEdgeLoader.new(@records, root_versions)
15
+ @prepared = {} #: Hash[Array[String], Array[String]]
16
+ end
17
+
18
+ #: () -> PreparedHistory
19
+ def call
20
+ load_node(@record.class, [@record.id], @tree, path: '')
21
+ @history
22
+ end
23
+
24
+ private
25
+
26
+ # @rbs @record: untyped
27
+ # @rbs @tree: AssociationTree
28
+ # @rbs @traversal: AssociationTraversal
29
+ # @rbs @records: PreparedRecordIndex
30
+ # @rbs @history: PreparedHistory
31
+ # @rbs @edges: PreparedEdgeLoader
32
+ # @rbs @prepared: Hash[Array[String], Array[String]]
33
+
34
+ #: (untyped, Array[untyped], AssociationTree, path: String) -> void
35
+ def load_node(model_class, ids, tree, path:)
36
+ load_records(model_class, ids)
37
+ @traversal.reflections_for(model_class, tree, path: path).each do |reflection|
38
+ subtree = tree.child(reflection.name)
39
+ next unless subtree
40
+
41
+ groups = prepare_reflection(model_class, ids, reflection)
42
+ load_groups(groups)
43
+ child_path = Support.association_path(path, reflection.name.to_s)
44
+ groups.each_value do |group|
45
+ load_node(group.fetch(:model), group.fetch(:ids), subtree, path: child_path)
46
+ end
47
+ end
48
+ end
49
+
50
+ #: (untyped, Array[untyped], untyped) -> Hash[String, Hash[Symbol, untyped]]
51
+ def prepare_reflection(owner_class, owner_ids, reflection)
52
+ return prepare_through(owner_class, owner_ids, reflection) if reflection.options[:through]
53
+
54
+ prepare_edge(owner_class, owner_ids, reflection)
55
+ end
56
+
57
+ #: (untyped, Array[untyped], untyped) -> Hash[String, Hash[Symbol, untyped]]
58
+ def prepare_through(owner_class, owner_ids, reflection)
59
+ through_groups = prepare_edge(owner_class, owner_ids, reflection.through_reflection)
60
+ targets = {} #: Hash[String, Hash[Symbol, untyped]]
61
+ through_groups.each_value do |group|
62
+ load_groups({ group.fetch(:model).name.to_s => group })
63
+ source = prepare_edge(group.fetch(:model), group.fetch(:ids), reflection.source_reflection)
64
+ targets = merge_groups(targets, source)
65
+ end
66
+ @history.merge_edge(owner_class, reflection, targets)
67
+ @history.mark_unsupported(owner_class, reflection) if deferred_macro?(reflection)
68
+ targets
69
+ end
70
+
71
+ #: (untyped, Array[untyped], untyped) -> Hash[String, Hash[Symbol, untyped]]
72
+ def prepare_edge(owner_class, owner_ids, reflection)
73
+ missing = unprepared_ids(owner_class, owner_ids, reflection)
74
+ return @history.edge(owner_class, reflection) if missing.empty?
75
+
76
+ groups, memberships = @edges.call(owner_class, missing, reflection)
77
+ @history.merge_edge(owner_class, reflection, groups)
78
+ @history.register_habtm(owner_class, reflection, memberships) if memberships
79
+ mark_prepared(owner_class, missing, reflection)
80
+ mark_unsupported(owner_class, reflection, groups)
81
+ load_groups(groups)
82
+ @history.edge(owner_class, reflection)
83
+ end
84
+
85
+ #: (untyped, Array[untyped], untyped) -> Array[untyped]
86
+ def unprepared_ids(owner_class, ids, reflection)
87
+ prepared = @prepared.fetch(edge_key(owner_class, reflection), [])
88
+ ids.reject { |id| prepared.include?(id.to_s) }
89
+ end
90
+
91
+ #: (untyped, Array[untyped], untyped) -> void
92
+ def mark_prepared(owner_class, ids, reflection)
93
+ key = edge_key(owner_class, reflection)
94
+ @prepared[key] = (@prepared.fetch(key, []) | ids.map(&:to_s))
95
+ end
96
+
97
+ #: (untyped, untyped, Hash[String, Hash[Symbol, untyped]]) -> void
98
+ def mark_unsupported(owner_class, reflection, groups)
99
+ unsupported = deferred_macro?(reflection) || reflection.foreign_key.is_a?(Array) ||
100
+ groups.values.any? { |group| !versioned?(group.fetch(:model)) }
101
+ @history.mark_unsupported(owner_class, reflection) if unsupported
102
+ end
103
+
104
+ #: (untyped) -> bool
105
+ def deferred_macro?(reflection)
106
+ reflection.options[:through] && !reflection.source_reflection.belongs_to?
107
+ end
108
+
109
+ #: (Hash[String, Hash[Symbol, untyped]]) -> void
110
+ def load_groups(groups)
111
+ groups.each_value { |group| load_records(group.fetch(:model), group.fetch(:ids)) }
112
+ end
113
+
114
+ #: (untyped, Array[untyped]) -> void
115
+ def load_records(model_class, ids)
116
+ @records.load(model_class, ids) if versioned?(model_class)
117
+ end
118
+
119
+ #: (untyped) -> bool
120
+ def versioned?(model_class)
121
+ model_class.respond_to?(:paper_trail)
122
+ end
123
+
124
+ #: (untyped, untyped) -> Array[String]
125
+ def edge_key(owner_class, reflection)
126
+ [owner_class.base_class.name.to_s, reflection.name.to_s]
127
+ end
128
+
129
+ #: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
130
+ def merge_groups(left, right)
131
+ left.merge(right) do |_name, first, second|
132
+ first.merge(ids: (first.fetch(:ids) | second.fetch(:ids)))
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Immutable scalar state from a PaperTrail version or live-record fallback.
6
+ class PreparedRecordState
7
+ #: (untyped) -> void
8
+ def initialize(record)
9
+ @model_class = record.class
10
+ @attributes = Support.immutable_copy(record.attributes)
11
+ freeze
12
+ end
13
+
14
+ #: () -> untyped
15
+ def instantiate
16
+ @model_class.new(@attributes)
17
+ end
18
+
19
+ # @rbs @model_class: untyped
20
+ # @rbs @attributes: Hash[untyped, untyped]
21
+ end
22
+
23
+ # Versions and live fallback for one model identity.
24
+ class PreparedRecordSeries
25
+ attr_reader :versions #: Array[untyped]
26
+
27
+ #: (versions: Array[untyped], live: PreparedRecordState?) -> void
28
+ def initialize(versions:, live:)
29
+ @versions = versions.freeze
30
+ @live = live
31
+ @states = {} #: Hash[untyped, PreparedRecordState?]
32
+ end
33
+
34
+ #: (untyped) -> untyped
35
+ def record_before(boundary)
36
+ version = versions.find { |candidate| eligible?(candidate, boundary) }
37
+ return state_for(version)&.instantiate if version
38
+
39
+ @live&.instantiate
40
+ end
41
+
42
+ #: () -> Array[untyped]
43
+ def records
44
+ version_records = versions.filter_map { |version| state_for(version)&.instantiate }
45
+ live = @live
46
+ live ? [*version_records, live.instantiate] : version_records
47
+ end
48
+
49
+ private
50
+
51
+ # @rbs @live: PreparedRecordState?
52
+ # @rbs @states: Hash[untyped, PreparedRecordState?]
53
+
54
+ #: (untyped, untyped) -> bool
55
+ def eligible?(version, boundary)
56
+ version.created_at >= boundary.created_at || same_transaction?(version, boundary)
57
+ end
58
+
59
+ #: (untyped, untyped) -> bool
60
+ def same_transaction?(version, boundary)
61
+ transaction_id = boundary.transaction_id if boundary.respond_to?(:transaction_id)
62
+ transaction_id && version.respond_to?(:transaction_id) &&
63
+ version.transaction_id == transaction_id
64
+ end
65
+
66
+ #: (untyped) -> PreparedRecordState?
67
+ def state_for(version)
68
+ return @states[version.id] if @states.key?(version.id)
69
+
70
+ record = version.reify(
71
+ dup: true,
72
+ has_many: false,
73
+ has_one: false,
74
+ belongs_to: false,
75
+ has_and_belongs_to_many: false
76
+ )
77
+ @states[version.id] = record && PreparedRecordState.new(record)
78
+ end
79
+ end
80
+
81
+ # Request-scoped scalar history loaded once per model identity.
82
+ class PreparedRecordIndex
83
+ #: (untyped) -> void
84
+ def initialize(start_version)
85
+ @start_time = start_version.created_at
86
+ @series = {} #: Hash[Array[String], PreparedRecordSeries]
87
+ end
88
+
89
+ #: (untyped, Array[untyped]) -> void
90
+ def load(model_class, ids)
91
+ missing = missing_ids(model_class, ids)
92
+ return if missing.empty?
93
+
94
+ versions = versions_for(model_class, missing).group_by { |version| version.item_id.to_s }
95
+ live = live_records_for(model_class, missing).to_h { |record| [record.id.to_s, record] }
96
+ missing.each { |id| add_series(model_class, id, versions, live) }
97
+ end
98
+
99
+ #: (untyped, untyped, untyped) -> untyped
100
+ def record_before(model_class, id, boundary)
101
+ @series.fetch(identity(model_class, id)).record_before(boundary)
102
+ end
103
+
104
+ #: (untyped, Array[untyped]) -> Array[untyped]
105
+ def records_for(model_class, ids)
106
+ ids.flat_map { |id| @series.fetch(identity(model_class, id)).records }
107
+ end
108
+
109
+ #: (untyped, untyped) -> bool
110
+ def loaded?(model_class, id)
111
+ @series.key?(identity(model_class, id))
112
+ end
113
+
114
+ private
115
+
116
+ # @rbs @start_time: untyped
117
+ # @rbs @series: Hash[Array[String], PreparedRecordSeries]
118
+
119
+ #: (untyped, untyped) -> Array[String]
120
+ def identity(model_class, id)
121
+ [model_class.base_class.name.to_s, id.to_s]
122
+ end
123
+
124
+ #: (untyped, Array[untyped]) -> Array[untyped]
125
+ def missing_ids(model_class, ids)
126
+ ids.compact.uniq.reject { |id| @series.key?(identity(model_class, id)) }
127
+ end
128
+
129
+ #: (untyped, untyped, Hash[String, Array[untyped]], Hash[String, untyped]) -> void
130
+ def add_series(model_class, id, versions, live)
131
+ record = live[id.to_s]
132
+ @series[identity(model_class, id)] = PreparedRecordSeries.new(
133
+ versions: versions.fetch(id.to_s, []),
134
+ live: record && PreparedRecordState.new(record)
135
+ )
136
+ end
137
+
138
+ #: (untyped, Array[untyped]) -> Array[untyped]
139
+ def versions_for(model_class, ids)
140
+ model_class.paper_trail.version_class.where(
141
+ item_type: model_class.base_class.name,
142
+ item_id: ids
143
+ ).where('created_at >= ?', @start_time).order(:id).to_a
144
+ end
145
+
146
+ #: (untyped, Array[untyped]) -> Array[untyped]
147
+ def live_records_for(model_class, ids)
148
+ primary_key = model_class.primary_key
149
+ return [] if primary_key.is_a?(Array)
150
+
151
+ model_class.base_class.unscoped.where(primary_key => ids).to_a
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Immutable identity shared by snapshots and stable-record changes.
6
+ class RecordReference
7
+ attr_reader :type #: String
8
+ attr_reader :id #: untyped
9
+
10
+ #: (type: String, id: untyped) -> void
11
+ def initialize(type:, id:)
12
+ @type = Support.immutable_copy(type.to_s)
13
+ @id = Support.immutable_copy(id)
14
+ freeze
15
+ end
16
+
17
+ #: (reference_key) -> untyped
18
+ def [](key)
19
+ case key.to_s
20
+ when 'type' then type
21
+ when 'id' then id
22
+ end
23
+ end
24
+
25
+ #: (reference_key) -> untyped
26
+ def fetch(key)
27
+ value = self[key]
28
+ return value if %w[type id].include?(key.to_s)
29
+
30
+ raise KeyError, "key not found: #{key.inspect}"
31
+ end
32
+
33
+ #: () -> Hash[Symbol, untyped]
34
+ def to_h
35
+ { type: type, id: id }
36
+ end
37
+ end
38
+
39
+ # A normalized association in a bounded record snapshot tree.
40
+ class AssociationSnapshot
41
+ COLLECTION_KINDS = %i[has_many has_and_belongs_to_many].freeze
42
+ SUPPORTED_KINDS = %i[belongs_to has_one has_many has_and_belongs_to_many].freeze
43
+ private_constant :COLLECTION_KINDS, :SUPPORTED_KINDS
44
+
45
+ class << self
46
+ #: (Symbol) -> bool
47
+ def collection_kind?(kind)
48
+ COLLECTION_KINDS.include?(kind)
49
+ end
50
+ end
51
+
52
+ attr_reader :kind #: Symbol
53
+ attr_reader :records #: Array[RecordSnapshot]
54
+
55
+ #: (kind: Symbol, records: Array[RecordSnapshot]) -> void
56
+ def initialize(kind:, records:)
57
+ unless SUPPORTED_KINDS.include?(kind)
58
+ raise ArgumentError, "unsupported association kind: #{kind.inspect}"
59
+ end
60
+ if !self.class.collection_kind?(kind) && records.length > 1
61
+ raise ArgumentError, "#{kind} association snapshots accept at most one record"
62
+ end
63
+
64
+ @kind = kind
65
+ @records = records.dup.freeze
66
+ freeze
67
+ end
68
+
69
+ #: () -> Hash[Symbol, untyped]
70
+ def to_h
71
+ { kind: kind, records: Support.serialize(records) }
72
+ end
73
+ end
74
+
75
+ # An immutable, ActiveRecord-independent representation of a reified record.
76
+ class RecordSnapshot
77
+ attr_reader :type #: String
78
+ attr_reader :id #: untyped
79
+ attr_reader :identity #: Array[untyped]
80
+ attr_reader :attributes #: Hash[String, untyped]
81
+ attr_reader :associations #: Hash[String, AssociationSnapshot]
82
+
83
+ #: (type: String, id: untyped, attributes: snapshot_attributes, ?associations: snapshot_associations) -> void
84
+ def initialize(type:, id:, attributes:, associations: {})
85
+ @type = Support.immutable_copy(type.to_s)
86
+ @id = Support.immutable_copy(id)
87
+ @identity = [@type, @id].freeze
88
+ @attributes = normalize_hash(attributes)
89
+ @associations = normalize_hash(associations)
90
+ freeze
91
+ end
92
+
93
+ #: () -> RecordReference
94
+ def reference
95
+ RecordReference.new(type: type, id: id)
96
+ end
97
+
98
+ #: () -> Hash[Symbol, untyped]
99
+ def to_h
100
+ value = {
101
+ type: type,
102
+ id: id,
103
+ attributes: Support.serialize(attributes)
104
+ }
105
+ value[:associations] = Support.serialize(associations) unless associations.empty?
106
+ value
107
+ end
108
+
109
+ private
110
+
111
+ #: (Hash[untyped, untyped]) -> Hash[String, untyped]
112
+ def normalize_hash(hash)
113
+ normalized = {} #: Hash[String, untyped]
114
+ hash.sort_by { |key, _value| key.to_s }.each do |key, value|
115
+ normalized[Support.immutable_copy(key.to_s)] = Support.immutable_copy(value)
116
+ end
117
+ normalized.freeze
118
+ end
119
+ end
120
+ end