iknow_view_models 2.8.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci/config.yml +115 -0
- data/.gitignore +36 -0
- data/.travis.yml +31 -0
- data/Appraisals +9 -0
- data/Gemfile +19 -0
- data/LICENSE.txt +22 -0
- data/README.md +19 -0
- data/Rakefile +21 -0
- data/appveyor.yml +22 -0
- data/gemfiles/rails_5_2.gemfile +15 -0
- data/gemfiles/rails_6_0_beta.gemfile +15 -0
- data/iknow_view_models.gemspec +49 -0
- data/lib/iknow_view_models.rb +12 -0
- data/lib/iknow_view_models/railtie.rb +8 -0
- data/lib/iknow_view_models/version.rb +5 -0
- data/lib/view_model.rb +333 -0
- data/lib/view_model/access_control.rb +154 -0
- data/lib/view_model/access_control/composed.rb +216 -0
- data/lib/view_model/access_control/open.rb +13 -0
- data/lib/view_model/access_control/read_only.rb +13 -0
- data/lib/view_model/access_control/tree.rb +264 -0
- data/lib/view_model/access_control_error.rb +10 -0
- data/lib/view_model/active_record.rb +383 -0
- data/lib/view_model/active_record/association_data.rb +178 -0
- data/lib/view_model/active_record/association_manipulation.rb +389 -0
- data/lib/view_model/active_record/cache.rb +265 -0
- data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
- data/lib/view_model/active_record/cloner.rb +113 -0
- data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
- data/lib/view_model/active_record/controller.rb +77 -0
- data/lib/view_model/active_record/controller_base.rb +185 -0
- data/lib/view_model/active_record/nested_controller_base.rb +93 -0
- data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
- data/lib/view_model/active_record/update_context.rb +252 -0
- data/lib/view_model/active_record/update_data.rb +749 -0
- data/lib/view_model/active_record/update_operation.rb +810 -0
- data/lib/view_model/active_record/visitor.rb +77 -0
- data/lib/view_model/after_transaction_runner.rb +29 -0
- data/lib/view_model/callbacks.rb +219 -0
- data/lib/view_model/changes.rb +62 -0
- data/lib/view_model/config.rb +29 -0
- data/lib/view_model/controller.rb +142 -0
- data/lib/view_model/deserialization_error.rb +437 -0
- data/lib/view_model/deserialize_context.rb +16 -0
- data/lib/view_model/error.rb +191 -0
- data/lib/view_model/error_view.rb +35 -0
- data/lib/view_model/record.rb +367 -0
- data/lib/view_model/record/attribute_data.rb +48 -0
- data/lib/view_model/reference.rb +31 -0
- data/lib/view_model/references.rb +48 -0
- data/lib/view_model/registry.rb +73 -0
- data/lib/view_model/schemas.rb +45 -0
- data/lib/view_model/serialization_error.rb +10 -0
- data/lib/view_model/serialize_context.rb +118 -0
- data/lib/view_model/test_helpers.rb +103 -0
- data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
- data/lib/view_model/traversal_context.rb +126 -0
- data/lib/view_model/utils.rb +24 -0
- data/lib/view_model/utils/collections.rb +49 -0
- data/test/helpers/arvm_test_models.rb +59 -0
- data/test/helpers/arvm_test_utilities.rb +187 -0
- data/test/helpers/callback_tracer.rb +27 -0
- data/test/helpers/controller_test_helpers.rb +270 -0
- data/test/helpers/match_enumerator.rb +58 -0
- data/test/helpers/query_logging.rb +71 -0
- data/test/helpers/test_access_control.rb +56 -0
- data/test/helpers/viewmodel_spec_helpers.rb +326 -0
- data/test/unit/view_model/access_control_test.rb +769 -0
- data/test/unit/view_model/active_record/alias_test.rb +35 -0
- data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
- data/test/unit/view_model/active_record/cache_test.rb +351 -0
- data/test/unit/view_model/active_record/cloner_test.rb +313 -0
- data/test/unit/view_model/active_record/controller_test.rb +561 -0
- data/test/unit/view_model/active_record/counter_test.rb +80 -0
- data/test/unit/view_model/active_record/customization_test.rb +388 -0
- data/test/unit/view_model/active_record/has_many_test.rb +957 -0
- data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
- data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
- data/test/unit/view_model/active_record/has_one_test.rb +334 -0
- data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
- data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
- data/test/unit/view_model/active_record/poly_test.rb +320 -0
- data/test/unit/view_model/active_record/shared_test.rb +285 -0
- data/test/unit/view_model/active_record/version_test.rb +121 -0
- data/test/unit/view_model/active_record_test.rb +542 -0
- data/test/unit/view_model/callbacks_test.rb +582 -0
- data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
- data/test/unit/view_model/record_test.rb +524 -0
- data/test/unit/view_model/traversal_context_test.rb +371 -0
- data/test/unit/view_model_test.rb +62 -0
- metadata +490 -0
@@ -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
|