iknow_view_models 2.9.0 → 3.0.0

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/iknow_view_models.gemspec +1 -1
  3. data/lib/iknow_view_models/version.rb +1 -1
  4. data/lib/view_model/active_record/association_data.rb +206 -92
  5. data/lib/view_model/active_record/association_manipulation.rb +22 -12
  6. data/lib/view_model/active_record/cache/cacheable_view.rb +3 -13
  7. data/lib/view_model/active_record/cache.rb +2 -2
  8. data/lib/view_model/active_record/cloner.rb +11 -11
  9. data/lib/view_model/active_record/controller.rb +0 -2
  10. data/lib/view_model/active_record/update_context.rb +21 -3
  11. data/lib/view_model/active_record/update_data.rb +43 -45
  12. data/lib/view_model/active_record/update_operation.rb +265 -153
  13. data/lib/view_model/active_record/visitor.rb +9 -6
  14. data/lib/view_model/active_record.rb +94 -74
  15. data/lib/view_model/after_transaction_runner.rb +3 -18
  16. data/lib/view_model/changes.rb +24 -16
  17. data/lib/view_model/config.rb +6 -2
  18. data/lib/view_model/deserialization_error.rb +31 -0
  19. data/lib/view_model/deserialize_context.rb +2 -6
  20. data/lib/view_model/error_view.rb +6 -5
  21. data/lib/view_model/record/attribute_data.rb +11 -6
  22. data/lib/view_model/record.rb +44 -24
  23. data/lib/view_model/serialize_context.rb +2 -63
  24. data/lib/view_model.rb +17 -8
  25. data/shell.nix +1 -1
  26. data/test/helpers/arvm_test_utilities.rb +6 -0
  27. data/test/helpers/controller_test_helpers.rb +5 -3
  28. data/test/helpers/viewmodel_spec_helpers.rb +63 -52
  29. data/test/unit/view_model/access_control_test.rb +88 -37
  30. data/test/unit/view_model/active_record/belongs_to_test.rb +110 -178
  31. data/test/unit/view_model/active_record/cache_test.rb +3 -2
  32. data/test/unit/view_model/active_record/cloner_test.rb +1 -1
  33. data/test/unit/view_model/active_record/controller_test.rb +12 -20
  34. data/test/unit/view_model/active_record/has_many_test.rb +540 -316
  35. data/test/unit/view_model/active_record/has_many_through_poly_test.rb +12 -15
  36. data/test/unit/view_model/active_record/has_many_through_test.rb +15 -58
  37. data/test/unit/view_model/active_record/has_one_test.rb +288 -135
  38. data/test/unit/view_model/active_record/poly_test.rb +0 -1
  39. data/test/unit/view_model/active_record/shared_test.rb +21 -39
  40. data/test/unit/view_model/active_record/version_test.rb +3 -2
  41. data/test/unit/view_model/active_record_test.rb +5 -63
  42. data/test/unit/view_model/callbacks_test.rb +1 -0
  43. data/test/unit/view_model/record_test.rb +0 -32
  44. data/test/unit/view_model/traversal_context_test.rb +13 -12
  45. metadata +5 -8
  46. data/test/unit/view_model/active_record/optional_attribute_view_test.rb +0 -58
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53525088a6e3b8f394861ea5326ee235d94eb69a7b49b346ca09f26894e62f38
4
- data.tar.gz: e394ad428ee6131d5dcdf7107ca5fea22d23eb41ab6bc6c5ba96626343180f89
3
+ metadata.gz: a86c98f9ceda1cb4bf6980a84eb2051b5bc3904c08ab85b8546c4b16c78d26ff
4
+ data.tar.gz: 3b988a29d63e06d929c3fa0ca31b7b11f585e03263653c8f5bea64f2583c2fa3
5
5
  SHA512:
6
- metadata.gz: 342baae02e08f68f1f4b624c153bb9fd00144f21aef38c5623144899a4554730fd98651411e26528e05f40b16fa51136235ae1831ebc25bb4860904de465aef8
7
- data.tar.gz: af565e5b9374ce950178437a16def8cda1b50639d2e98e5c367be5af0cacaff85ddcb7f0fc57a1a4309466e3f682760ae8a213f0ac9ed740eeed4bd9b975736e
6
+ metadata.gz: f281b79eda3e83689d5f2aec0239f89d73a4c1ad0778ee017c20af65088c50dc7356d2149bce7b487766a68947c85645cddd0f6e92f0312a7c2cad1489ccb6f0
7
+ data.tar.gz: a3f4960911039bc0679c068cc67c86bee0cdad278ab7acfbea66e36cdf8e064e45eed8ea709ecd3b75ddc42a7934645f93ad9699be5c40a0595a3d2b5db10dc6
@@ -24,7 +24,7 @@ Gem::Specification.new do |spec|
24
24
  spec.add_dependency "activesupport", ">= 5.0"
