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