iknow_view_models 3.5.0 → 3.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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