25
25
 
26
26
  spec.add_dependency "acts_as_manual_list"
27
- spec.add_dependency "deep_preloader"
27
+ spec.add_dependency "deep_preloader", ">= 1.0.1"
28
28
  spec.add_dependency "iknow_cache"
29
29
  spec.add_dependency "iknow_params", "~> 2.2.0"
30
30
  spec.add_dependency "safe_values"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IknowViewModels
4
- VERSION = '2.9.0'
4
+ VERSION = '3.0.0'
5
5
  end
@@ -1,95 +1,137 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # TODO consider rephrase scope for consistency
4
3
  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
4
+ class InvalidAssociation < RuntimeError; end
5
+
6
+ attr_reader :association_name, :direct_reflection
7
+
8
+ def initialize(owner:,
9
+ association_name:,
10
+ direct_association_name:,
11
+ indirect_association_name:,
12
+ target_viewmodels:,
13
+ external:,
14
+ through_order_attr:,
15
+ read_only:)
16
+ @association_name = association_name
17
+
18
+ @direct_reflection = owner.model_class.reflect_on_association(direct_association_name)
19
+ if @direct_reflection.nil?
20
+ raise InvalidAssociation.new("Association '#{direct_association_name}' not found in model '#{model_class.name}'")
21
+ end
22
+
23
+ @indirect_association_name = indirect_association_name
24
+
25
+ @read_only = read_only
26
+ @external = external
27
+ @through_order_attr = through_order_attr
28
+ @target_viewmodels = target_viewmodels
29
+
30
+ # Target models/reflections/viewmodels are lazily evaluated so that we can
31
+ # safely express cycles.
32
+ @initialized = false
33
+ @mutex = Mutex.new
34
+ end
35
+
36
+ def lazy_initialize!
37
+ @mutex.synchronize do
38
+ return if @initialized
39
+
40
+ if through?
41
+ intermediate_model = @direct_reflection.klass
42
+ @indirect_reflection = load_indirect_reflection(intermediate_model, @indirect_association_name)
43
+ target_reflection = @indirect_reflection
44
+ else
45
+ target_reflection = @direct_reflection
46
+ end
47
+
48
+ @viewmodel_classes =
49
+ if @target_viewmodels.present?
50
+ # Explicitly named
51
+ @target_viewmodels.map { |v| resolve_viewmodel_class(v) }
22
52
  else
23
- raise ArgumentError.new("Invalid viewmodel class: #{v.inspect}")
53
+ # Infer name from name of model
54
+ if target_reflection.polymorphic?
55
+ raise InvalidAssociation.new(
56
+ 'Cannot automatically infer target viewmodels from polymorphic association')
57
+ end
58
+ infer_viewmodel_class(target_reflection.klass)
24
59
  end
60
+
61
+ @referenced = @viewmodel_classes.first.root?
62
+
63
+ # Non-referenced viewmodels must be owned. For referenced viewmodels, we
64
+ # own it if it points to us. Through associations aren't considered
65
+ # `owned?`: while we do own the implicit direct viewmodel, we don't own
66
+ # the target of the association.
67
+ @owned = !@referenced || (target_reflection.macro != :belongs_to)
68
+
69
+ unless @viewmodel_classes.all? { |v| v.root? == @referenced }
70
+ raise InvalidAssociation.new('Invalid association target: mixed root and non-root viewmodels')
25
71
  end
26
- end
27
72
 
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
73
+ if external? && !@referenced
74
+ raise InvalidAssociation.new('External associations must be to root viewmodels')
75
+ end
40
76
 
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
77
+ if through?
78
+ unless @referenced
79
+ raise InvalidAssociation.new('Through associations must be to root viewmodels')
80
+ end
81
+
82
+ @direct_viewmodel = build_direct_viewmodel(@direct_reflection, @indirect_reflection,
83
+ @viewmodel_classes, @through_order_attr)
84
+ end
85
+
86
+ @initialized = true
47
87
  end
