iknow_view_models 3.5.1 → 3.6.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: 1b538a2d547114ecfbb76bfc790daea11b2185abdbad54d5da82723bb7fa1bef
4
- data.tar.gz: 97b7e6d1d66cae14d56fe53931d06a4079e5c9b4b61e60bf56027d539fd41224
3
+ metadata.gz: 7f05051b924e9602a09da08b3aec5e5c4de224753d70d29523d66eb34db6947f
4
+ data.tar.gz: aac9f7453c54bb216d3cf59b438070c2857f186138c8ff871abaf026a94e4ab4
5
5
  SHA512:
6
- metadata.gz: 4f70185a7ba12a222eee6a1549f724c08754f7213a1c1594d5b2ea0060baac04fce839cb480615e02741adcc82cd2d910e8d82e519127da15efd6e14dbdf3b0b
7
- data.tar.gz: 82a343d49bf61a6e351653c8a3e3855344b5dd017acc7722cc526e41af8915d6aedb028995f859354df23de82f55b2c342b2fa1a572af76b1690345fb120ae02
6
+ metadata.gz: 3edbbb12ef310f58fea255589d2e2112cc62760518af31628c1121b0ea1db9634cb5ace9657087bceecd7f1bd38188fe9bec8b7e03c88613b773fafd2c6b7b6a
7
+ data.tar.gz: b063d4884cf2846bef6baca22bbc7f8533ddb2c876bf5ffa5e456a5834d7729881b0d08aab69ff524b5cf0fd451599346cce022502334f2bba26673b753a8068
data/.circleci/config.yml CHANGED
@@ -121,6 +121,11 @@ workflows:
121
121
  ruby-version: "3.0"
122
122
  pg-version: "12"
123
123
  gemfile: gemfiles/rails_6_1.gemfile
124
+ - test:
125
+ name: 'ruby 3.0 rails 7.0 pg 12'
126
+ ruby-version: "3.0"
127
+ pg-version: "12"
128
+ gemfile: gemfiles/rails_7_0.gemfile
124
129
  - publish:
125
130
  filters:
126
131
  branches:
data/Appraisals CHANGED
@@ -12,3 +12,8 @@ appraise 'rails-6-1' do
12
12
  gem 'activerecord', '~> 6.1.0'
13
13
  gem 'activesupport', '~> 6.1.0'
14
14
  end
15
+
16
+ appraise 'rails-7-0' do
17
+ gem 'activerecord', '~> 7.0.0'
18
+ gem 'activesupport', '~> 7.0.0'
19
+ end
data/Gemfile CHANGED
@@ -11,5 +11,5 @@ gem 'rubocop-iknow'
11
11
  gem 'minitest-ci'
12
12
 
13
13
  # Override gemspec for development version preferences
14
- gem 'activerecord', '~> 6.0.0'
15
- gem 'activesupport', '~> 6.0.0'
14
+ gem 'activerecord', '~> 7.0.0'
15
+ gem 'activesupport', '~> 7.0.0'
@@ -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", "~> 7.0.0"
7
+ gem "activesupport", "~> 7.0.0"
8
+
9
+ gemspec path: '../'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IknowViewModels
4
- VERSION = '3.5.1'
4
+ VERSION = '3.6.1'
5
5
  end
@@ -14,9 +14,9 @@ class ViewModel::ActiveRecord
14
14
  def release!
15
15
  model = viewmodel.model
16
16
  case association_data.direct_reflection.options[:dependent]
17
- when :delete
17
+ when :delete, :delete_all
18
18
  model.delete
19
- when :destroy
19
+ when :destroy, :destroy_async
20
20
  model.destroy
21
21
  end
22
22
  end
@@ -34,14 +34,36 @@ module ViewModel::MigratableView
34
34
  end
35
35
  end
36
36
 
37
+ protected
38
+
39
+ def migration_class(from, to)
40
+ @migration_classes.fetch([from, to]) do
41
+ raise ViewModel::Migration::NoPathError.new(self, from, to)
42
+ end
43
+ end
44
+
37
45
  private
38
46
 
39
47
  # Define a migration on this viewmodel
40
- def migrates(from:, to:, &block)
48
+ def migrates(from:, to:, inherit: nil, at: nil, &block)
41
49
  @migrations_lock.synchronize do
42
- builder = ViewModel::Migration::Builder.new
50
+ migration_superclass =
51
+ if inherit
52
+ raise ArgumentError.new('Must provide inherit version') unless at
53
+
54
+ inherit.migration_class(at - 1, at)
55
+ else
56
+ ViewModel::Migration
57
+ end
58
+
59
+ builder = ViewModel::Migration::Builder.new(migration_superclass)
43
60
  builder.instance_exec(&block)
44
- @migration_classes[[from, to]] = builder.build!
61
+
62
+ migration_class = builder.build!
63
+
64
+ const_set(:"Migration_#{from}_To_#{to}", migration_class)
65
+ @migration_classes[[from, to]] = migration_class
66
+
45
67
  @realized_migration_paths = false
46
68
  end
