iknow_view_models 3.2.10 → 3.3.0

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: 280c8d764916ec30bb500e2f0e982131416d27ea51a3a25d1cd7e3908bb4ad5b
4
- data.tar.gz: 50d202b87cde141ac58ad82a5de6b73332f9b60a71c23fbbd378ba6c1def8184
3
+ metadata.gz: 680a05c75dacc9f5380b920439650249c11d04ab3f1b685e88081414fc4a72d3
4
+ data.tar.gz: 7328f45000837430c6627ecb1f916f529172b53fe0266aba8a0d02c48d131935
5
5
  SHA512:
6
- metadata.gz: 51333e3292118de4b71e8869ea4843be804ad2d24ce0c7296918c3f09b0df93740561afeb466b4a8874a1b5b667922b664745e994151b8fe47ce458bb087d14d
7
- data.tar.gz: 999d1feb717d4ef5ce11b400abf67772c5b70fd7c91cf3e328f14e5ff66a373b7c98aa5f701cb436cf4bf7df448bc553fca96131de570cead59734e8131c4788
6
+ metadata.gz: 5ae7508982f9e95354f137e84e8bb844b5373f5236526c7640276cc93f3bdb78846864a0cc35209ded4cd24963bbab248010849bfe6cbabcf8c01046288c97bd
7
+ data.tar.gz: 459318dca0636a4381b96e83ce6d260380b74106834b6d60b88af886363c846233c9dc2547e082e009b799d8e674d2cf48811bd49765d3bd2e2a4f2f8e307882
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.10'
4
+ VERSION = '3.3.0'
5
5
  end
data/lib/view_model.rb CHANGED
@@ -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)
@@ -377,3 +388,4 @@ require 'view_model/deserialize_context'
377
388
  require 'view_model/changes'
378
389
  require 'view_model/schemas'
379
390
  require 'view_model/error_view'
391
+ require 'view_model/garbage_collection'
@@ -182,9 +182,14 @@ class ViewModel::ActiveRecord < ViewModel::Record
182
182
  end
183
183
 
184
184
  # Constructs a preload specification of the required models for
185
- # serializing/deserializing this view.
186
- def eager_includes(serialize_context: new_serialize_context, include_referenced: true)
185
+ # serializing/deserializing this view. Cycles in the schema will be broken
186
+ # after two layers of eager loading.
187
+ def eager_includes(serialize_context: new_serialize_context, include_referenced: true, vm_path: [])
187
188
  association_specs = {}
189
+
190
+ return nil if vm_path.count(self) > 2
191
+
192
+ child_path = vm_path + [self]
188
193
  _members.each do |assoc_name, association_data|
189
194
  next unless association_data.is_a?(AssociationData)
190
195
  next if association_data.external?
@@ -201,7 +206,7 @@ class ViewModel::ActiveRecord < ViewModel::Record
201
206
  case
202
207
  when association_data.through?
203
208
  viewmodel = association_data.direct_viewmodel
204
- children = viewmodel.eager_includes(serialize_context: child_context, include_referenced: include_referenced)
209
+ children = viewmodel.eager_includes(serialize_context: child_context, include_referenced: include_referenced, vm_path: child_path)
205
210
 
206
211
  when !include_referenced && association_data.referenced?
207
212
  children = nil # Load up to the root viewmodel, but no further
@@ -210,13 +215,13 @@ class ViewModel::ActiveRecord < ViewModel::Record
210
215
  children_by_klass = {}
211
216
  association_data.viewmodel_classes.each do |vm_class|
212
217
  klass = vm_class.model_class.name
213
- children_by_klass[klass] = vm_class.eager_includes(serialize_context: child_context, include_referenced: include_referenced)
218
+ children_by_klass[klass] = vm_class.eager_includes(serialize_context: child_context, include_referenced: include_referenced, vm_path: child_path)
214
219
  end
215
220
  children = DeepPreloader::PolymorphicSpec.new(children_by_klass)
216
221
 
217
222
  else
218
223
  viewmodel = association_data.viewmodel_class
