iknow_view_models 3.4.1 → 3.5.0

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.
@@ -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