48
88
  end
49
89
 
50
- def polymorphic?
51
- target_reflection.polymorphic?
90
+ def association?
91
+ true
52
92
  end
53
93
 
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
94
+ def referenced?
95
+ lazy_initialize! unless @initialized
96
+ @referenced
67
97
  end
68
98
 
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
99
+ def nested?
100
+ !referenced?
73
101
  end
74
102
 
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
103
+ def owned?
104
+ lazy_initialize! unless @initialized
105
+ @owned
82
106
  end
83
107
 
84
108
  def shared?
85
- @shared
109
+ !owned?
110
+ end
111
+
112
+ def external?
113
+ @external
114
+ end
115
+
116
+ def read_only?
117
+ @read_only
118
+ end
119
+
120
+ # reflection for the target of this association: indirect if through, direct otherwise
121
+ def target_reflection
122
+ if through?
123
+ indirect_reflection
124
+ else
125
+ direct_reflection
126
+ end
86
127
  end
87
128
 
88
- def optional?
89
- @optional
129
+ def polymorphic?
130
+ target_reflection.polymorphic?
90
131
  end
91
132
 
92
- def pointer_location # TODO name
133
+ # The side of the immediate association that holds the pointer.
134
+ def pointer_location
93
135
  case direct_reflection.macro
94
136
  when :belongs_to
95
137
  :local
@@ -98,6 +140,24 @@ class ViewModel::ActiveRecord::AssociationData
98
140
  end
99
141
  end
100
142
 
143
+ def indirect_reflection
144
+ lazy_initialize! unless @initialized
145
+ @indirect_reflection
146
+ end
147
+
148
+ def direct_reflection_inverse(foreign_class = nil)
149
+ if direct_reflection.polymorphic?
150
+ direct_reflection.polymorphic_inverse_of(foreign_class)
151
+ else
152
+ direct_reflection.inverse_of
153
+ end
154
+ end
155
+
156
+ def viewmodel_classes
157
+ lazy_initialize! unless @initialized
158
+ @viewmodel_classes
159
+ end
160
+
101
161
  def viewmodel_class_for_model(model_class)
102
162
  model_to_viewmodel[model_class]
103
163
  end
@@ -132,47 +192,101 @@ class ViewModel::ActiveRecord::AssociationData
132
192
  unless viewmodel_classes.size == 1
133
193
  raise ArgumentError.new("More than one possible class for association '#{target_reflection.name}'")
134
194
  end
195
+
135
196
  viewmodel_classes.first
136
197
  end
137
198
 
138
199
  def through?
139
- @through_to.present?
200
+ @indirect_association_name.present?
140
201
  end
141
202
 
142
203
  def direct_viewmodel
143
- @direct_viewmodel ||= begin
144
- raise 'not a through association' unless through?
204
+ raise ArgumentError.new('not a through association') unless through?
205
+ lazy_initialize! unless @initialized
206
+ @direct_viewmodel
207
+ end
208
+
209
+ def collection?
210
+ through? || direct_reflection.collection?
211
+ end
145
212
 
146
- # Join table viewmodel class
213
+ def indirect_association_data
214
+ direct_viewmodel._association_data(indirect_reflection.name)
215
+ end
147
216
 
148
- # For A has_many B through T; where this association is defined on A
217
+ private
149
218
 
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
219
+ # Through associations must always be to a root viewmodel, via an owned
220
+ # has_many association to an intermediate model. A synthetic viewmodel is
221
+ # created to represent this intermediate, but is used only internally by the
222
+ # deserialization update operations, which directly understands the semantics
223
+ # of through associations.
224
+ def load_indirect_reflection(intermediate_model, indirect_association_name)
225
+ indirect_reflection =
226
+ intermediate_model.reflect_on_association(ActiveSupport::Inflector.singularize(indirect_association_name))
155
227
 
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
228
+ if indirect_reflection.nil?
229
+ raise InvalidAssociation.new(
230
+ "Indirect association '#{@indirect_association_name}' not found in "\
231
+ "intermediate model '#{intermediate_model.name}'")
232
+ end
233
+
234
+ unless direct_reflection.macro == :has_many
235
+ raise InvalidAssociation.new('Through associations must be `has_many`')
163
236
  end
237
+
238
+ indirect_reflection
164
239
  end
165
240
 
