iknow_view_models 3.5.2 → 3.6.2
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 +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
|