47
69
  end
@@ -15,13 +15,14 @@ class ViewModel::Migration
15
15
 
16
16
  # Tiny DSL for defining migration classes
17
17
  class Builder
18
- def initialize
18
+ def initialize(superclass = ViewModel::Migration)
19
+ @superclass = superclass
19
20
  @up_block = nil
20
21
  @down_block = nil
21
22
  end
22
23
 
23
24
  def build!
24
- migration = Class.new(ViewModel::Migration)
25
+ migration = Class.new(@superclass)
25
26
  migration.define_method(:up, &@up_block) if @up_block
26
27
  migration.define_method(:down, &@down_block) if @down_block
27
28
  migration
@@ -93,7 +93,7 @@ class ViewModel::Record < ViewModel
93
93
 
94
94
  if metadata.schema_version && !self.accepts_schema_version?(metadata.schema_version)
95
95
  raise ViewModel::DeserializationError::SchemaVersionMismatch.new(
96
- self, version, ViewModel::Reference.new(self, metadata.id))
96
+ self, metadata.schema_version, ViewModel::Reference.new(self, metadata.id))
97
97
  end
98
98
 
99
99
  viewmodel = resolve_viewmodel(metadata, view_hash, deserialize_context: deserialize_context)
@@ -359,12 +359,20 @@ class ViewModel::Record < ViewModel
359
359
 
360
360
  attribute_changed!(vm_attr_name)
361
361
 
362
- if attr_data.using_viewmodel? && !value.nil?
363
- # Extract model from target viewmodel(s) to attach to our model
364
- value = attr_data.map_value(value) { |vm| vm.model }
365
- end
362
+ model_value =
363
+ if attr_data.using_viewmodel? && !value.nil?
364
+ # Extract model from target viewmodel(s) to attach to our model
365
+ attr_data.map_value(value) { |vm| vm.model }
366
+ else
367
+ value
368
+ end
369
+
370
+ model.public_send("#{attr_data.model_attr_name}=", model_value)
366
371
 
367
- model.public_send("#{attr_data.model_attr_name}=", value)
372
+ elsif new_model?
373
+ # Record attribute_changed for mutable values asserted on a new model, even where
374
+ # they match the ActiveRecord default.
375
+ attribute_changed!(vm_attr_name) unless attr_data.read_only? && !attr_data.write_once?
368
376
  end
369
377
 
370
378
  if attr_data.using_viewmodel?
@@ -65,8 +65,11 @@ class ViewModel::TestHelpers::ARVMBuilder
65
65
  ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS #{name.underscore.pluralize} CASCADE")
66
66
  namespace.send(:remove_const, name)
67
67
  namespace.send(:remove_const, viewmodel_name) if viewmodel
68
+
68
69
  # prevent cached old class from being used to resolve associations
69
- ActiveSupport::Dependencies::Reference.clear!
70
+ if ActiveSupport::VERSION::MAJOR < 7
71
+ ActiveSupport::Dependencies::Reference.clear!
72
+ end
70
73
  end
71
74
 
72
75
  private
data/nix/dependencies.nix CHANGED
@@ -1,5 +1,5 @@
1
1
  {pkgs}:
2
2
  {
3
- ruby = pkgs.ruby_2_7;
3
+ ruby = pkgs.ruby_3_0;
4
4
  postgresql = pkgs.postgresql_12;
5
5
  }
@@ -323,7 +323,11 @@ module ControllerTestControllers
323
323
  CONTROLLER_NAMES.each do |name|
324
324
  Object.send(:remove_const, name)
325
325
  end
326
- ActiveSupport::Dependencies::Reference.clear!
326
+
327
+ if ActiveSupport::VERSION::MAJOR < 7
328
+ ActiveSupport::Dependencies::Reference.clear!
329
+ end
330
+
327
331
  super
328
332
  end
329
333
  end
@@ -13,6 +13,7 @@ class TestAccessControl < ViewModel::AccessControl
13
13
  @editable_checks = []
14
14
  @visible_checks = []
15
15
  @valid_edit_checks = []
16
+ @changes = []
16
17
  end
17
18
 
18
19
  # Collect
@@ -33,6 +34,17 @@ class TestAccessControl < ViewModel::AccessControl
33
34
  ViewModel::AccessControl::Result.new(@can_view)
34
35
  end
35
36
 
37
+ def record_deserialize_changes(ref, changes)
38
+ @changes << [ref, changes]
39
+ end
40
+
41
+ # Collect all changes on after_deserialize, to allow inspecting changes that
42
+ # didn't result in `changed?`
43
+ after_deserialize do
44
+ ref = view.to_reference
45
+ record_deserialize_changes(ref, changes)
46
+ end
47
+
36
48
  # Query (also see attr_accessors)
37
49
 
38
50
  def valid_edit_refs
@@ -55,4 +67,10 @@ class TestAccessControl < ViewModel::AccessControl
55
67
  def was_edited?(ref)
56
68
  all_valid_edit_changes(ref).present?
57
69
  end
