iknow_view_models 2.8.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +115 -0
  3. data/.gitignore +36 -0
  4. data/.travis.yml +31 -0
  5. data/Appraisals +9 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +19 -0
  9. data/Rakefile +21 -0
  10. data/appveyor.yml +22 -0
  11. data/gemfiles/rails_5_2.gemfile +15 -0
  12. data/gemfiles/rails_6_0_beta.gemfile +15 -0
  13. data/iknow_view_models.gemspec +49 -0
  14. data/lib/iknow_view_models.rb +12 -0
  15. data/lib/iknow_view_models/railtie.rb +8 -0
  16. data/lib/iknow_view_models/version.rb +5 -0
  17. data/lib/view_model.rb +333 -0
  18. data/lib/view_model/access_control.rb +154 -0
  19. data/lib/view_model/access_control/composed.rb +216 -0
  20. data/lib/view_model/access_control/open.rb +13 -0
  21. data/lib/view_model/access_control/read_only.rb +13 -0
  22. data/lib/view_model/access_control/tree.rb +264 -0
  23. data/lib/view_model/access_control_error.rb +10 -0
  24. data/lib/view_model/active_record.rb +383 -0
  25. data/lib/view_model/active_record/association_data.rb +178 -0
  26. data/lib/view_model/active_record/association_manipulation.rb +389 -0
  27. data/lib/view_model/active_record/cache.rb +265 -0
  28. data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
  29. data/lib/view_model/active_record/cloner.rb +113 -0
  30. data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
  31. data/lib/view_model/active_record/controller.rb +77 -0
  32. data/lib/view_model/active_record/controller_base.rb +185 -0
  33. data/lib/view_model/active_record/nested_controller_base.rb +93 -0
  34. data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
  35. data/lib/view_model/active_record/update_context.rb +252 -0
  36. data/lib/view_model/active_record/update_data.rb +749 -0
  37. data/lib/view_model/active_record/update_operation.rb +810 -0
  38. data/lib/view_model/active_record/visitor.rb +77 -0
  39. data/lib/view_model/after_transaction_runner.rb +29 -0
  40. data/lib/view_model/callbacks.rb +219 -0
  41. data/lib/view_model/changes.rb +62 -0
  42. data/lib/view_model/config.rb +29 -0
  43. data/lib/view_model/controller.rb +142 -0
  44. data/lib/view_model/deserialization_error.rb +437 -0
  45. data/lib/view_model/deserialize_context.rb +16 -0
  46. data/lib/view_model/error.rb +191 -0
  47. data/lib/view_model/error_view.rb +35 -0
  48. data/lib/view_model/record.rb +367 -0
  49. data/lib/view_model/record/attribute_data.rb +48 -0
  50. data/lib/view_model/reference.rb +31 -0
  51. data/lib/view_model/references.rb +48 -0
  52. data/lib/view_model/registry.rb +73 -0
  53. data/lib/view_model/schemas.rb +45 -0
  54. data/lib/view_model/serialization_error.rb +10 -0
  55. data/lib/view_model/serialize_context.rb +118 -0
  56. data/lib/view_model/test_helpers.rb +103 -0
  57. data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
  58. data/lib/view_model/traversal_context.rb +126 -0
  59. data/lib/view_model/utils.rb +24 -0
  60. data/lib/view_model/utils/collections.rb +49 -0
  61. data/test/helpers/arvm_test_models.rb +59 -0
  62. data/test/helpers/arvm_test_utilities.rb +187 -0
  63. data/test/helpers/callback_tracer.rb +27 -0
  64. data/test/helpers/controller_test_helpers.rb +270 -0
  65. data/test/helpers/match_enumerator.rb +58 -0
  66. data/test/helpers/query_logging.rb +71 -0
  67. data/test/helpers/test_access_control.rb +56 -0
  68. data/test/helpers/viewmodel_spec_helpers.rb +326 -0
  69. data/test/unit/view_model/access_control_test.rb +769 -0
  70. data/test/unit/view_model/active_record/alias_test.rb +35 -0
  71. data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
  72. data/test/unit/view_model/active_record/cache_test.rb +351 -0
  73. data/test/unit/view_model/active_record/cloner_test.rb +313 -0
  74. data/test/unit/view_model/active_record/controller_test.rb +561 -0
  75. data/test/unit/view_model/active_record/counter_test.rb +80 -0
  76. data/test/unit/view_model/active_record/customization_test.rb +388 -0
  77. data/test/unit/view_model/active_record/has_many_test.rb +957 -0
  78. data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
  79. data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
  80. data/test/unit/view_model/active_record/has_one_test.rb +334 -0
  81. data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
  82. data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
  83. data/test/unit/view_model/active_record/poly_test.rb +320 -0
  84. data/test/unit/view_model/active_record/shared_test.rb +285 -0
  85. data/test/unit/view_model/active_record/version_test.rb +121 -0
  86. data/test/unit/view_model/active_record_test.rb +542 -0
  87. data/test/unit/view_model/callbacks_test.rb +582 -0
  88. data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
  89. data/test/unit/view_model/record_test.rb +524 -0
  90. data/test/unit/view_model/traversal_context_test.rb +371 -0
  91. data/test/unit/view_model_test.rb +62 -0
  92. 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