219
- children = viewmodel.eager_includes(serialize_context: child_context, include_referenced: include_referenced)
224
+ children = viewmodel.eager_includes(serialize_context: child_context, include_referenced: include_referenced, vm_path: child_path)
220
225
  end
221
226
 
222
227
  association_specs[association_data.direct_reflection.name.to_s] = children
@@ -340,12 +345,15 @@ class ViewModel::ActiveRecord < ViewModel::Record
340
345
 
341
346
  case
342
347
  when association_data.through?
343
- # associated here are join_table models; we need to get the far side out
348
+ # associated here are join-table models; we need to get the far side out
349
+ join_models = associated
350
+
344
351
  if association_data.direct_viewmodel._list_member?
345
- associated.order(association_data.direct_viewmodel._list_attribute_name)
352
+ attr = association_data.direct_viewmodel._list_attribute_name
353
+ join_models = join_models.sort_by { |j| j[attr] }
346
354
  end
347
355
 
348
- associated.map do |through_model|
356
+ join_models.map do |through_model|
349
357
  model = through_model.public_send(association_data.indirect_reflection.name)
350
358
  association_data.viewmodel_class_for_model!(model.class).new(model)
351
359
  end
@@ -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!
@@ -177,28 +214,27 @@ class ViewModel::ActiveRecord::Cache
177
214
  ViewModel.serialize(viewmodel, json, serialize_context: serialize_context)
178
215
  end
179
216
 
217
+ # viewmodels referenced from roots
180
218
  referenced_viewmodels = serialize_context.extract_referenced_views!
181
219
 
182
220
  if migration_versions.present?
183
221
  migrator = ViewModel::DownMigrator.new(migration_versions)
184
222
 
185
- # This migration isn't given the chance to inspect/alter the contents
186
- # of referenced views, only their presence: it's strictly less
187
- # powerful than migrations on a fully serialized tree, as the only
188
- # possible action on a referenced child is to delete it. The effect of
189
- # this is that for sufficiently complex migrations where a parent view
190
- # must introduce children or alter the contents of its referenced
191
- # children, we may have to avoid caching while the migration is in
192
- # use.
223
+ # This migration isn't able to affect the contents of referenced
224
+ # views, only their presence. The references will be themselves
225
+ # rendered (and migrated) independently later. We mark the dummy
226
+ # references provided to exclude their partial contents from being
227
+ # themselves migrated.
193
228
  dummy_references = referenced_viewmodels.transform_values do |ref_vm|
194
229
  {
195
230
  ViewModel::TYPE_ATTRIBUTE => ref_vm.class.view_name,
196
231
  ViewModel::VERSION_ATTRIBUTE => ref_vm.class.schema_version,
197
232
  ViewModel::ID_ATTRIBUTE => ref_vm.id,
233
+ ViewModel::Migrator::EXCLUDE_FROM_MIGRATION => true,
198
234
  }.freeze
199
235
  end
200
236
 
201
- migrator.migrate!(builder.attributes!, references: dummy_references)
237
+ migrator.migrate!({ 'data' => builder.attributes!, 'references' => dummy_references })
202
238
 
203
239
  # Removed dummy references can be removed from referenced_viewmodels.
204
240
  referenced_viewmodels.keep_if { |k, _| dummy_references.has_key?(k) }
@@ -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
@@ -71,12 +71,12 @@ module ViewModel::ActiveRecord::Controller
71
71
  super.tap do |update_hash, refs|
72
72
  if migration_versions.present?
73
73
  migrator = ViewModel::UpMigrator.new(migration_versions)
74
- migrator.migrate!([update_hash, refs], references: refs)
74
+ migrator.migrate!({ 'data' => update_hash, 'references' => refs })
75
75
  end
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
 
@@ -84,7 +84,7 @@ module ViewModel::ActiveRecord::Controller
84
84
  if migration_versions.present?
85
85
  tree = jbuilder.attributes!
86
86
  migrator = ViewModel::DownMigrator.new(migration_versions)
