iknow_view_models 3.5.0 → 3.6.0

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: 0cb094f674e990ef9a67bbcb7b0ee260065356cdb6a56ee6e1b19582c320e96c
4
- data.tar.gz: 5bb5c175e7fd30442794c2ff87687eb5dec132fa7da0d0128a1b012bd50131d6
3
+ metadata.gz: 573266179096880a34febada583570484e420b91a242920198cfa119192c3fbc
4
+ data.tar.gz: c024eb9398a4b0d49103d1806379d0f20312f7c6965e9fe2751b09bb720f2fc3
5
5
  SHA512:
6
- metadata.gz: f196d4af404bcb91d88519e04ed2afcd7d73c590af4838e16b32b4f73171d20500a4a092cb76d693d7a683bf4ca6ad337d5e79a10a5233870f9c244acfffe969
7
- data.tar.gz: bfdd726357dc50f72732498d112274301089ab82719e46d7c5d7bfc5414a6bcf3dc12459bb9dee6ffa9c174dea1c81e2a0499649d9bbe1d1618702bfad18ef04
6
+ metadata.gz: 353f42c901c4d52cba6bbe459cc6266c2e59f3a56ca432bac616cd4534c95116d0a0e27b8329799717259640e098c704c4f6b63c83713cd42cb3096c6ca6c5b3
7
+ data.tar.gz: 4e74aa987e3ff26dd9b14bb3fbebc291ce86087dca8f8bcc93ef9b320d7d7ed22f150ce4f3153e27c51f6a2f8d6561fe53cdcb7ac45734caf156f50b10a543d1
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IknowViewModels
4
- VERSION = '3.5.0'
4
+ VERSION = '3.6.0'
5
5
  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
@@ -75,6 +75,32 @@ class ViewModel
75
75
  class UpMigrator < Migrator
76
76
  private
77
77
 
78
+ def migrate_tree!(node, references:)
79
+ if node.is_a?(Hash) && node[ViewModel::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
80
+ migrate_functional_update!(node, references: references)
81
+ else
82
+ super
83
+ end
84
+ end
85
+
86
+ NESTED_FUPDATE_TYPES = ['append', 'update'].freeze
87
+
88
+ # The functional update structure uses `_type` internally with a
89
+ # context-dependent meaning. Retrospectively this was a poor choice, but we
90
+ # need to account for it here.
91
+ def migrate_functional_update!(node, references:)
92
+ actions = node[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE]
93
+ actions&.each do |action|
94
+ action_type = action[ViewModel::TYPE_ATTRIBUTE]
95
+ next unless NESTED_FUPDATE_TYPES.include?(action_type)
96
+
97
+ values = action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE]
98
+ values&.each do |value|
99
+ migrate_tree!(value, references: references)
100
+ end
101
+ end
102
+ end
103
+
78
104
  def migrate_viewmodel!(view_name, source_version, view_hash, references)
79
105
  path = @paths[view_name]
80
106
  return false unless path
@@ -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?
@@ -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
@@ -178,6 +178,98 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
178
178
  end
179
179
  end
180
180
  end
181
+
182
+ describe 'with a functional update' do
183
+ # note that this wouldn't actually be deserializable as child is not a collection
184
+ def subject_data
185
+ data = super()
186
+ data['child'] = wrap_with_fupdate(data['child'])
187
+ data
188
+ end
189
+
190
+ def expected_result
191
+ data = super()
192
+ data['data']['child'] = wrap_with_fupdate(data['data']['child'])
193
+ data
194
+ end
195
+
196
+ def wrap_with_fupdate(child)
197
+ # The 'after' and remove shouldn't get changed in migration, even though it has _type: Child
198
+ build_fupdate do
199
+ append([child], after: { '_type' => 'Child', 'id' => 9999 })
200
+ update([child.deep_merge('id' => 8888)])
201
+ remove([{ '_type' => 'Child', 'id' => 7777 }])
202
+ end
203
+ end
204
+
205
+ it 'migrates' do
206
+ migrate!
207
+ assert_equal(expected_result, subject)
208
+ end
209
+ end
210
+ end
211
+ end
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
181
273
  end
182
274
  end
183
275
 
@@ -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
@@ -391,77 +477,102 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
391
477
  end
392
478
 
393
479
  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') }
480
+ let(:subject_nested_model) { nested_model_class.new('member') }
481
+ let(:subject_nested_view) { view_base.merge('_type' => 'Nested', 'member' => 'member') }
396
482
 
397
483
  let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class } } }
398
484
 
399
- let(:default_view_values) { { nested: default_nested_view } }
400
- let(:default_model_values) { { nested: default_nested_model } }
485
+ let(:subject_view_attributes) { { nested: subject_nested_view } }
486
+ let(:subject_model_attributes) { { nested: subject_nested_model } }
401
487
 
402
488
  let(:update_context) do
403
- TestDeserializeContext.new(targets: [default_model, default_nested_model],
404
- access_control: access_control)
489
+ TestDeserializeContext.new(
490
+ targets: [subject_model, subject_nested_model],
491
+ access_control: access_control)
405
492
  end
406
493
 
407
494
  include CanSerialize
408
- include CanDeserializeToNew
495
+
496
+ it 'can deserialize to a new model' do
497
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
498
+ assert_equal(subject_model, vm.model)
499
+ refute(subject_model.equal?(vm.model))
500
+
501
+ assert_equal(subject_nested_model, vm.model.nested)
502
+ refute(subject_nested_model.equal?(vm.model.nested))
503
+
504
+ assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
505
+ end
506
+
409
507
  include CanDeserializeToExisting
