iknow_view_models 3.2.13 → 3.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3991314cdb010347b417869ff3e2e22b0e7adb4ab590e25739cb6b12bf49a219
4
- data.tar.gz: 0cd5994d07bdd9b9d311732be56b01a51d7b4f1eb71f7fa1d0217bf71911b97f
3
+ metadata.gz: 87a056bba1e37b5593a5ad90365c5b761976529f1c5ab4074b26dd666060d57e
4
+ data.tar.gz: 5a796e74204b2fb1b55d8f94bf95c347b3b80a65bf3d48036f7b6de06cba8cf2
5
5
  SHA512:
6
- metadata.gz: a5cd70dd6f79db33a97dbdfb05eefec460c7e332784839ad461cf5a359307ce8abedb39c046d42d04a3b7355280d1ad54948cb6cc61fdc35effa0f684ea4c821
7
- data.tar.gz: 6a673b6387892588434f981b57f8131e9c7a97991cc16d3901e72f043b9429efd0fef7a8e1f44cb0ea1ee364256d63d48f198a10e270b487d3da61311780eec7
6
+ metadata.gz: 1b7d484cb7ab2d0286bfc99460f029e8a9ccbb5edcc355cee42671e285f7ce2efe45946f2f7130d528b947fdb8e3bd41aa12dc007a049674b35d7a41b6f6b5be
7
+ data.tar.gz: 7ab16b3a6ec5569704da7a5bdfccde6374d43770c53e1e78ceca961e7043400842c8e84e3614528eee3b8f7c81ccf884f2d0ffc24793ef5cc8328d96cee12152
data/.circleci/config.yml CHANGED
@@ -5,7 +5,7 @@ executors:
5
5
  parameters:
6
6
  ruby-version:
7
7
  type: string
8
- default: "2.6"
8
+ default: "2.7"
9
9
  pg-version:
10
10
  type: string
11
11
  default: "11"
@@ -102,8 +102,8 @@ workflows:
102
102
  build:
103
103
  jobs:
104
104
  - test:
105
- name: 'ruby 2.6 rails 5.2 pg 12'
106
- ruby-version: "2.6"
105
+ name: 'ruby 2.7 rails 5.2 pg 12'
106
+ ruby-version: "2.7"
107
107
  pg-version: "12"
108
108
  gemfile: gemfiles/rails_5_2.gemfile
109
109
  - test:
@@ -111,6 +111,16 @@ workflows:
111
111
  ruby-version: "2.7"
112
112
  pg-version: "12"
113
113
  gemfile: gemfiles/rails_6_0.gemfile
114
+ - test:
115
+ name: 'ruby 2.7 rails 6.1 pg 12'
116
+ ruby-version: "2.7"
117
+ pg-version: "12"
118
+ gemfile: gemfiles/rails_6_1.gemfile
119
+ - test:
120
+ name: 'ruby 3.0 rails 6.1 pg 12'
121
+ ruby-version: "3.0"
122
+ pg-version: "12"
123
+ gemfile: gemfiles/rails_6_1.gemfile
114
124
  - publish:
115
125
  filters:
116
126
  branches:
data/Appraisals CHANGED
@@ -7,3 +7,8 @@ appraise 'rails-6-0' do
7
7
  gem 'activerecord', '~> 6.0.0'
8
8
  gem 'activesupport', '~> 6.0.0'
9
9
  end
10
+
11
+ appraise 'rails-6-1' do
12
+ gem 'activerecord', '~> 6.1.0'
13
+ gem 'activesupport', '~> 6.1.0'
14
+ end
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # IknowViewModels
2
2
 
