iknow_view_models 3.4.2 → 3.5.1

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