87
- migrator.migrate!(tree, references: tree['references'])
87
+ migrator.migrate!(tree)
88
88
  end
89
89
  end
90
90
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ViewModel::GarbageCollection
4
+ class << self
5
+ def garbage_collect_references!(serialization)
6
+ return unless serialization.has_key?('references')
7
+
8
+ roots = serialization.except('references')
9
+ references = serialization['references']
10
+
11
+ worklist = Set.new(collect_references(roots))
12
+ visited = Set.new
13
+
14
+ while (live = worklist.first)
15
+ worklist.delete(live)
16
+ visited << live
17
+ collect_references(references[live]) do |ref|
18
+ worklist << ref unless visited.include?(ref)
19
+ end
20
+ end
21
+
22
+ references.keep_if { |ref, _val| visited.include?(ref) }
23
+ end
24
+
25
+ private
26
+
27
+ ## yield each reference encountered in tree
28
+ def collect_references(tree, &block)
29
+ return enum_for(__method__, tree) unless block_given?
30
+
31
+ case tree
32
+ when Hash
33
+ if tree.size == 1 && (ref = tree[ViewModel::REFERENCE_ATTRIBUTE])
34
+ block.(ref)
35
+ else
36
+ tree.each_value { |t| collect_references(t, &block) }
37
+ end
38
+ when Array
39
+ tree.each { |t| collect_references(t, &block) }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  class ViewModel
4
4
  class Migrator
5
+ EXCLUDE_FROM_MIGRATION = '_exclude_from_migration'
6
+
5
7
  class << self
6
8
  def migrated_deep_schema_version(viewmodel_class, required_versions, include_referenced: true)
7
9
  deep_schema_version = viewmodel_class.deep_schema_version(include_referenced: include_referenced)
@@ -34,27 +36,37 @@ class ViewModel
34
36
  end
35
37
  end
36
38
 
37
- def migrate!(node, references:)
39
+ def migrate!(serialization)
40
+ migrate_tree!(serialization, references: serialization['references'] || {})
41
+ GarbageCollection.garbage_collect_references!(serialization)
42
+ end
43
+
44
+ private
45
+
46
+ def migrate_tree!(node, references:)
38
47
  case node
39
48
  when Hash
40
49
  if (type = node[ViewModel::TYPE_ATTRIBUTE])
41
50
  version = node[ViewModel::VERSION_ATTRIBUTE]
42
51
 
52
+ # We allow subtrees to be excluded from migration. This is used
53
+ # internally to permit stub references that are not a full
54
+ # serialization of the referenced type: see ViewModel::Cache.
55
+ return if node[EXCLUDE_FROM_MIGRATION]
56
+
43
57
  if migrate_viewmodel!(type, version, node, references)
44
58
  node[ViewModel::MIGRATED_ATTRIBUTE] = true
45
59
  end
46
60
  end
47
61
 
48
62
  node.each_value do |child|
49
- migrate!(child, references: references)
63
+ migrate_tree!(child, references: references)
50
64
  end
51
65
  when Array
52
- node.each { |child| migrate!(child, references: references) }
66
+ node.each { |child| migrate_tree!(child, references: references) }
53
67
  end
54
68
  end
55
69
 
56
- private
57
-
58
70
  def migrate_viewmodel!(_view_name, _version, _view_hash, _references)
59
71
  raise RuntimeError.new('abstract method')
60
72
  end
@@ -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)
@@ -141,10 +141,10 @@ module ViewModelSpecHelpers
141
141
  end
142
142
  end
143
143
 
144
- module ParentAndBelongsToChildWithMigration
144
+ module ParentAndChildMigrations
145
145
  extend ActiveSupport::Concern
