iknow_view_models 3.4.3 → 3.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/iknow_view_models.gemspec +1 -1
- data/lib/iknow_view_models/version.rb +1 -1
- data/lib/view_model/active_record/association_manipulation.rb +183 -46
- data/lib/view_model/active_record/cloner.rb +13 -12
- data/lib/view_model/active_record/collection_nested_controller.rb +5 -2
- data/lib/view_model/active_record/controller_base.rb +45 -6
- data/lib/view_model/active_record/nested_controller_base.rb +125 -13
- data/lib/view_model/active_record/singular_nested_controller.rb +5 -2
- data/lib/view_model/active_record.rb +46 -5
- data/lib/view_model/callbacks.rb +1 -1
- data/lib/view_model/controller.rb +24 -2
- data/lib/view_model/migrator.rb +26 -0
- data/lib/view_model/record.rb +2 -2
- data/lib/view_model/schemas.rb +44 -0
- data/lib/view_model.rb +12 -0
- data/test/helpers/arvm_test_utilities.rb +65 -0
- data/test/helpers/controller_test_helpers.rb +65 -34
- data/test/unit/view_model/active_record/controller_nested_test.rb +599 -0
- data/test/unit/view_model/active_record/controller_test.rb +6 -362
- data/test/unit/view_model/active_record/has_many_test.rb +10 -5
- data/test/unit/view_model/active_record/has_many_through_test.rb +28 -12
- data/test/unit/view_model/active_record/migration_test.rb +29 -0
- data/test/unit/view_model/record_test.rb +7 -0
- data/test/unit/view_model/traversal_context_test.rb +15 -1
- metadata +5 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f51edf25fab0aec533446c939c44975f76806871e1341518aa4470f32c23d839
|
4
|
+
data.tar.gz: 140166cc68f15484a0291f4e8faa8b4e26c3233a2b3bcfacfa9a4972a2a5e0ef
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 27e85b3cd097d4f19e28d66d3500db4142dedd4a65a44731754afd35430d86945bb2e7f928df476e4aadb665d34518fa10211113f17b1ad0718727f957c8156f
|
7
|
+
data.tar.gz: 7e740ea172d15e55b870336fd833a6138c3f5aaec97da84828813b6da57d9512bbb3cd98f404f24c58c7adf7b53d875639bd22561b200d429e74523770a3a483
|
data/iknow_view_models.gemspec
CHANGED
@@ -8,7 +8,7 @@ Gem::Specification.new do |spec|
|
|
8
8
|
spec.name = 'iknow_view_models'
|
9
9
|
spec.version = IknowViewModels::VERSION
|
10
10
|
spec.authors = ['iKnow Team']
|
11
|
-
spec.email = ['
|
11
|
+
spec.email = ['systems@iknow.jp']
|
12
12
|
spec.summary = 'ViewModels provide a means of encapsulating a collection of related data and specifying its JSON serialization.'
|
13
13
|
spec.description = ''
|
14
14
|
spec.homepage = 'https://github.com/iknow/cerego_view_models'
|
@@ -3,6 +3,8 @@
|
|
3
3
|
# Mix-in for VM::ActiveRecord providing direct manipulation of
|
4
4
|
# directly-associated entities. Avoids loading entire collections.
|
5
5
|
module ViewModel::ActiveRecord::AssociationManipulation
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
6
8
|
def load_associated(association_name, scope: nil, eager_include: true, serialize_context: self.class.new_serialize_context)
|
7
9
|
association_data = self.class._association_data(association_name)
|
8
10
|
direct_reflection = association_data.direct_reflection
|
@@ -49,49 +51,63 @@ module ViewModel::ActiveRecord::AssociationManipulation
|
|
49
51
|
end
|
50
52
|
end
|
51
53
|
|
52
|
-
# Replace the current member(s) of an association with the provided
|
54
|
+
# Replace the current member(s) of an association with the provided
|
55
|
+
# hash(es). Only mentioned member(s) will be returned.
|
56
|
+
#
|
57
|
+
# This interface deals with associations directly where reasonable,
|
58
|
+
# with the notable exception of referenced+shared associations. That
|
59
|
+
# is to say, that owned associations should be presented in the form
|
60
|
+
# of direct update hashes, regardless of their
|
61
|
+
# referencing. Reference and shared associations are excluded to
|
62
|
+
# ensure that the update hash for a shared entity is unique, and
|
63
|
+
# that edits may only be specified once.
|
53
64
|
def replace_associated(association_name, update_hash, references: {}, deserialize_context: self.class.new_deserialize_context)
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
|
65
|
-
if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
|
66
|
-
# Remove actions are always type/id refs; others need to be translated to proper refs
|
67
|
-
next
|
68
|
-
end
|
65
|
+
_updated_parent, changed_children =
|
66
|
+
self.class.replace_associated_bulk(
|
67
|
+
association_name,
|
68
|
+
{ self.id => update_hash },
|
69
|
+
references: references,
|
70
|
+
deserialize_context: deserialize_context
|
71
|
+
).first
|
72
|
+
|
73
|
+
changed_children
|
74
|
+
end
|
69
75
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
76
|
+
class_methods do
|
77
|
+
# Replace the current member(s) of an association with the provided
|
78
|
+
# hash(es) for many viewmodels. Only mentioned members will be returned.
|
79
|
+
#
|
80
|
+
# This is an interim implementation that requires loading the contents of
|
81
|
+
# all collections into memory and filtering for the mentioned entities,
|
82
|
+
# even for functional updates. This is in contrast to append_associated,
|
83
|
+
# which only operates on the new entities.
|
84
|
+
def replace_associated_bulk(association_name, updates_by_parent_id, references:, deserialize_context: self.class.new_deserialize_context)
|
85
|
+
association_data = _association_data(association_name)
|
86
|
+
|
87
|
+
touched_ids = updates_by_parent_id.each_with_object({}) do |(parent_id, update_hash), acc|
|
88
|
+
acc[parent_id] =
|
89
|
+
mentioned_children(
|
90
|
+
update_hash,
|
91
|
+
references: references,
|
92
|
+
association_data: association_data,
|
93
|
+
).to_set
|
83
94
|
end
|
84
|
-
end
|
85
95
|
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
96
|
+
root_update_hashes = updates_by_parent_id.map do |parent_id, update_hash|
|
97
|
+
{
|
98
|
+
ViewModel::ID_ATTRIBUTE => parent_id,
|
99
|
+
ViewModel::TYPE_ATTRIBUTE => view_name,
|
100
|
+
association_name.to_s => update_hash,
|
101
|
+
}
|
102
|
+
end
|
91
103
|
|
92
|
-
|
104
|
+
root_update_viewmodels = deserialize_from_view(
|
105
|
+
root_update_hashes, references: references, deserialize_context: deserialize_context)
|
93
106
|
|
94
|
-
|
107
|
+
root_update_viewmodels.each_with_object({}) do |updated, acc|
|
108
|
+
acc[updated] = updated._read_association_touched(association_name, touched_ids: touched_ids.fetch(updated.id))
|
109
|
+
end
|
110
|
+
end
|
95
111
|
end
|
96
112
|
|
97
113
|
# Create or update members of a associated collection. For an ordered
|
@@ -313,7 +329,10 @@ module ViewModel::ActiveRecord::AssociationManipulation
|
|
313
329
|
indirect_update_data, referenced_update_data = ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references)
|
314
330
|
|
315
331
|
# Convert associated update data to references
|
316
|
-
indirect_references =
|
332
|
+
indirect_references =
|
333
|
+
self.class.convert_updates_to_references(
|
334
|
+
indirect_update_data, key: 'indirect_append')
|
335
|
+
|
317
336
|
referenced_update_data.merge!(indirect_references)
|
318
337
|
|
319
338
|
# Find any existing models for the direct association: need to re-use any
|
@@ -352,12 +371,6 @@ module ViewModel::ActiveRecord::AssociationManipulation
|
|
352
371
|
return direct_update_data, referenced_update_data
|
353
372
|
end
|
354
373
|
|
355
|
-
def convert_updates_to_references(indirect_update_data, key:)
|
356
|
-
indirect_update_data.each.with_index.with_object({}) do |(update, i), indirect_references|
|
357
|
-
indirect_references["__#{key}_ref_#{i}"] = update
|
358
|
-
end
|
359
|
-
end
|
360
|
-
|
361
374
|
# TODO: this functionality could reasonably be extracted into `acts_as_manual_list`.
|
362
375
|
def select_append_positions(association_data, position_attr, append_count, before:, after:)
|
363
376
|
direct_reflection = association_data.direct_reflection
|
@@ -395,10 +408,134 @@ module ViewModel::ActiveRecord::AssociationManipulation
|
|
395
408
|
def check_association_type!(association_data, type)
|
396
409
|
if type && !association_data.accepts?(type)
|
397
410
|
raise ViewModel::SerializationError.new(
|
398
|
-
"Type error: association '#{
|
411
|
+
"Type error: association '#{association_data.association_name}' can't refer to viewmodel #{type.view_name}")
|
399
412
|
elsif association_data.polymorphic? && !type
|
400
413
|
raise ViewModel::SerializationError.new(
|
401
|
-
"Need to specify target viewmodel type for polymorphic association '#{
|
414
|
+
"Need to specify target viewmodel type for polymorphic association '#{association_data.association_name}'")
|
415
|
+
end
|
416
|
+
end
|
417
|
+
|
418
|
+
class_methods do
|
419
|
+
def convert_updates_to_references(indirect_update_data, key:)
|
420
|
+
indirect_update_data.each.with_index.with_object({}) do |(update, i), indirect_references|
|
421
|
+
indirect_references["__#{key}_ref_#{i}"] = update
|
422
|
+
end
|
423
|
+
end
|
424
|
+
|
425
|
+
def add_reference_indirection(update_hash, association_data:, references:, key:)
|
426
|
+
raise ArgumentError.new('Not a referenced association') unless association_data.referenced?
|
427
|
+
|
428
|
+
is_fupdate =
|
429
|
+
association_data.collection? &&
|
430
|
+
update_hash.is_a?(Hash) &&
|
431
|
+
update_hash[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
|
432
|
+
|
433
|
+
if is_fupdate
|
434
|
+
update_hash[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE].each_with_index do |action, i|
|
435
|
+
action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
|
436
|
+
if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
|
437
|
+
# Remove actions are always type/id refs; others need to be translated to proper refs
|
438
|
+
next
|
439
|
+
end
|
440
|
+
|
441
|
+
association_references = convert_updates_to_references(
|
442
|
+
action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE],
|
443
|
+
key: "#{key}_#{action_type_name}_#{i}")
|
444
|
+
references.merge!(association_references)
|
445
|
+
action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE] =
|
446
|
+
association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
|
447
|
+
end
|
448
|
+
|
449
|
+
update_hash
|
450
|
+
else
|
451
|
+
ViewModel::Utils.wrap_one_or_many(update_hash) do |sh|
|
452
|
+
association_references = convert_updates_to_references(sh, key: "#{key}_replace")
|
453
|
+
references.merge!(association_references)
|
454
|
+
association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
|
455
|
+
end
|
456
|
+
end
|
457
|
+
end
|
458
|
+
|
459
|
+
# Traverses literals and fupdates to return referenced children.
|
460
|
+
#
|
461
|
+
# Runs before the main parser, so must be defensive
|
462
|
+
def each_child_hash(assoc_update, association_data:)
|
463
|
+
return enum_for(__method__, assoc_update, association_data: association_data) unless block_given?
|
464
|
+
|
465
|
+
is_fupdate =
|
466
|
+
association_data.collection? &&
|
467
|
+
assoc_update.is_a?(Hash) &&
|
468
|
+
assoc_update[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
|
469
|
+
|
470
|
+
if is_fupdate
|
471
|
+
assoc_update.fetch(ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE).each do |action|
|
472
|
+
action_type_name = action[ViewModel::ActiveRecord::TYPE_ATTRIBUTE]
|
473
|
+
if action_type_name.nil?
|
474
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
475
|
+
"Functional update missing '#{ViewModel::ActiveRecord::TYPE_ATTRIBUTE}'"
|
476
|
+
)
|
477
|
+
end
|
478
|
+
|
479
|
+
if action_type_name == ViewModel::ActiveRecord::FunctionalUpdate::Remove::NAME
|
480
|
+
# Remove actions are not considered children of the action.
|
481
|
+
next
|
482
|
+
end
|
483
|
+
|
484
|
+
values = action.fetch(ViewModel::ActiveRecord::VALUES_ATTRIBUTE) {
|
485
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
486
|
+
"Functional update missing '#{ViewModel::ActiveRecord::VALUES_ATTRIBUTE}'"
|
487
|
+
)
|
488
|
+
}
|
489
|
+
values.each { |x| yield x }
|
490
|
+
end
|
491
|
+
else
|
492
|
+
ViewModel::Utils.wrap_one_or_many(assoc_update) do |assoc_updates|
|
493
|
+
assoc_updates.each { |u| yield u }
|
494
|
+
end
|
495
|
+
end
|
496
|
+
end
|
497
|
+
|
498
|
+
# Collects the ids of children that are mentioned in the update data.
|
499
|
+
#
|
500
|
+
# Runs before the main parser, so must be defensive.
|
501
|
+
def mentioned_children(assoc_update, references:, association_data:)
|
502
|
+
return enum_for(__method__, assoc_update, references: references, association_data: association_data) unless block_given?
|
503
|
+
|
504
|
+
each_child_hash(assoc_update, association_data: association_data).each do |child_hash|
|
505
|
+
unless child_hash.is_a?(Hash)
|
506
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
507
|
+
"Expected update hash, received: #{child_hash}"
|
508
|
+
)
|
509
|
+
end
|
510
|
+
|
511
|
+
if association_data.referenced?
|
512
|
+
ref_handle = child_hash.fetch(ViewModel::REFERENCE_ATTRIBUTE) {
|
513
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
514
|
+
"Reference hash missing '#{ViewModel::REFERENCE_ATTRIBUTE}'"
|
515
|
+
)
|
516
|
+
}
|
517
|
+
|
518
|
+
ref_update_hash = references.fetch(ref_handle) {
|
519
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
520
|
+
"Reference '#{ref_handle}' does not exist in references"
|
521
|
+
)
|
522
|
+
}
|
523
|
+
|
524
|
+
unless ref_update_hash.is_a?(Hash)
|
525
|
+
raise ViewModel::DeserializationError::InvalidSyntax.new(
|
526
|
+
"Expected update hash, received: #{child_hash}"
|
527
|
+
)
|
528
|
+
end
|
529
|
+
|
530
|
+
if (id = ref_update_hash[ViewModel::ID_ATTRIBUTE])
|
531
|
+
yield id
|
532
|
+
end
|
533
|
+
else
|
534
|
+
if (id = child_hash[ViewModel::ID_ATTRIBUTE])
|
535
|
+
yield id
|
536
|
+
end
|
537
|
+
end
|
538
|
+
end
|
402
539
|
end
|
403
540
|
end
|
404
541
|
end
|
@@ -52,22 +52,23 @@ class ViewModel::ActiveRecord::Cloner
|
|
52
52
|
new_associated = associated
|
53
53
|
else
|
54
54
|
# Otherwise descend into the child, and attach the result
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
55
|
+
build_vm = ->(model) do
|
56
|
+
vm_class =
|
57
|
+
if association_data.through?
|
58
|
+
# descend into the synthetic join table viewmodel
|
59
|
+
association_data.direct_viewmodel
|
60
|
+
else
|
61
|
+
association_data.viewmodel_class_for_model!(model.class)
|
62
|
+
end
|
63
|
+
|
64
|
+
vm_class.new(model)
|
65
|
+
end
|
65
66
|
|
66
67
|
new_associated =
|
67
68
|
if ViewModel::Utils.array_like?(associated)
|
68
|
-
associated.map { |m| clone(
|
69
|
+
associated.map { |m| clone(build_vm.(m)) }.compact
|
69
70
|
else
|
70
|
-
clone(
|
71
|
+
clone(build_vm.(associated))
|
71
72
|
end
|
72
73
|
end
|
73
74
|
end
|
@@ -3,8 +3,7 @@
|
|
3
3
|
require 'view_model/active_record/nested_controller_base'
|
4
4
|
|
5
5
|
# Controller mixin for accessing a root ViewModel which can be accessed in a
|
6
|
-
# collection by a parent model.
|
7
|
-
# :children` on the viewmodel controller
|
6
|
+
# collection by a parent model.
|
8
7
|
|
9
8
|
# Contributes the following routes:
|
10
9
|
# PUT /parents/:parent_id/children #append -- deserialize (possibly existing) children and append to collection
|
@@ -32,6 +31,10 @@ module ViewModel::ActiveRecord::CollectionNestedController
|
|
32
31
|
write_association(serialize_context: serialize_context, deserialize_context: deserialize_context, lock_owner: lock_owner, &block)
|
33
32
|
end
|
34
33
|
|
34
|
+
def replace_bulk(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, &block)
|
35
|
+
write_association_bulk(serialize_context: serialize_context, deserialize_context: deserialize_context, &block)
|
36
|
+
end
|
37
|
+
|
35
38
|
def disassociate_all(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
|
36
39
|
destroy_association(true, serialize_context: serialize_context, deserialize_context: deserialize_context, lock_owner: lock_owner)
|
37
40
|
end
|
@@ -102,15 +102,35 @@ module ActionDispatch
|
|
102
102
|
def arvm_resources(resource_name, options = {}, &block)
|
103
103
|
except = options.delete(:except) { [] }
|
104
104
|
add_shallow_routes = options.delete(:add_shallow_routes) { true }
|
105
|
+
defaults = options.delete(:defaults) { {} }
|
105
106
|
|
106
107
|
nested = shallow_nesting_depth > 0
|
107
108
|
|
109
|
+
association_name = options.delete(:association_name) { resource_name.to_s }
|
110
|
+
owner_viewmodel = options.delete(:owner_viewmodel) do
|
111
|
+
if nested
|
112
|
+
parent_resource.name.to_s.singularize.camelize
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
defaults = {
|
117
|
+
association_name: association_name,
|
118
|
+
owner_viewmodel: owner_viewmodel,
|
119
|
+
}.merge(defaults)
|
120
|
+
|
108
121
|
only_routes = []
|
109
122
|
only_routes += [:create] unless nested
|
110
123
|
only_routes += [:show, :destroy] if add_shallow_routes
|
111
124
|
only_routes -= except
|
112
125
|
|
113
|
-
|
126
|
+
# Bulk replace
|
127
|
+
if nested && !except.include?(:replace_bulk)
|
128
|
+
collection do
|
129
|
+
post resource_name, controller: resource_name, action: :replace_bulk, as: nil, defaults: defaults
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
resources resource_name, shallow: true, only: only_routes, defaults: defaults, **options do
|
114
134
|
instance_eval(&block) if block_given?
|
115
135
|
|
116
136
|
if nested
|
@@ -149,16 +169,35 @@ module ActionDispatch
|
|
149
169
|
def arvm_resource(resource_name, options = {}, &block)
|
150
170
|
except = options.delete(:except) { [] }
|
151
171
|
add_shallow_routes = options.delete(:add_shallow_routes) { true }
|
172
|
+
defaults = options.delete(:defaults) { {} }
|
152
173
|
|
153
174
|
only_routes = []
|
154
|
-
|
155
|
-
|
156
|
-
|
175
|
+
nested = shallow_nesting_depth > 0
|
176
|
+
|
177
|
+
association_name = options.delete(:association_name) { resource_name.to_s }
|
178
|
+
owner_viewmodel = options.delete(:owner_viewmodel) do
|
179
|
+
if nested
|
180
|
+
parent_resource.name.to_s.singularize.camelize
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
defaults = {
|
185
|
+
association_name: association_name,
|
186
|
+
owner_viewmodel: owner_viewmodel,
|
187
|
+
}.merge(defaults)
|
188
|
+
|
189
|
+
if nested && !except.include?(:create_associated_bulk)
|
190
|
+
collection do
|
191
|
+
post resource_name, controller: resource_name, action: :create_associated_bulk, as: nil, defaults: defaults
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
resource resource_name, shallow: true, only: only_routes, defaults: defaults, **options do
|
157
196
|
instance_eval(&block) if block_given?
|
158
197
|
|
159
198
|
name_route = { as: '' } # Only one route may take the name
|
160
199
|
|
161
|
-
if
|
200
|
+
if nested
|
162
201
|
post('', action: :create_associated, **name_route.extract!(:as)) unless except.include?(:create)
|
163
202
|
get('', action: :show_associated, **name_route.extract!(:as)) unless except.include?(:show)
|
164
203
|
delete('', action: :destroy_associated, **name_route.extract!(:as)) unless except.include?(:destroy)
|
@@ -170,7 +209,7 @@ module ActionDispatch
|
|
170
209
|
end
|
171
210
|
|
172
211
|
# singularly nested resources provide collection accessors at the top level
|
173
|
-
if
|
212
|
+
if nested && add_shallow_routes
|
174
213
|
resources resource_name.to_s.pluralize, shallow: true, only: [:show, :destroy] - except do
|
175
214
|
shallow_scope do
|
176
215
|
collection do
|
@@ -7,9 +7,45 @@ require 'view_model/active_record/controller_base'
|
|
7
7
|
module ViewModel::ActiveRecord::NestedControllerBase
|
8
8
|
extend ActiveSupport::Concern
|
9
9
|
|
10
|
+
class ParentProxyModel < ViewModel
|
11
|
+
# Prevent this from appearing in hooks
|
12
|
+
self.synthetic = true
|
13
|
+
|
14
|
+
attr_reader :parent, :association_data, :changed_children
|
15
|
+
|
16
|
+
def initialize(parent, association_data, changed_children)
|
17
|
+
@parent = parent
|
18
|
+
@association_data = association_data
|
19
|
+
@changed_children = changed_children
|
20
|
+
end
|
21
|
+
|
22
|
+
def serialize(json, serialize_context:)
|
23
|
+
ViewModel::Callbacks.wrap_serialize(parent, context: serialize_context) do
|
24
|
+
child_context = parent.context_for_child(association_data.association_name, context: serialize_context)
|
25
|
+
|
26
|
+
json.set!(ViewModel::ID_ATTRIBUTE, parent.id)
|
27
|
+
json.set!(ViewModel::BULK_UPDATE_ATTRIBUTE) do
|
28
|
+
if association_data.referenced? && !association_data.owned?
|
29
|
+
if association_data.collection?
|
30
|
+
json.array!(changed_children) do |child|
|
31
|
+
ViewModel.serialize_as_reference(child, json, serialize_context: child_context)
|
32
|
+
end
|
33
|
+
else
|
34
|
+
ViewModel.serialize_as_reference(changed_children, json, serialize_context: child_context)
|
35
|
+
end
|
36
|
+
else
|
37
|
+
ViewModel.serialize(changed_children, json, serialize_context: child_context)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
10
44
|
protected
|
11
45
|
|
12
46
|
def show_association(scope: nil, serialize_context: new_serialize_context, lock_owner: nil)
|
47
|
+
require_external_referenced_association!
|
48
|
+
|
13
49
|
associated_views = nil
|
14
50
|
pre_rendered = owner_viewmodel.transaction do
|
15
51
|
owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, lock: lock_owner)
|
@@ -27,11 +63,27 @@ module ViewModel::ActiveRecord::NestedControllerBase
|
|
27
63
|
associated_views
|
28
64
|
end
|
29
65
|
|
66
|
+
# This method always takes direct update hashes, and returns
|
67
|
+
# viewmodels directly.
|
68
|
+
#
|
69
|
+
# There's no multi membership, so when viewing the children of a
|
70
|
+
# single parent each child can only appear once. This means it's
|
71
|
+
# safe to use update hashes directly.
|
30
72
|
def write_association(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
|
73
|
+
require_external_referenced_association!
|
74
|
+
|
31
75
|
association_view = nil
|
32
76
|
pre_rendered = owner_viewmodel.transaction do
|
33
77
|
update_hash, refs = parse_viewmodel_updates
|
34
78
|
|
79
|
+
update_hash =
|
80
|
+
ViewModel::ActiveRecord.add_reference_indirection(
|
81
|
+
update_hash,
|
82
|
+
association_data: association_data,
|
83
|
+
references: refs,
|
84
|
+
key: 'write-association',
|
85
|
+
)
|
86
|
+
|
35
87
|
owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, lock: lock_owner)
|
36
88
|
|
37
89
|
association_view = owner_view.replace_associated(association_name, update_hash,
|
@@ -49,7 +101,67 @@ module ViewModel::ActiveRecord::NestedControllerBase
|
|
49
101
|
association_view
|
50
102
|
end
|
51
103
|
|
104
|
+
# This method takes direct update hashes for owned associations, and
|
105
|
+
# reference hashes for shared associations. The return value matches
|
106
|
+
# the input structure.
|
107
|
+
#
|
108
|
+
# If an association is referenced and owned, each child may only
|
109
|
+
# appear once so each is guaranteed to have a unique update
|
110
|
+
# hash. This means it's only safe to use update hashes directly in
|
111
|
+
# this case.
|
112
|
+
def write_association_bulk(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
|
113
|
+
require_external_referenced_association!
|
114
|
+
|
115
|
+
updated_by_parent_viewmodel = nil
|
116
|
+
|
117
|
+
pre_rendered = owner_viewmodel.transaction do
|
118
|
+
updates_by_parent_id, references = parse_bulk_update
|
119
|
+
|
120
|
+
if association_data.owned?
|
121
|
+
updates_by_parent_id.transform_values!.with_index do |update_hash, index|
|
122
|
+
ViewModel::ActiveRecord.add_reference_indirection(
|
123
|
+
update_hash,
|
124
|
+
association_data: association_data,
|
125
|
+
references: references,
|
126
|
+
key: "write-association-bulk-#{index}",
|
127
|
+
)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
updated_by_parent_viewmodel =
|
132
|
+
owner_viewmodel.replace_associated_bulk(
|
133
|
+
association_name,
|
134
|
+
updates_by_parent_id,
|
135
|
+
references: references,
|
136
|
+
deserialize_context: deserialize_context,
|
137
|
+
)
|
138
|
+
|
139
|
+
views = updated_by_parent_viewmodel.flat_map { |_parent_viewmodel, updated_views| Array.wrap(updated_views) }
|
140
|
+
|
141
|
+
ViewModel.preload_for_serialization(views)
|
142
|
+
|
143
|
+
updated_by_parent_viewmodel = yield(updated_by_parent_viewmodel) if block_given?
|
144
|
+
|
145
|
+
return_updates = updated_by_parent_viewmodel.map do |owner_view, updated_views|
|
146
|
+
ParentProxyModel.new(owner_view, association_data, updated_views)
|
147
|
+
end
|
148
|
+
|
149
|
+
return_structure = {
|
150
|
+
ViewModel::TYPE_ATTRIBUTE => ViewModel::BULK_UPDATE_TYPE,
|
151
|
+
ViewModel::BULK_UPDATES_ATTRIBUTE => return_updates,
|
152
|
+
}
|
153
|
+
|
154
|
+
prerender_viewmodel(return_structure, serialize_context: serialize_context)
|
155
|
+
end
|
156
|
+
|
157
|
+
render_json_string(pre_rendered)
|
158
|
+
updated_by_parent_viewmodel
|
159
|
+
end
|
160
|
+
|
161
|
+
|
52
162
|
def destroy_association(collection, serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
|
163
|
+
require_external_referenced_association!
|
164
|
+
|
53
165
|
if lock_owner
|
54
166
|
owner_viewmodel.find(owner_viewmodel_id, eager_include: false, lock: lock_owner)
|
55
167
|
end
|
@@ -61,7 +173,7 @@ module ViewModel::ActiveRecord::NestedControllerBase
|
|
61
173
|
end
|
62
174
|
|
63
175
|
def association_data
|
64
|
-
owner_viewmodel._association_data(association_name)
|
176
|
+
@association_data ||= owner_viewmodel._association_data(association_name)
|
65
177
|
end
|
66
178
|
|
67
179
|
def owner_update_hash(update)
|
@@ -78,22 +190,22 @@ module ViewModel::ActiveRecord::NestedControllerBase
|
|
78
190
|
parse_param(id_param_name, **default)
|
79
191
|
end
|
80
192
|
|
81
|
-
|
82
|
-
|
193
|
+
def owner_viewmodel_class_for_name(name)
|
194
|
+
ViewModel::Registry.for_view_name(name)
|
83
195
|
end
|
84
196
|
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
unless owner.is_a?(Class) && owner < ViewModel::Record
|
90
|
-
owner = ViewModel::Registry.for_view_name(owner.to_s.camelize)
|
91
|
-
end
|
197
|
+
def owner_viewmodel
|
198
|
+
name = params.fetch(:owner_viewmodel) { raise ArgumentError.new("No owner viewmodel present") }
|
199
|
+
owner_viewmodel_class_for_name(name.to_s.camelize)
|
200
|
+
end
|
92
201
|
|
93
|
-
|
94
|
-
|
202
|
+
def association_name
|
203
|
+
params.fetch(:association_name) { raise ArgumentError.new('No association name from routes') }
|
204
|
+
end
|
95
205
|
|
96
|
-
|
206
|
+
def require_external_referenced_association!
|
207
|
+
unless association_data.referenced? && association_data.external?
|
208
|
+
raise ArgumentError.new("Expected referenced external association: '#{association_name}'")
|
97
209
|
end
|
98
210
|
end
|
99
211
|
end
|
@@ -1,8 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
# Controller mixin for accessing a root ViewModel which can be accessed
|
4
|
-
# individually by a parent model.
|
5
|
-
# :child` on the viewmodel controller
|
4
|
+
# individually by a parent model.
|
6
5
|
|
7
6
|
# Contributes the following routes:
|
8
7
|
# POST /parents/:parent_id/child #create_associated -- deserialize (possibly existing) child, replacing existing child
|
@@ -28,6 +27,10 @@ module ViewModel::ActiveRecord::SingularNestedController
|
|
28
27
|
write_association(serialize_context: serialize_context, deserialize_context: deserialize_context, lock_owner: lock_owner, &block)
|
29
28
|
end
|
30
29
|
|
30
|
+
def create_associated_bulk(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, &block)
|
31
|
+
write_association_bulk(serialize_context: serialize_context, deserialize_context: deserialize_context, &block)
|
32
|
+
end
|
33
|
+
|
31
34
|
def destroy_associated(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
|
32
35
|
destroy_association(false, serialize_context: serialize_context, deserialize_context: deserialize_context, lock_owner: lock_owner)
|
33
36
|
end
|