iknow_view_models 3.2.13 → 3.4.1

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