iknow_view_models 3.4.2 → 3.5.1

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