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.
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