iknow_view_models 3.5.1 → 3.6.1
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 +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
|