3
- [![Build Status](https://travis-ci.org/iknow/iknow_view_models.svg?branch=master)](https://travis-ci.org/iknow/iknow_view_models)
3
+ [![Build Status](https://circleci.com/gh/iknow/iknow_view_models.svg?style=svg)](https://circleci.com/gh/iknow/iknow_view_models/)
4
4
 
5
5
  ViewModels provide a means of encapsulating a collection of related data and specifying its JSON serialization.
6
6
 
@@ -0,0 +1,9 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gem 'minitest-ci'
6
+ gem 'activerecord', '~> 6.1.0'
7
+ gem 'activesupport', '~> 6.1.0'
8
+
9
+ gemspec path: '../'
@@ -19,7 +19,7 @@ Gem::Specification.new do |spec|
19
19
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
20
20
  spec.require_paths = ['lib']
21
21
 
22
- spec.required_ruby_version = 2.6
22
+ spec.required_ruby_version = '>= 2.7'
23
23
 
24
24
  spec.add_dependency 'activerecord', '>= 5.0'
25
25
  spec.add_dependency 'activesupport', '>= 5.0'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IknowViewModels
4
- VERSION = '3.2.13'
4
+ VERSION = '3.4.1'
5
5
  end
data/lib/view_model.rb CHANGED
@@ -134,7 +134,7 @@ class ViewModel
134
134
  # If this viewmodel represents an AR model, what associations does it make
135
135
  # use of? Returns a includes spec appropriate for DeepPreloader, either as
136
136
  # AR-style nested hashes or DeepPreloader::Spec.
137
- def eager_includes(serialize_context: new_serialize_context, include_referenced: true)
137
+ def eager_includes(include_referenced: true)
138
138
  {}
139
139
  end
140
140
 
@@ -173,6 +173,17 @@ class ViewModel
173
173
  Jbuilder.new { |json| serialize(viewmodel, json, serialize_context: serialize_context) }.attributes!
174
174
  end
175
175
 
176
+ def serialize_from_cache(views, migration_versions: {}, locked: false, serialize_context:)
177
+ plural = views.is_a?(Array)
178
+ views = Array.wrap(views)
179
+
180
+ json_views, json_refs = ViewModel::ActiveRecord::Cache.render_viewmodels_from_cache(
181
+ views, locked: locked, migration_versions: migration_versions, serialize_context: serialize_context)
182
+
183
+ json_views = json_views.first unless plural
184
+ return json_views, json_refs
185
+ end
186
+
176
187
  def encode_json(value)
177
188
  # Jbuilder#encode no longer uses MultiJson, but instead calls `.to_json`. In
178
189
  # the context of ActiveSupport, we don't want this, because AS replaces the
@@ -227,16 +238,16 @@ class ViewModel
227
238
  ViewModel::SerializeContext
228
239
  end
229
240
 
230
- def new_serialize_context(*args)
231
- serialize_context_class.new(*args)
241
+ def new_serialize_context(...)
242
+ serialize_context_class.new(...)
232
243
  end
233
244
 
234
245
  def deserialize_context_class
235
246
  ViewModel::DeserializeContext
236
247
  end
237
248
 
238
- def new_deserialize_context(*args)
239
- deserialize_context_class.new(*args)
249
+ def new_deserialize_context(...)
250
+ deserialize_context_class.new(...)
240
251
  end
241
252
 
242
253
  def accepts_schema_version?(schema_version)
@@ -256,10 +267,10 @@ class ViewModel
256
267
  Base64.urlsafe_encode64(hash, padding: false)
257
268
  end
258
269
 
259
- def preload_for_serialization(viewmodels, serialize_context: new_serialize_context, include_referenced: true, lock: nil)
270
+ def preload_for_serialization(viewmodels, include_referenced: true, lock: nil)
260
271
  Array.wrap(viewmodels).group_by(&:class).each do |type, views|
261
272
  DeepPreloader.preload(views.map(&:model),
262
- type.eager_includes(serialize_context: serialize_context, include_referenced: include_referenced),
273
+ type.eager_includes(include_referenced: include_referenced),
263
274
  lock: lock)
264
275
  end
265
276
  end
@@ -343,8 +354,8 @@ class ViewModel
343
354
  context.for_child(self, association_name: member_name)
344
355
  end
345
356
 
346
- def preload_for_serialization(lock: nil, serialize_context: self.class.new_serialize_context)
347
- ViewModel.preload_for_serialization([self], lock: lock, serialize_context: serialize_context)
357
+ def preload_for_serialization(lock: nil)
358
+ ViewModel.preload_for_serialization([self], lock: lock)
348
359
  end
349
360
 
350
361
  def ==(other)
@@ -135,7 +135,7 @@ class ViewModel::ActiveRecord < ViewModel::Record
135
135
  end
136
136
 
137
137
  ## Load instances of the viewmodel by id(s)
138
- def find(id_or_ids, scope: nil, lock: nil, eager_include: true, serialize_context: new_serialize_context)
138
+ def find(id_or_ids, scope: nil, lock: nil, eager_include: true)
139
139
  find_scope = self.model_class.all
140
140
  find_scope = find_scope.order(:id).lock(lock) if lock
141
141
  find_scope = find_scope.merge(scope) if scope
@@ -152,19 +152,19 @@ class ViewModel::ActiveRecord < ViewModel::Record
152
152
  end
153
153
 
154
154
  vms = models.map { |m| self.new(m) }
155
- ViewModel.preload_for_serialization(vms, lock: lock, serialize_context: serialize_context) if eager_include
155
+ ViewModel.preload_for_serialization(vms, lock: lock) if eager_include
156
156
  vms
157
157
  end
158
158
  end
159
159
 
160
160
  ## Load instances of the viewmodel by scope
161
161
  ## TODO: is this too much of a encapsulation violation?
162
- def load(scope: nil, eager_include: true, lock: nil, serialize_context: new_serialize_context)
162
+ def load(scope: nil, eager_include: true, lock: nil)
163
163
  load_scope = self.model_class.all
164
164
  load_scope = load_scope.lock(lock) if lock
165
165
  load_scope = load_scope.merge(scope) if scope
166
166
  vms = load_scope.map { |model| self.new(model) }
167
- ViewModel.preload_for_serialization(vms, lock: lock, serialize_context: serialize_context) if eager_include
167
+ ViewModel.preload_for_serialization(vms, lock: lock) if eager_include
168
168
  vms
169
169
  end
170
170
 
@@ -184,7 +184,7 @@ class ViewModel::ActiveRecord < ViewModel::Record
184
184
  # Constructs a preload specification of the required models for
185
185
  # serializing/deserializing this view. Cycles in the schema will be broken
186
186
  # after two layers of eager loading.
187
- def eager_includes(serialize_context: new_serialize_context, include_referenced: true, vm_path: [])
187
+ def eager_includes(include_referenced: true, vm_path: [])
188
188
  association_specs = {}
189
189
 
190
190
  return nil if vm_path.count(self) > 2
@@ -194,19 +194,10 @@ class ViewModel::ActiveRecord < ViewModel::Record
194
194
  next unless association_data.is_a?(AssociationData)
195
195
  next if association_data.external?
196
196
 
197
- child_context =
198
- if self.synthetic
199
- serialize_context
200
- elsif association_data.referenced?
201
- serialize_context.for_references
202
- else
203
- serialize_context.for_child(nil, association_name: assoc_name)
204
- end
205
-
206
197
  case
207
198
  when association_data.through?
208
199
  viewmodel = association_data.direct_viewmodel
209
- children = viewmodel.eager_includes(serialize_context: child_context, include_referenced: include_referenced, vm_path: child_path)
200
+ children = viewmodel.eager_includes(include_referenced: include_referenced, vm_path: child_path)
210
201
 
211
202
  when !include_referenced && association_data.referenced?
212
203
  children = nil # Load up to the root viewmodel, but no further
@@ -215,13 +206,13 @@ class ViewModel::ActiveRecord < ViewModel::Record
215
206
  children_by_klass = {}
216
207
  association_data.viewmodel_classes.each do |vm_class|
217
208
  klass = vm_class.model_class.name
218
- children_by_klass[klass] = vm_class.eager_includes(serialize_context: child_context, include_referenced: include_referenced, vm_path: child_path)
209
+ children_by_klass[klass] = vm_class.eager_includes(include_referenced: include_referenced, vm_path: child_path)
219
210
  end
220
211
  children = DeepPreloader::PolymorphicSpec.new(children_by_klass)
221
212
 
222
213
  else
223
214
  viewmodel = association_data.viewmodel_class
224
- children = viewmodel.eager_includes(serialize_context: child_context, include_referenced: include_referenced, vm_path: child_path)
215
+ children = viewmodel.eager_includes(include_referenced: include_referenced, vm_path: child_path)
225
216
  end
226
217
 
227
218
  association_specs[association_data.direct_reflection.name.to_s] = children
@@ -348,7 +339,7 @@ class ViewModel::ActiveRecord < ViewModel::Record
348
339
  # associated here are join-table models; we need to get the far side out
349
340
  join_models = associated
350
341
 
351
- if association_data.direct_viewmodel._list_member?
342
+ if association_data.ordered?
352
343
  attr = association_data.direct_viewmodel._list_attribute_name
353
344
  join_models = join_models.sort_by { |j| j[attr] }
354
345
  end
@@ -359,11 +350,16 @@ class ViewModel::ActiveRecord < ViewModel::Record
359
350
  end
360
351
 
361
352
  when association_data.collection?
362
- associated_viewmodel_class = association_data.viewmodel_class
363
- associated_viewmodels = associated.map { |x| associated_viewmodel_class.new(x) }
364
- if associated_viewmodel_class._list_member?
353
+ associated_viewmodels = associated.map do |x|
354
+ associated_viewmodel_class = association_data.viewmodel_class_for_model!(x.class)
355
+ associated_viewmodel_class.new(x)
356
+ end
357
+
358
+ # If any associated type is a list member, they must all be
359
+ if association_data.ordered?
365
360
  associated_viewmodels.sort_by!(&:_list_attribute)
366
361
  end
362
+
367
363
  associated_viewmodels
368
364
 
369
365
  else
@@ -196,6 +196,21 @@ class ViewModel::ActiveRecord::AssociationData
196
196
  viewmodel_classes.first
197
197
  end
198
198
 
199
+ def ordered?
200
+ @ordered ||=
201
+ if through?
202
+ direct_viewmodel._list_member?
203
+ else
204
+ list_members = viewmodel_classes.map { |c| c._list_member? }.uniq
205
+
206
+ if list_members.size > 1
207
+ raise ArgumentError.new('Inconsistent associated views: mixed list membership')
208
+ end
209
+
210
+ list_members[0]
211
+ end
212
+ end
213
+
199
214
  def through?
200
215
  @indirect_association_name.present?
201
216
  end
@@ -16,11 +16,13 @@ module ViewModel::ActiveRecord::AssociationManipulation
16
16
  associated_viewmodel = association_data.viewmodel_class
17
17
  direct_viewmodel = association_data.direct_viewmodel
18
18
  else
19
+ raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.viewmodel_classes.size > 1
20
+
19
21
  associated_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) }
20
22
  direct_viewmodel = associated_viewmodel
21
23
  end
22
24
 
23
- if direct_viewmodel._list_member?
25
+ if association_data.ordered?
24
26
  association_scope = association_scope.order(direct_viewmodel._list_attribute_name)
25
27
  end
26
28
 
@@ -34,10 +36,7 @@ module ViewModel::ActiveRecord::AssociationManipulation
34
36
 
35
37
  vms = association_scope.map { |model| associated_viewmodel.new(model) }
36
38
 
37
- if eager_include
38
- child_context = self.context_for_child(association_name, context: serialize_context)
39
- ViewModel.preload_for_serialization(vms, serialize_context: child_context)
40
- end
39
+ ViewModel.preload_for_serialization(vms) if eager_include
41
40
 
42
41
  if association_data.collection?
43
42
  vms
@@ -119,6 +118,8 @@ module ViewModel::ActiveRecord::AssociationManipulation
119
118
  direct_viewmodel_class = association_data.direct_viewmodel
120
119
  root_update_data, referenced_update_data = construct_indirect_append_updates(association_data, subtree_hashes, references)
121
120
  else
121
+ raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.viewmodel_classes.size > 1
122
+
122
123
  direct_viewmodel_class = association_data.viewmodel_class
123
124
  root_update_data, referenced_update_data = construct_direct_append_updates(association_data, subtree_hashes, references)
124
125
  end
@@ -130,7 +131,7 @@ module ViewModel::ActiveRecord::AssociationManipulation
130
131
  update_context.root_updates.each { |update| update.reparent_to = new_parent }
131
132
 
132
133
  # Set place in list.
133
- if direct_viewmodel_class._list_member?
134
+ if association_data.ordered?
134
135
  new_positions = select_append_positions(association_data,
135
136
  direct_viewmodel_class._list_attribute_name,
136
137
  update_context.root_updates.count,
@@ -241,6 +242,8 @@ module ViewModel::ActiveRecord::AssociationManipulation
241
242
  direct_viewmodel = association_data.direct_viewmodel
242
243
  association_scope = association_scope.where(association_data.indirect_reflection.foreign_key => associated_id)
243
244
  else
245
+ raise ArgumentError.new('Polymorphic STI relationships not supported yet') if association_data.viewmodel_classes.size > 1
246
+
244
247
  # viewmodel type for current association: nil in case of empty polymorphic association
245
248
  direct_viewmodel = association.klass.try { |k| association_data.viewmodel_class_for_model!(k) }
246
249
 
@@ -11,6 +11,30 @@ class ViewModel::ActiveRecord::Cache
11
11
 
12
12
  attr_reader :viewmodel_class
13
13
 
14
+ class << self
15
+ def render_viewmodels_from_cache(viewmodels, migration_versions: {}, locked: false, serialize_context: nil)
16
+ if viewmodels.empty?
17
+ return [], {}
18
+ end
19
+
20
+ ids = viewmodels.map(&:id)
21
+ # ideally the roots wouldn't have to all be the same type
22
+ viewmodel_class = viewmodels.first.class
23
+ serialize_context ||= viewmodel_class.new_serialize_context
24
+
25
+ render_from_cache(viewmodel_class, ids,
26
+ initial_viewmodels: viewmodels,
27
+ migration_versions: migration_versions,
28
+ locked: locked,
29
+ serialize_context: serialize_context)
30
+ end
31
+
32
+ def render_from_cache(viewmodel_class, ids, initial_viewmodels: nil, migration_versions: {}, locked: false, serialize_context: viewmodel_class.new_serialize_context)
33
+ worker = CacheWorker.new(migration_versions: migration_versions, serialize_context: serialize_context)
34
+ worker.render_from_cache(viewmodel_class, ids, initial_viewmodels: initial_viewmodels, locked: locked)
35
+ end
36
+ end
37
+
14
38
  # If cache_group: is specified, it must be a group of a single key: `:id`
15
39
  def initialize(viewmodel_class, cache_group: nil)
16
40
  @viewmodel_class = viewmodel_class
@@ -35,85 +59,98 @@ class ViewModel::ActiveRecord::Cache
35
59
  @cache_group.invalidate_cache_group
36
60
  end
37
61
 
62
+ # @deprecated Replaced by class methods
38
63
  def fetch_by_viewmodel(viewmodels, migration_versions: {}, locked: false, serialize_context: @viewmodel_class.new_serialize_context)
39
64
  ids = viewmodels.map(&:id)
40
65
  fetch(ids, initial_viewmodels: viewmodels, migration_versions: migration_versions, locked: locked, serialize_context: serialize_context)
41
66
  end
42
67
 
68
+ # @deprecated Replaced by class methods
43
69
  def fetch(ids, initial_viewmodels: nil, migration_versions: {}, locked: false, serialize_context: @viewmodel_class.new_serialize_context)
44
- data_serializations = Array.new(ids.size)
45
- worker = CacheWorker.new(migration_versions: migration_versions, serialize_context: serialize_context)
46
-
47
- # If initial root viewmodels were provided, visit them to ensure that they
48
- # are visible. Other than this, no traversal callbacks are performed, as a
49
- # view may be resolved from the cache without ever loading its viewmodel.
50
- # Note that if unlocked, these views will be reloaded as part of obtaining a
51
- # share lock. If the visibility of this viewmodel can change due to edits,
52
- # it is necessary to obtain a lock before calling `fetch`.
53
- initial_viewmodels&.each do |v|
54
- serialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeVisit, v)
55
- serialize_context.run_callback(ViewModel::Callbacks::Hook::AfterVisit, v)
56
- end
70
+ self.class.render_from_cache(@viewmodel_class, ids,
71
+ initial_viewmodels: initial_viewmodels, locked: locked,
72
+ migration_versions: migration_versions, serialize_context: serialize_context)
73
+ end
57
74
 
58
- # Collect input array positions for each id, allowing duplicates
59
- positions = ids.each_with_index.with_object({}) do |(id, i), h|
60
- (h[id] ||= []) << i
61
- end
75
+ class CacheWorker
76
+ SENTINEL = Object.new
77
+ WorklistEntry = Struct.new(:ref_name, :viewmodel)
62
78
 
63
- # Fetch duplicates only once
64
- ids = positions.keys
79
+ attr_reader :migration_versions, :serialize_context, :resolved_references
65
80
 
66
- # Load existing serializations from the cache
67
- cached_serializations = worker.load_from_cache(self, ids)
68
- cached_serializations.each do |id, data|
69
- positions[id].each do |idx|
70
- data_serializations[idx] = data
71
- end
81
+ def initialize(migration_versions:, serialize_context:)
82
+ @worklist = {} # Hash[type_name, Hash[id, WorklistEntry]]
83
+ @resolved_references = {} # Hash[refname, json]
84
+ @migration_versions = migration_versions
85
+ @migrated_cache_versions = {}
86
+ @serialize_context = serialize_context
72
87
  end
73
88
 
74
- # Resolve and serialize missing views
75
- missing_ids = ids.to_set.subtract(cached_serializations.keys)
89
+ def render_from_cache(viewmodel_class, ids, initial_viewmodels: nil, locked: false)
90
+ viewmodel_class.transaction do
91
+ root_serializations = Array.new(ids.size)
76
92
 
77
- # If initial viewmodels have been locked, we can serialize them for cache
78
- # misses.
79
- available_viewmodels =
80
- if locked
81
- initial_viewmodels&.each_with_object({}) do |vm, h|
82
- h[vm.id] = vm if missing_ids.include?(vm.id)
93
+ # Collect input array positions for each id, allowing duplicates
94
+ positions = ids.each_with_index.with_object({}) do |(id, i), h|
95
+ (h[id] ||= []) << i
83
96
  end
84
- end
85
97
 
86
- @viewmodel_class.transaction do
87
- # Load remaining views and serialize
88
- viewmodels = worker.find_and_preload_viewmodels(@viewmodel_class, missing_ids.to_a,
89
- available_viewmodels: available_viewmodels)
98
+ # If duplicates are specified, fetch each only once
99
+ ids = positions.keys
100
+
101
+ ids_to_render = ids.to_set
102
+
103
+ if viewmodel_class < CacheableView
104
+ # Load existing serializations from the cache
105
+ cached_serializations = load_from_cache(viewmodel_class.viewmodel_cache, ids)
106
+ cached_serializations.each do |id, data|
107
+ positions[id].each do |idx|
108
+ root_serializations[idx] = data
109
+ end
110
+ end
90
111
 
91
- loaded_serializations = worker.serialize_and_cache(viewmodels)
92
- loaded_serializations.each do |id, data|
93
- positions[id].each do |idx|
94
- data_serializations[idx] = data
112
+ ids_to_render.subtract(cached_serializations.keys)
113
+
114
+ # If initial root viewmodels were provided, call hooks on any
115
+ # viewmodels which were rendered from the cache to ensure that the
116
+ # root is visible (in isolation). Other than this, no traversal
117
+ # callbacks are performed for cache-rendered views. This particularly
118
+ # requires care for references: if a visible view may refer to
119
+ # non-visible cacheable views, those referenced views will not be
120
+ # access control checked.
121
+ initial_viewmodels&.each do |v|
122
+ next unless cached_serializations.has_key?(v.id)
123
+ serialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeVisit, v)
124
+ serialize_context.run_callback(ViewModel::Callbacks::Hook::AfterVisit, v)
125
+ end
95
126
  end
96
- end
97
127
 
98
- # Resolve references
99
- worker.resolve_references!
128
+ # Render remaining views. If initial viewmodels have been locked, we may
129
+ # use them to serialize from, otherwise we must reload with share lock
130
+ # in find_and_preload.
131
+ available_viewmodels =
132
+ if locked
133
+ initial_viewmodels&.each_with_object({}) do |vm, h|
134
+ h[vm.id] = vm if ids_to_render.include?(vm.id)
135
+ end
136
+ end
100
137
 
101
- return data_serializations, worker.resolved_references
102
- end
103
- end
138
+ viewmodels = find_and_preload_viewmodels(viewmodel_class, ids_to_render.to_a,
139
+ available_viewmodels: available_viewmodels)
104
140
 
105
- class CacheWorker
106
- SENTINEL = Object.new
107
- WorklistEntry = Struct.new(:ref_name, :viewmodel)
141
+ loaded_serializations = serialize_and_cache(viewmodels)
108
142
 
109
- attr_reader :migration_versions, :serialize_context, :resolved_references
143
+ loaded_serializations.each do |id, data|
144
+ positions[id].each do |idx|
145
+ root_serializations[idx] = data
146
+ end
147
+ end
110
148
 
111
- def initialize(migration_versions:, serialize_context:)
112
- @worklist = {}
113
- @resolved_references = {}
114
- @migration_versions = migration_versions
115
- @migrated_cache_versions = {}
116
- @serialize_context = serialize_context
149
+ # recursively resolve referenced views
150
+ self.resolve_references!
151
+
152
+ [root_serializations, self.resolved_references]
153
+ end
117
154
  end
118
155
 
119
156
  def resolve_references!
@@ -159,7 +196,7 @@ class ViewModel::ActiveRecord::Cache
159
196
  # {id=>serialized_view}. Any references encountered are added to the
160
197
  # worklist.
161
198
  def load_from_cache(viewmodel_cache, ids)
162
- cached_serializations = viewmodel_cache.load(ids, migrated_cache_version(viewmodel_cache), serialize_context: serialize_context)
199
+ cached_serializations = viewmodel_cache.load(ids, migrated_cache_version(viewmodel_cache))
163
200
 
164
201
  cached_serializations.each_with_object({}) do |(id, cached_serialization), result|
165
202
  add_refs_to_worklist(cached_serialization[:ref_cache])
@@ -218,8 +255,7 @@ class ViewModel::ActiveRecord::Cache
218
255
  if viewmodel.class < CacheableView
219
256
  cacheable_references = referenced_viewmodels.transform_values { |vm| cacheable_reference(vm) }
220
257
  target_cache = viewmodel.class.viewmodel_cache
221
- target_cache.store(viewmodel.id, migrated_cache_version(target_cache), data_serialization, cacheable_references,
222
- serialize_context: serialize_context)
258
+ target_cache.store(viewmodel.id, migrated_cache_version(target_cache), data_serialization, cacheable_references)
223
259
  end
224
260
 
225
261
  result[viewmodel.id] = data_serialization
@@ -242,15 +278,13 @@ class ViewModel::ActiveRecord::Cache
242
278
  if ids.present?
243
279
  found = viewmodel_class.find(ids,
244
280
  eager_include: false,
245
- lock: 'FOR SHARE',
246
- serialize_context: serialize_context)
281
+ lock: 'FOR SHARE')
247
282
  viewmodels.concat(found)
248
283
  end
249
284
 
250
285
  ViewModel.preload_for_serialization(viewmodels,
251
286
  include_referenced: false,
252
- lock: 'FOR SHARE',
253
- serialize_context: serialize_context)
287
+ lock: 'FOR SHARE')
254
288
 
255
289
  viewmodels
256
290
  end
@@ -298,12 +332,12 @@ class ViewModel::ActiveRecord::Cache
298
332
  end
299
333
 
300
334
  # Save the provided serialization and reference data in the cache
301
- def store(id, migration_version, data_serialization, ref_cache, serialize_context:)
335
+ def store(id, migration_version, data_serialization, ref_cache)
302
336
  key = key_for(id, migration_version)
303
337
  cache_for(migration_version).write(key, { data: data_serialization, ref_cache: ref_cache })
304
338
  end
305
339
 
306
- def load(ids, migration_version, serialize_context:)
340
+ def load(ids, migration_version)
307
341
  keys = ids.map { |id| key_for(id, migration_version) }
308
342
  results = cache_for(migration_version).read_multi(keys)
309
343
  results.transform_keys! { |key| id_for(key) }
@@ -39,14 +39,6 @@ module ViewModel::ActiveRecord::Cache::CacheableView
39
39
  def viewmodel_cache
40
40
  @viewmodel_cache
41
41
  end
42
-
43
- def serialize_from_cache(views, migration_versions: {}, locked: false, serialize_context:)
44
- plural = views.is_a?(Array)
45
- views = Array.wrap(views)
46
- json_views, json_refs = viewmodel_cache.fetch_by_viewmodel(views, locked: locked, migration_versions: migration_versions, serialize_context: serialize_context)
47
- json_views = json_views.first unless plural
48
- return json_views, json_refs
49
- end
50
42
  end
51
43
 
52
44
  # Clear the cache if the view or its nested children were changed during
@@ -22,23 +22,23 @@ module ViewModel::ActiveRecord::CollectionNestedController
22
22
  extend ActiveSupport::Concern
23
23
  include ViewModel::ActiveRecord::NestedControllerBase
24
24
 
25
- def index_associated(scope: nil, serialize_context: new_serialize_context, &block)
26
- show_association(scope: scope, serialize_context: serialize_context, &block)
25
+ def index_associated(scope: nil, serialize_context: new_serialize_context, lock_owner: nil, &block)
26
+ show_association(scope: scope, serialize_context: serialize_context, lock_owner: lock_owner, &block)
27
27
  end
28
28
 
29
29
  # Deserialize items of the associated type and associate them with the owner,
30
30
  # replacing previously associated items.