166
- def indirect_reflection
167
- @indirect_reflection ||=
168
- direct_reflection.klass.reflect_on_association(ActiveSupport::Inflector.singularize(@through_to))
241
+ def build_direct_viewmodel(direct_reflection, indirect_reflection, viewmodel_classes, through_order_attr)
242
+ # Join table viewmodel class. For A has_many B through T; where this association is defined on A
243
+ # direct_reflection = A -> T
244
+ # indirect_reflection = T -> B
245
+
246
+ Class.new(ViewModel::ActiveRecord) do
247
+ self.synthetic = true
248
+ self.model_class = direct_reflection.klass
249
+ self.view_name = direct_reflection.klass.name
250
+ association indirect_reflection.name, viewmodels: viewmodel_classes
251
+ acts_as_list through_order_attr if through_order_attr
252
+ end
169
253
  end
170
254
 
171
- def collection?
172
- through? || direct_reflection.collection?
255
+ def resolve_viewmodel_class(v)
256
+ case v
257
+ when String, Symbol
258
+ ViewModel::Registry.for_view_name(v.to_s)
259
+ when Class
260
+ v
261
+ else
262
+ raise InvalidAssociation.new("Invalid viewmodel class: #{v.inspect}")
263
+ end
173
264
  end
174
265
 
175
- def indirect_association_data
176
- direct_viewmodel._association_data(indirect_reflection.name)
266
+ def infer_viewmodel_class(model_class)
267
+ # If we weren't given explicit viewmodel classes, try to work out from the
268
+ # names. This should work unless the association is polymorphic.
269
+ if model_class.nil?
270
+ raise InvalidAssociation.new("Couldn't derive target class for model association '#{target_reflection.name}'")
271
+ end
272
+
273
+ inferred_view_name = ViewModel::Registry.default_view_name(model_class.name)
274
+ viewmodel_class = ViewModel::Registry.for_view_name(inferred_view_name) # TODO: improve error message to show it's looking for default name
275
+ [viewmodel_class]
276
+ end
277
+
278
+ def model_to_viewmodel
279
+ @model_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h|
280
+ h[vm.model_class] = vm
281
+ end
282
+ end
283
+
284
+ def name_to_viewmodel
285
+ @name_to_viewmodel ||= viewmodel_classes.each_with_object({}) do |vm, h|
286
+ h[vm.view_name] = vm
287
+ vm.view_aliases.each do |view_alias|
288
+ h[view_alias] = vm
289
+ end
290
+ end
177
291
  end
178
292
  end
@@ -52,9 +52,7 @@ module ViewModel::ActiveRecord::AssociationManipulation
52
52
  def replace_associated(association_name, update_hash, references: {}, deserialize_context: self.class.new_deserialize_context)
53
53
  association_data = self.class._association_data(association_name)
54
54
 
55
- # TODO: structure checking
56
-
57
- if association_data.through? || association_data.shared?
55
+ if association_data.referenced?
58
56
  is_fupdate =
59
57
  association_data.collection? &&
60
58
  update_hash.is_a?(Hash) &&
@@ -125,10 +123,6 @@ module ViewModel::ActiveRecord::AssociationManipulation
125
123
 
126
124
  update_context = ViewModel::ActiveRecord::UpdateContext.build!(root_update_data, referenced_update_data, root_type: direct_viewmodel_class)
127
125
 
128
- # Provide information about what was updated
129
- deserialize_context.updated_associations = root_update_data.map(&:updated_associations)
130
- .inject({}) { |acc, assocs| acc.deep_merge(assocs) }
131
-
132
126
  # Set new parent
133
127
  new_parent = ViewModel::ActiveRecord::UpdateOperation::ParentData.new(direct_reflection.inverse_of, self)
134
128
  update_context.root_updates.each { |update| update.reparent_to = new_parent }
@@ -177,15 +171,26 @@ module ViewModel::ActiveRecord::AssociationManipulation
177
171
  child_context = self.context_for_child(association_name, context: deserialize_context)
178
172
  updated_viewmodels = update_context.run!(deserialize_context: child_context)
179
173
 
174
+ # Propagate changes and finalize the parent
175
+ updated_viewmodels.each do |child|
176
+ child_changes = child.previous_changes
177
+
178
+ if association_data.nested?
179
+ nested_children_changed! if child_changes.changed_nested_tree?
180
+ referenced_children_changed! if child_changes.changed_referenced_children?
181
+ elsif association_data.owned?
182
+ referenced_children_changed! if child_changes.changed_owned_tree?
183
+ end
184
+ end
185
+
186
+ final_changes = self.clear_changes!
187
+
180
188
  if association_data.through?
