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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3560a98616dd2f0548ac7e663827e68d86fa9991969ac6ee49633a0090e7d628
4
- data.tar.gz: deec44e5132f91dfc1deef0dd889ee20c1b01c6d5d7791d356f1e9dc99ec1c8a
3
+ metadata.gz: f51edf25fab0aec533446c939c44975f76806871e1341518aa4470f32c23d839
4
+ data.tar.gz: 140166cc68f15484a0291f4e8faa8b4e26c3233a2b3bcfacfa9a4972a2a5e0ef
5
5
  SHA512:
6
- metadata.gz: 5af2b38b2e8fb8805480bd665ea24e7ad4b489951c8b72660e502a053a945798f18269b60991eb416ab6959fc46e4831a911a15005810b57e288e2eeaaf37fd5
7
- data.tar.gz: 5a73108237e3ffae5751f50a6e693e8fd737a4d1936f087095138ec2db47cbd7ec852f81e6ef881f794a38d67ba85429825b2393a850c149f363121f321adf30
6
+ metadata.gz: 27e85b3cd097d4f19e28d66d3500db4142dedd4a65a44731754afd35430d86945bb2e7f928df476e4aadb665d34518fa10211113f17b1ad0718727f957c8156f
7
+ data.tar.gz: 7e740ea172d15e55b870336fd833a6138c3f5aaec97da84828813b6da57d9512bbb3cd98f404f24c58c7adf7b53d875639bd22561b200d429e74523770a3a483
@@ -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 = ['edge@iknow.jp']
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'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IknowViewModels
4
- VERSION = '3.4.3'
4
+ VERSION = '3.5.2'
5
5
  end
@@ -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 hash(es).
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
- association_data = self.class._association_data(association_name)
55
-
56
- if association_data.referenced?
57
- is_fupdate =
58
- association_data.collection? &&
59
- update_hash.is_a?(Hash) &&
60
- update_hash[ViewModel::ActiveRecord::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
61
-
62
- if is_fupdate
63
- update_hash[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE].each_with_index do |action, i|
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
- association_references = convert_updates_to_references(
71
- action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE],
72
- key: "#{action_type_name}_#{i}")
73
- references.merge!(association_references)
74
- action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE] =
75
- association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
76
- end
77
- else
78
- update_hash = ViewModel::Utils.wrap_one_or_many(update_hash) do |sh|
79
- association_references = convert_updates_to_references(sh, key: 'replace')
80
- references.merge!(association_references)
81
- association_references.each_key.map { |ref| { ViewModel::REFERENCE_ATTRIBUTE => ref } }
82
- end
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
- root_update_hash = {
87
- ViewModel::ID_ATTRIBUTE => self.id,
88
- ViewModel::TYPE_ATTRIBUTE => self.class.view_name,
89
- association_name.to_s => update_hash,
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
- root_update_viewmodel = self.class.deserialize_from_view(root_update_hash, references: references, deserialize_context: deserialize_context)
104
+ root_update_viewmodels = deserialize_from_view(
105
+ root_update_hashes, references: references, deserialize_context: deserialize_context)
93
106
 
94
- root_update_viewmodel._read_association(association_name)
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 = convert_updates_to_references(indirect_update_data, key: 'indirect_append')
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 '#{direct_reflection.name}' can't refer to viewmodel #{type.view_name}")
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 '#{direct_reflection.name}'")
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
- vm_class =
56
- case
57
- when association_data.through?
58
- # descend into the synthetic join table viewmodel
59
- association_data.direct_viewmodel
60
- when association_data.collection?
61
- association_data.viewmodel_class
62
- else
63
- association_data.viewmodel_class_for_model!(associated.class)
64
- end
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(vm_class.new(m)) }.compact
69
+ associated.map { |m| clone(build_vm.(m)) }.compact
69
70
  else
70
- clone(vm_class.new(associated))
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. Enabled by calling `nested_in :parent, as:
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
- resources resource_name, shallow: true, only: only_routes, **options do
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
- is_shallow = false
155
- resource resource_name, shallow: true, only: only_routes, **options do
156
- is_shallow = shallow_nesting_depth > 1
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 is_shallow
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 is_shallow && add_shallow_routes
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
- included do
82
- delegate :owner_viewmodel, :association_name, to: 'self.class'
193
+ def owner_viewmodel_class_for_name(name)
194
+ ViewModel::Registry.for_view_name(name)
83
195
  end
84
196
 
85
- class_methods do
86
- attr_accessor :owner_viewmodel, :association_name
87
-
88
- def nested_in(owner, as:)
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
- self.owner_viewmodel = owner
94
- raise ArgumentError.new("Could not find owner ViewModel class '#{owner_name}'") if owner_viewmodel.nil?
202
+ def association_name
203
+ params.fetch(:association_name) { raise ArgumentError.new('No association name from routes') }
204
+ end
95
205
 
96
- self.association_name = as
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. Enabled by calling `nested_in :parent, as:
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