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,35 @@
1
+ require_relative "../../../helpers/arvm_test_utilities.rb"
2
+ require_relative "../../../helpers/arvm_test_models.rb"
3
+ require_relative "../../../helpers/viewmodel_spec_helpers.rb"
4
+
5
+ require "minitest/autorun"
6
+
7
+ require "view_model/active_record"
8
+
9
+ class ViewModel::ActiveRecord::Alias < ActiveSupport::TestCase
10
+ include ARVMTestUtilities
11
+ extend Minitest::Spec::DSL
12
+
13
+ include ViewModelSpecHelpers::ParentAndBelongsToChild
14
+
15
+ def child_attributes
16
+ super.merge(
17
+ viewmodel: ->(v) do
18
+ add_view_alias "ChildA"
19
+ add_view_alias "ChildB"
20
+ end
21
+ )
22
+ end
23
+
24
+ it "permits association types to be aliased" do
25
+ %w(Child ChildA ChildB).each do |view_alias|
26
+ view = {
27
+ "_type" => viewmodel_class.view_name,
28
+ "child" => { "_type" => view_alias },
29
+ }
30
+
31
+ parent = viewmodel_class.deserialize_from_view(view).model
32
+ assert_instance_of(child_model_class, parent.child)
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,376 @@
1
+ require_relative "../../../helpers/arvm_test_utilities.rb"
2
+ require_relative "../../../helpers/arvm_test_models.rb"
3
+
4
+ require "minitest/autorun"
5
+
6
+ require "view_model/active_record"
7
+
8
+ class ViewModel::ActiveRecord::BelongsToTest < ActiveSupport::TestCase
9
+ include ARVMTestUtilities
10
+
11
+ module WithLabel
12
+ def before_all
13
+ super
14
+
15
+ build_viewmodel(:Label) do
16
+ define_schema do |t|
17
+ t.string :text
18
+ end
19
+
20
+ define_model do
21
+ has_one :parent, inverse_of: :label
22
+ end
23
+
24
+ define_viewmodel do
25
+ attributes :text
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ module WithParent
32
+ def before_all
33
+ super
34
+
35
+ build_viewmodel(:Parent) do
36
+ define_schema do |t|
37
+ t.string :name
38
+ t.references :label, foreign_key: true
39
+ end
40
+
41
+ define_model do
42
+ belongs_to :label, inverse_of: :parent, dependent: :destroy
43
+ end
44
+
45
+ define_viewmodel do
46
+ attributes :name
47
+ associations :label
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ module WithOwner
54
+ def before_all
55
+ super
56
+
57
+ build_viewmodel(:Owner) do
58
+ define_schema do |t|
59
+ t.integer :deleted_id
60
+ t.integer :ignored_id
61
+ end
62
+
63
+ define_model do
64
+ belongs_to :deleted, class_name: Label.name, dependent: :delete
65
+ belongs_to :ignored, class_name: Label.name
66
+ end
67
+
68
+ define_viewmodel do
69
+ associations :deleted, :ignored
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ include WithLabel
76
+ include WithParent
77
+
78
+ def setup
79
+ super
80
+
81
+ # TODO make a `has_list?` that allows a parent to set all children as an array
82
+ @parent1 = Parent.new(name: "p1",
83
+ label: Label.new(text: "p1l"))
84
+ @parent1.save!
85
+
86
+ @parent2 = Parent.new(name: "p2",
87
+ label: Label.new(text: "p2l"))
88
+
89
+ @parent2.save!
90
+
91
+ enable_logging!
92
+ end
93
+
94
+ def test_serialize_view
95
+ view, _refs = serialize_with_references(ParentView.new(@parent1))
96
+
97
+ assert_equal({ "_type" => "Parent",
98
+ "_version" => 1,
99
+ "id" => @parent1.id,
100
+ "name" => @parent1.name,
101
+ "label" => { "_type" => "Label",
102
+ "_version" => 1,
103
+ "id" => @parent1.label.id,
104
+ "text" => @parent1.label.text },
105
+ },
106
+ view)
107
+ end
108
+
109
+ def test_loading_batching
110
+ log_queries do
111
+ serialize(ParentView.load)
112
+ end
113
+
114
+ assert_equal(['Parent Load', 'Label Load'],
115
+ logged_load_queries)
116
+ end
117
+
118
+ def test_create_from_view
119
+ view = {
120
+ "_type" => "Parent",
121
+ "name" => "p",
122
+ "label" => { "_type" => "Label", "text" => "l" },
123
+ }
124
+
125
+ pv = ParentView.deserialize_from_view(view)
126
+ p = pv.model
127
+
128
+ assert(!p.changed?)
129
+ assert(!p.new_record?)
130
+
131
+ assert_equal("p", p.name)
132
+
133
+ assert(p.label.present?)
134
+ assert_equal("l", p.label.text)
135
+ end
136
+
137
+ def test_create_belongs_to_nil
138
+ view = { '_type' => 'Parent', 'name' => 'p', 'label' => nil }
139
+ pv = ParentView.deserialize_from_view(view)
140
+ assert_nil(pv.model.label)
141
+ end
142
+
143
+ def test_create_invalid_child_type
144
+ view = { '_type' => 'Parent', 'name' => 'p', 'label' => { '_type' => 'Parent', 'name' => 'q' } }
145
+ assert_raises(ViewModel::DeserializationError::InvalidAssociationType) do
146
+ ParentView.deserialize_from_view(view)
147
+ end
148
+ end
149
+
150
+ def test_belongs_to_create
151
+ @parent1.update(label: nil)
152
+
153
+ alter_by_view!(ParentView, @parent1) do |view, refs|
154
+ view['label'] = { '_type' => 'Label', 'text' => 'cheese' }
155
+ end
156
+
157
+ assert_equal('cheese', @parent1.label.text)
158
+ end
159
+
160
+ def test_belongs_to_replace
161
+ old_label = @parent1.label
162
+
163
+ alter_by_view!(ParentView, @parent1) do |view, refs|
164
+ view['label'] = { '_type' => 'Label', 'text' => 'cheese' }
165
+ end
166
+
167
+ assert_equal('cheese', @parent1.label.text)
168
+ assert(Label.where(id: old_label).blank?)
169
+ end
170
+
171
+ def test_belongs_to_move_and_replace
172
+ old_p1_label = @parent1.label
173
+ old_p2_label = @parent2.label
174
+
175
+ set_by_view!(ParentView, [@parent1, @parent2]) do |(p1, p2), refs|
176
+ p1['label'] = nil
177
+ p2['label'] = update_hash_for(LabelView, old_p1_label)
178
+ end
179
+
180
+ assert(@parent1.label.blank?, 'l1 label reference removed')
181
+ assert_equal(old_p1_label, @parent2.label, 'p2 has label from p1')
182
+ assert(Label.where(id: old_p2_label).blank?, 'p2 old label deleted')
183
+ end
184
+
185
+ def test_belongs_to_swap
186
+ old_p1_label = @parent1.label
187
+ old_p2_label = @parent2.label
188
+
189
+ alter_by_view!(ParentView, [@parent1, @parent2]) do |(p1, p2), refs|
190
+ p1['label'] = update_hash_for(LabelView, old_p2_label)
191
+ p2['label'] = update_hash_for(LabelView, old_p1_label)
192
+ end
193
+
194
+ assert_equal(old_p2_label, @parent1.label, 'p1 has label from p2')
195
+ assert_equal(old_p1_label, @parent2.label, 'p2 has label from p1')
196
+ end
197
+
198
+ def test_moved_child_is_not_delete_checked
199
+ # move from p1 to p3
200
+ d_context = ParentView.new_deserialize_context
201
+
202
+ target_label = Label.create
203
+ from_parent = Parent.create(name: 'from', label: target_label)
204
+ to_parent = Parent.create(name: 'p3')
205
+
206
+ alter_by_view!(
207
+ ParentView, [from_parent, to_parent],
208
+ deserialize_context: d_context
209
+ ) do |(from, to), refs|
210
+ from['label'] = nil
211
+ to['label'] = update_hash_for(LabelView, target_label)
212
+ end
213
+
214
+ assert_equal(target_label, to_parent.label, 'target label moved')
215
+ assert_equal([ViewModel::Reference.new(ParentView, from_parent.id),
216
+ ViewModel::Reference.new(ParentView, to_parent.id)],
217
+ d_context.valid_edit_refs,
218
+ "only parents are checked for change; child was not")
219
+ end
220
+
221
+ def test_implicit_release_invalid_belongs_to
222
+ taken_label_ref = update_hash_for(LabelView, @parent1.label)
223
+ assert_raises(ViewModel::DeserializationError::ParentNotFound) do
224
+ ParentView.deserialize_from_view(
225
+ [{ '_type' => 'Parent',
226
+ 'name' => 'newp',
227
+ 'label' => taken_label_ref }])
228
+ end
229
+ end
230
+
231
+ class GCTests < ActiveSupport::TestCase
232
+ include ARVMTestUtilities
233
+ include WithLabel
234
+ include WithOwner
235
+ include WithParent
236
+
237
+ # test belongs_to garbage collection - dependent: delete_all
238
+ def test_gc_dependent_delete_all
239
+ owner = Owner.create(deleted: Label.new(text: 'one'))
240
+ old_label = owner.deleted
241
+
242
+ alter_by_view!(OwnerView, owner) do |ov, refs|
243
+ ov['deleted'] = { '_type' => 'Label', 'text' => 'two' }
244
+ end
245
+
246
+ assert_equal('two', owner.deleted.text)
247
+ refute_equal(old_label, owner.deleted)
248
+ assert(Label.where(id: old_label.id).blank?)
249
+ end
250
+
251
+ def test_no_gc_dependent_ignore
252
+ owner = Owner.create(ignored: Label.new(text: "one"))
253
+ old_label = owner.ignored
254
+
255
+ alter_by_view!(OwnerView, owner) do |ov, refs|
256
+ ov['ignored'] = { '_type' => 'Label', 'text' => 'two' }
257
+ end
258
+ assert_equal('two', owner.ignored.text)
259
+ refute_equal(old_label, owner.ignored)
260
+ assert_equal(1, Label.where(id: old_label.id).count)
261
+ end
262
+ end
263
+
264
+ class RenamedTest < ActiveSupport::TestCase
265
+ include ARVMTestUtilities
266
+ include WithLabel
267
+
268
+ def before_all
269
+ super
270
+
271
+ build_viewmodel(:Parent) do
272
+ define_schema do |t|
273
+ t.string :name
274
+ t.references :label, foreign_key: true
275
+ end
276
+
277
+ define_model do
278
+ belongs_to :label, inverse_of: :parent, dependent: :destroy
279
+ end
280
+
281
+ define_viewmodel do
282
+ attributes :name
283
+ association :label, as: :something_else
284
+ end
285
+ end
286
+ end
287
+
288
+ def setup
289
+ super
290
+
291
+ @parent = Parent.create(name: 'p1', label: Label.new(text: 'l1'))
292
+
293
+ enable_logging!
294
+ end
295
+
296
+ def test_dependencies
297
+ root_updates, _ref_updates = ViewModel::ActiveRecord::UpdateData.parse_hashes([{ '_type' => 'Parent', 'something_else' => nil }])
298
+ assert_equal(DeepPreloader::Spec.new('label' => DeepPreloader::Spec.new), root_updates.first.preload_dependencies)
299
+ assert_equal({ 'something_else' => {} }, root_updates.first.updated_associations)
300
+ end
301
+
302
+ def test_renamed_roundtrip
303
+ alter_by_view!(ParentView, @parent) do |view, refs|
304
+ assert_equal({ 'id' => @parent.label.id,
305
+ '_type' => 'Label',
306
+ '_version' => 1,
307
+ 'text' => 'l1' },
308
+ view['something_else'])
309
+ view['something_else']['text'] = 'new l1 text'
310
+ end
311
+ assert_equal('new l1 text', @parent.label.text)
312
+ end
313
+ end
314
+
315
+ class FreedChildrenTest < ActiveSupport::TestCase
316
+ include ARVMTestUtilities
317
+
318
+ def before_all
319
+ build_viewmodel(:Aye) do
320
+ define_schema do |t|
321
+ t.references :bee
322
+ end
323
+ define_model do
324
+ belongs_to :bee, inverse_of: :aye, dependent: :destroy
325
+ end
326
+ define_viewmodel do
327
+ association :bee
328
+ end
329
+ end
330
+
331
+ build_viewmodel(:Bee) do
332
+ define_schema do |t|
333
+ t.references :cee
334
+ end
335
+ define_model do
336
+ has_one :aye, inverse_of: :bee
337
+ belongs_to :cee, inverse_of: :bee, dependent: :destroy
338
+ end
339
+ define_viewmodel do
340
+ association :cee
341
+ end
342
+ end
343
+
344
+ build_viewmodel(:Cee) do
345
+ define_schema do |t|
346
+ end
347
+ define_model do
348
+ has_one :bee, inverse_of: :cee
349
+ end
350
+ define_viewmodel do
351
+ end
352
+ end
353
+ end
354
+
355
+
356
+ # Do we support replacing a node in the tree and reparenting its children
357
+ # back to it? In theory we want to, but currently we don't: the child node
358
+ # is unresolvable.
359
+
360
+ # To support it we could maintain a list of child elements that will be
361
+ # implicitly freed by each freelist entry. Then worklist entries could
362
+ # resolve themselves from these children, and nil out the association target
363
+ # in the freelist to prevent them from being deleted when the freelist is
364
+ # cleaned. If the freelist entry is subsequently reclaimed, double update
365
+ # protection should prevent the child from being reused, but that will need
366
+ # testing.
367
+ def test_move
368
+ model = Aye.create(bee: Bee.new(cee: Cee.new))
369
+ assert_raises(ViewModel::DeserializationError::ParentNotFound) do
370
+ alter_by_view!(AyeView, model) do |view, refs|
371
+ view['bee'].delete("id")
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "minitest/unit"
5
+ require "minitest/hooks"
6
+
7
+ require_relative "../../../helpers/arvm_test_models.rb"
8
+ require_relative "../../../helpers/arvm_test_utilities.rb"
9
+ require_relative "../../../helpers/viewmodel_spec_helpers.rb"
10
+
11
+ require "view_model"
12
+ require "view_model/active_record"
13
+
14
+ # IknowCache uses Rails.cache: create a dummy cache.
15
+
16
+ DUMMY_RAILS_CACHE = ActiveSupport::Cache::MemoryStore.new
17
+
18
+ module Rails
19
+ def self.cache
20
+ DUMMY_RAILS_CACHE
21
+ end
22
+ end
23
+
24
+ class ViewModel::ActiveRecord
25
+ class CacheTest < ActiveSupport::TestCase
26
+ using ViewModel::Utils::Collections
27
+ extend Minitest::Spec::DSL
28
+ include ARVMTestUtilities
29
+
30
+ # Defines a cacheable parent Model with a owned Child and a cachable shared Shared.
31
+ module CacheableParentAndChildren
32
+ extend ActiveSupport::Concern
33
+ include ViewModelSpecHelpers::ParentAndBelongsToChild
34
+
35
+ def model_attributes
36
+ super.merge(
37
+ schema: ->(t) { t.references :shared, foreign_key: true },
38
+ model: ->(_) { belongs_to :shared, inverse_of: :models },
39
+ viewmodel: ->(_) {
40
+ association :shared, shared: true, optional: false
41
+ cacheable!
42
+ }
43
+ )
44
+ end
45
+
46
+ def shared_cache_group
47
+ @shared_cache_group ||= IknowCache.register_group(:shared, :id)
48
+ end
49
+
50
+ def shared_viewmodel_class
51
+ shared_cache_group = self.shared_cache_group
52
+ @shared_viewmodel_class ||= define_viewmodel_class(:Shared, namespace: namespace) do
53
+ define_schema do |t|
54
+ t.string :name
55
+ end
56
+
57
+ define_model do
58
+ has_many :models
59
+ end
60
+
61
+ define_viewmodel do
62
+ attributes :name
63
+ cacheable!(cache_group: shared_cache_group)
64
+ end
65
+ end
66
+ end
67
+
68
+ def shared_model_class
69
+ shared_viewmodel_class.model_class
70
+ end
71
+
72
+ # parent depends on children, ensure it's touched first
73
+ def viewmodel_class
74
+ shared_viewmodel_class
75
+ super
76
+ end
77
+
78
+ included do
79
+ let(:shared) { shared_model_class.create!(name: "shared1") }
80
+ let(:root) { model_class.create!(name: "root1", child: Child.new(name: "owned1"), shared: shared) }
81
+ let(:root_view) { viewmodel_class.new(root) }
82
+ end
83
+ end
84
+
85
+ before(:each) do
86
+ DUMMY_RAILS_CACHE.clear
87
+ end
88
+
89
+ # Extract the iKnowCaches to verify their contents
90
+ def read_cache(viewmodel_class, id)
91
+ vm_cache = viewmodel_class.viewmodel_cache
92
+ vm_cache.send(:cache).read(vm_cache.key_for(id))
93
+ end
94
+
95
+ def serialize_from_database
96
+ view = viewmodel_class.new(model_class.find(root.id))
97
+ context = viewmodel_class.new_serialize_context
98
+ data = ViewModel.serialize_to_hash([view], serialize_context: context)
99
+ refs = context.serialize_references_to_hash
100
+ [data, refs]
101
+ end
102
+
103
+ def parse_result(result)
104
+ data_json, refs_json = result
105
+ data = data_json.map { |d| JSON.parse(d) }
106
+ refs = refs_json.transform_values { |v| JSON.parse(v) }
107
+ [data, refs]
108
+ end
109
+
110
+ def fetch_with_cache
111
+ viewmodel_class.viewmodel_cache.fetch([root.id])
112
+ end
113
+
114
+ def serialize_with_cache
115
+ parse_result(fetch_with_cache)
116
+ end
117
+
118
+ module BehavesLikeACache
119
+ extend ActiveSupport::Concern
120
+ included do
121
+ it 'returns the right serialization' do
122
+ value(serialize_with_cache).must_equal(serialize_from_database)
123
+ end
124
+
125
+ it 'returns the right serialization after caching' do
126
+ fetch_with_cache
127
+ value(serialize_from_database).must_equal(serialize_with_cache)
128
+ end
129
+
130
+ it 'writes to the cache after fetching' do
131
+ cached_value = read_cache(viewmodel_class, root.id)
132
+ value(cached_value).wont_be(:present?)
133
+
134
+ fetch_with_cache
135
+
136
+ cached_value = read_cache(viewmodel_class, root.id)
137
+ value(cached_value).must_be(:present?)
138
+ end
139
+
140
+ it 'saves the returned serialization in the cache' do
141
+ data, refs = fetch_with_cache
142
+ value(data.size).must_equal(1)
143
+
144
+ cached_root = read_cache(viewmodel_class, root.id)
145
+ value(cached_root).must_be(:present?)
146
+ value(cached_root[:data]).must_equal(data.first)
147
+
148
+ ref_cache = cached_root[:ref_cache]
149
+ value(refs.size).must_equal(ref_cache.size)
150
+
151
+ refs.each do |key, ref_data|
152
+ view_name, id = ref_cache[key]
153
+ value(view_name).must_be(:present?)
154
+ value(id).must_be(:present?)
155
+
156
+ # The cached reference must correspond to the returned data.
157
+ parsed_data = JSON.parse(ref_data)
158
+ value(parsed_data["id"]).must_equal(id)
159
+ value(parsed_data["_type"]).must_equal(view_name)
160
+
161
+ # When the cached reference is to independently cached data
162
+ # (SharedView in this test), make sure that data is correctly
163
+ # cached.
164
+ next unless view_name == "Shared"
165
+ value(id).must_equal(shared.id)
166
+ cached_shared = read_cache(shared_viewmodel_class, id)
167
+ value(cached_shared).must_be(:present?)
168
+ value(cached_shared[:data]).must_equal(ref_data)
169
+ value(cached_shared[:ref_cache]).must_be(:blank?)
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ describe 'with owned and shared children' do
176
+ include CacheableParentAndChildren
177
+ include BehavesLikeACache
178
+
179
+ describe 'with a record in the cache' do
180
+ # Fetch the root record to ensure it's in the cache
181
+ before(:each) do
182
+ viewmodel_class.viewmodel_cache.fetch([root.id])
183
+ end
184
+
185
+ def change_in_database
186
+ root.update_attribute(:name, "CHANGEDROOT")
187
+ shared.update_attribute(:name, "CHANGEDSHARED")
188
+ end
189
+
190
+ it 'resolves from the cache' do
191
+ before_data, before_refs = serialize_from_database
192
+ change_in_database
193
+
194
+ cache_data, cache_refs = serialize_with_cache
195
+ value(cache_data).must_equal(before_data)
196
+ value(cache_refs).must_equal(before_refs)
197
+ end
198
+
199
+ it 'can clear the root cache' do
200
+ _before_data, before_refs = serialize_from_database
201
+ change_in_database
202
+ viewmodel_class.viewmodel_cache.clear
203
+
204
+ cache_data, cache_refs = serialize_with_cache
205
+ value(cache_data[0]["name"]).must_equal("CHANGEDROOT") # Root view invalidated
206
+ value(cache_refs).must_equal(before_refs) # Shared view not invalidated
207
+ end
208
+
209
+ describe 'when deserializing' do
210
+ it 'does not clear the cache on round-trip' do
211
+ alter_by_view!(viewmodel_class, root) {}
212
+
213
+ cached_root_value = read_cache(viewmodel_class, root.id)
214
+ value(cached_root_value).must_be(:present?)
215
+
216
+ cached_root_value = read_cache(shared_viewmodel_class, shared.id)
217
+ value(cached_root_value).must_be(:present?)
218
+ end
219
+
220
+ it 'clears only the root cache on edit to root' do
221
+ alter_by_view!(viewmodel_class, root) do |data, _refs|
222
+ data['name'] = 'new name'
223
+ end
224
+
225
+ cached_root_value = read_cache(viewmodel_class, root.id)
226
+ value(cached_root_value).wont_be(:present?)
227
+
228
+ cached_root_value = read_cache(shared_viewmodel_class, shared.id)
229
+ value(cached_root_value).must_be(:present?)
230
+ end
231
+
232
+ it 'clears only the root cache on edit to owned child' do
233
+ alter_by_view!(viewmodel_class, root) do |data, _refs|
234
+ data['child']['name'] = 'new child name'
235
+ end
236
+
237
+ cached_root_value = read_cache(viewmodel_class, root.id)
238
+ value(cached_root_value).wont_be(:present?)
239
+
240
+ cached_root_value = read_cache(shared_viewmodel_class, shared.id)
241
+ value(cached_root_value).must_be(:present?)
242
+ end
243
+
244
+ it 'clears only the shared child cache on edit to shared child' do
245
+ alter_by_view!(viewmodel_class, root) do |_data, refs|
246
+ refs.values.first['name'] = 'new shared name'
247
+ end
248
+
249
+ cached_root_value = read_cache(viewmodel_class, root.id)
250
+ value(cached_root_value).must_be(:present?)
251
+
252
+ cached_root_value = read_cache(shared_viewmodel_class, shared.id)
253
+ value(cached_root_value).wont_be(:present?)
254
+ end
255
+ end
256
+
257
+ it 'can delete an entity from a cache' do
258
+ _before_data, before_refs = serialize_from_database
259
+ change_in_database
260
+ viewmodel_class.viewmodel_cache.delete(root.id)
261
+
262
+ cache_data, cache_refs = serialize_with_cache
263
+ value(cache_data[0]["name"]).must_equal("CHANGEDROOT")
264
+ value(cache_refs).must_equal(before_refs)
265
+ end
266
+
267
+ it 'can clear a referenced cache' do
268
+ change_in_database
269
+ shared_viewmodel_class.viewmodel_cache.clear
270
+
271
+ # Shared view invalidated, but root view not
272
+ cache_data, cache_hrefs = serialize_with_cache
273
+ value(cache_data[0]["name"]).must_equal("root1")
274
+ value(cache_hrefs.values[0]["name"]).must_equal("CHANGEDSHARED")
275
+ end
276
+
277
+ it 'can clear a cache via its external cache group' do
278
+ change_in_database
279
+ shared_cache_group.invalidate_cache_group
280
+
281
+ # Shared view invalidated, but root view not
282
+ cache_data, cache_hrefs = serialize_with_cache
283
+ value(cache_data[0]["name"]).must_equal("root1")
284
+ value(cache_hrefs.values[0]["name"]).must_equal("CHANGEDSHARED")
285
+ end
286
+
287
+ describe 'and a record not in the cache' do
288
+ let(:root2) { model_class.create!(name: "root2", child: Child.new(name: "owned2"), shared: shared) }
289
+
290
+ def serialize_from_database
291
+ views = model_class.find(root.id, root2.id).map { |r| viewmodel_class.new(r) }
292
+ context = viewmodel_class.new_serialize_context
293
+ data = ViewModel.serialize_to_hash(views, serialize_context: context)
294
+ refs = context.serialize_references_to_hash
295
+ [data, refs]
296
+ end
297
+
298
+ def fetch_with_cache
299
+ viewmodel_class.viewmodel_cache.fetch([root.id, root2.id])
300
+ end
301
+
302
+ it 'merges matching shared references between cache hits and misses' do
303
+ db_data, db_refs = serialize_from_database
304
+ value(db_refs.size).must_equal(1)
305
+
306
+ cache_data, cache_refs = serialize_with_cache
307
+ value(cache_data).must_equal(db_data)
308
+ value(cache_refs).must_equal(db_refs)
309
+ end
310
+
311
+ it 'merges cache hits and misses' do
312
+ _, refs = serialize_from_database
313
+ change_in_database
314
+
315
+ cache_data, cache_refs = serialize_with_cache
316
+ value(cache_data[0]["name"]).must_equal("root1")
317
+ value(cache_data[1]["name"]).must_equal("root2")
318
+ value(cache_refs).must_equal(refs)
319
+ end
320
+ end
321
+ end
322
+ end
323
+
324
+ describe "with a non-cacheable shared child" do
325
+ include ViewModelSpecHelpers::ParentAndSharedChild
326
+ def model_attributes
327
+ super.merge(viewmodel: ->(_) { cacheable! })
328
+ end
329
+
330
+ let(:root) { model_class.create!(name: "root1", child: Child.new(name: "owned1")) }
331
+ let(:root_view) { viewmodel_class.new(root) }
332
+
333
+ include BehavesLikeACache
334
+ end
335
+
336
+ describe 'when fetched by viewmodel' do
337
+ def fetch_with_cache
338
+ viewmodel_class.viewmodel_cache.fetch_by_viewmodel([root_view])
339
+ end
340
+
341
+ include CacheableParentAndChildren
342
+ include BehavesLikeACache
343
+
344
+ it 'can handle duplicates' do
345
+ data, _refs = viewmodel_class.viewmodel_cache.fetch_by_viewmodel([root_view, root_view])
346
+ value(data.size).must_equal(2)
347
+ value(data[0]).must_equal(data[1])
348
+ end
349
+ end
350
+ end
351
+ end