410
508
 
411
509
  it 'can update the nested value' do
412
- new_view = default_view.merge('nested' => default_nested_view.merge('member' => 'changed'))
510
+ new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
413
511
 
414
512
  vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
415
513
 
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')
514
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
515
+ assert(subject_nested_model.equal?(vm.model.nested), 'returned nested model was not the same')
418
516
 
419
- assert_equal('changed', default_model.nested.member)
517
+ assert_equal('changed', subject_model.nested.member)
420
518
 
421
519
  assert_unchanged(vm)
520
+
521
+ # The parent is itself not `changed?`, but it must record that its children are
522
+ change = access_control.all_changes(vm.to_reference)[0]
523
+ assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
524
+
422
525
  assert_edited(vm.nested, changed_attributes: [:member])
423
526
  end
424
527
 
425
528
  it 'can replace the nested value' do
426
529
  # The value will be unified if it is different after deserialization
427
- new_view = default_view.merge('nested' => default_nested_view.merge('member' => 'changed'))
530
+ new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
428
531
 
429
- partial_update_context = TestDeserializeContext.new(targets: [default_model],
532
+ partial_update_context = TestDeserializeContext.new(targets: [subject_model],
430
533
  access_control: access_control)
431
534
 
432
535
  vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: partial_update_context)
433
536
 
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')
537
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
538
+ refute(subject_nested_model.equal?(vm.model.nested), 'returned nested model was the same')
436
539
 
437
- assert_edited(vm, new: false, changed_attributes: [:nested])
540
+ assert_edited(vm, new: false, changed_attributes: [:nested], changed_nested_children: true)
438
541
  assert_edited(vm.nested, new: true, changed_attributes: [:member])
439
542
  end
440
543
  end
441
544
 
442
545
  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') }
546
+ let(:subject_nested_model_1) { nested_model_class.new('member1') }
547
+ let(:subject_nested_view_1) { view_base.merge('_type' => 'Nested', 'member' => 'member1') }
445
548
 
446
- let(:default_nested_model_2) { nested_model_class.new('member2') }
447
- let(:default_nested_view_2) { view_base.merge('_type' => 'Nested', 'member' => 'member2') }
549
+ let(:subject_nested_model_2) { nested_model_class.new('member2') }
550
+ let(:subject_nested_view_2) { view_base.merge('_type' => 'Nested', 'member' => 'member2') }
448
551
 
449
552
  let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class, array: true } } }
450
553
 
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] } }
554
+ let(:subject_view_attributes) { { nested: [subject_nested_view_1, subject_nested_view_2] } }
555
+ let(:subject_model_attributes) { { nested: [subject_nested_model_1, subject_nested_model_2] } }
453
556
 
454
557
  let(:update_context) {
455
- TestDeserializeContext.new(targets: [default_model, default_nested_model_1, default_nested_model_2],
558
+ TestDeserializeContext.new(targets: [subject_model, subject_nested_model_1, subject_nested_model_2],
456
559
  access_control: access_control)
457
560
  }
458
561
 
459
562
  include CanSerialize
460
- include CanDeserializeToNew
563
+
564
+ it 'can deserialize to a new model' do
565
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
566
+ assert_equal(subject_model, vm.model)
567
+ refute(subject_model.equal?(vm.model))
568
+
569
+ assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
570
+ end
571
+
461
572
  include CanDeserializeToExisting
462
573
 
463
574
  it 'rejects change to attribute' do
464
- new_view = default_view.merge('nested' => 'terrible')
575
+ new_view = subject_view.merge('nested' => 'terrible')
465
576
  ex = assert_raises(ViewModel::DeserializationError::InvalidAttributeType) do
466
577
  viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
467
578
  end
@@ -471,32 +582,37 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
471
582
  end
472
583
 
473
584
  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')
585
+ subject_view['nested'][0]['member'] = 'changed'
586
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
587
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
477
588
  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]))
589
+ assert(subject_nested_model_1.equal?(vm.model.nested[0]))
590
+ assert(subject_nested_model_2.equal?(vm.model.nested[1]))
480
591
 
481
592
  assert_unchanged(vm)
593
+
594
+ # The parent is itself not `changed?`, but it must record that its children are
595
+ change = access_control.all_changes(vm.to_reference)[0]
596
+ assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
597
+
482
598
  assert_edited(vm.nested[0], changed_attributes: [:member])
483
599
  end
484
600
 
485
601
  it 'can append a nested value' do
486
- default_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
602
+ subject_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
487
603
 
488
- vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
604
+ vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
489
605
 
490
- assert(default_model.equal?(vm.model), 'returned model was not the same')
606
+ assert(subject_model.equal?(vm.model), 'returned model was not the same')
491
607
  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]))
608
+ assert(subject_nested_model_1.equal?(vm.model.nested[0]))
609
+ assert(subject_nested_model_2.equal?(vm.model.nested[1]))
494
610
 
495
611
  vm.model.nested.each_with_index do |nvm, i|
496
612
  assert_equal("member#{i + 1}", nvm.member)
497
613
  end
498
614
 
499
- assert_edited(vm, changed_attributes: [:nested])
615
+ assert_edited(vm, changed_attributes: [:nested], changed_nested_children: true)
500
616
  assert_edited(vm.nested[2], new: true, changed_attributes: [:member])
501
617
  end
502
618
  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.0
4
+ version: 3.6.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-08-17 00:00:00.000000000 Z
11
+ date: 2021-12-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord