iknow_view_models 3.5.1 → 3.6.1
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 +2 -2
- data/gemfiles/rails_7_0.gemfile +9 -0
- data/lib/iknow_view_models/version.rb +1 -1
- 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 +14 -6
- 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/active_record/migration_test.rb +63 -0
- data/test/unit/view_model/record_test.rb +220 -100
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7f05051b924e9602a09da08b3aec5e5c4de224753d70d29523d66eb34db6947f
|
4
|
+
data.tar.gz: aac9f7453c54bb216d3cf59b438070c2857f186138c8ff871abaf026a94e4ab4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3edbbb12ef310f58fea255589d2e2112cc62760518af31628c1121b0ea1db9634cb5ace9657087bceecd7f1bd38188fe9bec8b7e03c88613b773fafd2c6b7b6a
|
7
|
+
data.tar.gz: b063d4884cf2846bef6baca22bbc7f8533ddb2c876bf5ffa5e456a5834d7729881b0d08aab69ff524b5cf0fd451599346cce022502334f2bba26673b753a8068
|
data/.circleci/config.yml
CHANGED
@@ -121,6 +121,11 @@ workflows:
|
|
121
121
|
ruby-version: "3.0"
|
122
122
|
pg-version: "12"
|
123
123
|
gemfile: gemfiles/rails_6_1.gemfile
|
124
|
+
- test:
|
125
|
+
name: 'ruby 3.0 rails 7.0 pg 12'
|
126
|
+
ruby-version: "3.0"
|
127
|
+
pg-version: "12"
|
128
|
+
gemfile: gemfiles/rails_7_0.gemfile
|
124
129
|
- publish:
|
125
130
|
filters:
|
126
131
|
branches:
|
data/Appraisals
CHANGED
data/Gemfile
CHANGED
@@ -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
@@ -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,
|
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
|
-
|
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
|
@@ -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,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
|
-
|
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
|
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(
|
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
|
-
|
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(
|
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(:
|
238
|
-
let(:
|
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(
|
246
|
-
vm = viewmodel_class.new(
|
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(:
|
255
|
-
let(:
|
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 =
|
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 =
|
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
|
-
|
283
|
-
|
332
|
+
describe 'asserting the default' do
|
333
|
+
include CanSerialize
|
334
|
+
include CanDeserializeToExisting
|
284
335
|
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
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
|
-
|
294
|
-
|
295
|
-
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)
|
296
349
|
end
|
297
|
-
assert_equal('read_only', ex.attribute)
|
298
350
|
end
|
299
351
|
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
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 =
|
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(:
|
337
|
-
let(:
|
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 =
|
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(
|
362
|
-
assert_equal(10,
|
447
|
+
assert(subject_model.equal?(vm.model), 'returned model was not the same')
|
448
|
+
assert_equal(10, subject_model.overridden)
|
363
449
|
|
364
450
|
assert_edited(vm, changed_attributes: [:overridden])
|
365
451
|
end
|
@@ -386,82 +472,111 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
386
472
|
def teardown
|
387
473
|
Object.send(:remove_const, :Nested)
|
388
474
|
Object.send(:remove_const, :NestedView)
|
389
|
-
|
475
|
+
|
476
|
+
if ActiveSupport::VERSION::MAJOR < 7
|
477
|
+
ActiveSupport::Dependencies::Reference.clear!
|
478
|
+
end
|
479
|
+
|
390
480
|
super
|
391
481
|
end
|
392
482
|
|
393
483
|
describe 'with nested viewmodel' do
|
394
|
-
let(:
|
395
|
-
let(:
|
484
|
+
let(:subject_nested_model) { nested_model_class.new('member') }
|
485
|
+
let(:subject_nested_view) { view_base.merge('_type' => 'Nested', 'member' => 'member') }
|
396
486
|
|
397
487
|
let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class } } }
|
398
488
|
|
399
|
-
let(:
|
400
|
-
let(:
|
489
|
+
let(:subject_view_attributes) { { nested: subject_nested_view } }
|
490
|
+
let(:subject_model_attributes) { { nested: subject_nested_model } }
|
401
491
|
|
402
492
|
let(:update_context) do
|
403
|
-
TestDeserializeContext.new(
|
404
|
-
|
493
|
+
TestDeserializeContext.new(
|
494
|
+
targets: [subject_model, subject_nested_model],
|
495
|
+
access_control: access_control)
|
405
496
|
end
|
406
497
|
|
407
498
|
include CanSerialize
|
408
|
-
|
499
|
+
|
500
|
+
it 'can deserialize to a new model' do
|
501
|
+
vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
|
502
|
+
assert_equal(subject_model, vm.model)
|
503
|
+
refute(subject_model.equal?(vm.model))
|
504
|
+
|
505
|
+
assert_equal(subject_nested_model, vm.model.nested)
|
506
|
+
refute(subject_nested_model.equal?(vm.model.nested))
|
507
|
+
|
508
|
+
assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
|
509
|
+
end
|
510
|
+
|
409
511
|
include CanDeserializeToExisting
|
410
512
|
|
411
513
|
it 'can update the nested value' do
|
412
|
-
new_view =
|
514
|
+
new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
|
413
515
|
|
414
516
|
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
415
517
|
|
416
|
-
assert(
|
417
|
-
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')
|
418
520
|
|
419
|
-
assert_equal('changed',
|
521
|
+
assert_equal('changed', subject_model.nested.member)
|
420
522
|
|
421
523
|
assert_unchanged(vm)
|
524
|
+
|
525
|
+
# The parent is itself not `changed?`, but it must record that its children are
|
526
|
+
change = access_control.all_changes(vm.to_reference)[0]
|
527
|
+
assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
|
528
|
+
|
422
529
|
assert_edited(vm.nested, changed_attributes: [:member])
|
423
530
|
end
|
424
531
|
|
425
532
|
it 'can replace the nested value' do
|
426
533
|
# The value will be unified if it is different after deserialization
|
427
|
-
new_view =
|
534
|
+
new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
|
428
535
|
|
429
|
-
partial_update_context = TestDeserializeContext.new(targets: [
|
536
|
+
partial_update_context = TestDeserializeContext.new(targets: [subject_model],
|
430
537
|
access_control: access_control)
|
431
538
|
|
432
539
|
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: partial_update_context)
|
433
540
|
|
434
|
-
assert(
|
435
|
-
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')
|
436
543
|
|
437
|
-
assert_edited(vm, new: false, changed_attributes: [:nested])
|
544
|
+
assert_edited(vm, new: false, changed_attributes: [:nested], changed_nested_children: true)
|
438
545
|
assert_edited(vm.nested, new: true, changed_attributes: [:member])
|
439
546
|
end
|
440
547
|
end
|
441
548
|
|
442
549
|
describe 'with array of nested viewmodel' do
|
443
|
-
let(:
|
444
|
-
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') }
|
445
552
|
|
446
|
-
let(:
|
447
|
-
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') }
|
448
555
|
|
449
556
|
let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class, array: true } } }
|
450
557
|
|
451
|
-
let(:
|
452
|
-
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] } }
|
453
560
|
|
454
561
|
let(:update_context) {
|
455
|
-
TestDeserializeContext.new(targets: [
|
562
|
+
TestDeserializeContext.new(targets: [subject_model, subject_nested_model_1, subject_nested_model_2],
|
456
563
|
access_control: access_control)
|
457
564
|
}
|
458
565
|
|
459
566
|
include CanSerialize
|
460
|
-
|
567
|
+
|
568
|
+
it 'can deserialize to a new model' do
|
569
|
+
vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
|
570
|
+
assert_equal(subject_model, vm.model)
|
571
|
+
refute(subject_model.equal?(vm.model))
|
572
|
+
|
573
|
+
assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
|
574
|
+
end
|
575
|
+
|
461
576
|
include CanDeserializeToExisting
|
462
577
|
|
463
578
|
it 'rejects change to attribute' do
|
464
|
-
new_view =
|
579
|
+
new_view = subject_view.merge('nested' => 'terrible')
|
465
580
|
ex = assert_raises(ViewModel::DeserializationError::InvalidAttributeType) do
|
466
581
|
viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
467
582
|
end
|
@@ -471,32 +586,37 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
471
586
|
end
|
472
587
|
|
473
588
|
it 'can edit a nested value' do
|
474
|
-
|
475
|
-
vm = viewmodel_class.deserialize_from_view(
|
476
|
-
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')
|
477
592
|
assert_equal(2, vm.model.nested.size)
|
478
|
-
assert(
|
479
|
-
assert(
|
593
|
+
assert(subject_nested_model_1.equal?(vm.model.nested[0]))
|
594
|
+
assert(subject_nested_model_2.equal?(vm.model.nested[1]))
|
480
595
|
|
481
596
|
assert_unchanged(vm)
|
597
|
+
|
598
|
+
# The parent is itself not `changed?`, but it must record that its children are
|
599
|
+
change = access_control.all_changes(vm.to_reference)[0]
|
600
|
+
assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
|
601
|
+
|
482
602
|
assert_edited(vm.nested[0], changed_attributes: [:member])
|
483
603
|
end
|
484
604
|
|
485
605
|
it 'can append a nested value' do
|
486
|
-
|
606
|
+
subject_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
|
487
607
|
|
488
|
-
vm = viewmodel_class.deserialize_from_view(
|
608
|
+
vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
|
489
609
|
|
490
|
-
assert(
|
610
|
+
assert(subject_model.equal?(vm.model), 'returned model was not the same')
|
491
611
|
assert_equal(3, vm.model.nested.size)
|
492
|
-
assert(
|
493
|
-
assert(
|
612
|
+
assert(subject_nested_model_1.equal?(vm.model.nested[0]))
|
613
|
+
assert(subject_nested_model_2.equal?(vm.model.nested[1]))
|
494
614
|
|
495
615
|
vm.model.nested.each_with_index do |nvm, i|
|
496
616
|
assert_equal("member#{i + 1}", nvm.member)
|
497
617
|
end
|
498
618
|
|
499
|
-
assert_edited(vm, changed_attributes: [:nested])
|
619
|
+
assert_edited(vm, changed_attributes: [:nested], changed_nested_children: true)
|
500
620
|
assert_edited(vm.nested[2], new: true, changed_attributes: [:member])
|
501
621
|
end
|
502
622
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: iknow_view_models
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.
|
4
|
+
version: 3.6.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- iKnow Team
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-01-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -380,6 +380,7 @@ files:
|
|
380
380
|
- gemfiles/rails_5_2.gemfile
|
381
381
|
- gemfiles/rails_6_0.gemfile
|
382
382
|
- gemfiles/rails_6_1.gemfile
|
383
|
+
- gemfiles/rails_7_0.gemfile
|
383
384
|
- iknow_view_models.gemspec
|
384
385
|
- lib/iknow_view_models.rb
|
385
386
|
- lib/iknow_view_models/railtie.rb
|