146
- include ViewModelSpecHelpers::ParentAndBelongsToChild
147
- def model_attributes
146
+
147
+ def model_attributes
148
148
  super.merge(
149
149
  schema: ->(t) { t.integer :new_field, default: 1, null: false },
150
150
  viewmodel: ->(_v) {
@@ -206,6 +206,12 @@ module ViewModelSpecHelpers
206
206
  end
207
207
  end
208
208
 
209
+ module ParentAndBelongsToChildWithMigration
210
+ extend ActiveSupport::Concern
211
+ include ViewModelSpecHelpers::ParentAndBelongsToChild
212
+ include ViewModelSpecHelpers::ParentAndChildMigrations
213
+ end
214
+
209
215
  module ParentAndSharedBelongsToChild
210
216
  extend ActiveSupport::Concern
211
217
  include ViewModelSpecHelpers::ParentAndBelongsToChild
@@ -122,7 +122,7 @@ class ViewModel::ActiveRecord
122
122
 
123
123
  if migration_versions.present?
124
124
  migrator = ViewModel::DownMigrator.new(migration_versions)
125
- migrator.migrate!([data, refs], references: refs)
125
+ migrator.migrate!({ 'data' => data, 'references' => refs })
126
126
  end
127
127
 
128
128
  [data, 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
@@ -101,8 +101,10 @@ class ViewModel::ActiveRecord::HasManyThroughTest < ActiveSupport::TestCase
101
101
  @tag1, @tag2, @tag3 = (1..3).map { |x| Tag.create!(name: "tag#{x}") }
102
102
 
103
103
  @parent1 = Parent.create(name: 'p1',
104
- parents_tags: [ParentsTag.new(tag: @tag1, position: 1.0),
105
- ParentsTag.new(tag: @tag2, position: 2.0),])
104
+ parents_tags: [
105
+ ParentsTag.new(tag: @tag2, position: 2.0),
106
+ ParentsTag.new(tag: @tag1, position: 1.0),
107
+ ])
106
108
 
107
109
  enable_logging!
108
110
  end
@@ -24,25 +24,39 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
24
24
  let(:up_migrator) { ViewModel::UpMigrator.new(migration_versions) }
25
25
 
26
26
  def migrate!
27
- migrator.migrate!(subject, references: {})
27
+ migrator.migrate!(subject)
28
28
  end
29
29
 
30
- let(:current_serialization) { ViewModel.serialize_to_hash(viewmodel) }
30
+ let(:current_serialization) do
31
+ ctx = viewmodel_class.new_serialize_context
32
+ view = ViewModel.serialize_to_hash(viewmodel, serialize_context: ctx)
33
+ refs = ctx.serialize_references_to_hash
34
+ { 'data' => view, 'references' => refs }
35
+ end
31
36
 
32
- let(:v2_serialization) do
37
+ let(:v2_serialization_data) do
33
38
  {
34
- ViewModel::TYPE_ATTRIBUTE => viewmodel_class.view_name,
35
- ViewModel::VERSION_ATTRIBUTE => 2,
36
- ViewModel::ID_ATTRIBUTE => viewmodel.id,
37
- 'name' => viewmodel.name,
38
- 'old_field' => 1,
39
- 'child' => {
40
- ViewModel::TYPE_ATTRIBUTE => child_viewmodel_class.view_name,
39
+ ViewModel::TYPE_ATTRIBUTE => viewmodel_class.view_name,
41
40
  ViewModel::VERSION_ATTRIBUTE => 2,
42
- ViewModel::ID_ATTRIBUTE => viewmodel.child.id,
43
- 'name' => viewmodel.child.name,
44
- 'former_field' => 'former_value',
45
- },
41
+ ViewModel::ID_ATTRIBUTE => viewmodel.id,
42
+ 'name' => viewmodel.name,
43
+ 'old_field' => 1,
44
+ 'child' => {
45
+ ViewModel::TYPE_ATTRIBUTE => child_viewmodel_class.view_name,
46
+ ViewModel::VERSION_ATTRIBUTE => 2,
47
+ ViewModel::ID_ATTRIBUTE => viewmodel.child.id,
48
+ 'name' => viewmodel.child.name,
49
+ 'former_field' => 'former_value',
50
+ },
51
+ }
52
+ end
53
+
54
+ let(:v2_serialization_references) { {} }
55
+
56
+ let(:v2_serialization) do
57
+ {
58
+ 'data' => v2_serialization_data,
59
+ 'references' => v2_serialization_references,
46
60
  }
47
61
  end
48
62
 
@@ -56,14 +70,15 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
56
70
  let(:expected_result) do
57
71
  v2_serialization.deep_merge(
58
72
  {
59
- ViewModel::MIGRATED_ATTRIBUTE => true,
60
- 'old_field' => -1,
61
- 'child' => {
73
+ 'data' => {
62
74
  ViewModel::MIGRATED_ATTRIBUTE => true,
63
- 'former_field' => 'reconstructed',
75
+ 'old_field' => -1,
76
+ 'child' => {
77
+ ViewModel::MIGRATED_ATTRIBUTE => true,
78
+ 'former_field' => 'reconstructed',
79
+ },
64
80
  },
65
- },
66
- )
81
+ })
67
82
  end
68
83
 
69
84
  it 'migrates' do
@@ -85,15 +100,21 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
85
100
 
86
101
  describe 'upwards' do
87
102
  let(:migrator) { up_migrator }
88
- let(:subject) { v2_serialization.deep_dup }
103
+ let(:subject_data) { v2_serialization_data.deep_dup }
104
+ let(:subject_references) { v2_serialization_references.deep_dup }
105
+ let(:subject) { { 'data' => subject_data, 'references' => subject_references } }
89
106
 
90
107
  let(:expected_result) do
91
108
  current_serialization.deep_merge(
92
- ViewModel::MIGRATED_ATTRIBUTE => true,
93
- 'new_field' => 3,
94
- 'child' => {
95
- ViewModel::MIGRATED_ATTRIBUTE => true,
96
- },
109
+ {
110
+ 'data' => {
111
+ ViewModel::MIGRATED_ATTRIBUTE => true,
112
+ 'new_field' => 3,
113
+ 'child' => {
114
+ ViewModel::MIGRATED_ATTRIBUTE => true,
115
+ },
116
+ },
117
+ }
97
118
  )
98
119
  end
99
120
 
@@ -104,9 +125,8 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
104
125
  end
105
126
 
106
127
  describe 'with version unspecified' do
107
- let(:subject) do
108
- v2_serialization
109
- .except(ViewModel::VERSION_ATTRIBUTE)
128
+ let(:subject_data) do
129
+ v2_serialization_data.except(ViewModel::VERSION_ATTRIBUTE)
110
130
  end
111
131
 
112
132
  it 'treats it as the requested version' do
@@ -116,8 +136,8 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
116
136
  end
117
137
 
118
138
  describe 'with a version not in the specification' do
119
- let(:subject) do
120
- v2_serialization
139
+ let(:subject_data) do
140
+ v2_serialization_data
121
141
  .except('old_field')
122
142
  .deep_merge(ViewModel::VERSION_ATTRIBUTE => 3, 'mid_field' => 1)
123
143
  end
@@ -132,8 +152,8 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
132
152
  describe 'from an unreachable version' do
133
153
  let(:migration_versions) { { viewmodel_class => 2, child_viewmodel_class => 1 } }
134
154
 
135
- let(:subject) do
136
- v2_serialization.deep_merge(
155
+ let(:subject_data) do
156
+ v2_serialization_data.deep_merge(
137
157
  'child' => { ViewModel::VERSION_ATTRIBUTE => 1 },
138
158
  )
139
159
  end
@@ -148,8 +168,8 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
148
168
  describe 'in an undefined direction' do
149
169
  let(:migration_versions) { { viewmodel_class => 1, child_viewmodel_class => 2 } }
150
170
 
151
- let(:subject) do
152
- v2_serialization.except('old_field').merge(ViewModel::VERSION_ATTRIBUTE => 1)
171
+ let(:subject_data) do
172
+ v2_serialization_data.except('old_field').merge(ViewModel::VERSION_ATTRIBUTE => 1)
153
173
  end
154
174
 
155
175
  it 'raises' do
@@ -161,6 +181,75 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
161
181
  end
162
182
  end
163
183
 
184
+ describe 'garbage collection' do
185
+ include ViewModelSpecHelpers::ParentAndSharedBelongsToChild
186
+
187
+ # current (v2) features the shared child, v1 did not
188
+ def model_attributes
189
+ super.merge(
190
+ viewmodel: ->(_v) {
191
+ self.schema_version = 2
192
+ migrates from: 1, to: 2 do
193
+ down do |view, refs|
194
+ view.delete('child')
195
+ end
196
+ end
197
+ })
198
+ end
199
+
200
+ # current (v2) refers to another child, v1 did not
201
+ def child_attributes
202
+ super.merge(
203
+ schema: ->(t) { t.references :child, foreign_key: true },
204
+ model: ->(m) {
205
+ belongs_to :child, inverse_of: :parent, dependent: :destroy
206
+ has_one :parent, inverse_of: :child, class_name: self.name
207
+ },
208
+ viewmodel: ->(_v) {
209
+ self.schema_version = 2
210
+ association :child
211
+ migrates from: 1, to: 2 do
212
+ down do |view, refs|
213
+ view.delete('child')
214
+ end
215
+ end
216
+ })
217
+ end
218
+
219
+ def new_model
220
+ model_class.new(name: 'm1',
221
+ child: child_model_class.new(
222
+ name: 'c1',
223
+ child: child_model_class.new(
224
+ name: 'c2')))
225
+ end
226
+
227
+
228
+ let(:migrator) { down_migrator }
229
+ let(:migration_versions) { { viewmodel_class => 1, child_viewmodel_class => 1 } }
230
+
231
+ let(:subject) { current_serialization.deep_dup }
232
+
233
+ let(:expected_result) do
234
+ {
235
+ 'data' => {
236
+ ViewModel::TYPE_ATTRIBUTE => viewmodel_class.view_name,
237
+ ViewModel::VERSION_ATTRIBUTE => 1,
238
+ ViewModel::ID_ATTRIBUTE => viewmodel.id,
239
+ ViewModel::MIGRATED_ATTRIBUTE => true,
240
+ 'name' => viewmodel.name,
241
+ },
242
+ 'references' => {},
243
+ }
244
+ end
245
+
246
+ it 'migrates' do
247
+ migrate!
248
+
249
+ assert_equal(expected_result, subject)
250
+ end
251
+ end
252
+
164
253
  describe 'without migrations' do
165
254
  describe 'to an unreachable version' do
166
255
  include ViewModelSpecHelpers::ParentAndBelongsToChild
@@ -416,6 +416,11 @@ class ViewModel::ActiveRecordTest < ActiveSupport::TestCase
416
416
  @list = List.new(child: List.new(child: nil))
417
417
  end
418
418
 
419
+ def test_list_eager_includes
420
+ expected_includes = 3.times.inject(nil) { |x| DeepPreloader::Spec.new({ 'child' => x }) }
421
+ assert_equal(expected_includes, ListView.eager_includes)
422
+ end
423
+
419
424
  def test_deserialize_context
420
425
  view = {
421
426
  '_type' => 'List',
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'minitest/unit'
5
+
6
+ require 'view_model'
7
+ require 'view_model/garbage_collection'
8
+
9
+ class ViewModel::GarbageCollectionTest < ActiveSupport::TestCase
10
+ extend Minitest::Spec::DSL
11
+
12
+ # Generate a viewmodel-serialization alike from a minimal structure
13
+ # @param [Hash<Symbol, Array<Symbol>] structure mapping from id to referenced ids
14
+ # @param [Hash<Symbol, Array<Symbol>] data_ids list of ids of data elements
15
+ def mock_serialization(data_skeleton, refs_skeleton)
16
+ data = []
17
+ references = {}
18
+
19
+ generate(data_skeleton) do |id, body|
20
+ data << body
21
+ end
22
+
23
+ generate(refs_skeleton) do |id, body|
24
+ references[id] = body
25
+ end
26
+
27
+ {
28
+ "data" => data,
29
+ "references" => references,
30
+ }
31
+ end
32
+
33
+ def generate(skeleton)
34
+ skeleton.each do |id, referred|
35
+ yield id, ({
36
+ ViewModel::ID_ATTRIBUTE => id,
37
+ :children => referred.map do |referred_id|
38
+ { ViewModel::REFERENCE_ATTRIBUTE => referred_id }
39
+ end
40
+ })
41
+ end
42
+ end
43
+
44
+ def retained_ids(data_skeleton, refs_skeleton)
45
+ serialization = mock_serialization(data_skeleton, refs_skeleton)
46
+ ViewModel::GarbageCollection.garbage_collect_references!(serialization)
47
+ Set.new(
48
+ (serialization['data'].map { |x| x[ViewModel::ID_ATTRIBUTE] }) +
49
+ (serialization['references'].keys),
50
+ )
51
+ end
52
+
53
+ it 'keeps all roots' do
54
+ assert_equal(
55
+ Set.new([:a, :b, :c]),
56
+ retained_ids({ a: [], b: [], c: [] }, {})
57
+ )
58
+ end
59
+
60
+ it 'keeps a list' do
61
+ assert_equal(
62
+ Set.new([:a, :b, :c, :d]),
63
+ retained_ids({ a: [:b], }, { b: [:c], c: [:d], d: [] }),
64
+ )
65
+ end
66
+
67
+ it 'keeps a child with a removed reference' do
68
+ assert_equal(
69
+ Set.new([:a, :z]),
70
+ retained_ids({ a: [:z], }, { b: [:z], z: [] }),
71
+ )
72
+ end
73
+
74
+ it 'prunes a list at the head' do
75
+ assert_equal(
76
+ Set.new([:a]),
77
+ retained_ids({ a: [], }, { b: [:c], c: [:d], d: [] }),
78
+ )
79
+ end
80
+ 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.10
4
+ version: 3.3.0
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-01-12 00:00:00.000000000 Z
11
+ date: 2021-04-13 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
@@ -414,6 +415,7 @@ files:
414
415
  - lib/view_model/deserialize_context.rb
415
416
  - lib/view_model/error.rb
416
417
  - lib/view_model/error_view.rb
418
+ - lib/view_model/garbage_collection.rb
417
419
  - lib/view_model/migratable_view.rb
418
420
  - lib/view_model/migration.rb
419
421
  - lib/view_model/migration/no_path_error.rb
@@ -467,6 +469,7 @@ files:
467
469
  - test/unit/view_model/callbacks_test.rb
468
470
  - test/unit/view_model/controller_test.rb
469
471
  - test/unit/view_model/deserialization_error/unique_violation_test.rb
472
+ - test/unit/view_model/garbage_collection_test.rb
470
473
  - test/unit/view_model/record_test.rb
471
474
  - test/unit/view_model/registry_test.rb
472
475
  - test/unit/view_model/traversal_context_test.rb
@@ -483,14 +486,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
483
486
  requirements:
484
487
  - - ">="
485
488
  - !ruby/object:Gem::Version
486
- version: '0'
489
+ version: '2.7'
487
490
  required_rubygems_version: !ruby/object:Gem::Requirement
488
491
  requirements:
489
492
  - - ">="
490
493
  - !ruby/object:Gem::Version
491
494
  version: '0'
492
495
  requirements: []
493
- rubygems_version: 3.0.3
496
+ rubygems_version: 3.1.6
494
497
  signing_key:
495
498
  specification_version: 4
496
499
  summary: ViewModels provide a means of encapsulating a collection of related data
@@ -527,6 +530,7 @@ test_files:
527
530
  - test/unit/view_model/callbacks_test.rb
528
531
  - test/unit/view_model/controller_test.rb
529
532
  - test/unit/view_model/deserialization_error/unique_violation_test.rb
533
+ - test/unit/view_model/garbage_collection_test.rb
530
534
  - test/unit/view_model/record_test.rb
531
535
  - test/unit/view_model/registry_test.rb
532
536
  - test/unit/view_model/traversal_context_test.rb