70
+
71
+ def all_changes(ref)
72
+ @changes
73
+ .select { |cref, _changes| cref == ref }
74
+ .map { |_cref, changes| changes }
75
+ end
58
76
  end
@@ -206,6 +206,55 @@ module ViewModelSpecHelpers
206
206
  end
207
207
  end
208
208
 
209
+ module SingleWithInheritedMigration
210
+ extend ActiveSupport::Concern
211
+ include ViewModelSpecHelpers::Base
212
+
213
+ def migration_bearing_viewmodel_class
214
+ define_viewmodel_class(
215
+ :MigrationBearingView,
216
+ namespace: namespace,
217
+ viewmodel_base: viewmodel_base,
218
+ model_base: model_base,
219
+ spec: ViewModel::TestHelpers::ARVMBuilder::Spec.new(
220
+ schema: ->(_) {},
221
+ model: ->(_) {},
222
+ viewmodel: ->(v) {
223
+ root!
224
+ self.schema_version = 2
225
+ migrates from: 1, to: 2 do
226
+ down do |view, _refs|
227
+ view['inherited_base'] = 'present'
228
+ end
229
+ end
230
+ }))
231
+ end
232
+
233
+ def model_attributes
234
+ migration_bearing_viewmodel_class = self.migration_bearing_viewmodel_class
235
+
236
+ super.merge(
237
+ schema: ->(t) { t.integer :new_field, default: 1, null: false },
238
+ viewmodel: ->(_v) {
239
+ self.schema_version = 2
240
+
241
+ attribute :new_field
242
+
243
+ migrates from: 1, to: 2, inherit: migration_bearing_viewmodel_class, at: 2 do
244
+ down do |view, refs|
245
+ super(view, refs)
246
+ view.delete('new_field')
247
+ end
248
+
249
+ up do |view, refs|
250
+ view.delete('inherited_base')
251
+ view['new_field'] = 100
252
+ end
253
+ end
254
+ })
255
+ end
256
+ end
257
+
209
258
  module ParentAndBelongsToChildWithMigration
210
259
  extend ActiveSupport::Concern
211
260
  include ViewModelSpecHelpers::ParentAndBelongsToChild
@@ -210,6 +210,69 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
210
210
  end
211
211
  end
212
212
 
213
+ describe 'inherited migrations' do
214
+ include ViewModelSpecHelpers::SingleWithInheritedMigration
215
+
216
+ def new_model
217
+ model_class.new(name: 'm1')
218
+ end
219
+
220
+ let(:migration_versions) { { viewmodel_class => 1 } }
221
+
222
+ let(:v1_serialization_data) do
223
+ {
224
+ ViewModel::TYPE_ATTRIBUTE => viewmodel_class.view_name,
225
+ ViewModel::VERSION_ATTRIBUTE => 1,
226
+ ViewModel::ID_ATTRIBUTE => viewmodel.id,
227
+ 'name' => viewmodel.name,
228
+ 'inherited_base' => 'present',
229
+ }
230
+ end
231
+
232
+ let(:v1_serialization_references) { {} }
233
+
234
+ let(:v1_serialization) do
235
+ {
236
+ 'data' => v1_serialization_data,
237
+ 'references' => v1_serialization_references,
238
+ }
239
+ end
240
+
241
+ describe 'downwards' do
242
+ let(:migrator) { down_migrator }
243
+ let(:subject) { current_serialization.deep_dup }
244
+ let(:expected_result) do
245
+ v1_serialization.deep_merge({ 'data' => { ViewModel::MIGRATED_ATTRIBUTE => true } })
246
+ end
247
+
248
+ it 'migrates' do
249
+ migrate!
250
+ assert_equal(expected_result, subject)
251
+ end
252
+ end
253
+
254
+ describe 'upwards' do
255
+ let(:migrator) { up_migrator }
256
+ let(:subject) { v1_serialization.deep_dup }
257
+
258
+ let(:expected_result) do
259
+ current_serialization.deep_merge(
260
+ {
261
+ 'data' => {
262
+ ViewModel::MIGRATED_ATTRIBUTE => true,
263
+ 'new_field' => 100,
264
+ },
265
+ },
266
+ )
267
+ end
268
+
269
+ it 'migrates' do
270
+ migrate!
271
+ assert_equal(expected_result, subject)
272
+ end
273
+ end
274
+ end
275
+
213
276
  describe 'garbage collection' do
214
277
  include ViewModelSpecHelpers::ParentAndSharedBelongsToChild
215
278
 
@@ -57,11 +57,30 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
57
57
  let(:model_body) { nil }
58
58
  let(:viewmodel_body) { nil }
59
59
 
