iknow_view_models 2.8.4
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/.circleci/config.yml +115 -0
- data/.gitignore +36 -0
- data/.travis.yml +31 -0
- data/Appraisals +9 -0
- data/Gemfile +19 -0
- data/LICENSE.txt +22 -0
- data/README.md +19 -0
- data/Rakefile +21 -0
- data/appveyor.yml +22 -0
- data/gemfiles/rails_5_2.gemfile +15 -0
- data/gemfiles/rails_6_0_beta.gemfile +15 -0
- data/iknow_view_models.gemspec +49 -0
- data/lib/iknow_view_models.rb +12 -0
- data/lib/iknow_view_models/railtie.rb +8 -0
- data/lib/iknow_view_models/version.rb +5 -0
- data/lib/view_model.rb +333 -0
- data/lib/view_model/access_control.rb +154 -0
- data/lib/view_model/access_control/composed.rb +216 -0
- data/lib/view_model/access_control/open.rb +13 -0
- data/lib/view_model/access_control/read_only.rb +13 -0
- data/lib/view_model/access_control/tree.rb +264 -0
- data/lib/view_model/access_control_error.rb +10 -0
- data/lib/view_model/active_record.rb +383 -0
- data/lib/view_model/active_record/association_data.rb +178 -0
- data/lib/view_model/active_record/association_manipulation.rb +389 -0
- data/lib/view_model/active_record/cache.rb +265 -0
- data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
- data/lib/view_model/active_record/cloner.rb +113 -0
- data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
- data/lib/view_model/active_record/controller.rb +77 -0
- data/lib/view_model/active_record/controller_base.rb +185 -0
- data/lib/view_model/active_record/nested_controller_base.rb +93 -0
- data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
- data/lib/view_model/active_record/update_context.rb +252 -0
- data/lib/view_model/active_record/update_data.rb +749 -0
- data/lib/view_model/active_record/update_operation.rb +810 -0
- data/lib/view_model/active_record/visitor.rb +77 -0
- data/lib/view_model/after_transaction_runner.rb +29 -0
- data/lib/view_model/callbacks.rb +219 -0
- data/lib/view_model/changes.rb +62 -0
- data/lib/view_model/config.rb +29 -0
- data/lib/view_model/controller.rb +142 -0
- data/lib/view_model/deserialization_error.rb +437 -0
- data/lib/view_model/deserialize_context.rb +16 -0
- data/lib/view_model/error.rb +191 -0
- data/lib/view_model/error_view.rb +35 -0
- data/lib/view_model/record.rb +367 -0
- data/lib/view_model/record/attribute_data.rb +48 -0
- data/lib/view_model/reference.rb +31 -0
- data/lib/view_model/references.rb +48 -0
- data/lib/view_model/registry.rb +73 -0
- data/lib/view_model/schemas.rb +45 -0
- data/lib/view_model/serialization_error.rb +10 -0
- data/lib/view_model/serialize_context.rb +118 -0
- data/lib/view_model/test_helpers.rb +103 -0
- data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
- data/lib/view_model/traversal_context.rb +126 -0
- data/lib/view_model/utils.rb +24 -0
- data/lib/view_model/utils/collections.rb +49 -0
- data/test/helpers/arvm_test_models.rb +59 -0
- data/test/helpers/arvm_test_utilities.rb +187 -0
- data/test/helpers/callback_tracer.rb +27 -0
- data/test/helpers/controller_test_helpers.rb +270 -0
- data/test/helpers/match_enumerator.rb +58 -0
- data/test/helpers/query_logging.rb +71 -0
- data/test/helpers/test_access_control.rb +56 -0
- data/test/helpers/viewmodel_spec_helpers.rb +326 -0
- data/test/unit/view_model/access_control_test.rb +769 -0
- data/test/unit/view_model/active_record/alias_test.rb +35 -0
- data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
- data/test/unit/view_model/active_record/cache_test.rb +351 -0
- data/test/unit/view_model/active_record/cloner_test.rb +313 -0
- data/test/unit/view_model/active_record/controller_test.rb +561 -0
- data/test/unit/view_model/active_record/counter_test.rb +80 -0
- data/test/unit/view_model/active_record/customization_test.rb +388 -0
- data/test/unit/view_model/active_record/has_many_test.rb +957 -0
- data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
- data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
- data/test/unit/view_model/active_record/has_one_test.rb +334 -0
- data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
- data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
- data/test/unit/view_model/active_record/poly_test.rb +320 -0
- data/test/unit/view_model/active_record/shared_test.rb +285 -0
- data/test/unit/view_model/active_record/version_test.rb +121 -0
- data/test/unit/view_model/active_record_test.rb +542 -0
- data/test/unit/view_model/callbacks_test.rb +582 -0
- data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
- data/test/unit/view_model/record_test.rb +524 -0
- data/test/unit/view_model/traversal_context_test.rb +371 -0
- data/test/unit/view_model_test.rb +62 -0
- metadata +490 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Mix-in for VM::ActiveRecord providing direct manipulation of
|
|
4
|
+
# directly-associated entities. Avoids loading entire collections.
|
|
5
|
+
module ViewModel::ActiveRecord::AssociationManipulation
|
|
6
|
+
def load_associated(association_name, scope: nil, eager_include: true, serialize_context: self.class.new_serialize_context)
|
|
7
|
+
association_data = self.class._association_data(association_name)
|
|
8
|
+
direct_reflection = association_data.direct_reflection
|
|
9
|
+
|
|
10
|
+
association = self.model.association(direct_reflection.name)
|
|
11
|
+
association_scope = association.scope
|
|
12
|
+
|
|
13
|
+
if association_data.through?
|
|
14
|
+
raise ArgumentError.new("Polymorphic through relationships not supported yet") if association_data.polymorphic?
|
|
15
|
+
associated_viewmodel = association_data.viewmodel_class
|
|
16
|
+
direct_viewmodel = association_data.direct_viewmodel
|
|
17
|
+
else
|
|
18
|
+
associated_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) }
|
|
19
|
+
direct_viewmodel = associated_viewmodel
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if direct_viewmodel._list_member?
|
|
23
|
+
association_scope = association_scope.order(direct_viewmodel._list_attribute_name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if association_data.through?
|
|
27
|
+
association_scope = associated_viewmodel.model_class
|
|
28
|
+
.joins(association_data.indirect_reflection.inverse_of.name)
|
|
29
|
+
.merge(association_scope)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
association_scope = association_scope.merge(scope) if scope
|
|
33
|
+
|
|
34
|
+
vms = association_scope.map { |model| associated_viewmodel.new(model) }
|
|
35
|
+
|
|
36
|
+
if eager_include
|
|
37
|
+
child_context = self.context_for_child(association_name, context: serialize_context)
|
|
38
|
+
ViewModel.preload_for_serialization(vms, serialize_context: child_context)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if association_data.collection?
|
|
42
|
+
vms
|
|
43
|
+
else
|
|
44
|
+
if vms.size > 1
|
|
45
|
+
raise ViewModel::DeserializationError::Internal.new("Internal error: encountered multiple records for single association #{association_name}", self.blame_reference)
|
|
46
|
+
end
|
|
47
|
+
vms.first
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Replace the current member(s) of an association with the provided hash(es).
|
|
52
|
+
def replace_associated(association_name, update_hash, references: {}, deserialize_context: self.class.new_deserialize_context)
|
|
53
|
+
association_data = self.class._association_data(association_name)
|
|
54
|
+
|
|
55
|
+
# TODO: structure checking
|
|
56
|
+
|
|
57
|
+
if association_data.through? || association_data.shared?
|
|
58
|
+
is_fupdate =
|
|
59
|
+
association_data.collection? &&
|
|
60
|
+
update_hash.is_a?(Hash) &&
|
|
61
|
+
update_hash[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
|
|
62
|
+
|
|
63
|
+
if is_fupdate
|
|
64
|
+
update_hash[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE].each_with_index do |action, i|
|
|
65
|
+
action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
|
|
66
|
+
if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
|
|
67
|
+
# Remove actions are always type/id refs; others need to be translated to proper refs
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
association_references = convert_updates_to_references(
|
|
72
|
+
action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE],
|
|
73
|
+
key: "#{action_type_name}_#{i}")
|
|
74
|
+
references.merge!(association_references)
|
|
75
|
+
action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE] =
|
|
76
|
+
association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
|
|
77
|
+
end
|
|
78
|
+
else
|
|
79
|
+
update_hash = ViewModel::Utils.wrap_one_or_many(update_hash) do |sh|
|
|
80
|
+
association_references = convert_updates_to_references(sh, key: 'replace')
|
|
81
|
+
references.merge!(association_references)
|
|
82
|
+
association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
root_update_hash = {
|
|
88
|
+
ViewModel::ID_ATTRIBUTE => self.id,
|
|
89
|
+
ViewModel::TYPE_ATTRIBUTE => self.class.view_name,
|
|
90
|
+
association_name.to_s => update_hash,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
root_update_viewmodel = self.class.deserialize_from_view(root_update_hash, references: references, deserialize_context: deserialize_context)
|
|
94
|
+
|
|
95
|
+
root_update_viewmodel._read_association(association_name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Create or update members of a associated collection. For an ordered
|
|
99
|
+
# collection, the items are inserted either before `before`, after `after`, or
|
|
100
|
+
# at the end.
|
|
101
|
+
def append_associated(association_name, subtree_hash_or_hashes, references: {}, before: nil, after: nil, deserialize_context: self.class.new_deserialize_context)
|
|
102
|
+
if self.changes.changed?
|
|
103
|
+
raise ArgumentError.new('Invalid call to append_associated on viewmodel with pending changes')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
association_data = self.class._association_data(association_name)
|
|
107
|
+
direct_reflection = association_data.direct_reflection
|
|
108
|
+
raise ArgumentError.new("Cannot append to single association '#{association_name}'") unless association_data.collection?
|
|
109
|
+
|
|
110
|
+
ViewModel::Utils.wrap_one_or_many(subtree_hash_or_hashes) do |subtree_hashes|
|
|
111
|
+
model_class.transaction do
|
|
112
|
+
ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control|
|
|
113
|
+
association_changed!(association_name)
|
|
114
|
+
deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, self)
|
|
115
|
+
|
|
116
|
+
if association_data.through?
|
|
117
|
+
raise ArgumentError.new("Polymorphic through relationships not supported yet") if association_data.polymorphic?
|
|
118
|
+
|
|
119
|
+
direct_viewmodel_class = association_data.direct_viewmodel
|
|
120
|
+
root_update_data, referenced_update_data = construct_indirect_append_updates(association_data, subtree_hashes, references)
|
|
121
|
+
else
|
|
122
|
+
direct_viewmodel_class = association_data.viewmodel_class
|
|
123
|
+
root_update_data, referenced_update_data = construct_direct_append_updates(association_data, subtree_hashes, references)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
update_context = ViewModel::ActiveRecord::UpdateContext.build!(root_update_data, referenced_update_data, root_type: direct_viewmodel_class)
|
|
127
|
+
|
|
128
|
+
# Provide information about what was updated
|
|
129
|
+
deserialize_context.updated_associations = root_update_data.map(&:updated_associations)
|
|
130
|
+
.inject({}) { |acc, assocs| acc.deep_merge(assocs) }
|
|
131
|
+
|
|
132
|
+
# Set new parent
|
|
133
|
+
new_parent = ViewModel::ActiveRecord::UpdateOperation::ParentData.new(direct_reflection.inverse_of, self)
|
|
134
|
+
update_context.root_updates.each { |update| update.reparent_to = new_parent }
|
|
135
|
+
|
|
136
|
+
# Set place in list.
|
|
137
|
+
if direct_viewmodel_class._list_member?
|
|
138
|
+
new_positions = select_append_positions(association_data,
|
|
139
|
+
direct_viewmodel_class._list_attribute_name,
|
|
140
|
+
update_context.root_updates.count,
|
|
141
|
+
before: before, after: after)
|
|
142
|
+
|
|
143
|
+
update_context.root_updates.zip(new_positions).each do |update, new_pos|
|
|
144
|
+
update.reposition_to = new_pos
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Because append_associated can take from other parents, edit-check previous parents (other than this model)
|
|
149
|
+
unless association_data.through?
|
|
150
|
+
inverse_assoc_name = direct_reflection.inverse_of.name
|
|
151
|
+
|
|
152
|
+
previous_parent_ids = Set.new
|
|
153
|
+
update_context.root_updates.each do |update|
|
|
154
|
+
update_model = update.viewmodel.model
|
|
155
|
+
parent_model_id = update_model.read_attribute(update_model
|
|
156
|
+
.association(inverse_assoc_name)
|
|
157
|
+
.reflection.foreign_key)
|
|
158
|
+
|
|
159
|
+
if parent_model_id && parent_model_id != self.id
|
|
160
|
+
previous_parent_ids << parent_model_id
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
if previous_parent_ids.present?
|
|
165
|
+
previous_parents = self.class.find(previous_parent_ids.to_a, eager_include: false)
|
|
166
|
+
|
|
167
|
+
previous_parents.each do |parent_view|
|
|
168
|
+
ViewModel::Callbacks.wrap_deserialize(parent_view, deserialize_context: deserialize_context) do |pp_hook_control|
|
|
169
|
+
changes = ViewModel::Changes.new(changed_associations: [association_name])
|
|
170
|
+
deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, parent_view, changes: changes)
|
|
171
|
+
pp_hook_control.record_changes(changes)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
child_context = self.context_for_child(association_name, context: deserialize_context)
|
|
178
|
+
updated_viewmodels = update_context.run!(deserialize_context: child_context)
|
|
179
|
+
|
|
180
|
+
if association_data.through?
|
|
181
|
+
updated_viewmodels.map! do |direct_vm|
|
|
182
|
+
direct_vm._read_association(association_data.indirect_reflection.name)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Finalize the parent
|
|
187
|
+
final_changes = self.clear_changes!
|
|
188
|
+
|
|
189
|
+
# Could happen if hooks attempted to change the parent, which aren't
|
|
190
|
+
# valid since we're only editing children here.
|
|
191
|
+
unless final_changes.contained_to?(associations: [association_name.to_s])
|
|
192
|
+
raise ViewModel::DeserializationError::InvalidParentEdit.new(final_changes, blame_reference)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: final_changes)
|
|
196
|
+
hook_control.record_changes(final_changes)
|
|
197
|
+
|
|
198
|
+
updated_viewmodels
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Removes the association between the models represented by this viewmodel and
|
|
205
|
+
# the provided associated viewmodel. The associated model will be
|
|
206
|
+
# garbage-collected if the assocation is specified with `dependent: :destroy`
|
|
207
|
+
# or `:delete_all`
|
|
208
|
+
def delete_associated(association_name, associated_id, type: nil, deserialize_context: self.class.new_deserialize_context)
|
|
209
|
+
if self.changes.changed?
|
|
210
|
+
raise ArgumentError.new('Invalid call to delete_associated on viewmodel with pending changes')
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
association_data = self.class._association_data(association_name)
|
|
214
|
+
direct_reflection = association_data.direct_reflection
|
|
215
|
+
|
|
216
|
+
unless association_data.collection?
|
|
217
|
+
raise ArgumentError.new("Cannot remove element from single association '#{association_name}'")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
check_association_type!(association_data, type)
|
|
221
|
+
target_ref = ViewModel::Reference.new(type || association_data.viewmodel_class, associated_id)
|
|
222
|
+
|
|
223
|
+
model_class.transaction do
|
|
224
|
+
ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control|
|
|
225
|
+
association_changed!(association_name)
|
|
226
|
+
deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, self)
|
|
227
|
+
|
|
228
|
+
association = self.model.association(direct_reflection.name)
|
|
229
|
+
association_scope = association.scope
|
|
230
|
+
|
|
231
|
+
if association_data.through?
|
|
232
|
+
raise ArgumentError.new('Polymorphic through relationships not supported yet') if association_data.polymorphic?
|
|
233
|
+
|
|
234
|
+
direct_viewmodel = association_data.direct_viewmodel
|
|
235
|
+
association_scope = association_scope.where(association_data.indirect_reflection.foreign_key => associated_id)
|
|
236
|
+
else
|
|
237
|
+
# viewmodel type for current association: nil in case of empty polymorphic association
|
|
238
|
+
direct_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) }
|
|
239
|
+
|
|
240
|
+
if association_data.pointer_location == :local
|
|
241
|
+
# If we hold the pointer, we can immediately check if the type and id match.
|
|
242
|
+
if target_ref != ViewModel::Reference.new(direct_viewmodel, model.read_attribute(direct_reflection.foreign_key))
|
|
243
|
+
raise ViewModel::DeserializationError::AssociatedNotFound.new(association_name.to_s, target_ref, blame_reference)
|
|
244
|
+
end
|
|
245
|
+
else
|
|
246
|
+
# otherwise add the target constraint to the association scope
|
|
247
|
+
association_scope = association_scope.where(id: associated_id)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
models = association_scope.to_a
|
|
252
|
+
|
|
253
|
+
if models.blank?
|
|
254
|
+
raise ViewModel::DeserializationError::AssociatedNotFound.new(association_name.to_s, target_ref, blame_reference)
|
|
255
|
+
elsif models.size > 1
|
|
256
|
+
raise ViewModel::DeserializationError::Internal.new(
|
|
257
|
+
"Internal error: encountered multiple records for #{target_ref} in association #{association_name}",
|
|
258
|
+
blame_reference)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
child_context = self.context_for_child(association_name, context: deserialize_context)
|
|
262
|
+
child_vm = direct_viewmodel.new(models.first)
|
|
263
|
+
|
|
264
|
+
ViewModel::Callbacks.wrap_deserialize(child_vm, deserialize_context: child_context) do |child_hook_control|
|
|
265
|
+
changes = ViewModel::Changes.new(deleted: true)
|
|
266
|
+
child_context.run_callback(ViewModel::Callbacks::Hook::OnChange, child_vm, changes: changes)
|
|
267
|
+
child_hook_control.record_changes(changes)
|
|
268
|
+
|
|
269
|
+
association.delete(child_vm.model)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
self.children_changed!
|
|
273
|
+
final_changes = self.clear_changes!
|
|
274
|
+
|
|
275
|
+
unless final_changes.contained_to?(associations: [association_name.to_s])
|
|
276
|
+
raise ViewModel::DeserializationError::InvalidParentEdit.new(final_changes, blame_reference)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: final_changes)
|
|
280
|
+
hook_control.record_changes(final_changes)
|
|
281
|
+
|
|
282
|
+
child_vm
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private
|
|
288
|
+
|
|
289
|
+
def construct_direct_append_updates(association_data, subtree_hashes, references)
|
|
290
|
+
ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def construct_indirect_append_updates(association_data, subtree_hashes, references)
|
|
294
|
+
indirect_reflection = association_data.indirect_reflection
|
|
295
|
+
direct_viewmodel_class = association_data.direct_viewmodel
|
|
296
|
+
|
|
297
|
+
# Construct updates for the provided indirectly-associated hashes
|
|
298
|
+
indirect_update_data, referenced_update_data = ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references)
|
|
299
|
+
|
|
300
|
+
# Convert associated update data to references
|
|
301
|
+
indirect_references = convert_updates_to_references(indirect_update_data, key: 'indirect_append')
|
|
302
|
+
referenced_update_data.merge!(indirect_references)
|
|
303
|
+
|
|
304
|
+
# Find any existing models for the direct association: need to re-use any
|
|
305
|
+
# existing join-table entries, to maintain single membership of each
|
|
306
|
+
# associate.
|
|
307
|
+
# TODO: this won't handle polymorphic associations! In the case of polymorphism,
|
|
308
|
+
# need to join on (type, id) pairs instead.
|
|
309
|
+
if association_data.polymorphic?
|
|
310
|
+
raise ArgumentError.new("Internal error: append_association is not yet supported for polymorphic indirect associations")
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
existing_indirect_associates = indirect_update_data.map { |upd| upd.id unless upd.new? }.compact
|
|
314
|
+
|
|
315
|
+
direct_association_scope = model.association(association_data.direct_reflection.name).scope
|
|
316
|
+
|
|
317
|
+
existing_direct_ids = direct_association_scope
|
|
318
|
+
.where(indirect_reflection.foreign_key => existing_indirect_associates)
|
|
319
|
+
.pluck(indirect_reflection.foreign_key, :id)
|
|
320
|
+
.to_h
|
|
321
|
+
|
|
322
|
+
direct_update_data = indirect_references.map do |ref_name, update|
|
|
323
|
+
existing_id = existing_direct_ids[update.id] unless update.new?
|
|
324
|
+
|
|
325
|
+
metadata = ViewModel::Metadata.new(existing_id,
|
|
326
|
+
direct_viewmodel_class.view_name,
|
|
327
|
+
direct_viewmodel_class.schema_version,
|
|
328
|
+
existing_id.nil?)
|
|
329
|
+
|
|
330
|
+
ViewModel::ActiveRecord::UpdateData.new(
|
|
331
|
+
direct_viewmodel_class,
|
|
332
|
+
metadata,
|
|
333
|
+
{ indirect_reflection.name.to_s => { ViewModel::REFERENCE_ATTRIBUTE => ref_name }},
|
|
334
|
+
[ref_name])
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
return direct_update_data, referenced_update_data
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def convert_updates_to_references(indirect_update_data, key:)
|
|
341
|
+
indirect_update_data.each.with_index.with_object({}) do |(update, i), indirect_references|
|
|
342
|
+
indirect_references["__#{key}_ref_#{i}"] = update
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# TODO: this functionality could reasonably be extracted into `acts_as_manual_list`.
|
|
347
|
+
def select_append_positions(association_data, position_attr, append_count, before:, after:)
|
|
348
|
+
direct_reflection = association_data.direct_reflection
|
|
349
|
+
association_scope = model.association(direct_reflection.name).scope
|
|
350
|
+
|
|
351
|
+
search_key =
|
|
352
|
+
if association_data.through?
|
|
353
|
+
association_data.indirect_reflection.foreign_key
|
|
354
|
+
else
|
|
355
|
+
:id
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
if (relative_ref = (before || after))
|
|
359
|
+
relative_target = association_scope.where(search_key => relative_ref.model_id).select(:position)
|
|
360
|
+
if before
|
|
361
|
+
end_pos, start_pos = association_scope.where("#{position_attr} <= (?)", relative_target).order("#{position_attr} DESC").limit(2).pluck(:position)
|
|
362
|
+
else
|
|
363
|
+
start_pos, end_pos = association_scope.where("#{position_attr} >= (?)", relative_target).order("#{position_attr} ASC").limit(2).pluck(:position)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
if start_pos.nil? && end_pos.nil?
|
|
367
|
+
# Attempted to insert relative to ref that's not in the association
|
|
368
|
+
raise ViewModel::DeserializationError::AssociatedNotFound.new(association_data.association_name.to_s,
|
|
369
|
+
relative_ref,
|
|
370
|
+
blame_reference)
|
|
371
|
+
end
|
|
372
|
+
else
|
|
373
|
+
start_pos = association_scope.maximum(position_attr)
|
|
374
|
+
end_pos = nil
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
ActsAsManualList.select_positions(start_pos, end_pos, append_count)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def check_association_type!(association_data, type)
|
|
381
|
+
if type && !association_data.accepts?(type)
|
|
382
|
+
raise ViewModel::SerializationError.new(
|
|
383
|
+
"Type error: association '#{direct_reflection.name}' can't refer to viewmodel #{type.view_name}")
|
|
384
|
+
elsif association_data.polymorphic? && !type
|
|
385
|
+
raise ViewModel::SerializationError.new(
|
|
386
|
+
"Need to specify target viewmodel type for polymorphic association '#{direct_reflection.name}'")
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'iknow_cache'
|
|
4
|
+
|
|
5
|
+
# Cache for ViewModels that wrap ActiveRecord models.
|
|
6
|
+
class ViewModel::ActiveRecord::Cache
|
|
7
|
+
require 'view_model/active_record/cache/cacheable_view'
|
|
8
|
+
|
|
9
|
+
class UncacheableViewModelError < RuntimeError; end
|
|
10
|
+
|
|
11
|
+
attr_reader :viewmodel_class
|
|
12
|
+
|
|
13
|
+
# If cache_group: is specified, it must be a group of a single key: `:id`
|
|
14
|
+
def initialize(viewmodel_class, cache_group: nil)
|
|
15
|
+
@viewmodel_class = viewmodel_class
|
|
16
|
+
@cache_group = cache_group || create_default_cache_group # requires @viewmodel_class
|
|
17
|
+
@cache = @cache_group.register_cache(cache_name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def delete(*ids)
|
|
21
|
+
ids.each do |id|
|
|
22
|
+
@cache_group.delete_all(key_for(id))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def clear
|
|
27
|
+
@cache_group.invalidate_cache_group
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch_by_viewmodel(viewmodels, locked: false, serialize_context: @viewmodel_class.new_serialize_context)
|
|
31
|
+
ids = viewmodels.map(&:id)
|
|
32
|
+
fetch(ids, initial_viewmodels: viewmodels, locked: false, serialize_context: serialize_context)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fetch(ids, initial_viewmodels: nil, locked: false, serialize_context: @viewmodel_class.new_serialize_context)
|
|
36
|
+
data_serializations = Array.new(ids.size)
|
|
37
|
+
worker = CacheWorker.new(serialize_context: serialize_context)
|
|
38
|
+
|
|
39
|
+
# If initial root viewmodels were provided, visit them to ensure that they
|
|
40
|
+
# are visible. Other than this, no traversal callbacks are performed, as a
|
|
41
|
+
# view may be resolved from the cache without ever loading its viewmodel.
|
|
42
|
+
# Note that if unlocked, these views will be reloaded as part of obtaining a
|
|
43
|
+
# share lock. If the visibility of this viewmodel can change due to edits,
|
|
44
|
+
# it is necessary to obtain a lock before calling `fetch`.
|
|
45
|
+
initial_viewmodels&.each do |v|
|
|
46
|
+
serialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeVisit, v)
|
|
47
|
+
serialize_context.run_callback(ViewModel::Callbacks::Hook::AfterVisit, v)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Collect input array positions for each id, allowing duplicates
|
|
51
|
+
positions = ids.each_with_index.with_object({}) do |(id, i), h|
|
|
52
|
+
(h[id] ||= []) << i
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Fetch duplicates only once
|
|
56
|
+
ids = positions.keys
|
|
57
|
+
|
|
58
|
+
# Load existing serializations from the cache
|
|
59
|
+
cached_serializations = worker.load_from_cache(self, ids)
|
|
60
|
+
cached_serializations.each do |id, data|
|
|
61
|
+
positions[id].each do |idx|
|
|
62
|
+
data_serializations[idx] = data
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Resolve and serialize missing views
|
|
67
|
+
missing_ids = ids.to_set.subtract(cached_serializations.keys)
|
|
68
|
+
|
|
69
|
+
# If initial viewmodels have been locked, we can serialize them for cache
|
|
70
|
+
# misses.
|
|
71
|
+
available_viewmodels =
|
|
72
|
+
if locked
|
|
73
|
+
initial_viewmodels&.each_with_object({}) do |vm, h|
|
|
74
|
+
h[vm.id] = vm if missing_ids.include?(vm.id)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@viewmodel_class.transaction do
|
|
79
|
+
# Load remaining views and serialize
|
|
80
|
+
viewmodels = worker.find_and_preload_viewmodels(@viewmodel_class, missing_ids.to_a,
|
|
81
|
+
available_viewmodels: available_viewmodels)
|
|
82
|
+
|
|
83
|
+
loaded_serializations = worker.serialize_and_cache(viewmodels)
|
|
84
|
+
loaded_serializations.each do |id, data|
|
|
85
|
+
positions[id].each do |idx|
|
|
86
|
+
data_serializations[idx] = data
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Resolve references
|
|
91
|
+
worker.resolve_references!
|
|
92
|
+
|
|
93
|
+
return data_serializations, worker.resolved_references
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class CacheWorker
|
|
98
|
+
SENTINEL = Object.new
|
|
99
|
+
WorklistEntry = Struct.new(:ref_name, :viewmodel)
|
|
100
|
+
|
|
101
|
+
attr_reader :serialize_context, :resolved_references
|
|
102
|
+
|
|
103
|
+
def initialize(serialize_context:)
|
|
104
|
+
@worklist = {}
|
|
105
|
+
@resolved_references = {}
|
|
106
|
+
@serialize_context = serialize_context
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def resolve_references!
|
|
110
|
+
@serialize_context = serialize_context.for_references
|
|
111
|
+
|
|
112
|
+
while @worklist.present?
|
|
113
|
+
type_name, required_entries = @worklist.shift
|
|
114
|
+
viewmodel_class = ViewModel::Registry.for_view_name(type_name)
|
|
115
|
+
|
|
116
|
+
required_entries.each do |_id, entry|
|
|
117
|
+
@resolved_references[entry.ref_name] = SENTINEL
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if viewmodel_class < CacheableView
|
|
121
|
+
cached_serializations = load_from_cache(viewmodel_class.viewmodel_cache, required_entries.keys)
|
|
122
|
+
cached_serializations.each do |id, data|
|
|
123
|
+
ref_name = required_entries.delete(id).ref_name
|
|
124
|
+
@resolved_references[ref_name] = data
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Load remaining entries from database
|
|
129
|
+
available_viewmodels = required_entries.each_with_object({}) do |(id, entry), h|
|
|
130
|
+
h[id] = entry.viewmodel if entry.viewmodel
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
viewmodels = find_and_preload_viewmodels(viewmodel_class, required_entries.keys,
|
|
134
|
+
available_viewmodels: available_viewmodels)
|
|
135
|
+
|
|
136
|
+
loaded_serializations = serialize_and_cache(viewmodels)
|
|
137
|
+
loaded_serializations.each do |id, data|
|
|
138
|
+
ref_name = required_entries[id].ref_name
|
|
139
|
+
@resolved_references[ref_name] = data
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Loads the specified entities from the cache and returns a hash of
|
|
145
|
+
# {id=>serialized_view}. Any references encountered are added to the
|
|
146
|
+
# worklist.
|
|
147
|
+
def load_from_cache(viewmodel_cache, ids)
|
|
148
|
+
cached_serializations = viewmodel_cache.load(ids, serialize_context: serialize_context)
|
|
149
|
+
|
|
150
|
+
cached_serializations.each_with_object({}) do |(id, cached_serialization), result|
|
|
151
|
+
add_refs_to_worklist(cached_serialization[:ref_cache])
|
|
152
|
+
result[id] = cached_serialization[:data]
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Serializes the specified preloaded viewmodels and returns a hash of
|
|
157
|
+
# {id=>serialized_view}. If the viewmodel type is cacheable, it will be
|
|
158
|
+
# added to the cache. Any references encountered during serialization are
|
|
159
|
+
# added to the worklist.
|
|
160
|
+
def serialize_and_cache(viewmodels)
|
|
161
|
+
viewmodels.each_with_object({}) do |viewmodel, result|
|
|
162
|
+
data_serialization = Jbuilder.encode do |json|
|
|
163
|
+
ViewModel.serialize(viewmodel, json, serialize_context: serialize_context)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
referenced_viewmodels = serialize_context.extract_referenced_views!
|
|
167
|
+
add_viewmodels_to_worklist(referenced_viewmodels)
|
|
168
|
+
|
|
169
|
+
if viewmodel.class < CacheableView
|
|
170
|
+
cacheable_references = referenced_viewmodels.transform_values { |vm| cacheable_reference(vm) }
|
|
171
|
+
viewmodel.class.viewmodel_cache.store(viewmodel.id, data_serialization, cacheable_references,
|
|
172
|
+
serialize_context: serialize_context)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
result[viewmodel.id] = data_serialization
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Resolves viewmodels for the provided ids from the database or
|
|
180
|
+
# available_viewmodels and shallowly preloads them.
|
|
181
|
+
def find_and_preload_viewmodels(viewmodel_class, ids, available_viewmodels: nil)
|
|
182
|
+
viewmodels = []
|
|
183
|
+
|
|
184
|
+
if available_viewmodels.present?
|
|
185
|
+
ids = ids.reject do |id|
|
|
186
|
+
if (vm = available_viewmodels[id])
|
|
187
|
+
viewmodels << vm
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if ids.present?
|
|
193
|
+
found = viewmodel_class.find(ids,
|
|
194
|
+
eager_include: false,
|
|
195
|
+
lock: "FOR SHARE",
|
|
196
|
+
serialize_context: serialize_context)
|
|
197
|
+
viewmodels.concat(found)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
ViewModel.preload_for_serialization(viewmodels,
|
|
201
|
+
include_shared: false,
|
|
202
|
+
lock: "FOR SHARE",
|
|
203
|
+
serialize_context: serialize_context)
|
|
204
|
+
|
|
205
|
+
viewmodels
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Store VM references in the cache as viewmodel name + id pairs.
|
|
209
|
+
def cacheable_reference(viewmodel)
|
|
210
|
+
[viewmodel.class.view_name, viewmodel.id]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def add_refs_to_worklist(cacheable_references)
|
|
214
|
+
cacheable_references.each do |ref_name, (type, id)|
|
|
215
|
+
next if resolved_references.has_key?(ref_name)
|
|
216
|
+
(@worklist[type] ||= {})[id] = WorklistEntry.new(ref_name, nil)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def add_viewmodels_to_worklist(referenced_viewmodels)
|
|
221
|
+
referenced_viewmodels.each do |ref_name, viewmodel|
|
|
222
|
+
next if resolved_references.has_key?(ref_name)
|
|
223
|
+
(@worklist[viewmodel.class.view_name] ||= {})[viewmodel.id] = WorklistEntry.new(ref_name, viewmodel)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def key_for(id)
|
|
229
|
+
cache.key.new(id)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def id_for(key)
|
|
233
|
+
key[:id]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Save the provided serialization and reference data in the cache
|
|
237
|
+
def store(id, data_serialization, ref_cache, serialize_context:)
|
|
238
|
+
cache.write(key_for(id), { data: data_serialization, ref_cache: ref_cache })
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def load(ids, serialize_context:)
|
|
242
|
+
keys = ids.map { |id| key_for(id) }
|
|
243
|
+
results = cache.read_multi(keys)
|
|
244
|
+
results.transform_keys! { |key| id_for(key) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
attr_reader :cache
|
|
250
|
+
|
|
251
|
+
def create_default_cache_group
|
|
252
|
+
IknowCache.register_group(@viewmodel_class.name, :id)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Statically version the terminal cache based on the deep schema versions of
|
|
256
|
+
# the constituent viewmodels, so that viewmodel changes force invalidation.
|
|
257
|
+
def cache_name
|
|
258
|
+
"#{@viewmodel_class.name}_#{cache_version}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def cache_version
|
|
262
|
+
version_string = @viewmodel_class.deep_schema_version(include_shared: false).to_a.sort.join(',')
|
|
263
|
+
Base64.urlsafe_encode64(Digest::MD5.digest(version_string))
|
|
264
|
+
end
|
|
265
|
+
end
|