iknow_view_models 2.8.4

Sign up to get free protection for your applications and to get access to all the features.
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