60
+ # Generate an ActiveModel-like keyword argument constructor.
61
+ def generate_model_constructor(model_class, model_defaults)
62
+ args = model_class.members
63
+ params = args.map do |arg_name|
64
+ "#{arg_name}: self.class.__constructor_default(:#{arg_name})"
65
+ end
66
+
67
+ <<-SRC
68
+ def initialize(#{params.join(", ")})
69
+ super(#{args.join(", ")})
70
+ end
71
+ SRC
72
+ end
73
+
60
74
  let(:model_class) do
61
75
  mb = model_body
62
- Struct.new(*attributes.keys) do
63
- class_eval(&mb) if mb
64
- end
76
+ mds = model_defaults
77
+
78
+ model = Struct.new(*attributes.keys)
79
+ constructor = generate_model_constructor(model, mds)
80
+ model.class_eval(constructor)
81
+ model.define_singleton_method(:__constructor_default) { |name| mds[name] }
82
+ model.class_eval(&mb) if mb
83
+ model
65
84
  end
66
85
 
67
86
  let(:viewmodel_class) do
@@ -96,21 +115,39 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
96
115
  end
97
116
  end
98
117
 
99
- let(:default_values) { {} }
100
- let(:default_view_values) { default_values }
101
- let(:default_model_values) { default_values }
118
+ # Default values for each model attribute, nil if absent
119
+ let(:model_defaults) { {} }
102
120
 
103
- let(:default_view) do
104
- attribute_names.each_with_object(view_base.dup) do |(model_attr_name, vm_attr_name), view|
105
- view[vm_attr_name] = default_view_values.fetch(vm_attr_name.to_sym, model_attr_name)
106
- end
121
+ # attribute values used to instantiate the subject model and subject view (if not overridden)
122
+ let(:subject_attributes) { {} }
123
+
124
+ # attribute values used to instantiate the subject model
125
+ let(:subject_model_attributes) { subject_attributes }
126
+
127
+ # attribute values used to deserialize the subject view: these are expected to
128
+ # deserialize to create a model equal to subject_model
129
+ let(:subject_view_attributes) { subject_attributes }
130
+
131
+ # Subject model to compare with or deserialize into
132
+ let(:subject_model) do
133
+ model_class.new(**subject_model_attributes)
134
+ end
135
+
136
+ # View that when deserialized into a new model will be equal to subject_model
137
+ let(:subject_view) do
138
+ view_base.merge(subject_view_attributes.stringify_keys)
107
139
  end
108
140
 
109
- let(:default_model) do
110
- attr_values = attribute_names.map do |model_attr_name, _vm_attr_name|
111
- default_model_values.fetch(model_attr_name.to_sym, model_attr_name)
141
+ # The expected result of serializing subject_model (depends on subject_view corresponding to subject_model)
142
+ let(:expected_view) do
143
+ view = subject_view.dup
144
+ attribute_names.each do |model_attr_name, vm_attr_name|
145
+ unless view.has_key?(vm_attr_name)
146
+ expected_value = subject_model_attributes.fetch(model_attr_name) { model_defaults[model_attr_name] }
147
+ view[vm_attr_name] = expected_value
148
+ end
112
149
  end
113
- model_class.new(*attr_values)
150
+ view
114
151
  end
115
152
 
116
153
  let(:access_control) { TestAccessControl.new(true, true, true) }
@@ -118,7 +155,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
118
155
  let(:create_context) { TestDeserializeContext.new(access_control: access_control) }
119
156
 
120
157
  # Prime our simplistic `resolve_viewmodel` with the desired models to update
121
- let(:update_context) { TestDeserializeContext.new(targets: [default_model], access_control: access_control) }
158
+ let(:update_context) { TestDeserializeContext.new(targets: [subject_model], access_control: access_control) }
122
159
 
123
160
  def assert_edited(vm, **changes)
124
161
  ref = vm.to_reference
@@ -139,9 +176,9 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
139
176
  def self.included(base)
140
177
  base.instance_eval do
141
178
  it 'can deserialize to a new model' do
142
- vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: create_context)
143
- assert_equal(default_model, vm.model)
144
- refute(default_model.equal?(vm.model))
179
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
180
+ assert_equal(subject_model, vm.model)
181
+ refute(subject_model.equal?(vm.model))
145
182
 
146
183
  all_view_attrs = attribute_names.map { |_mname, vname| vname }
147
184
  assert_edited(vm, new: true, changed_attributes: all_view_attrs)
@@ -154,8 +191,8 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
154
191
  def self.included(base)
155
192
  base.instance_eval do
156
193
  it 'can deserialize to existing model with no changes' do
157
- vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
158
- assert(default_model.equal?(vm.model))
194
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
195
+ assert(subject_model.equal?(vm.model))
159
196
 
160
197
  assert_unchanged(vm)
161
198
  end
@@ -167,8 +204,8 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
167
204
  def self.included(base)
168
205
  base.instance_eval do
169
206
  it 'can serialize to the expected view' do
170
- h = viewmodel_class.new(default_model).to_hash
171
- assert_equal(default_view, h)
207
+ h = viewmodel_class.new(subject_model).to_hash
208
+ assert_equal(expected_view, h)
172
209
  end
173
210
  end
174
211
  end
@@ -176,37 +213,48 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
176
213
 
177
214
  describe 'with simple attribute' do
178
215
  let(:attributes) { { simple: {} } }
216
+ let(:subject_attributes) { { simple: "simple" } }
217
+
179
218
  include CanSerialize
180
219
  include CanDeserializeToNew
181
220
  include CanDeserializeToExisting
182
221
 
183
222
  it 'can be updated' do
184
- new_view = default_view.merge('simple' => 'changed')
223
+ update_view = subject_view.merge('simple' => 'changed')
185
224
 
186
- vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
225
+ vm = viewmodel_class.deserialize_from_view(update_view, deserialize_context: update_context)
187
226
 
188
- assert(default_model.equal?(vm.model), 'returned model was not the same')
189
- assert_equal('changed', default_model.simple)
227
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
228
+ assert_equal('changed', subject_model.simple)
190
229
  assert_edited(vm, changed_attributes: [:simple])
191
230
  end
192
231
 
193
232
  it 'rejects unknown attributes' do
194
- view = default_view.merge('unknown' => 'illegal')
233
+ view = subject_view.merge('unknown' => 'illegal')
195
234
  ex = assert_raises(ViewModel::DeserializationError::UnknownAttribute) do
196
235
  viewmodel_class.deserialize_from_view(view, deserialize_context: create_context)
197
236
  end
198
237
  assert_equal('unknown', ex.attribute)
199
238
  end
200
239
 
240
+ it 'rejects unknown versions' do
241
+ view = subject_view.merge(ViewModel::VERSION_ATTRIBUTE => 100)
242
+ ex = assert_raises(ViewModel::DeserializationError::SchemaVersionMismatch) do
243
+ viewmodel_class.deserialize_from_view(view, deserialize_context: create_context)
244
+ end
245
+ end
246
+
201
247
  it 'edit checks when creating empty' do
202
248
  vm = viewmodel_class.deserialize_from_view(view_base, deserialize_context: create_context)
203
- refute(default_model.equal?(vm.model), 'returned model was the same')
249
+ refute(subject_model.equal?(vm.model), 'returned model was the same')
204
250
  assert_edited(vm, new: true)
205
251
  end
206
252
  end
207
253
 
208
254
  describe 'with validated simple attribute' do
209
255
  let(:attributes) { { validated: {} } }
256
+ let(:subject_attributes) { { validated: "validated" } }
257
+
210
258
  let(:viewmodel_body) do
211
259
  ->(_x) do
212
260
  def validate!
@@ -222,10 +270,10 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
222
270
  include CanDeserializeToExisting
223
271
 
224
272
  it 'rejects update when validation fails' do
225
- new_view = default_view.merge('validated' => 'naughty')
273
+ update_view = subject_view.merge('validated' => 'naughty')
226
274
 
227
275
  ex = assert_raises(ViewModel::DeserializationError::Validation) do
228
- viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
276
+ viewmodel_class.deserialize_from_view(update_view, deserialize_context: update_context)
229
277
  end
230
278
  assert_equal('validated', ex.attribute)
231
279
  assert_equal('was naughty', ex.reason)
@@ -234,16 +282,16 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
234
282
 
235
283
  describe 'with renamed attribute' do
236
284
  let(:attributes) { { modelname: { as: :viewname } } }
237
- let(:default_model_values) { { modelname: 'value' } }
238
- let(:default_view_values) { { viewname: 'value' } }
285
+ let(:subject_model_attributes) { { modelname: 'value' } }
286
+ let(:subject_view_attributes) { { viewname: 'value' } }
239
287
 
240
288
  include CanSerialize
241
289
  include CanDeserializeToNew
242
290
  include CanDeserializeToExisting
243
291
 
244
292
  it 'makes attributes available on their new names' do
245
- value(default_model.modelname).must_equal('value')
246
- vm = viewmodel_class.new(default_model)
293
+ value(subject_model.modelname).must_equal('value')
294
+ vm = viewmodel_class.new(subject_model)
247
295
  value(vm.viewname).must_equal('value')
248
296
  end
249
297
  end
@@ -251,15 +299,15 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
251
299
  describe 'with formatted attribute' do
252
300
  let(:attributes) { { moment: { format: IknowParams::Serializer::Time } } }
253
301
  let(:moment) { 1.week.ago.change(usec: 0) }
254
- let(:default_model_values) { { moment: moment } }
255
- let(:default_view_values) { { moment: moment.iso8601 } }
302
+ let(:subject_model_attributes) { { moment: moment } }
303
+ let(:subject_view_attributes) { { moment: moment.iso8601 } }
256
304
 
257
305
  include CanSerialize
258
306
  include CanDeserializeToNew
259
307
  include CanDeserializeToExisting
260
308
 
261
309
  it 'raises correctly on an unparseable value' do
262
- bad_view = default_view.tap { |v| v['moment'] = 'not a timestamp' }
310
+ bad_view = subject_view.merge('moment' => 'not a timestamp')
263
311
  ex = assert_raises(ViewModel::DeserializationError::Validation) do
264
312
  viewmodel_class.deserialize_from_view(bad_view, deserialize_context: create_context)
265
313
  end
@@ -268,7 +316,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
268
316
  end
269
317
 
270
318
  it 'raises correctly on an undeserializable value' do
271
- bad_model = default_model.tap { |m| m.moment = 2.7 }
319
+ bad_model = subject_model.tap { |m| m.moment = 2.7 }
272
320
  ex = assert_raises(ViewModel::SerializationError) do
273
321
  viewmodel_class.new(bad_model).to_hash
274
322
  end
@@ -278,36 +326,51 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
278
326
 
279
327
  describe 'with read-only attribute' do
280
328
  let(:attributes) { { read_only: { read_only: true } } }
329
+ let(:model_defaults) { { read_only: 'immutable' } }
330
+ let(:subject_attributes) { { read_only: 'immutable' } }
281
331
 
282
- include CanSerialize
283
- include CanDeserializeToExisting
332
+ describe 'asserting the default' do
333
+ include CanSerialize
334
+ include CanDeserializeToExisting
284
335
 
285
- it 'deserializes to new without the attribute' do
286
- new_view = default_view.tap { |v| v.delete('read_only') }
287
- vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: create_context)
288
- refute(default_model.equal?(vm.model))
289
- assert_nil(vm.model.read_only)
290
- assert_edited(vm, new: true)
291
- end
336
+ it 'deserializes to new with the attribute' do
337
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
338
+ assert_equal(subject_model, vm.model)
339
+ refute(subject_model.equal?(vm.model))
340
+ assert_edited(vm, new: true)
341
+ end
292
342
 
293
- it 'rejects deserialize from new' do
294
- ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
295
- viewmodel_class.deserialize_from_view(default_view, deserialize_context: create_context)
343
+ it 'deserializes to new without the attribute' do
344
+ new_view = subject_view.tap { |v| v.delete('read_only') }
345
+ vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: create_context)
346
+ assert_equal(subject_model, vm.model)
347
+ refute(subject_model.equal?(vm.model))
348
+ assert_edited(vm, new: true)
296
349
  end
297
- assert_equal('read_only', ex.attribute)
298
350
  end
299
351
 
300
- it 'rejects update if changed' do
301
- new_view = default_view.merge('read_only' => 'written')
302
- ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
303
- viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
352
+ describe 'attempting a change' do
353
+ let(:update_view) { subject_view.merge('read_only' => 'attempted change') }
354
+
355
+ it 'rejects deserialize from new' do
356
+ ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
357
+ viewmodel_class.deserialize_from_view(update_view, deserialize_context: create_context)
358
+ end
359
+ assert_equal('read_only', ex.attribute)
360
+ end
361
+
362
+ it 'rejects update' do
363
+ ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
364
+ viewmodel_class.deserialize_from_view(update_view, deserialize_context: update_context)
365
+ end
366
+ assert_equal('read_only', ex.attribute)
304
367
  end
