iknow_view_models 3.5.1 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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