iknow_view_models 2.9.0 → 3.0.0

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