181
189
  updated_viewmodels.map! do |direct_vm|
182
190
  direct_vm._read_association(association_data.indirect_reflection.name)
183
191
  end
184
192
  end
185
193
 
186
- # Finalize the parent
187
- final_changes = self.clear_changes!
188
-
189
194
  # Could happen if hooks attempted to change the parent, which aren't
190
195
  # valid since we're only editing children here.
191
196
  unless final_changes.contained_to?(associations: [association_name.to_s])
@@ -269,7 +274,12 @@ module ViewModel::ActiveRecord::AssociationManipulation
269
274
  association.delete(child_vm.model)
270
275
  end
271
276
 
272
- self.children_changed!
277
+ if association_data.nested?
278
+ nested_children_changed!
279
+ elsif association_data.owned?
280
+ referenced_children_changed!
281
+ end
282
+
273
283
  final_changes = self.clear_changes!
274
284
 
275
285
  unless final_changes.contained_to?(associations: [association_name.to_s])
@@ -286,7 +296,7 @@ module ViewModel::ActiveRecord::AssociationManipulation
286
296
 
287
297
  private
288
298
 
289
- def construct_direct_append_updates(association_data, subtree_hashes, references)
299
+ def construct_direct_append_updates(_association_data, subtree_hashes, references)
290
300
  ViewModel::ActiveRecord::UpdateData.parse_hashes(subtree_hashes, references)
291
301
  end
292
302
 
@@ -12,17 +12,7 @@ module ViewModel::ActiveRecord::Cache::CacheableView
12
12
  CacheClearer = Struct.new(:cache, :id) do
13
13
  include ViewModel::AfterTransactionRunner
14
14
 
15
- # It's important that we clear the cache before committing, because we rely
16
- # on database locking to prevent cache race conditions. We require
17
- # reading/refreshing the cache to obtain a FOR SHARE lock, which means that
18
- # a reader must wait for a concurrent writer to commit before continuing to
19
- # the cache. If the writer cleared the cache after commit, the reader could
20
- # obtain old data before the clear, and then save the old data after it.
21
- def before_commit
22
- cache.delete(id)
23
- end
24
-
25
- def after_rollback
15
+ def after_transaction
26
16
  cache.delete(id)
27
17
  end
28
18
 
@@ -49,12 +39,12 @@ module ViewModel::ActiveRecord::Cache::CacheableView
49
39
  end
50
40
  end
51
41
 
52
- # Clear the cache if the view or its owned children were changed during
42
+ # Clear the cache if the view or its nested children were changed during
53
43
  # deserialization
54
44
  def after_deserialize(deserialize_context:, changes:)
55
45
  super if defined?(super)
56
46
 
57
- if !changes.new? && changes.changed_tree?
47
+ if !changes.new? && changes.changed_nested_tree?
58
48
  CacheClearer.new(self.class.viewmodel_cache, id).add_to_transaction
59
49
  end
60
50
  end
@@ -198,7 +198,7 @@ class ViewModel::ActiveRecord::Cache
198
198
  end
199
199
 
200
200
  ViewModel.preload_for_serialization(viewmodels,
201
- include_shared: false,
201
+ include_referenced: false,
202
202
  lock: "FOR SHARE",
203
203
  serialize_context: serialize_context)
204
204
 
@@ -259,7 +259,7 @@ class ViewModel::ActiveRecord::Cache
259
259
  end
260
260
 
261
261
  def cache_version
262
- version_string = @viewmodel_class.deep_schema_version(include_shared: false).to_a.sort.join(',')
262
+ version_string = @viewmodel_class.deep_schema_version(include_referenced: false).to_a.sort.join(',')
263
263
  Base64.urlsafe_encode64(Digest::MD5.digest(version_string))
264
264
  end
265
265
  end
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # Simple visitor for cloning models through the tree structure defined by
2
4
  # ViewModel::ActiveRecord. Owned associations will be followed and cloned, while
3
- # shared associations will be copied directly. Attributes (including association
4
- # foreign keys not covered by ViewModel `association`s) will be copied from the
5
- # original.
5
+ # non-owned referenced associations will be copied directly as references.
6
+ # Attributes (including association foreign keys not covered by ViewModel
7
+ # `association`s) will be copied from the original.
6
8
  #
