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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +45 -0
- data/LICENSE +22 -0
- data/README.md +473 -0
- data/lib/paper_trail_diff/activity_boundary.rb +82 -0
- data/lib/paper_trail_diff/activity_event.rb +128 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +493 -0
- data/lib/paper_trail_diff/activity_range.rb +58 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +156 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +161 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +177 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +196 -0
- data/lib/paper_trail_diff/analysis.rb +26 -0
- data/lib/paper_trail_diff/association_discovery.rb +132 -0
- data/lib/paper_trail_diff/association_traversal.rb +131 -0
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +191 -0
- data/lib/paper_trail_diff/configuration.rb +170 -0
- data/lib/paper_trail_diff/diagnostics.rb +242 -0
- data/lib/paper_trail_diff/endpoint.rb +87 -0
- data/lib/paper_trail_diff/engine.rb +180 -0
- data/lib/paper_trail_diff/errors.rb +34 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +103 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +144 -0
- data/lib/paper_trail_diff/live_association_reader.rb +10 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +182 -0
- data/lib/paper_trail_diff/prepared_association_reifier.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +155 -0
- data/lib/paper_trail_diff/prepared_history.rb +217 -0
- data/lib/paper_trail_diff/prepared_history_loader.rb +136 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +154 -0
- data/lib/paper_trail_diff/snapshot.rb +120 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +182 -0
- data/lib/paper_trail_diff/step.rb +28 -0
- data/lib/paper_trail_diff/support.rb +80 -0
- data/lib/paper_trail_diff/timeline_builder.rb +60 -0
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -0
- data/lib/paper_trail_diff/value_objects.rb +134 -0
- data/lib/paper_trail_diff/version.rb +6 -0
- data/lib/paper_trail_diff/version_range.rb +81 -0
- data/lib/paper_trail_diff.rb +131 -0
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +43 -0
- data/sig/generated/paper_trail_diff/activity_event.rbs +64 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +98 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +36 -0
- data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +54 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +71 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +61 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +71 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +18 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +63 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +54 -0
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +56 -0
- data/sig/generated/paper_trail_diff/configuration.rbs +67 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +109 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +30 -0
- data/sig/generated/paper_trail_diff/engine.rbs +54 -0
- data/sig/generated/paper_trail_diff/errors.rbs +43 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +37 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +50 -0
- data/sig/generated/paper_trail_diff/live_association_reader.rbs +9 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +72 -0
- data/sig/generated/paper_trail_diff/prepared_association_reifier.rbs +27 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +65 -0
- data/sig/generated/paper_trail_diff/prepared_history.rbs +93 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +67 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +84 -0
- data/sig/generated/paper_trail_diff/snapshot.rbs +69 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +76 -0
- data/sig/generated/paper_trail_diff/step.rbs +18 -0
- data/sig/generated/paper_trail_diff/support.rbs +30 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +31 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -0
- data/sig/generated/paper_trail_diff/value_objects.rbs +87 -0
- data/sig/generated/paper_trail_diff/version.rbs +5 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +35 -0
- data/sig/generated/paper_trail_diff.rbs +56 -0
- 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
|