iknow_view_models 3.4.3 → 3.5.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -35,14 +35,9 @@ class ViewModel::ActiveRecord < ViewModel::Record
35
35
 
36
36
  class << self
37
37
  attr_reader :_list_attribute_name
38
- attr_accessor :synthetic
39
38
 
40
39
  delegate :transaction, to: :model_class
41
40
 
42
- def should_register?
43
- super && !synthetic
44
- end
45
-
46
41
  # Specifies that the model backing this viewmodel is a member of an
47
42
  # `acts_as_manual_list` collection.
48
43
  def acts_as_list(attr = :position)
@@ -368,6 +363,52 @@ class ViewModel::ActiveRecord < ViewModel::Record
368
363
  end
369
364
  end
370
365
 
366
+ # Rails 6.1 introduced "previously_new_record?", but this library still
367
+ # supports activerecord >= 5.0. This is an approximation.
368
+ def self.model_previously_new?(model)
369
+ if (id_changes = model.saved_change_to_id)
370
+ old_id, _new_id = id_changes
371
+ return true if old_id.nil?
372
+ end
373
+ false
374
+ end
375
+
376
+ # Helper to return entities that were part of the last deserialization. The
377
+ # interface is complex due to the data requirements, and the implementation is
378
+ # inefficient.
379
+ #
380
+ # Intended to be used by replace_associated style methods which may touch very
381
+ # large collections that must not be returned fully. Since the collection is
382
+ # not being returned, order is also ignored.
383
+ def _read_association_touched(association_name, touched_ids:)
384
+ association_data = self.class._association_data(association_name)
385
+
386
+ associated = model.public_send(association_data.direct_reflection.name)
387
+ return nil if associated.nil?
388
+
389
+ case
390
+ when association_data.through?
391
+ # associated here are join-table models; we need to get the far side out
392
+ associated.map do |through_model|
393
+ model = through_model.public_send(association_data.indirect_reflection.name)
394
+
395
+ next unless self.class.model_previously_new?(through_model) || touched_ids.include?(model.id)
396
+
397
+ association_data.viewmodel_class_for_model!(model.class).new(model)
398
+ end.reject(&:nil?)
399
+ when association_data.collection?
400
+ associated.map do |model|
401
+ next unless self.class.model_previously_new?(model) || touched_ids.include?(model.id)
402
+
403
+ association_data.viewmodel_class_for_model!(model.class).new(model)
404
+ end.reject(&:nil?)
405
+ else
406
+ # singleton always touched by definition
407
+ model = associated
408
+ association_data.viewmodel_class_for_model!(model.class).new(model)
409
+ end
410
+ end
411
+
371
412
  def _serialize_association(association_name, json, serialize_context:)
372
413
  associated = self.public_send(association_name)
373
414
  association_data = self.class._association_data(association_name)
@@ -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)
@@ -75,6 +75,32 @@ class ViewModel
75
75
  class UpMigrator < Migrator
76
76
  private
77
77
 
78
+ def migrate_tree!(node, references:)
79
+ if node.is_a?(Hash) && node[ViewModel::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
80
+ migrate_functional_update!(node, references: references)
81
+ else
82
+ super
83
+ end
84
+ end
85
+
86
+ NESTED_FUPDATE_TYPES = ['append', 'update'].freeze
87
+
88
+ # The functional update structure uses `_type` internally with a
89
+ # context-dependent meaning. Retrospectively this was a poor choice, but we
90
+ # need to account for it here.
91
+ def migrate_functional_update!(node, references:)
92
+ actions = node[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE]
93
+ actions&.each do |action|
94
+ action_type = action[ViewModel::TYPE_ATTRIBUTE]
95
+ next unless NESTED_FUPDATE_TYPES.include?(action_type)
96
+
97
+ values = action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE]
98
+ values&.each do |value|
99
+ migrate_tree!(value, references: references)
100
+ end
101
+ end
102
+ end
103
+
78
104
  def migrate_viewmodel!(view_name, source_version, view_hash, references)
79
105
  path = @paths[view_name]
80
106
  return false unless path
@@ -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
@@ -93,7 +93,7 @@ class ViewModel::Record < ViewModel
93
93
 
94
94
  if metadata.schema_version && !self.accepts_schema_version?(metadata.schema_version)
95
95
  raise ViewModel::DeserializationError::SchemaVersionMismatch.new(
96
- self, version, ViewModel::Reference.new(self, metadata.id))
96
+ self, metadata.schema_version, ViewModel::Reference.new(self, metadata.id))
97
97
  end
98
98
 
99
99
  viewmodel = resolve_viewmodel(metadata, view_hash, deserialize_context: deserialize_context)
@@ -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
data/lib/view_model.rb CHANGED
@@ -12,6 +12,10 @@ class ViewModel
12
12
  VERSION_ATTRIBUTE = '_version'
13
13
  NEW_ATTRIBUTE = '_new'
14
14
 
15
+ BULK_UPDATE_TYPE = '_bulk_update'
16
+ BULK_UPDATES_ATTRIBUTE = 'updates'
17
+ BULK_UPDATE_ATTRIBUTE = 'update'
18
+
15
19
  # Migrations leave a metadata attribute _migrated on any views that they
16
20
  # alter. This attribute is accessible as metadata when deserializing migrated
17
21
  # input, and is included in the output serialization sent to clients.
@@ -27,6 +31,14 @@ class ViewModel
27
31
  attr_reader :view_aliases
28
32
  attr_writer :view_name
29
33
 
34
+ # Boolean to indicate if the viewmodel is synthetic. Synthetic
35
+ # viewmodels are nearly-invisible glue. They're full viewmodels,
36
+ # but do not participate in hooks or registration. For example, a
37
+ # join table connecting A and B through T has a synthetic
38
+ # viewmodel T to represent the join model, but the external
39
+ # interface is a relationship of A to a list of Bs.
40
+ attr_accessor :synthetic
41
+
30
42
  def inherited(subclass)
31
43
  super
32
44
  subclass.initialize_as_viewmodel
@@ -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!