iknow_view_models 2.8.4

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.
Files changed (92) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +115 -0
  3. data/.gitignore +36 -0
  4. data/.travis.yml +31 -0
  5. data/Appraisals +9 -0
  6. data/Gemfile +19 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +19 -0
  9. data/Rakefile +21 -0
  10. data/appveyor.yml +22 -0
  11. data/gemfiles/rails_5_2.gemfile +15 -0
  12. data/gemfiles/rails_6_0_beta.gemfile +15 -0
  13. data/iknow_view_models.gemspec +49 -0
  14. data/lib/iknow_view_models.rb +12 -0
  15. data/lib/iknow_view_models/railtie.rb +8 -0
  16. data/lib/iknow_view_models/version.rb +5 -0
  17. data/lib/view_model.rb +333 -0
  18. data/lib/view_model/access_control.rb +154 -0
  19. data/lib/view_model/access_control/composed.rb +216 -0
  20. data/lib/view_model/access_control/open.rb +13 -0
  21. data/lib/view_model/access_control/read_only.rb +13 -0
  22. data/lib/view_model/access_control/tree.rb +264 -0
  23. data/lib/view_model/access_control_error.rb +10 -0
  24. data/lib/view_model/active_record.rb +383 -0
  25. data/lib/view_model/active_record/association_data.rb +178 -0
  26. data/lib/view_model/active_record/association_manipulation.rb +389 -0
  27. data/lib/view_model/active_record/cache.rb +265 -0
  28. data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
  29. data/lib/view_model/active_record/cloner.rb +113 -0
  30. data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
  31. data/lib/view_model/active_record/controller.rb +77 -0
  32. data/lib/view_model/active_record/controller_base.rb +185 -0
  33. data/lib/view_model/active_record/nested_controller_base.rb +93 -0
  34. data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
  35. data/lib/view_model/active_record/update_context.rb +252 -0
  36. data/lib/view_model/active_record/update_data.rb +749 -0
  37. data/lib/view_model/active_record/update_operation.rb +810 -0
  38. data/lib/view_model/active_record/visitor.rb +77 -0
  39. data/lib/view_model/after_transaction_runner.rb +29 -0
  40. data/lib/view_model/callbacks.rb +219 -0
  41. data/lib/view_model/changes.rb +62 -0
  42. data/lib/view_model/config.rb +29 -0
  43. data/lib/view_model/controller.rb +142 -0
  44. data/lib/view_model/deserialization_error.rb +437 -0
  45. data/lib/view_model/deserialize_context.rb +16 -0
  46. data/lib/view_model/error.rb +191 -0
  47. data/lib/view_model/error_view.rb +35 -0
  48. data/lib/view_model/record.rb +367 -0
  49. data/lib/view_model/record/attribute_data.rb +48 -0
  50. data/lib/view_model/reference.rb +31 -0
  51. data/lib/view_model/references.rb +48 -0
  52. data/lib/view_model/registry.rb +73 -0
  53. data/lib/view_model/schemas.rb +45 -0
  54. data/lib/view_model/serialization_error.rb +10 -0
  55. data/lib/view_model/serialize_context.rb +118 -0
  56. data/lib/view_model/test_helpers.rb +103 -0
  57. data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
  58. data/lib/view_model/traversal_context.rb +126 -0
  59. data/lib/view_model/utils.rb +24 -0
  60. data/lib/view_model/utils/collections.rb +49 -0
  61. data/test/helpers/arvm_test_models.rb +59 -0
  62. data/test/helpers/arvm_test_utilities.rb +187 -0
  63. data/test/helpers/callback_tracer.rb +27 -0
  64. data/test/helpers/controller_test_helpers.rb +270 -0
  65. data/test/helpers/match_enumerator.rb +58 -0
  66. data/test/helpers/query_logging.rb +71 -0
  67. data/test/helpers/test_access_control.rb +56 -0
  68. data/test/helpers/viewmodel_spec_helpers.rb +326 -0
  69. data/test/unit/view_model/access_control_test.rb +769 -0
  70. data/test/unit/view_model/active_record/alias_test.rb +35 -0
  71. data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
  72. data/test/unit/view_model/active_record/cache_test.rb +351 -0
  73. data/test/unit/view_model/active_record/cloner_test.rb +313 -0
  74. data/test/unit/view_model/active_record/controller_test.rb +561 -0
  75. data/test/unit/view_model/active_record/counter_test.rb +80 -0
  76. data/test/unit/view_model/active_record/customization_test.rb +388 -0
  77. data/test/unit/view_model/active_record/has_many_test.rb +957 -0
  78. data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
  79. data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
  80. data/test/unit/view_model/active_record/has_one_test.rb +334 -0
  81. data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
  82. data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
  83. data/test/unit/view_model/active_record/poly_test.rb +320 -0
  84. data/test/unit/view_model/active_record/shared_test.rb +285 -0
  85. data/test/unit/view_model/active_record/version_test.rb +121 -0
  86. data/test/unit/view_model/active_record_test.rb +542 -0
  87. data/test/unit/view_model/callbacks_test.rb +582 -0
  88. data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
  89. data/test/unit/view_model/record_test.rb +524 -0
  90. data/test/unit/view_model/traversal_context_test.rb +371 -0
  91. data/test/unit/view_model_test.rb +62 -0
  92. metadata +490 -0
