iknow_view_models 3.4.4 → 3.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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:)
@@ -86,6 +86,23 @@ module ViewModel::Controller
86
86
  return update_hash, refs
87
87
  end
88
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
+
89
106
  private
90
107
 
91
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
@@ -13,8 +13,11 @@ require 'acts_as_manual_list'
13
13
 
14
14
  # models for ARVM controller test
15
15
  module ControllerTestModels
16
- def before_all
17
- super
16
+ def build_controller_test_models(externalize: [])
17
+ unsupported_externals = externalize - [:label, :target, :child]
18
+ unless unsupported_externals.empty?
19
+ raise ArgumentError.new("build_controller_test_models cannot externalize: #{unsupported_externals.join(", ")}")
20
+ end
18
21
 
19
22
  build_viewmodel(:Label) do
20
23
  define_schema do |t|
@@ -25,6 +28,7 @@ module ControllerTestModels
25
28
  has_one :target
26
29
  end
27
30
  define_viewmodel do
31
+ root! if externalize.include?(:label)
28
32
  attributes :text
29
33
  end
30
34
  end
@@ -80,16 +84,19 @@ module ControllerTestModels
80
84
  has_one :target, dependent: :destroy, inverse_of: :parent
81
85
  belongs_to :poly, polymorphic: true, dependent: :destroy, inverse_of: :parent
82
86
  belongs_to :category
87
+ has_many :parent_tags
83
88
  end
84
89
  define_viewmodel do
85
90
  root!
86
91
  self.schema_version = 2
87
92
 
88
93
  attributes :name
89
- associations :label, :target
90
- association :children
94
+ association :target, external: externalize.include?(:target)
95
+ association :label, external: externalize.include?(:label)
96
+ association :children, external: externalize.include?(:child)
91
97
  association :poly, viewmodels: [:PolyOne, :PolyTwo]
92
98
  association :category, external: true
99
+ association :tags, through: :parent_tags, external: true
93
100
 
94
101
  migrates from: 1, to: 2 do
95
102
  up do |view, _refs|
@@ -105,6 +112,31 @@ module ControllerTestModels
105
112
  end
106
113
  end
107
114
 
115
+ build_viewmodel(:Tag) do
116
+ define_schema do |t|
117
+ t.string :name, null: false
118
+ end
119
+ define_model do
120
+ has_many :parent_tags
121
+ end
122
+ define_viewmodel do
123
+ root!
124
+ attributes :name
125
+ end
126
+ end
127
+
128
+ build_viewmodel(:ParentTag) do
129
+ define_schema do |t|
130
+ t.references :parent, foreign_key: true
131
+ t.references :tag, foreign_key: true
132
+ end
133
+ define_model do
134
+ belongs_to :parent
135
+ belongs_to :tag
136
+ end
137
+ no_viewmodel
138
+ end
139
+
108
140
  build_viewmodel(:Child) do
109
141
  define_schema do |t|
110
142
  t.references :parent, null: false, foreign_key: true
@@ -122,6 +154,7 @@ module ControllerTestModels
122
154
  validates :age, numericality: { less_than: 42 }, allow_nil: true
123
155
  end
124
156
  define_viewmodel do
157
+ root! if externalize.include?(:child)
125
158
  attributes :name, :age
126
159
  acts_as_list :position
127
160
  end
@@ -138,11 +171,22 @@ module ControllerTestModels
138
171
  belongs_to :label, dependent: :destroy
139
172
  end
140
173
  define_viewmodel do
174
+ root! if externalize.include?(:target)
141
175
  attributes :text
142
176
  association :label
143
177
  end
144
178
  end
145
179
  end
180
+
181
+ def make_parent(name: 'p', child_names: ['c1', 'c2'])
182
+ Parent.create(
183
+ name: name,
184
+ children: child_names.each_with_index.map { |c, pos|
185
+ Child.new(name: "c#{pos + 1}", position: (pos + 1).to_f)
186
+ },
187
+ label: Label.new,
188
+ target: Target.new)
189
+ end
146
190
  end
147
191
 
148
192
  ## Dummy Rails Controllers
@@ -253,43 +297,30 @@ module CallbackTracing
253
297
  end
254
298
 
255
299
  module ControllerTestControllers
300
+ CONTROLLER_NAMES = [
301
+ :ParentController,
302
+ :ChildController,
303
+ :LabelController,
304
+ :TargetController,
305
+ :CategoryController,
306
+ :TagController,
307
+ ]
308
+
256
309
  def before_all
257
310
  super
258
311
 
259
- Class.new(DummyController) do |_c|
260
- Object.const_set(:ParentController, self)
261
- include ViewModel::ActiveRecord::Controller
262
- include CallbackTracing
263
- self.access_control = ViewModel::AccessControl::Open
264
- end
265
-
266
- Class.new(DummyController) do |_c|
267
- Object.const_set(:ChildController, self)
268
- include ViewModel::ActiveRecord::Controller
269
- include CallbackTracing
270
- self.access_control = ViewModel::AccessControl::Open
271
- nested_in :parent, as: :children
272
- end
273
-
274
- Class.new(DummyController) do |_c|
275
- Object.const_set(:LabelController, self)
276
- include ViewModel::ActiveRecord::Controller
277
- include CallbackTracing
278
- self.access_control = ViewModel::AccessControl::Open
279
- nested_in :parent, as: :label
280
- end
281
-
282
- Class.new(DummyController) do |_c|
283
- Object.const_set(:TargetController, self)
284
- include ViewModel::ActiveRecord::Controller
285
- include CallbackTracing
286
- self.access_control = ViewModel::AccessControl::Open
287
- nested_in :parent, as: :target
312
+ CONTROLLER_NAMES.each do |name|
313
+ Class.new(DummyController) do |_c|
314
+ Object.const_set(name, self)
315
+ include ViewModel::ActiveRecord::Controller
316
+ include CallbackTracing
317
+ self.access_control = ViewModel::AccessControl::Open
318
+ end
288
319
  end
289
320
  end
290
321
 
291
322
  def after_all
292
- [:ParentController, :ChildController, :LabelController, :TargetController].each do |name|
323
+ CONTROLLER_NAMES.each do |name|
293
324
  Object.send(:remove_const, name)
294
325
  end
295
326
  ActiveSupport::Dependencies::Reference.clear!