305
- assert_equal('read_only', ex.attribute)
306
368
  end
307
369
  end
308
370
 
309
371
  describe 'with read-only write-once attribute' do
310
372
  let(:attributes) { { write_once: { read_only: true, write_once: true } } }
373
+ let(:subject_attributes) { { write_once: 'frozen' } }
311
374
  let(:model_body) do
312
375
  ->(_x) do
313
376
  # For the purposes of testing, we assume a record is new and can be
@@ -323,7 +386,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
323
386
  include CanDeserializeToExisting
324
387
 
325
388
  it 'rejects change to attribute' do
326
- new_view = default_view.merge('write_once' => 'written')
389
+ new_view = subject_view.merge('write_once' => 'written')
327
390
  ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
328
391
  viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
329
392
  end
@@ -331,10 +394,33 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
331
394
  end
332
395
  end
333
396
 
397
+ describe 'with unspecified attributes falling back to the model default' do
398
+ let(:attributes) { { value: {} } }
399
+ let(:model_defaults) { { value: 5 } }
400
+ let(:subject_view_attributes) { { } }
401
+ let(:subject_model_attributes) { { value: 5 } }
402
+
403
+ it 'can deserialize to a new model' do
404
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
405
+ assert_equal(subject_model, vm.model)
406
+ refute(subject_model.equal?(vm.model))
407
+ assert_edited(vm, new: true, changed_attributes: [])
408
+ end
409
+ end
410
+
411
+ describe 'with model defaults being asserted' do
412
+ let(:attributes) { { value: {} } }
413
+ let(:model_defaults) { { value: 5 } }
414
+ let(:subject_attributes) { { value: 5 } }
415
+
416
+ include CanDeserializeToNew
417
+ end
418
+
334
419
  describe 'with custom serialization' do
335
420
  let(:attributes) { { overridden: {} } }
336
- let(:default_view_values) { { overridden: 10 } }
337
- let(:default_model_values) { { overridden: 5 } }
421
+ let(:subject_model_attributes) { { overridden: 5 } }
422
+ let(:subject_view_attributes) { { overridden: 10 } }
423
+
338
424
  let(:viewmodel_body) do
339
425
  ->(_x) do
340
426
  def serialize_overridden(json, serialize_context:)
@@ -344,7 +430,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
344
430
  def deserialize_overridden(value, references:, deserialize_context:)
