iknow_view_models 3.4.1 → 3.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,15 +7,51 @@ 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)
16
52
  ViewModel::Callbacks.wrap_serialize(owner_view, context: serialize_context) do
17
53
  # Association manipulation methods construct child contexts internally
18
- associated_views = owner_view.load_associated(association_name, scope: scope)
54
+ associated_views = owner_view.load_associated(association_name, scope: scope, serialize_context: serialize_context)
19
55
 
20
56
  associated_views = yield(associated_views) if block_given?
21
57
 
@@ -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
@@ -181,7 +181,7 @@ module ViewModel::Callbacks
181
181
  # ARVM synthetic views are considered part of their association and as such
182
182
  # are not visited by callbacks. Eligibility exclusion is intended to be
183
183
  # library-internal: subclasses should not attempt to extend this.
184
- view.is_a?(ViewModel::ActiveRecord) && view.class.synthetic
184
+ view.is_a?(ViewModel) && view.class.synthetic
185
185
  end
186
186
 
187
187
  def self.wrap_serialize(viewmodel, context:)
@@ -75,12 +75,34 @@ module ViewModel::Controller
75
75
  protected
76
76
 
77
77
  def parse_viewmodel_updates
78
- update_hash = _extract_update_data(params.fetch(:data))
79
- refs = _extract_param_hash(params.fetch(:references, {}))
78
+ data_param = params.fetch(:data) do
79
+ raise ViewModel::Error.new(status: 400, detail: "Missing 'data' parameter")
80
+ end
81
+ refs_param = params.fetch(:references, {})
82
+
83
+ update_hash = _extract_update_data(data_param)
84
+ refs = _extract_param_hash(refs_param)
80
85
 
81
86
  return update_hash, refs
82
87
  end
83
88
 
89
+ def parse_bulk_update
90
+ data, references = parse_viewmodel_updates
91
+
92
+ ViewModel::Schemas.verify_schema!(ViewModel::Schemas::BULK_UPDATE, data)
93
+
94
+ updates_by_parent =
95
+ data.fetch(ViewModel::BULK_UPDATES_ATTRIBUTE).each_with_object({}) do |parent_update, acc|
96
+ parent_id = parent_update.fetch(ViewModel::ID_ATTRIBUTE)
97
+ update = parent_update.fetch(ViewModel::BULK_UPDATE_ATTRIBUTE)
98
+
99
+ acc[parent_id] = update
100
+ end
101
+
102
+ return updates_by_parent, references
103
+ end
104
+
105
+
84
106
  private
85
107
 
86
108
  def _extract_update_data(data)
@@ -35,7 +35,7 @@ class ViewModel::Record < ViewModel
35
35
 
36
36
  # Should this class be registered in the viewmodel registry
37
37
  def should_register?
38
- !abstract_class && !unregistered
38
+ !abstract_class && !unregistered && !synthetic
39
39
  end
40
40
 
41
41
  # Specifies an attribute from the model to be serialized in this view
@@ -39,6 +39,50 @@ class ViewModel::Schemas
39
39
 
40
40
  VIEWMODEL_REFERENCE = JsonSchema.parse!(VIEWMODEL_REFERENCE_SCHEMA)
41
41
 
