iknow_view_models 3.5.2 → 3.6.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.circleci/config.yml +5 -0
- data/Appraisals +5 -0
- data/Gemfile +3 -2
- data/gemfiles/rails_5_2.gemfile +1 -0
- data/gemfiles/rails_6_0.gemfile +1 -0
- data/gemfiles/rails_6_1.gemfile +1 -0
- data/gemfiles/rails_7_0.gemfile +10 -0
- data/iknow_view_models.gemspec +1 -0
- data/lib/iknow_view_models/version.rb +1 -1
- data/lib/view_model/access_control/composed.rb +50 -12
- data/lib/view_model/access_control.rb +8 -0
- data/lib/view_model/active_record/update_context.rb +2 -2
- data/lib/view_model/migratable_view.rb +25 -3
- data/lib/view_model/migration.rb +3 -2
- data/lib/view_model/record.rb +13 -5
- data/lib/view_model/test_helpers/arvm_builder.rb +4 -1
- data/nix/dependencies.nix +1 -1
- data/test/helpers/controller_test_helpers.rb +5 -1
- data/test/helpers/test_access_control.rb +18 -0
- data/test/helpers/viewmodel_spec_helpers.rb +49 -0
- data/test/unit/view_model/access_control_test.rb +48 -0
- data/test/unit/view_model/active_record/cache_test.rb +3 -8
- data/test/unit/view_model/active_record/migration_test.rb +63 -0
- data/test/unit/view_model/record_test.rb +214 -101
- metadata +17 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 267212f1572c3f81b65462be6c30e02a12a6562a29d01bd5e0c342a12396acbc
|
4
|
+
data.tar.gz: e8c4addd984351d6df889ba7d75abbe8d5bde7793a48791e24a1b42e7a3fdf42
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6039dcc05bed20bce4681c322c3509be3f2a276c0a755fb3e27240eec74fd9eddef68f3c272df33554407853065a98e0f4d03a068c8c0e068cfc02be6f73ab67
|
7
|
+
data.tar.gz: 95fb89d93724d5028905612d846534916c4bdacd1bb88009ae434090b183e17d7311bb2709bfa81b08ff3dff45940ced0ce080ff861529c59cbb96671a661b64
|
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
data/Gemfile
CHANGED
@@ -11,5 +11,6 @@ gem 'rubocop-iknow'
|
|
11
11
|
gem 'minitest-ci'
|
12
12
|
|
13
13
|
# Override gemspec for development version preferences
|
14
|
-
gem 'activerecord', '~>
|
15
|
-
gem 'activesupport', '~>
|
14
|
+
gem 'activerecord', '~> 7.0.0'
|
15
|
+
gem 'activesupport', '~> 7.0.0'
|
16
|
+
gem 'actionpack', '~> 7.0.0'
|
data/gemfiles/rails_5_2.gemfile
CHANGED
data/gemfiles/rails_6_0.gemfile
CHANGED
data/gemfiles/rails_6_1.gemfile
CHANGED
data/iknow_view_models.gemspec
CHANGED
@@ -39,7 +39,7 @@ class ViewModel::AccessControl::Composed < ViewModel::AccessControl
|
|
39
39
|
case
|
40
40
|
when new_allow
|
41
41
|
nil
|
42
|
-
when self.allow_error && other.allow_error
|
42
|
+
when mergeable_error?(self.allow_error) && mergeable_error?(other.allow_error)
|
43
43
|
self.allow_error.merge(other.allow_error)
|
44
44
|
else
|
45
45
|
self.allow_error || other.allow_error
|
@@ -48,6 +48,12 @@ class ViewModel::AccessControl::Composed < ViewModel::AccessControl
|
|
48
48
|
ComposedResult.new(new_allow, other.veto, new_allow_error, other.veto_error)
|
49
49
|
end
|
50
50
|
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def mergeable_error?(err)
|
55
|
+
err&.is_a?(NoRequiredConditionsError)
|
56
|
+
end
|
51
57
|
end
|
52
58
|
|
53
59
|
PermissionsCheck = Struct.new(:location, :reason, :error_type, :checker) do
|
@@ -196,22 +202,54 @@ class ViewModel::AccessControl::Composed < ViewModel::AccessControl
|
|
196
202
|
protected
|
197
203
|
|
198
204
|
def check_delegates(env, ifs, unlesses)
|
199
|
-
|
205
|
+
veto, veto_error = detect_veto(env, unlesses)
|
206
|
+
allow, allow_error = detect_allow(env, ifs)
|
207
|
+
|
208
|
+
ComposedResult.new(allow, veto, allow_error, veto_error)
|
209
|
+
end
|
210
|
+
|
211
|
+
private
|
212
|
+
|
213
|
+
def detect_veto(env, checkers)
|
214
|
+
checkers.each do |checker|
|
215
|
+
result = checker.check(env)
|
216
|
+
next unless result
|
200
217
|
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
218
|
+
error =
|
219
|
+
if result.is_a?(StandardError)
|
220
|
+
result
|
221
|
+
else
|
222
|
+
checker.error_type.new('Action not permitted because: ' +
|
223
|
+
checker.reason,
|
224
|
+
env.view.blame_reference)
|
225
|
+
end
|
226
|
+
|
227
|
+
# short-circuit exit with failure
|
228
|
+
return true, error
|
206
229
|
end
|
207
230
|
|
208
|
-
|
231
|
+
return false, nil
|
232
|
+
end
|
233
|
+
|
234
|
+
def detect_allow(env, checkers)
|
235
|
+
error = nil
|
236
|
+
|
237
|
+
checkers.each do |checker|
|
238
|
+
result = checker.check(env)
|
239
|
+
next unless result
|
209
240
|
|
210
|
-
|
211
|
-
|
212
|
-
|
241
|
+
if result.is_a?(StandardError)
|
242
|
+
error ||= result
|
243
|
+
else
|
244
|
+
# short-circuit exit with success
|
245
|
+
return true, nil
|
246
|
+
end
|
213
247
|
end
|
214
248
|
|
215
|
-
|
249
|
+
error ||= NoRequiredConditionsError.new(
|
250
|
+
env.view.blame_reference,
|
251
|
+
checkers.map(&:name))
|
252
|
+
|
253
|
+
return false, error
|
216
254
|
end
|
217
255
|
end
|
@@ -149,6 +149,14 @@ class ViewModel::AccessControl
|
|
149
149
|
def raise_if_error!(result)
|
150
150
|
raise (result.error || yield) unless result.permit?
|
151
151
|
end
|
152
|
+
|
153
|
+
# Called from composed access controls via the `env`, this is used to make the
|
154
|
+
# if/unless DSL more readable when returning a custom failure error.
|
155
|
+
def failure(err)
|
156
|
+
raise ArgumentError.new("Unexpected failure type: #{err}") unless err.is_a?(StandardError)
|
157
|
+
|
158
|
+
err
|
159
|
+
end
|
152
160
|
end
|
153
161
|
|
154
162
|
require 'view_model/access_control/open'
|
@@ -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
|
-
|
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
|
-
|
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
|
data/lib/view_model/migration.rb
CHANGED
@@ -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(
|
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
|
data/lib/view_model/record.rb
CHANGED
@@ -359,12 +359,20 @@ class ViewModel::Record < ViewModel
|
|
359
359
|
|
360
360
|
attribute_changed!(vm_attr_name)
|
361
361
|
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
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
|
-
|
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::
|
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
@@ -323,7 +323,11 @@ module ControllerTestControllers
|
|
323
323
|
CONTROLLER_NAMES.each do |name|
|
324
324
|
Object.send(:remove_const, name)
|
325
325
|
end
|
326
|
-
|
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
|
@@ -156,6 +156,54 @@ class ViewModel::AccessControlTest < ActiveSupport::TestCase
|
|
156
156
|
assert_equal(2, ex.reasons.count)
|
157
157
|
end
|
158
158
|
|
159
|
+
def test_veto_ordering
|
160
|
+
TestAccessControl.visible_if!('always') { true }
|
161
|
+
|
162
|
+
TestAccessControl.visible_unless!('car starts with i') do
|
163
|
+
view.car =~ /^i/
|
164
|
+
end
|
165
|
+
|
166
|
+
TestAccessControl.visible_unless!('car ends with e') do
|
167
|
+
view.car =~ /e$/
|
168
|
+
end
|
169
|
+
|
170
|
+
assert_serializes(ListView, List.create!(car: 'ok'))
|
171
|
+
refute_serializes(ListView, List.create!(car: 'invisible'), /not permitted.*car starts with i/)
|
172
|
+
end
|
173
|
+
|
174
|
+
def test_custom_error_if
|
175
|
+
TestAccessControl.visible_if!('car is visible1') do
|
176
|
+
view.car == 'visible1' ||
|
177
|
+
# In principle a failure() may return any error, but by returning an
|
178
|
+
# AccessControlError we make it possible to test with refute_serializes
|
179
|
+
failure(ViewModel::AccessControlError.new('Custom Error Message', view.blame_reference))
|
180
|
+
end
|
181
|
+
|
182
|
+
TestAccessControl.visible_if!('car is visible2') do
|
183
|
+
view.car == 'visible2' ||
|
184
|
+
# Only the first failure() recorded by a failed if check will be
|
185
|
+
# raised as the error.
|
186
|
+
failure(ViewModel::AccessControlError.new('Should not be seen', view.blame_reference))
|
187
|
+
end
|
188
|
+
|
189
|
+
assert_serializes(ListView, List.create!(car: 'visible1'))
|
190
|
+
assert_serializes(ListView, List.create!(car: 'visible2'))
|
191
|
+
refute_serializes(ListView, List.create!(car: 'bad'), /Custom Error Message/)
|
192
|
+
end
|
193
|
+
|
194
|
+
def test_custom_error_unless
|
195
|
+
TestAccessControl.visible_if!('always') { true }
|
196
|
+
|
197
|
+
TestAccessControl.visible_unless!('car is invisible') do
|
198
|
+
if view.car == 'invisible'
|
199
|
+
failure(ViewModel::AccessControlError.new('Custom Error Message', view.blame_reference))
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
assert_serializes(ListView, List.create!(car: 'ok'))
|
204
|
+
refute_serializes(ListView, List.create!(car: 'invisible'), /Custom Error Message/)
|
205
|
+
end
|
206
|
+
|
159
207
|
def test_inheritance
|
160
208
|
child_access_control = Class.new(ViewModel::AccessControl::Composed)
|
161
209
|
child_access_control.include_from(TestAccessControl)
|
@@ -15,16 +15,11 @@ require 'view_model/active_record'
|
|
15
15
|
|
16
16
|
DUMMY_RAILS_CACHE = ActiveSupport::Cache::MemoryStore.new
|
17
17
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
end
|
18
|
+
IknowCache.configure! do
|
19
|
+
logger ::ActiveRecord::Base.logger
|
20
|
+
cache DUMMY_RAILS_CACHE
|
22
21
|
end
|
23
22
|
|
24
|
-
# Ensure we have a dummy Rails, and then prepend our dummy cache
|
25
|
-
module Rails; end
|
26
|
-
Rails.singleton_class.prepend(RailsDummyCache)
|
27
|
-
|
28
23
|
class ViewModel::ActiveRecord
|
29
24
|
class CacheTest < ActiveSupport::TestCase
|
30
25
|
using ViewModel::Utils::Collections
|
@@ -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
|
-
|
63
|
-
|
64
|
-
|
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
|
-
|
100
|
-
let(:
|
101
|
-
let(:default_model_values) { default_values }
|
118
|
+
# Default values for each model attribute, nil if absent
|
119
|
+
let(:model_defaults) { {} }
|
102
120
|
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
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
|
-
|
110
|
-
|
111
|
-
|
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
|
-
|
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: [
|
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(
|
143
|
-
assert_equal(
|
144
|
-
refute(
|
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(
|
158
|
-
assert(
|
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(
|
171
|
-
assert_equal(
|
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,22 +213,24 @@ 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
|
-
|
223
|
+
update_view = subject_view.merge('simple' => 'changed')
|
185
224
|
|
186
|
-
vm = viewmodel_class.deserialize_from_view(
|
225
|
+
vm = viewmodel_class.deserialize_from_view(update_view, deserialize_context: update_context)
|
187
226
|
|
188
|
-
assert(
|
189
|
-
assert_equal('changed',
|
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 =
|
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
|
@@ -199,7 +238,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
199
238
|
end
|
200
239
|
|
201
240
|
it 'rejects unknown versions' do
|
202
|
-
view =
|
241
|
+
view = subject_view.merge(ViewModel::VERSION_ATTRIBUTE => 100)
|
203
242
|
ex = assert_raises(ViewModel::DeserializationError::SchemaVersionMismatch) do
|
204
243
|
viewmodel_class.deserialize_from_view(view, deserialize_context: create_context)
|
205
244
|
end
|
@@ -207,13 +246,15 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
207
246
|
|
208
247
|
it 'edit checks when creating empty' do
|
209
248
|
vm = viewmodel_class.deserialize_from_view(view_base, deserialize_context: create_context)
|
210
|
-
refute(
|
249
|
+
refute(subject_model.equal?(vm.model), 'returned model was the same')
|
211
250
|
assert_edited(vm, new: true)
|
212
251
|
end
|
213
252
|
end
|
214
253
|
|
215
254
|
describe 'with validated simple attribute' do
|
216
255
|
let(:attributes) { { validated: {} } }
|
256
|
+
let(:subject_attributes) { { validated: "validated" } }
|
257
|
+
|
217
258
|
let(:viewmodel_body) do
|
218
259
|
->(_x) do
|
219
260
|
def validate!
|
@@ -229,10 +270,10 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
229
270
|
include CanDeserializeToExisting
|
230
271
|
|
231
272
|
it 'rejects update when validation fails' do
|
232
|
-
|
273
|
+
update_view = subject_view.merge('validated' => 'naughty')
|
233
274
|
|
234
275
|
ex = assert_raises(ViewModel::DeserializationError::Validation) do
|
235
|
-
viewmodel_class.deserialize_from_view(
|
276
|
+
viewmodel_class.deserialize_from_view(update_view, deserialize_context: update_context)
|
236
277
|
end
|
237
278
|
assert_equal('validated', ex.attribute)
|
238
279
|
assert_equal('was naughty', ex.reason)
|
@@ -241,16 +282,16 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
241
282
|
|
242
283
|
describe 'with renamed attribute' do
|
243
284
|
let(:attributes) { { modelname: { as: :viewname } } }
|
244
|
-
let(:
|
245
|
-
let(:
|
285
|
+
let(:subject_model_attributes) { { modelname: 'value' } }
|
286
|
+
let(:subject_view_attributes) { { viewname: 'value' } }
|
246
287
|
|
247
288
|
include CanSerialize
|
248
289
|
include CanDeserializeToNew
|
249
290
|
include CanDeserializeToExisting
|
250
291
|
|
251
292
|
it 'makes attributes available on their new names' do
|
252
|
-
value(
|
253
|
-
vm = viewmodel_class.new(
|
293
|
+
value(subject_model.modelname).must_equal('value')
|
294
|
+
vm = viewmodel_class.new(subject_model)
|
254
295
|
value(vm.viewname).must_equal('value')
|
255
296
|
end
|
256
297
|
end
|
@@ -258,15 +299,15 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
258
299
|
describe 'with formatted attribute' do
|
259
300
|
let(:attributes) { { moment: { format: IknowParams::Serializer::Time } } }
|
260
301
|
let(:moment) { 1.week.ago.change(usec: 0) }
|
261
|
-
let(:
|
262
|
-
let(:
|
302
|
+
let(:subject_model_attributes) { { moment: moment } }
|
303
|
+
let(:subject_view_attributes) { { moment: moment.iso8601 } }
|
263
304
|
|
264
305
|
include CanSerialize
|
265
306
|
include CanDeserializeToNew
|
266
307
|
include CanDeserializeToExisting
|
267
308
|
|
268
309
|
it 'raises correctly on an unparseable value' do
|
269
|
-
bad_view =
|
310
|
+
bad_view = subject_view.merge('moment' => 'not a timestamp')
|
270
311
|
ex = assert_raises(ViewModel::DeserializationError::Validation) do
|
271
312
|
viewmodel_class.deserialize_from_view(bad_view, deserialize_context: create_context)
|
272
313
|
end
|
@@ -275,7 +316,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
275
316
|
end
|
276
317
|
|
277
318
|
it 'raises correctly on an undeserializable value' do
|
278
|
-
bad_model =
|
319
|
+
bad_model = subject_model.tap { |m| m.moment = 2.7 }
|
279
320
|
ex = assert_raises(ViewModel::SerializationError) do
|
280
321
|
viewmodel_class.new(bad_model).to_hash
|
281
322
|
end
|
@@ -285,36 +326,51 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
285
326
|
|
286
327
|
describe 'with read-only attribute' do
|
287
328
|
let(:attributes) { { read_only: { read_only: true } } }
|
329
|
+
let(:model_defaults) { { read_only: 'immutable' } }
|
330
|
+
let(:subject_attributes) { { read_only: 'immutable' } }
|
288
331
|
|
289
|
-
|
290
|
-
|
332
|
+
describe 'asserting the default' do
|
333
|
+
include CanSerialize
|
334
|
+
include CanDeserializeToExisting
|
291
335
|
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
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
|
299
342
|
|
300
|
-
|
301
|
-
|
302
|
-
viewmodel_class.deserialize_from_view(
|
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)
|
303
349
|
end
|
304
|
-
assert_equal('read_only', ex.attribute)
|
305
350
|
end
|
306
351
|
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
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)
|
311
367
|
end
|
312
|
-
assert_equal('read_only', ex.attribute)
|
313
368
|
end
|
314
369
|
end
|
315
370
|
|
316
371
|
describe 'with read-only write-once attribute' do
|
317
372
|
let(:attributes) { { write_once: { read_only: true, write_once: true } } }
|
373
|
+
let(:subject_attributes) { { write_once: 'frozen' } }
|
318
374
|
let(:model_body) do
|
319
375
|
->(_x) do
|
320
376
|
# For the purposes of testing, we assume a record is new and can be
|
@@ -330,7 +386,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
330
386
|
include CanDeserializeToExisting
|
331
387
|
|
332
388
|
it 'rejects change to attribute' do
|
333
|
-
new_view =
|
389
|
+
new_view = subject_view.merge('write_once' => 'written')
|
334
390
|
ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
|
335
391
|
viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
336
392
|
end
|
@@ -338,10 +394,33 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
338
394
|
end
|
339
395
|
end
|
340
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
|
+
|
341
419
|
describe 'with custom serialization' do
|
342
420
|
let(:attributes) { { overridden: {} } }
|
343
|
-
let(:
|
344
|
-
let(:
|
421
|
+
let(:subject_model_attributes) { { overridden: 5 } }
|
422
|
+
let(:subject_view_attributes) { { overridden: 10 } }
|
423
|
+
|
345
424
|
let(:viewmodel_body) do
|
346
425
|
->(_x) do
|
347
426
|
def serialize_overridden(json, serialize_context:)
|
@@ -351,7 +430,7 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
351
430
|
def deserialize_overridden(value, references:, deserialize_context:)
|
352
431
|
before_value = model.overridden
|
353
432
|
model.overridden = value.try { |v| Integer(v) / 2 }
|
354
|
-
attribute_changed!(:overridden) unless before_value == model.overridden
|
433
|
+
attribute_changed!(:overridden) unless !new_model? && before_value == model.overridden
|
355
434
|
end
|
356
435
|
end
|
357
436
|
end
|
@@ -361,12 +440,12 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
361
440
|
include CanDeserializeToExisting
|
362
441
|
|
363
442
|
it 'can be updated' do
|
364
|
-
new_view =
|
443
|
+
new_view = subject_view.merge('overridden' => '20')
|
365
444
|
|
366
445
|
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
367
446
|
|
368
|
-
assert(
|
369
|
-
assert_equal(10,
|
447
|
+
assert(subject_model.equal?(vm.model), 'returned model was not the same')
|
448
|
+
assert_equal(10, subject_model.overridden)
|
370
449
|
|
371
450
|
assert_edited(vm, changed_attributes: [:overridden])
|
372
451
|
end
|
@@ -393,82 +472,111 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
393
472
|
def teardown
|
394
473
|
Object.send(:remove_const, :Nested)
|
395
474
|
Object.send(:remove_const, :NestedView)
|
396
|
-
|
475
|
+
|
476
|
+
if ActiveSupport::VERSION::MAJOR < 7
|
477
|
+
ActiveSupport::Dependencies::Reference.clear!
|
478
|
+
end
|
479
|
+
|
397
480
|
super
|
398
481
|
end
|
399
482
|
|
400
483
|
describe 'with nested viewmodel' do
|
401
|
-
let(:
|
402
|
-
let(:
|
484
|
+
let(:subject_nested_model) { nested_model_class.new('member') }
|
485
|
+
let(:subject_nested_view) { view_base.merge('_type' => 'Nested', 'member' => 'member') }
|
403
486
|
|
404
487
|
let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class } } }
|
405
488
|
|
406
|
-
let(:
|
407
|
-
let(:
|
489
|
+
let(:subject_view_attributes) { { nested: subject_nested_view } }
|
490
|
+
let(:subject_model_attributes) { { nested: subject_nested_model } }
|
408
491
|
|
409
492
|
let(:update_context) do
|
410
|
-
TestDeserializeContext.new(
|
411
|
-
|
493
|
+
TestDeserializeContext.new(
|
494
|
+
targets: [subject_model, subject_nested_model],
|
495
|
+
access_control: access_control)
|
412
496
|
end
|
413
497
|
|
414
498
|
include CanSerialize
|
415
|
-
|
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
|
+
|
416
511
|
include CanDeserializeToExisting
|
417
512
|
|
418
513
|
it 'can update the nested value' do
|
419
|
-
new_view =
|
514
|
+
new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
|
420
515
|
|
421
516
|
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
422
517
|
|
423
|
-
assert(
|
424
|
-
assert(
|
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')
|
425
520
|
|
426
|
-
assert_equal('changed',
|
521
|
+
assert_equal('changed', subject_model.nested.member)
|
427
522
|
|
428
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
|
+
|
429
529
|
assert_edited(vm.nested, changed_attributes: [:member])
|
430
530
|
end
|
431
531
|
|
432
532
|
it 'can replace the nested value' do
|
433
533
|
# The value will be unified if it is different after deserialization
|
434
|
-
new_view =
|
534
|
+
new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
|
435
535
|
|
436
|
-
partial_update_context = TestDeserializeContext.new(targets: [
|
536
|
+
partial_update_context = TestDeserializeContext.new(targets: [subject_model],
|
437
537
|
access_control: access_control)
|
438
538
|
|
439
539
|
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: partial_update_context)
|
440
540
|
|
441
|
-
assert(
|
442
|
-
refute(
|
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')
|
443
543
|
|
444
|
-
assert_edited(vm, new: false, changed_attributes: [:nested])
|
544
|
+
assert_edited(vm, new: false, changed_attributes: [:nested], changed_nested_children: true)
|
445
545
|
assert_edited(vm.nested, new: true, changed_attributes: [:member])
|
446
546
|
end
|
447
547
|
end
|
448
548
|
|
449
549
|
describe 'with array of nested viewmodel' do
|
450
|
-
let(:
|
451
|
-
let(:
|
550
|
+
let(:subject_nested_model_1) { nested_model_class.new('member1') }
|
551
|
+
let(:subject_nested_view_1) { view_base.merge('_type' => 'Nested', 'member' => 'member1') }
|
452
552
|
|
453
|
-
let(:
|
454
|
-
let(:
|
553
|
+
let(:subject_nested_model_2) { nested_model_class.new('member2') }
|
554
|
+
let(:subject_nested_view_2) { view_base.merge('_type' => 'Nested', 'member' => 'member2') }
|
455
555
|
|
456
556
|
let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class, array: true } } }
|
457
557
|
|
458
|
-
let(:
|
459
|
-
let(:
|
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] } }
|
460
560
|
|
461
561
|
let(:update_context) {
|
462
|
-
TestDeserializeContext.new(targets: [
|
562
|
+
TestDeserializeContext.new(targets: [subject_model, subject_nested_model_1, subject_nested_model_2],
|
463
563
|
access_control: access_control)
|
464
564
|
}
|
465
565
|
|
466
566
|
include CanSerialize
|
467
|
-
|
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
|
+
|
468
576
|
include CanDeserializeToExisting
|
469
577
|
|
470
578
|
it 'rejects change to attribute' do
|
471
|
-
new_view =
|
579
|
+
new_view = subject_view.merge('nested' => 'terrible')
|
472
580
|
ex = assert_raises(ViewModel::DeserializationError::InvalidAttributeType) do
|
473
581
|
viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
474
582
|
end
|
@@ -478,32 +586,37 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
478
586
|
end
|
479
587
|
|
480
588
|
it 'can edit a nested value' do
|
481
|
-
|
482
|
-
vm = viewmodel_class.deserialize_from_view(
|
483
|
-
assert(
|
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')
|
484
592
|
assert_equal(2, vm.model.nested.size)
|
485
|
-
assert(
|
486
|
-
assert(
|
593
|
+
assert(subject_nested_model_1.equal?(vm.model.nested[0]))
|
594
|
+
assert(subject_nested_model_2.equal?(vm.model.nested[1]))
|
487
595
|
|
488
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
|
+
|
489
602
|
assert_edited(vm.nested[0], changed_attributes: [:member])
|
490
603
|
end
|
491
604
|
|
492
605
|
it 'can append a nested value' do
|
493
|
-
|
606
|
+
subject_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
|
494
607
|
|
495
|
-
vm = viewmodel_class.deserialize_from_view(
|
608
|
+
vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
|
496
609
|
|
497
|
-
assert(
|
610
|
+
assert(subject_model.equal?(vm.model), 'returned model was not the same')
|
498
611
|
assert_equal(3, vm.model.nested.size)
|
499
|
-
assert(
|
500
|
-
assert(
|
612
|
+
assert(subject_nested_model_1.equal?(vm.model.nested[0]))
|
613
|
+
assert(subject_nested_model_2.equal?(vm.model.nested[1]))
|
501
614
|
|
502
615
|
vm.model.nested.each_with_index do |nvm, i|
|
503
616
|
assert_equal("member#{i + 1}", nvm.member)
|
504
617
|
end
|
505
618
|
|
506
|
-
assert_edited(vm, changed_attributes: [:nested])
|
619
|
+
assert_edited(vm, changed_attributes: [:nested], changed_nested_children: true)
|
507
620
|
assert_edited(vm.nested[2], new: true, changed_attributes: [:member])
|
508
621
|
end
|
509
622
|
end
|
metadata
CHANGED
@@ -1,15 +1,29 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: iknow_view_models
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.
|
4
|
+
version: 3.6.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- iKnow Team
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-02-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: actionpack
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '5.0'
|
13
27
|
- !ruby/object:Gem::Dependency
|
14
28
|
name: activerecord
|
15
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -380,6 +394,7 @@ files:
|
|
380
394
|
- gemfiles/rails_5_2.gemfile
|
381
395
|
- gemfiles/rails_6_0.gemfile
|
382
396
|
- gemfiles/rails_6_1.gemfile
|
397
|
+
- gemfiles/rails_7_0.gemfile
|
383
398
|
- iknow_view_models.gemspec
|
384
399
|
- lib/iknow_view_models.rb
|
385
400
|
- lib/iknow_view_models/railtie.rb
|