345
431
  before_value = model.overridden
346
432
  model.overridden = value.try { |v| Integer(v) / 2 }
347
- attribute_changed!(:overridden) unless before_value == model.overridden
433
+ attribute_changed!(:overridden) unless !new_model? && before_value == model.overridden
348
434
  end
349
435
  end
350
436
  end
@@ -354,12 +440,12 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
354
440
  include CanDeserializeToExisting
355
441
 
356
442
  it 'can be updated' do
357
- new_view = default_view.merge('overridden' => '20')
443
+ new_view = subject_view.merge('overridden' => '20')
358
444
 
359
445
  vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
360
446
 
361
- assert(default_model.equal?(vm.model), 'returned model was not the same')
362
- assert_equal(10, default_model.overridden)
447
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
448
+ assert_equal(10, subject_model.overridden)
363
449
 
364
450
  assert_edited(vm, changed_attributes: [:overridden])
365
451
  end
@@ -386,82 +472,111 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
386
472
  def teardown
387
473
  Object.send(:remove_const, :Nested)
388
474
  Object.send(:remove_const, :NestedView)
389
- ActiveSupport::Dependencies::Reference.clear!
475
+
476
+ if ActiveSupport::VERSION::MAJOR < 7
477
+ ActiveSupport::Dependencies::Reference.clear!
478
+ end
479
+
390
480
  super
391
481
  end
392
482
 
393
483
  describe 'with nested viewmodel' do
394
- let(:default_nested_model) { nested_model_class.new('member') }
395
- let(:default_nested_view) { view_base.merge('_type' => 'Nested', 'member' => 'member') }
484
+ let(:subject_nested_model) { nested_model_class.new('member') }
485
+ let(:subject_nested_view) { view_base.merge('_type' => 'Nested', 'member' => 'member') }
396
486
 
397
487
  let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class } } }
398
488
 
399
- let(:default_view_values) { { nested: default_nested_view } }
400
- let(:default_model_values) { { nested: default_nested_model } }
489
+ let(:subject_view_attributes) { { nested: subject_nested_view } }
490
+ let(:subject_model_attributes) { { nested: subject_nested_model } }
401
491
 
402
492
  let(:update_context) do
403
- TestDeserializeContext.new(targets: [default_model, default_nested_model],
404
- access_control: access_control)
493
+ TestDeserializeContext.new(
494
+ targets: [subject_model, subject_nested_model],
495
+ access_control: access_control)
405
496
  end
406
497
 
407
498
  include CanSerialize
408
- include CanDeserializeToNew
499
+
500
+ it 'can deserialize to a new model' do
501
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
502
+ assert_equal(subject_model, vm.model)
503
+ refute(subject_model.equal?(vm.model))
504
+
505
+ assert_equal(subject_nested_model, vm.model.nested)
506
+ refute(subject_nested_model.equal?(vm.model.nested))
507
+
508
+ assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
509
+ end
510
+
409
511
  include CanDeserializeToExisting
410
512
 
411
513
  it 'can update the nested value' do
412
- new_view = default_view.merge('nested' => default_nested_view.merge('member' => 'changed'))
514
+ new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
413
515
 
414
516
  vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
415
517
 
416
- assert(default_model.equal?(vm.model), 'returned model was not the same')
417
- assert(default_nested_model.equal?(vm.model.nested), 'returned nested model was not the same')
518
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
519
+ assert(subject_nested_model.equal?(vm.model.nested), 'returned nested model was not the same')
418
520
 
419
- assert_equal('changed', default_model.nested.member)
521
+ assert_equal('changed', subject_model.nested.member)
420
522
 
421
523
  assert_unchanged(vm)
524
+
525
+ # The parent is itself not `changed?`, but it must record that its children are
526
+ change = access_control.all_changes(vm.to_reference)[0]
527
+ assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
528
+
422
529
  assert_edited(vm.nested, changed_attributes: [:member])
423
530
  end
424
531
 
425
532
  it 'can replace the nested value' do
426
533
  # The value will be unified if it is different after deserialization
427
- new_view = default_view.merge('nested' => default_nested_view.merge('member' => 'changed'))
534
+ new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
428
535
 
429
- partial_update_context = TestDeserializeContext.new(targets: [default_model],
536
+ partial_update_context = TestDeserializeContext.new(targets: [subject_model],
430
537
  access_control: access_control)
431
538
 
432
539
  vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: partial_update_context)
433
540
 
434
- assert(default_model.equal?(vm.model), 'returned model was not the same')
435
- refute(default_nested_model.equal?(vm.model.nested), 'returned nested model was the same')
541
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
542
+ refute(subject_nested_model.equal?(vm.model.nested), 'returned nested model was the same')
436
543
 
437
- assert_edited(vm, new: false, changed_attributes: [:nested])
544
+ assert_edited(vm, new: false, changed_attributes: [:nested], changed_nested_children: true)
438
545
  assert_edited(vm.nested, new: true, changed_attributes: [:member])
439
546
  end
440
547
  end
441
548
 
