iknow_view_models 2.8.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci/config.yml +115 -0
- data/.gitignore +36 -0
- data/.travis.yml +31 -0
- data/Appraisals +9 -0
- data/Gemfile +19 -0
- data/LICENSE.txt +22 -0
- data/README.md +19 -0
- data/Rakefile +21 -0
- data/appveyor.yml +22 -0
- data/gemfiles/rails_5_2.gemfile +15 -0
- data/gemfiles/rails_6_0_beta.gemfile +15 -0
- data/iknow_view_models.gemspec +49 -0
- data/lib/iknow_view_models.rb +12 -0
- data/lib/iknow_view_models/railtie.rb +8 -0
- data/lib/iknow_view_models/version.rb +5 -0
- data/lib/view_model.rb +333 -0
- data/lib/view_model/access_control.rb +154 -0
- data/lib/view_model/access_control/composed.rb +216 -0
- data/lib/view_model/access_control/open.rb +13 -0
- data/lib/view_model/access_control/read_only.rb +13 -0
- data/lib/view_model/access_control/tree.rb +264 -0
- data/lib/view_model/access_control_error.rb +10 -0
- data/lib/view_model/active_record.rb +383 -0
- data/lib/view_model/active_record/association_data.rb +178 -0
- data/lib/view_model/active_record/association_manipulation.rb +389 -0
- data/lib/view_model/active_record/cache.rb +265 -0
- data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
- data/lib/view_model/active_record/cloner.rb +113 -0
- data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
- data/lib/view_model/active_record/controller.rb +77 -0
- data/lib/view_model/active_record/controller_base.rb +185 -0
- data/lib/view_model/active_record/nested_controller_base.rb +93 -0
- data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
- data/lib/view_model/active_record/update_context.rb +252 -0
- data/lib/view_model/active_record/update_data.rb +749 -0
- data/lib/view_model/active_record/update_operation.rb +810 -0
- data/lib/view_model/active_record/visitor.rb +77 -0
- data/lib/view_model/after_transaction_runner.rb +29 -0
- data/lib/view_model/callbacks.rb +219 -0
- data/lib/view_model/changes.rb +62 -0
- data/lib/view_model/config.rb +29 -0
- data/lib/view_model/controller.rb +142 -0
- data/lib/view_model/deserialization_error.rb +437 -0
- data/lib/view_model/deserialize_context.rb +16 -0
- data/lib/view_model/error.rb +191 -0
- data/lib/view_model/error_view.rb +35 -0
- data/lib/view_model/record.rb +367 -0
- data/lib/view_model/record/attribute_data.rb +48 -0
- data/lib/view_model/reference.rb +31 -0
- data/lib/view_model/references.rb +48 -0
- data/lib/view_model/registry.rb +73 -0
- data/lib/view_model/schemas.rb +45 -0
- data/lib/view_model/serialization_error.rb +10 -0
- data/lib/view_model/serialize_context.rb +118 -0
- data/lib/view_model/test_helpers.rb +103 -0
- data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
- data/lib/view_model/traversal_context.rb +126 -0
- data/lib/view_model/utils.rb +24 -0
- data/lib/view_model/utils/collections.rb +49 -0
- data/test/helpers/arvm_test_models.rb +59 -0
- data/test/helpers/arvm_test_utilities.rb +187 -0
- data/test/helpers/callback_tracer.rb +27 -0
- data/test/helpers/controller_test_helpers.rb +270 -0
- data/test/helpers/match_enumerator.rb +58 -0
- data/test/helpers/query_logging.rb +71 -0
- data/test/helpers/test_access_control.rb +56 -0
- data/test/helpers/viewmodel_spec_helpers.rb +326 -0
- data/test/unit/view_model/access_control_test.rb +769 -0
- data/test/unit/view_model/active_record/alias_test.rb +35 -0
- data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
- data/test/unit/view_model/active_record/cache_test.rb +351 -0
- data/test/unit/view_model/active_record/cloner_test.rb +313 -0
- data/test/unit/view_model/active_record/controller_test.rb +561 -0
- data/test/unit/view_model/active_record/counter_test.rb +80 -0
- data/test/unit/view_model/active_record/customization_test.rb +388 -0
- data/test/unit/view_model/active_record/has_many_test.rb +957 -0
- data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
- data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
- data/test/unit/view_model/active_record/has_one_test.rb +334 -0
- data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
- data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
- data/test/unit/view_model/active_record/poly_test.rb +320 -0
- data/test/unit/view_model/active_record/shared_test.rb +285 -0
- data/test/unit/view_model/active_record/version_test.rb +121 -0
- data/test/unit/view_model/active_record_test.rb +542 -0
- data/test/unit/view_model/callbacks_test.rb +582 -0
- data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
- data/test/unit/view_model/record_test.rb +524 -0
- data/test/unit/view_model/traversal_context_test.rb +371 -0
- data/test/unit/view_model_test.rb +62 -0
- metadata +490 -0
@@ -0,0 +1,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
|