@@ -0,0 +1,10 @@
1
+ class ViewModel::AccessControlError < ViewModel::AbstractErrorWithBlame
2
+ attr_reader :detail
3
+ status 403
4
+ code "AccessControl.Forbidden"
5
+
6
+ def initialize(detail, nodes = [])
7
+ @detail = detail
8
+ super(nodes)
9
+ end
10
+ end
@@ -0,0 +1,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_record"
5
+
6
+ require "view_model"
7
+ require "view_model/record"
8
+
9
+ require "lazily"
10
+ require "concurrent"
11
+
12
+ class ViewModel::ActiveRecord < ViewModel::Record
13
+ # Defined before requiring components so components can refer to them at parse time
14
+
15
+ # for functional updates
16
+ FUNCTIONAL_UPDATE_TYPE = "_update"
17
+ ACTIONS_ATTRIBUTE = "actions"
18
+ VALUES_ATTRIBUTE = "values"
19
+ BEFORE_ATTRIBUTE = "before"
20
+ AFTER_ATTRIBUTE = "after"
21
+
22
+ require 'view_model/utils/collections'
23
+ require 'view_model/active_record/association_data'
24
+ require 'view_model/active_record/update_data'
25
+ require 'view_model/active_record/update_context'
26
+ require 'view_model/active_record/update_operation'
27
+ require 'view_model/active_record/visitor'
28
+ require 'view_model/active_record/cloner'
29
+ require 'view_model/active_record/cache'
30
+ require 'view_model/active_record/association_manipulation'
31
+
32
+ include AssociationManipulation
33
+
34
+ attr_reader :changed_associations
35
+
36
+ class << self
37
+ attr_reader :_list_attribute_name
38
+ attr_accessor :synthetic
39
+
40
+ delegate :transaction, to: :model_class
41
+
42
+ def should_register?
43
+ super && !synthetic
44
+ end
45
+
46
+ # Specifies that the model backing this viewmodel is a member of an
47
+ # `acts_as_manual_list` collection.
48
+ def acts_as_list(attr = :position)
49
+ @_list_attribute_name = attr
50
+
51
+ @generated_accessor_module.module_eval do
52
+ define_method('_list_attribute') do
53
+ model.public_send(attr)
54
+ end
55
+
56
+ define_method('_list_attribute=') do |x|
57
+ model.public_send(:"#{attr}=", x)
58
+ end
59
+ end
60
+ end
61
+
62
+ def _list_member?
63
+ _list_attribute_name.present?
64
+ end
65
+
66
+ # Specifies an association from the model to be recursively serialized using
67
+ # another viewmodel. If the target viewmodel is not specified, attempt to
68
+ # locate a default viewmodel based on the name of the associated model.
69
+ # TODO document harder
70
+ # - +through+ names an ActiveRecord association that will be used like an
71
+ # ActiveRecord +has_many:through:+.
72
+ # - +through_order_attr+ the through model is ordered by the given attribute
73
+ # (only applies to when +through+ is set).
74
+ def association(association_name,
75
+ viewmodel: nil,
76
+ viewmodels: nil,
77
+ shared: false,
78
+ optional: false,
79
+ through: nil,
80
+ through_order_attr: nil,
81
+ as: nil)
82
+
83
+ if through
84
+ model_association_name = through
85
+ through_to = association_name
86
+ else
87
+ model_association_name = association_name
88
+ through_to = nil
89
+ end
90
+
91
+ vm_association_name = (as || association_name).to_s
92
+
93
+ reflection = model_class.reflect_on_association(model_association_name)
94
+
95
+ if reflection.nil?
96
+ raise ArgumentError.new("Association #{model_association_name} not found in #{model_class.name} model")
97
+ end
98
+
99
+ viewmodel_spec = viewmodel || viewmodels
100
+
101
+ association_data = AssociationData.new(vm_association_name, reflection, viewmodel_spec, shared, optional, through_to, through_order_attr)
102
+
103
+ _members[vm_association_name] = association_data
104
+
105
+ @generated_accessor_module.module_eval do
106
+ define_method vm_association_name do
107
+ _read_association(vm_association_name)
108
+ end
109
+
110
+ define_method :"serialize_#{vm_association_name}" do |json, serialize_context: self.class.new_serialize_context|
111
+ associated = self.public_send(vm_association_name)
112
+ json.set! vm_association_name do
113
+ case
114
+ when associated.nil?
115
+ json.null!
116
+ when association_data.through?
117
+ json.array!(associated) do |through_target|
118
+ self.class.serialize_as_reference(through_target, json, serialize_context: serialize_context)
119
+ end
120
+ when shared
121
+ self.class.serialize_as_reference(associated, json, serialize_context: serialize_context)
122
+ else
123
+ self.class.serialize(associated, json, serialize_context: serialize_context)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ # Specify multiple associations at once
131
+ def associations(*assocs, **args)
132
+ assocs.each { |assoc| association(assoc, **args) }
133
+ end
134
+
135
+ ## Load instances of the viewmodel by id(s)
136
+ def find(id_or_ids, scope: nil, lock: nil, eager_include: true, serialize_context: new_serialize_context)
137
+ find_scope = self.model_class.all
138
+ find_scope = find_scope.order(:id).lock(lock) if lock
139
+ find_scope = find_scope.merge(scope) if scope
140
+
141
+ ViewModel::Utils.wrap_one_or_many(id_or_ids) do |ids|
142
+ models = find_scope.where(id: ids).to_a
143
+
144
+ if models.size < ids.size
145
+ missing_ids = ids - models.map(&:id)
146
+ if missing_ids.present?
147
+ raise ViewModel::DeserializationError::NotFound.new(
148
+ missing_ids.map { |id| ViewModel::Reference.new(self, id) })
149
+ end
150
+ end
151
+
152
+ vms = models.map { |m| self.new(m) }
153
+ ViewModel.preload_for_serialization(vms, lock: lock, serialize_context: serialize_context) if eager_include
154
+ vms
155
+ end
156
+ end
157
+
158
+ ## Load instances of the viewmodel by scope
159
+ ## TODO: is this too much of a encapsulation violation?
160
+ def load(scope: nil, eager_include: true, lock: nil, serialize_context: new_serialize_context)
161
+ load_scope = self.model_class.all
162
+ load_scope = load_scope.lock(lock) if lock
163
+ load_scope = load_scope.merge(scope) if scope
164
+ vms = load_scope.map { |model| self.new(model) }
165
+ ViewModel.preload_for_serialization(vms, lock: lock, serialize_context: serialize_context) if eager_include
166
+ vms
167
+ end
168
+
169
+ def deserialize_from_view(subtree_hash_or_hashes, references: {}, deserialize_context: new_deserialize_context)
170
+ model_class.transaction do
171
+ ViewModel::Utils.wrap_one_or_many(subtree_hash_or_hashes) do |subtree_hashes|
172
+ root_update_data, referenced_update_data = UpdateData.parse_hashes(subtree_hashes, references)
173
+
174
+ # Provide information about what was updated
175
+ deserialize_context.updated_associations = root_update_data
176
+ .map { |upd| upd.updated_associations }
177
+ .inject({}) { |acc, assocs| acc.deep_merge(assocs) }
178
+
179
+ _updated_viewmodels =
180
+ UpdateContext
181
+ .build!(root_update_data, referenced_update_data, root_type: self)
182
+ .run!(deserialize_context: deserialize_context)
183
+ end
184
+ end
185
+ end
186
+
187
+ def eager_includes(serialize_context: new_serialize_context, include_shared: true)
188
+ # When serializing, we need to (recursively) include all intrinsic
189
+ # associations and also those optional (incl. shared) associations
190
+ # specified in the serialize_context.
191
+
192
+ # when deserializing, we start with intrinsic non-shared associations. We
193
+ # then traverse the structure of the tree to deserialize to map out which
194
+ # optional or shared associations are used from each type. We then explore
195
+ # from the root type to build an preload specification that will include
196
+ # them all. (We can subsequently use this same structure to build a
197
+ # serialization context featuring the same associations.)
198
+
199
+ association_specs = {}
200
+ _members.each do |assoc_name, association_data|
201
+ next unless association_data.is_a?(AssociationData)
202
+ next unless serialize_context.includes_member?(assoc_name, !association_data.optional?)
203
+
204
+ child_context =
205
+ if self.synthetic
206
+ serialize_context
207
+ elsif association_data.shared?
208
+ serialize_context.for_references
209
+ else
210
+ serialize_context.for_child(nil, association_name: assoc_name)
211
+ end
212
+
213
+ case
214
+ when association_data.through?
215
+ viewmodel = association_data.direct_viewmodel
216
+ children = viewmodel.eager_includes(serialize_context: child_context, include_shared: include_shared)
217
+
218
+ when !include_shared && association_data.shared?
219
+ children = nil # Load up to the shared model, but no further
220
+
221
+ when association_data.polymorphic?
222
+ children_by_klass = {}
223
+ association_data.viewmodel_classes.each do |vm_class|
224
+ klass = vm_class.model_class.name
225
+ children_by_klass[klass] = vm_class.eager_includes(serialize_context: child_context, include_shared: include_shared)
226
+ end
227
+ children = DeepPreloader::PolymorphicSpec.new(children_by_klass)
228
+
229
+ else
230
+ viewmodel = association_data.viewmodel_class
231
+ children = viewmodel.eager_includes(serialize_context: child_context, include_shared: include_shared)
232
+ end
233
+
234
+ association_specs[association_data.direct_reflection.name.to_s] = children
235
+ end
236
+ DeepPreloader::Spec.new(association_specs)
237
+ end
238
+
239
+ def dependent_viewmodels(seen = Set.new, include_shared: true)
240
+ return if seen.include?(self)
241
+
242
+ seen << self
243
+
244
+ _members.each_value do |data|
245
+ next unless data.is_a?(AssociationData)
246
+ next unless include_shared || !data.shared?
247
+ data.viewmodel_classes.each do |vm|
248
+ vm.dependent_viewmodels(seen, include_shared: include_shared)
249
+ end
250
+ end
251
+
252
+ seen
253
+ end
254
+
255
+ def deep_schema_version(include_shared: true)
256
+ (@deep_schema_version ||= {})[include_shared] ||=
257
+ begin
258
+ dependent_viewmodels(include_shared: include_shared).each_with_object({}) do |view, h|
259
+ h[view.view_name] = view.schema_version
260
+ end
261
+ end
262
+ end
263
+
264
+ def cacheable!(**opts)
265
+ include ViewModel::ActiveRecord::Cache::CacheableView
266
+ create_viewmodel_cache!(**opts)
267
+ end
268
+
269
+ # internal
270
+ def _association_data(association_name)
271
+ association_data = self._members[association_name.to_s]
272
+ raise ArgumentError.new("Invalid association '#{association_name}'") unless association_data.is_a?(AssociationData)
273
+ association_data
274
+ end
275
+ end
276
+
277
+ def initialize(*)
278
+ super
279
+ model_is_new! if model.new_record?
280
+ @changed_associations = []
281
+ end
282
+
283
+ def serialize_members(json, serialize_context: self.class.new_serialize_context)
284
+ self.class._members.each do |member_name, member_data|
285
+ next unless serialize_context.includes_member?(member_name, !member_data.optional?)
286
+
287
+ member_context =
288
+ case member_data
289
+ when AssociationData
290
+ self.context_for_child(member_name, context: serialize_context)
291
+ else
292
+ serialize_context
293
+ end
294
+
295
+ self.public_send("serialize_#{member_name}", json, serialize_context: member_context)
296
+ end
297
+ end
298
+
299
+ def destroy!(deserialize_context: self.class.new_deserialize_context)
300
+ model_class.transaction do
301
+ ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control|
302
+ changes = ViewModel::Changes.new(deleted: true)
303
+ deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: changes)
304
+ hook_control.record_changes(changes)
305
+ model.destroy!
306
+ end
307
+ end
308
+ end
309
+
310
+ def association_changed!(association_name)
311
+ association_name = association_name.to_s
312
+ unless @changed_associations.include?(association_name)
313
+ @changed_associations << association_name
314
+ end
315
+ end
316
+
317
+ def associations_changed?
318
+ @changed_associations.present?
319
+ end
320
+
321
+ # Additionally pass `changed_associations` while constructing changes.
322
+ def changes
323
+ ViewModel::Changes.new(
324
+ new: new_model?,
325
+ changed_attributes: changed_attributes,
326
+ changed_associations: changed_associations,
327
+ changed_children: changed_children?)
328
+ end
329
+
330
+ def clear_changes!
331
+ super.tap do
332
+ @changed_associations = []
333
+ end
334
+ end
335
+
336
+ def _read_association(association_name)
337
+ association_data = self.class._association_data(association_name)
338
+
339
+ associated = model.public_send(association_data.direct_reflection.name)
340
+ return nil if associated.nil?
341
+
342
+ case
343
+ when association_data.through?
344
+ # associated here are join_table models; we need to get the far side out
345
+ if association_data.direct_viewmodel._list_member?
346
+ associated.order(association_data.direct_viewmodel._list_attribute_name)
347
+ end
348
+
349
+ associated.map do |through_model|
350
+ model = through_model.public_send(association_data.indirect_reflection.name)
351
+ association_data.viewmodel_class_for_model!(model.class).new(model)
352
+ end
353
+
354
+ when association_data.collection?
355
+ associated_viewmodel_class = association_data.viewmodel_class
356
+ associated_viewmodels = associated.map { |x| associated_viewmodel_class.new(x) }
357
+ if associated_viewmodel_class._list_member?
358
+ associated_viewmodels.sort_by!(&:_list_attribute)
359
+ end
360
+ associated_viewmodels
361
+
362
+ else
363
+ associated_viewmodel_class = association_data.viewmodel_class_for_model!(associated.class)
364
+ associated_viewmodel_class.new(associated)
365
+ end
366
+ end
367
+
368
+ def context_for_child(member_name, context:)
369
+ # Synthetic viewmodels don't exist as far as the traversal context is
370
+ # concerned: pass through the child context received from the parent
371
+ return context if self.class.synthetic
372
+
373
+ # Shared associations start a new tree
374
+ member_data = self.class._members[member_name.to_s]
375
+ if member_data.is_a?(AssociationData) && member_data.shared?
376
+ return context.for_references
377
+ end
378
+
379
+ super
380
+ end
381
+
382
+ self.abstract_class = true
383
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ # TODO consider rephrase scope for consistency
4
+ class ViewModel::ActiveRecord::AssociationData
5
+ attr_reader :direct_reflection, :association_name
6
+
7
+ def initialize(association_name, direct_reflection, viewmodel_classes, shared, optional, through_to, through_order_attr)
8
+ @association_name = association_name
9
+ @direct_reflection = direct_reflection
10
+ @shared = shared
11
+ @optional = optional
12
+ @through_to = through_to
13
+ @through_order_attr = through_order_attr
14
+
15
+ if viewmodel_classes
16
+ @viewmodel_classes = Array.wrap(viewmodel_classes).map! do |v|
17
+ case v
18
+ when String, Symbol
19
+ ViewModel::Registry.for_view_name(v.to_s)
20
+ when Class
21
+ v
22
+ else
23
+ raise ArgumentError.new("Invalid viewmodel class: #{v.inspect}")
24
+ end
25
+ end
26
+ end
27
+
28
+ if through?
29
+ # Through associations must always be an owned direct association to a
30
+ # shared indirect target. We expect the user to set shared: true to
31
+ # express the ownership of the indirect target, but this direct
32
+ # association to the intermediate is in fact owned. This ownership
33
+ # property isn't directly used anywhere: the synthetic intermediate
34
+ # viewmodel is only used in the deserialization update operations, which
35
+ # directly understands the semantics of through associations.
36
+ raise ArgumentError.new("Through associations must be to a shared target") unless @shared
37
+ raise ArgumentError.new("Through associations must be `has_many`") unless direct_reflection.macro == :has_many
38
+ end
39
+ end
40
+
41
+ # reflection for the target of this association: indirect if through, direct otherwise
42
+ def target_reflection
43
+ if through?
44
+ indirect_reflection
45
+ else
46
+ direct_reflection
47
+ end
48
+ end
49
+
50
+ def polymorphic?
51
+ target_reflection.polymorphic?
52
+ end
53
+
54
+ def viewmodel_classes
55
+ # If we weren't given explicit viewmodel classes, try to work out from the
56
+ # names. This should work unless the association is polymorphic.
57
+ @viewmodel_classes ||=
58
+ begin
59
+ model_class = target_reflection.klass
60
+ if model_class.nil?
61
+ raise "Couldn't derive target class for association '#{target_reflection.name}"
62
+ end
63
+ inferred_view_name = ViewModel::Registry.default_view_name(model_class.name)
64
+ viewmodel_class = ViewModel::Registry.for_view_name(inferred_view_name) # TODO: improve error message to show it's looking for default name
65
+ [viewmodel_class]
66
+ end
67
+ end
68
+
69
+ private def model_to_viewmodel
70
+ @model_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h|
71
+ h[vm.model_class] = vm
72
+ end
73
+ end
74
+
75
+ private def name_to_viewmodel
76
+ @name_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h|
77
+ h[vm.view_name] = vm
78
+ vm.view_aliases.each do |view_alias|
79
+ h[view_alias] = vm
80
+ end
81
+ end
82
+ end
83
+
84
+ def shared?
85
+ @shared
86
+ end
87
+
88
+ def optional?
89
+ @optional
90
+ end
91
+
92
+ def pointer_location # TODO name
93
+ case direct_reflection.macro
94
+ when :belongs_to
95
+ :local
96
+ when :has_one, :has_many
97
+ :remote
98
+ end
99
+ end
100
+
101
+ def viewmodel_class_for_model(model_class)
102
+ model_to_viewmodel[model_class]
103
+ end
104
+
105
+ def viewmodel_class_for_model!(model_class)
106
+ vm_class = viewmodel_class_for_model(model_class)
107
+ if vm_class.nil?
108
+ raise ArgumentError.new(
109
+ "Invalid viewmodel model for association '#{target_reflection.name}': '#{model_class.name}'")
110
+ end
111
+ vm_class
112
+ end
113
+
114
+ def viewmodel_class_for_name(name)
115
+ name_to_viewmodel[name]
116
+ end
117
+
118
+ def viewmodel_class_for_name!(name)
119
+ vm_class = viewmodel_class_for_name(name)
120
+ if vm_class.nil?
121
+ raise ArgumentError.new(
122
+ "Invalid viewmodel name for association '#{target_reflection.name}': '#{name}'")
123
+ end
124
+ vm_class
125
+ end
126
+
127
+ def accepts?(viewmodel_class)
128
+ viewmodel_classes.include?(viewmodel_class)
129
+ end
130
+
131
+ def viewmodel_class
132
+ unless viewmodel_classes.size == 1
133
+ raise ArgumentError.new("More than one possible class for association '#{target_reflection.name}'")
134
+ end
135
+ viewmodel_classes.first
136
+ end
137
+
138
+ def through?
139
+ @through_to.present?
140
+ end
141
+
142
+ def direct_viewmodel
143
+ @direct_viewmodel ||= begin
144
+ raise 'not a through association' unless through?
145
+
146
+ # Join table viewmodel class
147
+
148
+ # For A has_many B through T; where this association is defined on A
149
+
150
+ # Copy into scope for new class block
151
+ direct_reflection = self.direct_reflection # A -> T
152
+ indirect_reflection = self.indirect_reflection # T -> B
153
+ through_order_attr = @through_order_attr
154
+ viewmodel_classes = self.viewmodel_classes
155
+
156
+ Class.new(ViewModel::ActiveRecord) do
157
+ self.synthetic = true
158
+ self.model_class = direct_reflection.klass
159
+ self.view_name = direct_reflection.klass.name
160
+ association indirect_reflection.name, shared: true, optional: false, viewmodels: viewmodel_classes
161
+ acts_as_list through_order_attr if through_order_attr
162
+ end
163
+ end
164
+ end
165
+
166
+ def indirect_reflection
167
+ @indirect_reflection ||=
168
+ direct_reflection.klass.reflect_on_association(ActiveSupport::Inflector.singularize(@through_to))
169
+ end
170
+
171
+ def collection?
172
+ through? || direct_reflection.collection?
173
+ end
174
+
175
+ def indirect_association_data
176
+ direct_viewmodel._association_data(indirect_reflection.name)
177
+ end
178
+ end