42
+ BULK_UPDATE_SCHEMA =
43
+ {
44
+ 'type' => 'object',
45
+ 'description' => 'bulk update collection',
46
+ 'properties' => {
47
+ ViewModel::TYPE_ATTRIBUTE => {
48
+ 'type' => 'string',
49
+ 'enum' => [ViewModel::BULK_UPDATE_TYPE],
50
+ },
51
+
52
+ ViewModel::BULK_UPDATES_ATTRIBUTE => {
53
+ 'type' => 'array',
54
+ 'items' => {
55
+ 'type' => 'object',
56
+ 'properties' => {
57
+ ViewModel::ID_ATTRIBUTE => ID_SCHEMA,
58
+
59
+ # These will be checked by the main deserialize operation. Any operations on the data
60
+ # before the main serialization must do its own checking of the presented update data.
61
+
62
+ ViewModel::BULK_UPDATE_ATTRIBUTE => {
63
+ 'oneOf' => [
64
+ { 'type' => 'array' },
65
+ { 'type' => 'object' },
66
+ ]
67
+ },
68
+ },
69
+ 'additionalProperties' => false,
70
+ 'required' => [
71
+ ViewModel::ID_ATTRIBUTE,
72
+ ViewModel::BULK_UPDATE_ATTRIBUTE,
73
+ ],
74
+ },
75
+ }
76
+ },
77
+ 'additionalProperties' => false,
78
+ 'required' => [
79
+ ViewModel::TYPE_ATTRIBUTE,
80
+ ViewModel::BULK_UPDATES_ATTRIBUTE,
81
+ ],
82
+ }.freeze
83
+
84
+ BULK_UPDATE = JsonSchema.parse!(BULK_UPDATE_SCHEMA)
85
+
42
86
  def self.verify_schema!(schema, value)
43
87
  valid, errors = schema.validate(value)
44
88
  unless valid
@@ -192,4 +192,69 @@ module ARVMTestUtilities
192
192
  def build_fupdate(attrs = {}, &block)
193
193
  FupdateBuilder.new.build!(&block).merge(attrs)
194
194
  end
195
+
196
+ def each_hook_span(trace)
197
+ return enum_for(:each_hook_span, trace) unless block_given?
198
+
199
+ hook_nesting = []
200
+
201
+ trace.each_with_index do |t, i|
202
+ case t.hook
203
+ when ViewModel::Callbacks::Hook::OnChange,
204
+ ViewModel::Callbacks::Hook::BeforeValidate
205
+ # ignore
206
+ when ViewModel::Callbacks::Hook::BeforeVisit,
207
+ ViewModel::Callbacks::Hook::BeforeDeserialize
208
+ hook_nesting.push([t, i])
209
+
210
+ when ViewModel::Callbacks::Hook::AfterVisit,
211
+ ViewModel::Callbacks::Hook::AfterDeserialize
212
+ (nested_top, nested_index) = hook_nesting.pop
213
+
214
+ unless nested_top.hook.name == t.hook.name.sub(/^After/, 'Before')
215
+ raise "Invalid nesting, processing '#{t.hook.name}', expected matching '#{nested_top.hook.name}'"
216
+ end
217
+
218
+ unless nested_top.view == t.view
219
+ raise "Invalid nesting, processing '#{t.hook.name}', " \
220
+ "expected viewmodel '#{t.view}' to match '#{nested_top.view}'"
221
+ end
222
+
223
+ yield t.view, (nested_index..i), t.hook.name.sub(/^After/, '')
224
+
225
+ else
226
+ raise 'Unexpected hook type'
227
+ end
228
+ end
229
+ end
230
+
231
+ def show_span(view, range, hook)
232
+ "#{view.class.name}(#{view.id}) #{range} #{hook}"
233
+ end
234
+
235
+ def enclosing_hooks(spans, inner_range)
236
+ spans.select do |_view, range, _hook|
237
+ inner_range != range && range.cover?(inner_range.min) && range.cover?(inner_range.max)
238
+ end
239
+ end
240
+
241
+ def assert_all_hooks_nested_inside_parent_hook(trace)
242
+ spans = each_hook_span(trace).to_a
243
+
244
+ spans.reject { |view, _range, _hook| view.class == ParentView }.each do |view, range, hook|
245
+ enclosing_spans = enclosing_hooks(spans, range)
246
+
247
+ enclosing_parent_hook = enclosing_spans.detect do |other_view, _other_range, other_hook|
248
+ other_hook == hook && other_view.class == ParentView
249
+ end
250
+
251
+ next if enclosing_parent_hook
252
+
253
+ self_str = show_span(view, range, hook)
254
+ enclosing_str = enclosing_spans.map { |ov, ora, oh| show_span(ov, ora, oh) }.join("\n")
255
+ assert_not_nil(
256
+ enclosing_parent_hook,
257
+ "Invalid nesting of hook: #{self_str}\nEnclosing hooks:\n#{enclosing_str}")
258
+ end
259
+ end
195
260
  end