7
9
  # To customize, subclasses may define methods `visit_x_view(node, new_model)`
8
10
  # for each type they wish to affect. These callbacks may update attributes of
@@ -19,9 +21,9 @@ class ViewModel::ActiveRecord::Cloner
19
21
  return nil if ignored?
20
22
 
21
23
  if node.class.name
22
- class_name = node.class.name.underscore.gsub('/', '__')
23
- visit = :"visit_#{class_name}"
24
- end_visit = :"end_visit_#{class_name}"
24
+ class_name = node.class.name.underscore.gsub('/', '__')
25
+ visit = :"visit_#{class_name}"
26
+ end_visit = :"end_visit_#{class_name}"
25
27
  end
26
28
 
27
29
  if visit && respond_to?(visit, true)
@@ -44,7 +46,7 @@ class ViewModel::ActiveRecord::Cloner
44
46
 
45
47
  if associated.nil?
46
48
  new_associated = nil
47
- elsif association_data.shared? && !association_data.through?
49
+ elsif !association_data.owned? && !association_data.through?
48
50
  # simply attach the associated target to the new model
49
51
  new_associated = associated
50
52
  else
@@ -82,11 +84,9 @@ class ViewModel::ActiveRecord::Cloner
82
84
  new_model
83
85
  end
84
86
 
85
- def pre_visit(node, new_model)
86
- end
87
+ def pre_visit(node, new_model); end
87
88
 
88
- def post_visit(node, new_model)
89
- end
89
+ def post_visit(node, new_model); end
90
90
 
91
91
  private
92
92
 
@@ -46,8 +46,6 @@ module ViewModel::ActiveRecord::Controller
46
46
  pre_rendered = viewmodel_class.transaction do
47
47
  view = viewmodel_class.deserialize_from_view(update_hash, references: refs, deserialize_context: deserialize_context)
48
48
 
49
- serialize_context.add_includes(deserialize_context.updated_associations)
50
-
51
49
  view = yield(view) if block_given?
52
50
 
53
51
  ViewModel.preload_for_serialization(view, serialize_context: serialize_context)
@@ -74,7 +74,7 @@ class ViewModel::ActiveRecord
74
74
 
75
75
  def initialize
76
76
  @root_update_operations = [] # The subject(s) of this update
77
- @referenced_update_operations = {} # Shared data updates, referred to by a ref hash
77
+ @referenced_update_operations = {} # data updates to other root models, referred to by a ref hash
78
78
 
79
79
  # Set of ViewModel::Reference used to assert only a single update is
80
80
  # present for each viewmodel
@@ -178,8 +178,20 @@ class ViewModel::ActiveRecord
178
178
  raise ViewModel::DeserializationError::ParentNotFound.new(@worklist.keys)
179
179
  end
180
180
 
181
- deferred_update = @worklist.delete(key)
182
- deferred_update.viewmodel = @release_pool.claim_from_pool(key)
181
+ deferred_update = @worklist.delete(key)
182
+ released_viewmodel = @release_pool.claim_from_pool(key)
183
+
184
+ if deferred_update.viewmodel
185
+ # Deferred reference updates already have a viewmodel: ensure it
186
+ # matches the tree
187
+ unless deferred_update.viewmodel == released_viewmodel
188
+ raise ViewModel::DeserializationError::Internal.new(
189
+ "Released viewmodel doesn't match reference update", blame_reference)
190
+ end
191
+ else
192
+ deferred_update.viewmodel = released_viewmodel
193
+ end
194
+
183
195
  deferred_update.build!(self)
184
196
  end
185
197
 
@@ -201,6 +213,12 @@ class ViewModel::ActiveRecord
201
213
  update_operation = ViewModel::ActiveRecord::UpdateOperation.new(
202
214
  nil, update_data, reparent_to: reparent_to, reposition_to: reposition_to)
203
215
  check_unique_update!(viewmodel_reference)
216
+ defer_update(viewmodel_reference, update_operation)
217
+ end
218
+
219
+ # Defer an existing update: used if we need to ensure that an owned
220
+ # reference has been freed before we use it.
221
+ def defer_update(viewmodel_reference, update_operation)
204
222
  @worklist[viewmodel_reference] = update_operation
205
223
  end
206
224