31
- def replace(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, &block)
32
- write_association(serialize_context: serialize_context, deserialize_context: deserialize_context, &block)
31
+ def replace(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil, &block)
32
+ write_association(serialize_context: serialize_context, deserialize_context: deserialize_context, lock_owner: lock_owner, &block)
33
33
  end
34
34
 
35
- def disassociate_all(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
36
- destroy_association(true, serialize_context: serialize_context, deserialize_context: deserialize_context)
35
+ def disassociate_all(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
36
+ destroy_association(true, serialize_context: serialize_context, deserialize_context: deserialize_context, lock_owner: lock_owner)
37
37
  end
38
38
 
39
39
  # Deserialize items of the associated type and append them to the owner's
40
40
  # collection.
41
- def append(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
41
+ def append(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
42
42
  assoc_view = nil
43
43
  pre_rendered = owner_viewmodel.transaction do
44
44
  update_hash, refs = parse_viewmodel_updates
@@ -50,7 +50,7 @@ module ViewModel::ActiveRecord::CollectionNestedController
50
50
  raise ViewModel::DeserializationError::InvalidSyntax.new('Can not provide both `before` and `after` anchors for a collection append')
51
51
  end
52
52
 
53
- owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
53
+ owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, lock: lock_owner)
54
54
 
55
55
  assoc_view = owner_view.append_associated(association_name,
56
56
  update_hash,
@@ -60,7 +60,7 @@ module ViewModel::ActiveRecord::CollectionNestedController
60
60
  deserialize_context: deserialize_context)
61
61
  ViewModel::Callbacks.wrap_serialize(owner_view, context: serialize_context) do
62
62
  child_context = owner_view.context_for_child(association_name, context: serialize_context)
63
- ViewModel.preload_for_serialization(assoc_view, serialize_context: child_context)
63
+ ViewModel.preload_for_serialization(assoc_view)
64
64
  assoc_view = yield(assoc_view) if block_given?
65
65
  prerender_viewmodel(assoc_view, serialize_context: child_context)
66
66
  end
@@ -69,9 +69,9 @@ module ViewModel::ActiveRecord::CollectionNestedController
69
69
  assoc_view
70
70
  end
71
71
 
72
- def disassociate(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
72
+ def disassociate(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
73
73
  owner_viewmodel.transaction do
74
- owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
74
+ owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, lock: lock_owner)
75
75
  owner_view.delete_associated(association_name, viewmodel_id, type: viewmodel_class, deserialize_context: deserialize_context)
76
76
  render_viewmodel(nil)
77
77
  end
@@ -22,7 +22,7 @@ module ViewModel::ActiveRecord::Controller
22
22
  def show(scope: nil, viewmodel_class: self.viewmodel_class, serialize_context: new_serialize_context(viewmodel_class: viewmodel_class))
23
23
  view = nil
24
24
  pre_rendered = viewmodel_class.transaction do
25
- view = viewmodel_class.find(viewmodel_id, scope: scope, serialize_context: serialize_context)
25
+ view = viewmodel_class.find(viewmodel_id, scope: scope)
26
26
  view = yield(view) if block_given?
27
27
  prerender_viewmodel(view, serialize_context: serialize_context)
28
28
  end
@@ -33,7 +33,7 @@ module ViewModel::ActiveRecord::Controller
33
33
  def index(scope: nil, viewmodel_class: self.viewmodel_class, serialize_context: new_serialize_context(viewmodel_class: viewmodel_class))
34
34
  views = nil
35
35
  pre_rendered = viewmodel_class.transaction do
36
- views = viewmodel_class.load(scope: scope, serialize_context: serialize_context)
36
+ views = viewmodel_class.load(scope: scope)
37
37
  views = yield(views) if block_given?
38
38
  prerender_viewmodel(views, serialize_context: serialize_context)
39
39
  end
@@ -47,7 +47,7 @@ module ViewModel::ActiveRecord::Controller
47
47
  view = nil
48
48
  pre_rendered = viewmodel_class.transaction do
49
49
  view = viewmodel_class.deserialize_from_view(update_hash, references: refs, deserialize_context: deserialize_context)
50
- ViewModel.preload_for_serialization(view, serialize_context: serialize_context)
50
+ ViewModel.preload_for_serialization(view)
51
51
  view = yield(view) if block_given?
52
52
  prerender_viewmodel(view, serialize_context: serialize_context)
53
53
  end
@@ -57,7 +57,7 @@ module ViewModel::ActiveRecord::Controller
57
57
 
58
58
  def destroy(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
59
59
  viewmodel_class.transaction do
60
- view = viewmodel_class.find(viewmodel_id, eager_include: false, serialize_context: serialize_context)
60
+ view = viewmodel_class.find(viewmodel_id, eager_include: false)
61
61
  view.destroy!(deserialize_context: deserialize_context)
62
62
  end
63
63
  render_viewmodel(nil)
@@ -76,7 +76,7 @@ module ViewModel::ActiveRecord::Controller
76
76
  end
77
77
  end
78
78
 
79
- def prerender_viewmodel(*)
79
+ def prerender_viewmodel(...)
80
80
  super do |jbuilder|
81
81
  yield(jbuilder) if block_given?
82
82
 
@@ -9,13 +9,13 @@ module ViewModel::ActiveRecord::NestedControllerBase
9
9
 
10
10
  protected
11
11
 
12
- def show_association(scope: nil, serialize_context: new_serialize_context)
12
+ def show_association(scope: nil, serialize_context: new_serialize_context, lock_owner: nil)
13
13
  associated_views = nil
14
14
  pre_rendered = owner_viewmodel.transaction do
15
- owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
15
+ owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, lock: lock_owner)
16
16
  ViewModel::Callbacks.wrap_serialize(owner_view, context: serialize_context) do
17
17
  # Association manipulation methods construct child contexts internally
18
- associated_views = owner_view.load_associated(association_name, scope: scope, serialize_context: serialize_context)
18
+ associated_views = owner_view.load_associated(association_name, scope: scope)
19
19
 
20
20
  associated_views = yield(associated_views) if block_given?
21
21
 
@@ -27,12 +27,12 @@ module ViewModel::ActiveRecord::NestedControllerBase
27
27
  associated_views
28
28
  end
29
29
 
30
- def write_association(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
30
+ def write_association(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
31
31
  association_view = nil
32
32
  pre_rendered = owner_viewmodel.transaction do
33
33
  update_hash, refs = parse_viewmodel_updates
34
34
 
35
- owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, serialize_context: serialize_context)
35
+ owner_view = owner_viewmodel.find(owner_viewmodel_id, eager_include: false, lock: lock_owner)
36
36
 
37
37
  association_view = owner_view.replace_associated(association_name, update_hash,
38
38
  references: refs,
@@ -40,7 +40,7 @@ module ViewModel::ActiveRecord::NestedControllerBase
40
40
 
41
41
  ViewModel::Callbacks.wrap_serialize(owner_view, context: serialize_context) do
42
42
  child_context = owner_view.context_for_child(association_name, context: serialize_context)
43
- ViewModel.preload_for_serialization(association_view, serialize_context: child_context)
43
+ ViewModel.preload_for_serialization(association_view)
44
44
  association_view = yield(association_view) if block_given?
45
45
  prerender_viewmodel(association_view, serialize_context: child_context)
46
46
  end
@@ -49,7 +49,11 @@ module ViewModel::ActiveRecord::NestedControllerBase
49
49
  association_view
50
50
  end
51
51
 
52
- def destroy_association(collection, serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
52
+ def destroy_association(collection, serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
53
+ if lock_owner
54
+ owner_viewmodel.find(owner_viewmodel_id, eager_include: false, lock: lock_owner)
55
+ end
56
+
53
57
  empty_update = collection ? [] : nil
54
58
  owner_viewmodel.deserialize_from_view(owner_update_hash(empty_update),
55
59
  deserialize_context: deserialize_context)
@@ -20,15 +20,15 @@ module ViewModel::ActiveRecord::SingularNestedController
20
20
  extend ActiveSupport::Concern
21
21
  include ViewModel::ActiveRecord::NestedControllerBase
22
22
 
23
- def show_associated(scope: nil, serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, &block)
24
- show_association(scope: scope, serialize_context: serialize_context, &block)
23
+ def show_associated(scope: nil, serialize_context: new_serialize_context, lock_owner: nil, &block)
24
+ show_association(scope: scope, serialize_context: serialize_context, lock_owner: lock_owner, &block)
25
25
  end
26
26
 
27
- def create_associated(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, &block)
28
- write_association(serialize_context: serialize_context, deserialize_context: deserialize_context, &block)
27
+ def create_associated(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil, &block)
28
+ write_association(serialize_context: serialize_context, deserialize_context: deserialize_context, lock_owner: lock_owner, &block)
29
29
  end
30
30
 
31
- def destroy_associated(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context)
32
- destroy_association(false, serialize_context: serialize_context, deserialize_context: deserialize_context)
31
+ def destroy_associated(serialize_context: new_serialize_context, deserialize_context: new_deserialize_context, lock_owner: nil)
32
+ destroy_association(false, serialize_context: serialize_context, deserialize_context: deserialize_context, lock_owner: lock_owner)
33
33
  end
34
34
  end
@@ -450,14 +450,13 @@ class ViewModel::ActiveRecord
450
450
  parent_data = ParentData.new(association_data.direct_reflection.inverse_of, viewmodel)
451
451
 
452
452
  # load children already attached to this model
453
- child_viewmodel_class = association_data.viewmodel_class
454
-
455
453
  previous_child_viewmodels =
456
454
  model.public_send(association_data.direct_reflection.name).map do |child_model|
455
+ child_viewmodel_class = association_data.viewmodel_class_for_model!(child_model.class)
457
456
  child_viewmodel_class.new(child_model)
458
457
  end
459
458
 
460
- if child_viewmodel_class._list_member?
459
+ if association_data.ordered?
461
460
  previous_child_viewmodels.sort_by!(&:_list_attribute)
462
461
  end
463
462
 
@@ -604,7 +603,7 @@ class ViewModel::ActiveRecord
604
603
  # need to be updated anyway since their parent pointer will change.
605
604
  new_positions = Array.new(resolved_children.length)
606
605
 
607
- if association_data.viewmodel_class._list_member?
606
+ if association_data.ordered?
608
607
  set_position = ->(index, pos) { new_positions[index] = pos }
609
608
 
610
609
  get_previous_position = ->(index) do
@@ -802,7 +801,7 @@ class ViewModel::ActiveRecord
802
801
  ReferencedCollectionMember.new(indirect_viewmodel_ref, direct_vm)
803
802
  end
804
803
 
805
- if direct_viewmodel_class._list_member?
804
+ if association_data.ordered?
806
805
  previous_members.sort_by!(&:position)
807
806
  end
808
807
 
@@ -868,7 +867,7 @@ class ViewModel::ActiveRecord
868
867
  viewmodel.association_changed!(association_data.association_name)
869
868
  end
870
869
 
871
- if direct_viewmodel_class._list_member?
870
+ if association_data.ordered?
872
871
  ActsAsManualList.update_positions(target_collection.members)
873
872
  end
874
873
 
@@ -102,11 +102,18 @@ class ViewModel
102
102
  class DownMigrator < Migrator
103
103
  private
104
104
 
105
- def migrate_viewmodel!(view_name, _, view_hash, references)
105
+ def migrate_viewmodel!(view_name, source_version, view_hash, references)
106
106
  path = @paths[view_name]
107
107
  return false unless path
108
108
 
109
- required_version, _current_version = @versions[view_name]
109
+ # In a serialized output, the source version should always be the present
110
+ # and the current version, unless already modified by a parent migration
111
+ required_version, current_version = @versions[view_name]
112
+ return false if source_version == required_version
113
+
114
+ unless source_version == current_version
115
+ raise ViewModel::Migration::UnspecifiedVersionError.new(view_name, source_version)
116
+ end
110
117
 
111
118
  path.reverse_each do |migration|
112
119
  migration.down(view_hash, references)
@@ -24,8 +24,8 @@ class ViewModel::TraversalContext
24
24
 
25
25
  delegate :access_control, :callbacks, to: :shared_context
26
26
 
27
- def self.new_child(*args)
28
- self.allocate.tap { |c| c.initialize_as_child(*args) }
27
+ def self.new_child(...)
28
+ self.allocate.tap { |c| c.initialize_as_child(...) }
29
29
  end
30
30
 
31
31
  def initialize(shared_context: nil, **shared_context_params)
@@ -168,26 +168,61 @@ class ViewModel::ActiveRecord::BelongsToTest < ActiveSupport::TestCase
168
168
 
169
169
  class GCTests < ActiveSupport::TestCase
170
170
  include ARVMTestUtilities
171
- include ViewModelSpecHelpers::ParentAndBelongsToChild
171
+ include ViewModelSpecHelpers::Base
172
172
 
173
173
  def model_attributes
174
174
  super.merge(
175
175
  schema: ->(t) do
176
+ t.integer :destroyed_child_id
176
177
  t.integer :deleted_child_id
177
178
  t.integer :ignored_child_id
179
+ t.foreign_key :children, column: :destroyed_child_id
180
+ t.foreign_key :children, column: :deleted_child_id
181
+ t.foreign_key :children, column: :ignored_child_id
178
182
  end,
179
183
  model: ->(_m) do
180
- belongs_to :deleted_child, class_name: Child.name, dependent: :delete
181
- belongs_to :ignored_child, class_name: Child.name
184
+ belongs_to :destroyed_child, class_name: Child.name, inverse_of: :destroyed_model, dependent: :destroy
185
+ belongs_to :deleted_child, class_name: Child.name, inverse_of: :deleted_model, dependent: :delete
186
+ belongs_to :ignored_child, class_name: Child.name, inverse_of: :ignored_model
182
187
  end,
183
188
  viewmodel: ->(_v) do
184
- associations :deleted_child, :ignored_child
189
+ associations :destroyed_child, :deleted_child, :ignored_child
185
190
  end)
186
191
  end
187
192
 
188
- # test belongs_to garbage collection - dependent: delete_all
189
- def test_gc_dependent_delete_all
193
+ def child_attributes
194
+ super.merge(
195
+ model: ->(_m) do
196
+ has_one :destroyed_model, class_name: 'Model', inverse_of: :destroyed_child, foreign_key: 'destroyed_child_id'
197
+ has_one :deleted_model, class_name: 'Model', inverse_of: :deleted_child, foreign_key: 'deleted_child_id'
198
+ has_one :ignored_model, class_name: 'Model', inverse_of: :ignored_child, foreign_key: 'ignored_child_id'
199
+ end)
200
+ end
201
+
202
+ def viewmodel_class
203
+ child_viewmodel_class
204
+ super
205
+ end
206
+
207
+ # test belongs_to garbage collection - dependent: destroy
208
+ def test_gc_dependent_destroy
209
+ model = model_class.create(destroyed_child: Child.new(name: 'one'))
210
+
211
+ old_child = model.destroyed_child
212
+
213
+ alter_by_view!(ModelView, model) do |ov, _refs|
214
+ ov['destroyed_child'] = { '_type' => 'Child', 'name' => 'two' }
215
+ end
216
+
217
+ assert_equal('two', model.destroyed_child.name)
218
+ refute_equal(old_child, model.destroyed_child)
219
+ assert(Child.where(id: old_child.id).blank?)
220
+ end
221
+
222
+ # test belongs_to garbage collection - dependent: delete
223
+ def test_gc_dependent_delete
190
224
  model = model_class.create(deleted_child: Child.new(name: 'one'))
225
+
191
226
  old_child = model.deleted_child
192
227
 
193
228
  alter_by_view!(ModelView, model) do |ov, _refs|
@@ -135,8 +135,8 @@ class ViewModel::ActiveRecord
135
135
  [data, refs]
136
136
  end
137
137
 
138
- def fetch_with_cache
139
- viewmodel_class.viewmodel_cache.fetch([root.id], migration_versions: migration_versions)
138
+ def fetch_with_cache(**rest)
139
+ ViewModel::ActiveRecord::Cache.render_from_cache(viewmodel_class, [root.id], migration_versions: migration_versions, **rest)
140
140
  end
141
141
 
142
142
  def serialize_with_cache
@@ -206,6 +206,19 @@ class ViewModel::ActiveRecord
206
206
 
207
207
  describe 'without migrations' do
208
208
  include BehavesLikeACache
209
+
210
+ it 'returns the right serialization with provided initial viewmodel' do
211
+ fetched_result = parse_result(fetch_with_cache(initial_viewmodels: [root_view]))
212
+
213
+ value(fetched_result).must_equal(serialize_from_database)
214
+ end
215
+
216
+ it 'returns the right serialization with provided locked initial viewmodel' do
217
+ locked_root_view = viewmodel_class.new(model_class.lock("FOR SHARE").find(root.id))
218
+ fetched_result = parse_result(fetch_with_cache(initial_viewmodels: [locked_root_view], locked: true))
219
+
220
+ value(fetched_result).must_equal(serialize_from_database)
221
+ end
209
222
  end
210
223
 
211
224
  describe 'with migrations' do
@@ -337,8 +350,8 @@ class ViewModel::ActiveRecord
337
350
  [data, refs]
338
351
  end
339
352
 
340
- def fetch_with_cache
341
- viewmodel_class.viewmodel_cache.fetch([root.id, root2.id])
353
+ def fetch_with_cache(**rest)
354
+ ViewModel::ActiveRecord::Cache.render_from_cache(viewmodel_class, [root.id, root2.id], **rest)
342
355
  end
343
356
 
344
357
  it 'merges matching shared references between cache hits and misses' do
@@ -376,8 +389,8 @@ class ViewModel::ActiveRecord
376
389
  end
377
390
 
378
391
  describe 'when fetched by viewmodel' do
379
- def fetch_with_cache
380
- viewmodel_class.viewmodel_cache.fetch_by_viewmodel([root_view])
392
+ def fetch_with_cache(**rest)
393
+ ViewModel::ActiveRecord::Cache.render_viewmodels_from_cache([root_view], **rest)
381
394
  end
382
395
 
383
396
  include CacheableParentAndChildren
@@ -808,6 +808,39 @@ class ViewModel::ActiveRecord::HasManyTest < ActiveSupport::TestCase
808
808
  assert_match(/Removed entities must have only _type and id fields/, ex.message)
809
809
  end
810
810
 
811
+ def test_functional_update_move
812
+ c1_id, c2_id, c3_id = @model1.children.pluck(:id)
813
+ c4_id, c5_id = @model2.children.pluck(:id)
814
+
815
+ remove_fupdate = build_fupdate do
816
+ remove([{ '_type' => 'Child', 'id' => c2_id }])
817
+ end
818
+
819
+ append_fupdate = build_fupdate do
820
+ append([{ '_type' => 'Child', 'id' => c2_id }])
821
+ end
822
+
823
+ move_view = [
824
+ {
825
+ '_type' => 'Model',
826
+ 'id' => @model1.id,
827
+ 'children' => remove_fupdate
828
+ },
829
+ {
830
+ '_type' => 'Model',
831
+ 'id' => @model2.id,
832
+ 'children' => append_fupdate
833
+ }
834
+ ]
835
+
836
+ viewmodel_class.deserialize_from_view(move_view)
837
+ @model1.reload
838
+ @model2.reload
839
+
840
+ assert_equal([c1_id, c3_id], @model1.children.pluck(:id))
841
+ assert_equal([c4_id, c5_id, c2_id], @model2.children.pluck(:id))
842
+ end
843
+
811
844
  def test_functional_update_update_success
812
845
  c1_id, c2_id, c3_id = @model1.children.pluck(:id)
813
846
 
@@ -865,6 +898,131 @@ class ViewModel::ActiveRecord::HasManyTest < ActiveSupport::TestCase
865
898
  assert_match(/Duplicate functional update targets\b.*\bChild\b/, ex.message)
866
899
  end
867
900
 
901
+ describe 'sti polymorphic children' do
902
+ def setup
903
+ child_viewmodel_class
904
+ dog_viewmodel_class
905
+ cat_viewmodel_class
906
+ enable_logging!
907
+ end
908
+
909
+ def child_attributes
910
+ super().merge(schema: ->(t) do
911
+ t.string :type, null: false
912
+ t.integer :dog_number
913
+ t.integer :cat_number
914
+ end)
915
+ end
916
+
917
+ def subject_association_features
918
+ { viewmodels: [:Dog, :Cat] }
919
+ end
920
+
921
+ def dog_viewmodel_class
922
+ @dog_viewmodel_class ||= define_viewmodel_class(:Dog, namespace: namespace, viewmodel_base: viewmodel_base, model_base: child_model_class) do
923
+ define_model {}
924
+ define_viewmodel do
925
+ attribute :dog_number
926
+ acts_as_list :position
927
+ end
928
+ end
929
+ end
930
+
931
+ def cat_viewmodel_class
932
+ @cat_viewmodel_class ||= define_viewmodel_class(:Cat, namespace: namespace, viewmodel_base: viewmodel_base, model_base: child_model_class) do
933
+ define_model {}
934
+ define_viewmodel do
935
+ attribute :cat_number
936
+ acts_as_list :position
937
+ end
938
+ end
939
+ end
940
+
941
+ def new_model
942
+ model_class.new(name: 'p', children: [Dog.new(position: 1, dog_number: 1), Cat.new(position: 2, cat_number: 2)])
943
+ end
944
+
945
+ it 'creates the model structure' do
946
+ m = create_model!
947
+ m.reload
948
+ assert(m.is_a?(Model))
949
+ children = m.children.order(:position)
950
+ assert_equal(2, children.size)
951
+ assert_kind_of(Dog, children[0])
952
+ assert_kind_of(Cat, children[1])
953
+ end
954
+
955
+ it 'serializes' do
956
+ model = create_model!
957
+ view = serialize(ModelView.new(model))
958
+ expected_view = {
959
+ 'id' => 1, '_type' => 'Model', '_version' => 1, 'name' => 'p',
960
+ 'children' => [
961
+ { 'id' => 1, '_type' => 'Dog', '_version' => 1, 'dog_number' => 1 },
962
+ { 'id' => 2, '_type' => 'Cat', '_version' => 1, 'cat_number' => 2 },
963
+ ]
964
+ }
965
+ assert_equal(expected_view, view)
966
+ end
967
+
968
+ it 'creates from view' do
969
+ view = {
970
+ '_type' => 'Model',
971
+ 'name' => 'p',
972
+ 'children' => [
973
+ { '_type' => 'Dog', 'dog_number' => 1 },
974
+ { '_type' => 'Cat', 'cat_number' => 2 },
975
+ ]
976
+ }
977
+
978
+ pv = ModelView.deserialize_from_view(view)
979
+ p = pv.model
980
+
981
+ assert(!p.changed?)
982
+ assert(!p.new_record?)
983
+
984
+ assert_equal('p', p.name)
985
+
986
+ children = p.children.order(:position)
987
+
988
+ assert_equal(2, children.size)
989
+ assert_kind_of(Dog, children[0])
990
+ assert_equal(1, children[0].dog_number)
991
+ assert_kind_of(Cat, children[1])
992
+ assert_equal(2, children[1].cat_number)
993
+ end
994
+
995
+ it 'updates with reordering' do
996
+ model = create_model!
997
+
998
+ alter_by_view!(ModelView, model) do |view, _refs|
999
+ view['children'].reverse!
1000
+ end
1001
+
1002
+ children = model.children.order(:position)
1003
+ assert_equal(2, children.size)
1004
+ assert_kind_of(Cat, children[0])
1005
+ assert_equal(2, children[0].cat_number)
1006
+ assert_kind_of(Dog, children[1])
1007
+ assert_equal(1, children[1].dog_number)
1008
+ end
1009
+
1010
+ it 'functional updates' do
1011
+ model = create_model!
1012
+
1013
+ alter_by_view!(ModelView, model) do |view, refs|
1014
+ view['children'] = build_fupdate do
1015
+ append([{ '_type' => 'Cat', 'cat_number' => 100 }])
1016
+ end
1017
+ end
1018
+
1019
+ assert_equal(3, model.children.size)
1020
+ new_child = model.children.order(:position).last
1021
+ assert_kind_of(Cat, new_child)
1022
+ assert_equal(100, new_child.cat_number)
1023
+ end
1024
+ end
1025
+
868
1026
  describe 'owned reference children' do
869
1027
  def child_attributes
870
1028
  super.merge(viewmodel: ->(_v) { root! })
@@ -118,7 +118,7 @@ class ViewModel::ActiveRecord::HasManyThroughPolyTest < ActiveSupport::TestCase
118
118
  def test_loading_batching
119
119
  context = ParentView.new_serialize_context
120
120
  log_queries do
121
- parent_views = ParentView.load(serialize_context: context)
121
+ parent_views = ParentView.load
122
122
  serialize(parent_views, serialize_context: context)
123
123
  end
124
124
 
@@ -112,7 +112,7 @@ class ViewModel::ActiveRecord::HasManyThroughTest < ActiveSupport::TestCase
112
112
  def test_loading_batching
113
113
  context = ParentView.new_serialize_context
114
114
  log_queries do
115
- parent_views = ParentView.load(serialize_context: context)
115
+ parent_views = ParentView.load
116
116
  serialize(parent_views, serialize_context: context)
117
117
  end
118
118
 
@@ -494,7 +494,7 @@ class ViewModel::CallbacksTest < ActiveSupport::TestCase
494
494
  let(:callback) { CallbackCrasher.new }
495
495
 
496
496
  it 'raises the callback error' do
497
- proc { serialize(vm) }.must_raise(Crash)
497
+ _(-> { serialize(vm) }).must_raise(Crash)
498
498
  end
499
499
 
500
500
  describe 'with an access control that rejects' do
@@ -503,7 +503,7 @@ class ViewModel::CallbacksTest < ActiveSupport::TestCase
503
503
  end
504
504
 
505
505
  it 'fails access control first' do
506
- proc { serialize(vm) }.must_raise(ViewModel::AccessControlError)
506
+ _(-> { serialize(vm) }).must_raise(ViewModel::AccessControlError)
507
507
  end
508
508
 
509
509
  describe 'and a view-mutating callback that crashes' do
@@ -514,7 +514,7 @@ class ViewModel::CallbacksTest < ActiveSupport::TestCase
514
514
  let(:callback) { MutatingCrasher.new }
515
515
 
516
516
  it 'raises the callback error first' do
517
- proc { serialize(vm) }.must_raise(Crash)
517
+ _(-> { serialize(vm) }).must_raise(Crash)
518
518
  end
519
519
  end
520
520
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: iknow_view_models
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.13
4
+ version: 3.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - iKnow Team
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-02-19 00:00:00.000000000 Z
11
+ date: 2021-06-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -379,6 +379,7 @@ files:
379
379
  - Rakefile
380
380
  - gemfiles/rails_5_2.gemfile
381
381
  - gemfiles/rails_6_0.gemfile
382
+ - gemfiles/rails_6_1.gemfile
382
383
  - iknow_view_models.gemspec
383
384
  - lib/iknow_view_models.rb
384
385
  - lib/iknow_view_models/railtie.rb
@@ -485,14 +486,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
485
486
  requirements:
486
487
  - - ">="
487
488
  - !ruby/object:Gem::Version
488
- version: '0'
489
+ version: '2.7'
489
490
  required_rubygems_version: !ruby/object:Gem::Requirement
490
491
  requirements:
491
492
  - - ">="
492
493
  - !ruby/object:Gem::Version
493
494
  version: '0'
494
495
  requirements: []
495
- rubygems_version: 3.0.3
496
+ rubygems_version: 3.1.6
496
497
  signing_key:
497
498
  specification_version: 4
498
499
  summary: ViewModels provide a means of encapsulating a collection of related data