442
549
  describe 'with array of nested viewmodel' do
443
- let(:default_nested_model_1) { nested_model_class.new('member1') }
444
- let(:default_nested_view_1) { view_base.merge('_type' => 'Nested', 'member' => 'member1') }
550
+ let(:subject_nested_model_1) { nested_model_class.new('member1') }
551
+ let(:subject_nested_view_1) { view_base.merge('_type' => 'Nested', 'member' => 'member1') }
445
552
 
446
- let(:default_nested_model_2) { nested_model_class.new('member2') }
447
- let(:default_nested_view_2) { view_base.merge('_type' => 'Nested', 'member' => 'member2') }
553
+ let(:subject_nested_model_2) { nested_model_class.new('member2') }
554
+ let(:subject_nested_view_2) { view_base.merge('_type' => 'Nested', 'member' => 'member2') }
448
555
 
449
556
  let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class, array: true } } }
450
557
 
451
- let(:default_view_values) { { nested: [default_nested_view_1, default_nested_view_2] } }
452
- let(:default_model_values) { { nested: [default_nested_model_1, default_nested_model_2] } }
558
+ let(:subject_view_attributes) { { nested: [subject_nested_view_1, subject_nested_view_2] } }
559
+ let(:subject_model_attributes) { { nested: [subject_nested_model_1, subject_nested_model_2] } }
453
560
 
454
561
  let(:update_context) {
455
- TestDeserializeContext.new(targets: [default_model, default_nested_model_1, default_nested_model_2],
562
+ TestDeserializeContext.new(targets: [subject_model, subject_nested_model_1, subject_nested_model_2],
456
563
  access_control: access_control)
457
564
  }
458
565
 
459
566
  include CanSerialize
460
- include CanDeserializeToNew
567
+
568
+ it 'can deserialize to a new model' do
569
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
570
+ assert_equal(subject_model, vm.model)
571
+ refute(subject_model.equal?(vm.model))
572
+
573
+ assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
574
+ end
575
+
461
576
  include CanDeserializeToExisting
462
577
 
463
578
  it 'rejects change to attribute' do
464
- new_view = default_view.merge('nested' => 'terrible')
579
+ new_view = subject_view.merge('nested' => 'terrible')
465
580
  ex = assert_raises(ViewModel::DeserializationError::InvalidAttributeType) do
466
581
  viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
467
582
  end
@@ -471,32 +586,37 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
471
586
  end
472
587
 
473
588
  it 'can edit a nested value' do
474
- default_view['nested'][0]['member'] = 'changed'
475
- vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
476
- assert(default_model.equal?(vm.model), 'returned model was not the same')
589
+ subject_view['nested'][0]['member'] = 'changed'
590
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
591
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
477
592
  assert_equal(2, vm.model.nested.size)
478
- assert(default_nested_model_1.equal?(vm.model.nested[0]))
479
- assert(default_nested_model_2.equal?(vm.model.nested[1]))
593
+ assert(subject_nested_model_1.equal?(vm.model.nested[0]))
594
+ assert(subject_nested_model_2.equal?(vm.model.nested[1]))
480
595
 
481
596
  assert_unchanged(vm)
597
+
598
+ # The parent is itself not `changed?`, but it must record that its children are
599
+ change = access_control.all_changes(vm.to_reference)[0]
600
+ assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
601
+
482
602
  assert_edited(vm.nested[0], changed_attributes: [:member])
483
603
  end
484
604
 
485
605
  it 'can append a nested value' do
486
- default_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
606
+ subject_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
487
607
 
488
- vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
608
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
489
609
 
490
- assert(default_model.equal?(vm.model), 'returned model was not the same')
610
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
491
611
  assert_equal(3, vm.model.nested.size)
492
- assert(default_nested_model_1.equal?(vm.model.nested[0]))
493
- assert(default_nested_model_2.equal?(vm.model.nested[1]))
612
+ assert(subject_nested_model_1.equal?(vm.model.nested[0]))
613
+ assert(subject_nested_model_2.equal?(vm.model.nested[1]))
494
614
 
495
615
  vm.model.nested.each_with_index do |nvm, i|
496
616
  assert_equal("member#{i + 1}", nvm.member)
497
617
  end
498
618
 
499
- assert_edited(vm, changed_attributes: [:nested])
619
+ assert_edited(vm, changed_attributes: [:nested], changed_nested_children: true)
500
620
  assert_edited(vm.nested[2], new: true, changed_attributes: [:member])
501
621
  end
502
622
  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.5.1
4
+ version: 3.6.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-10-07 00:00:00.000000000 Z
11
+ date: 2022-01-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -380,6 +380,7 @@ files:
380
380
  - gemfiles/rails_5_2.gemfile
381
381
  - gemfiles/rails_6_0.gemfile
382
382
  - gemfiles/rails_6_1.gemfile
383
+ - gemfiles/rails_7_0.gemfile
383
384
  - iknow_view_models.gemspec
384
385
  - lib/iknow_view_models.rb
385
386
  - lib/iknow_view_models/railtie.rb