iknow_view_models 3.5.0 → 3.6.0
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/lib/iknow_view_models/version.rb +1 -1
- data/lib/view_model/migratable_view.rb +25 -3
- data/lib/view_model/migration.rb +3 -2
- data/lib/view_model/migrator.rb +26 -0
- data/lib/view_model/record.rb +14 -6
- 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 +92 -0
- data/test/unit/view_model/record_test.rb +215 -99
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 573266179096880a34febada583570484e420b91a242920198cfa119192c3fbc
|
|
4
|
+
data.tar.gz: c024eb9398a4b0d49103d1806379d0f20312f7c6965e9fe2751b09bb720f2fc3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 353f42c901c4d52cba6bbe459cc6266c2e59f3a56ca432bac616cd4534c95116d0a0e27b8329799717259640e098c704c4f6b63c83713cd42cb3096c6ca6c5b3
|
|
7
|
+
data.tar.gz: 4e74aa987e3ff26dd9b14bb3fbebc291ce86087dca8f8bcc93ef9b320d7d7ed22f150ce4f3153e27c51f6a2f8d6561fe53cdcb7ac45734caf156f50b10a543d1
|
|
@@ -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/migrator.rb
CHANGED
|
@@ -75,6 +75,32 @@ class ViewModel
|
|
|
75
75
|
class UpMigrator < Migrator
|
|
76
76
|
private
|
|
77
77
|
|
|
78
|
+
def migrate_tree!(node, references:)
|
|
79
|
+
if node.is_a?(Hash) && node[ViewModel::TYPE_ATTRIBUTE] == ViewModel::ActiveRecord::FUNCTIONAL_UPDATE_TYPE
|
|
80
|
+
migrate_functional_update!(node, references: references)
|
|
81
|
+
else
|
|
82
|
+
super
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
NESTED_FUPDATE_TYPES = ['append', 'update'].freeze
|
|
87
|
+
|
|
88
|
+
# The functional update structure uses `_type` internally with a
|
|
89
|
+
# context-dependent meaning. Retrospectively this was a poor choice, but we
|
|
90
|
+
# need to account for it here.
|
|
91
|
+
def migrate_functional_update!(node, references:)
|
|
92
|
+
actions = node[ViewModel::ActiveRecord::ACTIONS_ATTRIBUTE]
|
|
93
|
+
actions&.each do |action|
|
|
94
|
+
action_type = action[ViewModel::TYPE_ATTRIBUTE]
|
|
95
|
+
next unless NESTED_FUPDATE_TYPES.include?(action_type)
|
|
96
|
+
|
|
97
|
+
values = action[ViewModel::ActiveRecord::VALUES_ATTRIBUTE]
|
|
98
|
+
values&.each do |value|
|
|
99
|
+
migrate_tree!(value, references: references)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
78
104
|
def migrate_viewmodel!(view_name, source_version, view_hash, references)
|
|
79
105
|
path = @paths[view_name]
|
|
80
106
|
return false unless path
|
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?
|
|
@@ -13,6 +13,7 @@ class TestAccessControl < ViewModel::AccessControl
|
|
|
13
13
|
@editable_checks = []
|
|
14
14
|
@visible_checks = []
|
|
15
15
|
@valid_edit_checks = []
|
|
16
|
+
@changes = []
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
# Collect
|
|
@@ -33,6 +34,17 @@ class TestAccessControl < ViewModel::AccessControl
|
|
|
33
34
|
ViewModel::AccessControl::Result.new(@can_view)
|
|
34
35
|
end
|
|
35
36
|
|
|
37
|
+
def record_deserialize_changes(ref, changes)
|
|
38
|
+
@changes << [ref, changes]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Collect all changes on after_deserialize, to allow inspecting changes that
|
|
42
|
+
# didn't result in `changed?`
|
|
43
|
+
after_deserialize do
|
|
44
|
+
ref = view.to_reference
|
|
45
|
+
record_deserialize_changes(ref, changes)
|
|
46
|
+
end
|
|
47
|
+
|
|
36
48
|
# Query (also see attr_accessors)
|
|
37
49
|
|
|
38
50
|
def valid_edit_refs
|
|
@@ -55,4 +67,10 @@ class TestAccessControl < ViewModel::AccessControl
|
|
|
55
67
|
def was_edited?(ref)
|
|
56
68
|
all_valid_edit_changes(ref).present?
|
|
57
69
|
end
|
|
70
|
+
|
|
71
|
+
def all_changes(ref)
|
|
72
|
+
@changes
|
|
73
|
+
.select { |cref, _changes| cref == ref }
|
|
74
|
+
.map { |_cref, changes| changes }
|
|
75
|
+
end
|
|
58
76
|
end
|
|
@@ -206,6 +206,55 @@ module ViewModelSpecHelpers
|
|
|
206
206
|
end
|
|
207
207
|
end
|
|
208
208
|
|
|
209
|
+
module SingleWithInheritedMigration
|
|
210
|
+
extend ActiveSupport::Concern
|
|
211
|
+
include ViewModelSpecHelpers::Base
|
|
212
|
+
|
|
213
|
+
def migration_bearing_viewmodel_class
|
|
214
|
+
define_viewmodel_class(
|
|
215
|
+
:MigrationBearingView,
|
|
216
|
+
namespace: namespace,
|
|
217
|
+
viewmodel_base: viewmodel_base,
|
|
218
|
+
model_base: model_base,
|
|
219
|
+
spec: ViewModel::TestHelpers::ARVMBuilder::Spec.new(
|
|
220
|
+
schema: ->(_) {},
|
|
221
|
+
model: ->(_) {},
|
|
222
|
+
viewmodel: ->(v) {
|
|
223
|
+
root!
|
|
224
|
+
self.schema_version = 2
|
|
225
|
+
migrates from: 1, to: 2 do
|
|
226
|
+
down do |view, _refs|
|
|
227
|
+
view['inherited_base'] = 'present'
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
}))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def model_attributes
|
|
234
|
+
migration_bearing_viewmodel_class = self.migration_bearing_viewmodel_class
|
|
235
|
+
|
|
236
|
+
super.merge(
|
|
237
|
+
schema: ->(t) { t.integer :new_field, default: 1, null: false },
|
|
238
|
+
viewmodel: ->(_v) {
|
|
239
|
+
self.schema_version = 2
|
|
240
|
+
|
|
241
|
+
attribute :new_field
|
|
242
|
+
|
|
243
|
+
migrates from: 1, to: 2, inherit: migration_bearing_viewmodel_class, at: 2 do
|
|
244
|
+
down do |view, refs|
|
|
245
|
+
super(view, refs)
|
|
246
|
+
view.delete('new_field')
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
up do |view, refs|
|
|
250
|
+
view.delete('inherited_base')
|
|
251
|
+
view['new_field'] = 100
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
})
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
209
258
|
module ParentAndBelongsToChildWithMigration
|
|
210
259
|
extend ActiveSupport::Concern
|
|
211
260
|
include ViewModelSpecHelpers::ParentAndBelongsToChild
|
|
@@ -178,6 +178,98 @@ class ViewModel::ActiveRecord::Migration < ActiveSupport::TestCase
|
|
|
178
178
|
end
|
|
179
179
|
end
|
|
180
180
|
end
|
|
181
|
+
|
|
182
|
+
describe 'with a functional update' do
|
|
183
|
+
# note that this wouldn't actually be deserializable as child is not a collection
|
|
184
|
+
def subject_data
|
|
185
|
+
data = super()
|
|
186
|
+
data['child'] = wrap_with_fupdate(data['child'])
|
|
187
|
+
data
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def expected_result
|
|
191
|
+
data = super()
|
|
192
|
+
data['data']['child'] = wrap_with_fupdate(data['data']['child'])
|
|
193
|
+
data
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def wrap_with_fupdate(child)
|
|
197
|
+
# The 'after' and remove shouldn't get changed in migration, even though it has _type: Child
|
|
198
|
+
build_fupdate do
|
|
199
|
+
append([child], after: { '_type' => 'Child', 'id' => 9999 })
|
|
200
|
+
update([child.deep_merge('id' => 8888)])
|
|
201
|
+
remove([{ '_type' => 'Child', 'id' => 7777 }])
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'migrates' do
|
|
206
|
+
migrate!
|
|
207
|
+
assert_equal(expected_result, subject)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
describe 'inherited migrations' do
|
|
214
|
+
include ViewModelSpecHelpers::SingleWithInheritedMigration
|
|
215
|
+
|
|
216
|
+
def new_model
|
|
217
|
+
model_class.new(name: 'm1')
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
let(:migration_versions) { { viewmodel_class => 1 } }
|
|
221
|
+
|
|
222
|
+
let(:v1_serialization_data) do
|
|
223
|
+
{
|
|
224
|
+
ViewModel::TYPE_ATTRIBUTE => viewmodel_class.view_name,
|
|
225
|
+
ViewModel::VERSION_ATTRIBUTE => 1,
|
|
226
|
+
ViewModel::ID_ATTRIBUTE => viewmodel.id,
|
|
227
|
+
'name' => viewmodel.name,
|
|
228
|
+
'inherited_base' => 'present',
|
|
229
|
+
}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
let(:v1_serialization_references) { {} }
|
|
233
|
+
|
|
234
|
+
let(:v1_serialization) do
|
|
235
|
+
{
|
|
236
|
+
'data' => v1_serialization_data,
|
|
237
|
+
'references' => v1_serialization_references,
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
describe 'downwards' do
|
|
242
|
+
let(:migrator) { down_migrator }
|
|
243
|
+
let(:subject) { current_serialization.deep_dup }
|
|
244
|
+
let(:expected_result) do
|
|
245
|
+
v1_serialization.deep_merge({ 'data' => { ViewModel::MIGRATED_ATTRIBUTE => true } })
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it 'migrates' do
|
|
249
|
+
migrate!
|
|
250
|
+
assert_equal(expected_result, subject)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
describe 'upwards' do
|
|
255
|
+
let(:migrator) { up_migrator }
|
|
256
|
+
let(:subject) { v1_serialization.deep_dup }
|
|
257
|
+
|
|
258
|
+
let(:expected_result) do
|
|
259
|
+
current_serialization.deep_merge(
|
|
260
|
+
{
|
|
261
|
+
'data' => {
|
|
262
|
+
ViewModel::MIGRATED_ATTRIBUTE => true,
|
|
263
|
+
'new_field' => 100,
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
it 'migrates' do
|
|
270
|
+
migrate!
|
|
271
|
+
assert_equal(expected_result, subject)
|
|
272
|
+
end
|
|
181
273
|
end
|
|
182
274
|
end
|
|
183
275
|
|
|
@@ -57,11 +57,30 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
|
57
57
|
let(:model_body) { nil }
|
|
58
58
|
let(:viewmodel_body) { nil }
|
|
59
59
|
|
|
60
|
+
# Generate an ActiveModel-like keyword argument constructor.
|
|
61
|
+
def generate_model_constructor(model_class, model_defaults)
|
|
62
|
+
args = model_class.members
|
|
63
|
+
params = args.map do |arg_name|
|
|
64
|
+
"#{arg_name}: self.class.__constructor_default(:#{arg_name})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
<<-SRC
|
|
68
|
+
def initialize(#{params.join(", ")})
|
|
69
|
+
super(#{args.join(", ")})
|
|
70
|
+
end
|
|
71
|
+
SRC
|
|
72
|
+
end
|
|
73
|
+
|
|
60
74
|
let(:model_class) do
|
|
61
75
|
mb = model_body
|
|
62
|
-
|
|
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
|
|
@@ -391,77 +477,102 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
|
391
477
|
end
|
|
392
478
|
|
|
393
479
|
describe 'with nested viewmodel' do
|
|
394
|
-
let(:
|
|
395
|
-
let(:
|
|
480
|
+
let(:subject_nested_model) { nested_model_class.new('member') }
|
|
481
|
+
let(:subject_nested_view) { view_base.merge('_type' => 'Nested', 'member' => 'member') }
|
|
396
482
|
|
|
397
483
|
let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class } } }
|
|
398
484
|
|
|
399
|
-
let(:
|
|
400
|
-
let(:
|
|
485
|
+
let(:subject_view_attributes) { { nested: subject_nested_view } }
|
|
486
|
+
let(:subject_model_attributes) { { nested: subject_nested_model } }
|
|
401
487
|
|
|
402
488
|
let(:update_context) do
|
|
403
|
-
TestDeserializeContext.new(
|
|
404
|
-
|
|
489
|
+
TestDeserializeContext.new(
|
|
490
|
+
targets: [subject_model, subject_nested_model],
|
|
491
|
+
access_control: access_control)
|
|
405
492
|
end
|
|
406
493
|
|
|
407
494
|
include CanSerialize
|
|
408
|
-
|
|
495
|
+
|
|
496
|
+
it 'can deserialize to a new model' do
|
|
497
|
+
vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
|
|
498
|
+
assert_equal(subject_model, vm.model)
|
|
499
|
+
refute(subject_model.equal?(vm.model))
|
|
500
|
+
|
|
501
|
+
assert_equal(subject_nested_model, vm.model.nested)
|
|
502
|
+
refute(subject_nested_model.equal?(vm.model.nested))
|
|
503
|
+
|
|
504
|
+
assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
|
|
505
|
+
end
|
|
506
|
+
|
|
409
507
|
include CanDeserializeToExisting
|
|
410
508
|
|
|
411
509
|
it 'can update the nested value' do
|
|
412
|
-
new_view =
|
|
510
|
+
new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
|
|
413
511
|
|
|
414
512
|
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
|
415
513
|
|
|
416
|
-
assert(
|
|
417
|
-
assert(
|
|
514
|
+
assert(subject_model.equal?(vm.model), 'returned model was not the same')
|
|
515
|
+
assert(subject_nested_model.equal?(vm.model.nested), 'returned nested model was not the same')
|
|
418
516
|
|
|
419
|
-
assert_equal('changed',
|
|
517
|
+
assert_equal('changed', subject_model.nested.member)
|
|
420
518
|
|
|
421
519
|
assert_unchanged(vm)
|
|
520
|
+
|
|
521
|
+
# The parent is itself not `changed?`, but it must record that its children are
|
|
522
|
+
change = access_control.all_changes(vm.to_reference)[0]
|
|
523
|
+
assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
|
|
524
|
+
|
|
422
525
|
assert_edited(vm.nested, changed_attributes: [:member])
|
|
423
526
|
end
|
|
424
527
|
|
|
425
528
|
it 'can replace the nested value' do
|
|
426
529
|
# The value will be unified if it is different after deserialization
|
|
427
|
-
new_view =
|
|
530
|
+
new_view = subject_view.merge('nested' => subject_nested_view.merge('member' => 'changed'))
|
|
428
531
|
|
|
429
|
-
partial_update_context = TestDeserializeContext.new(targets: [
|
|
532
|
+
partial_update_context = TestDeserializeContext.new(targets: [subject_model],
|
|
430
533
|
access_control: access_control)
|
|
431
534
|
|
|
432
535
|
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: partial_update_context)
|
|
433
536
|
|
|
434
|
-
assert(
|
|
435
|
-
refute(
|
|
537
|
+
assert(subject_model.equal?(vm.model), 'returned model was not the same')
|
|
538
|
+
refute(subject_nested_model.equal?(vm.model.nested), 'returned nested model was the same')
|
|
436
539
|
|
|
437
|
-
assert_edited(vm, new: false, changed_attributes: [:nested])
|
|
540
|
+
assert_edited(vm, new: false, changed_attributes: [:nested], changed_nested_children: true)
|
|
438
541
|
assert_edited(vm.nested, new: true, changed_attributes: [:member])
|
|
439
542
|
end
|
|
440
543
|
end
|
|
441
544
|
|
|
442
545
|
describe 'with array of nested viewmodel' do
|
|
443
|
-
let(:
|
|
444
|
-
let(:
|
|
546
|
+
let(:subject_nested_model_1) { nested_model_class.new('member1') }
|
|
547
|
+
let(:subject_nested_view_1) { view_base.merge('_type' => 'Nested', 'member' => 'member1') }
|
|
445
548
|
|
|
446
|
-
let(:
|
|
447
|
-
let(:
|
|
549
|
+
let(:subject_nested_model_2) { nested_model_class.new('member2') }
|
|
550
|
+
let(:subject_nested_view_2) { view_base.merge('_type' => 'Nested', 'member' => 'member2') }
|
|
448
551
|
|
|
449
552
|
let(:attributes) { { simple: {}, nested: { using: nested_viewmodel_class, array: true } } }
|
|
450
553
|
|
|
451
|
-
let(:
|
|
452
|
-
let(:
|
|
554
|
+
let(:subject_view_attributes) { { nested: [subject_nested_view_1, subject_nested_view_2] } }
|
|
555
|
+
let(:subject_model_attributes) { { nested: [subject_nested_model_1, subject_nested_model_2] } }
|
|
453
556
|
|
|
454
557
|
let(:update_context) {
|
|
455
|
-
TestDeserializeContext.new(targets: [
|
|
558
|
+
TestDeserializeContext.new(targets: [subject_model, subject_nested_model_1, subject_nested_model_2],
|
|
456
559
|
access_control: access_control)
|
|
457
560
|
}
|
|
458
561
|
|
|
459
562
|
include CanSerialize
|
|
460
|
-
|
|
563
|
+
|
|
564
|
+
it 'can deserialize to a new model' do
|
|
565
|
+
vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: create_context)
|
|
566
|
+
assert_equal(subject_model, vm.model)
|
|
567
|
+
refute(subject_model.equal?(vm.model))
|
|
568
|
+
|
|
569
|
+
assert_edited(vm, new: true, changed_attributes: ['nested'], changed_nested_children: true)
|
|
570
|
+
end
|
|
571
|
+
|
|
461
572
|
include CanDeserializeToExisting
|
|
462
573
|
|
|
463
574
|
it 'rejects change to attribute' do
|
|
464
|
-
new_view =
|
|
575
|
+
new_view = subject_view.merge('nested' => 'terrible')
|
|
465
576
|
ex = assert_raises(ViewModel::DeserializationError::InvalidAttributeType) do
|
|
466
577
|
viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
|
467
578
|
end
|
|
@@ -471,32 +582,37 @@ class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
|
471
582
|
end
|
|
472
583
|
|
|
473
584
|
it 'can edit a nested value' do
|
|
474
|
-
|
|
475
|
-
vm = viewmodel_class.deserialize_from_view(
|
|
476
|
-
assert(
|
|
585
|
+
subject_view['nested'][0]['member'] = 'changed'
|
|
586
|
+
vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
|
|
587
|
+
assert(subject_model.equal?(vm.model), 'returned model was not the same')
|
|
477
588
|
assert_equal(2, vm.model.nested.size)
|
|
478
|
-
assert(
|
|
479
|
-
assert(
|
|
589
|
+
assert(subject_nested_model_1.equal?(vm.model.nested[0]))
|
|
590
|
+
assert(subject_nested_model_2.equal?(vm.model.nested[1]))
|
|
480
591
|
|
|
481
592
|
assert_unchanged(vm)
|
|
593
|
+
|
|
594
|
+
# The parent is itself not `changed?`, but it must record that its children are
|
|
595
|
+
change = access_control.all_changes(vm.to_reference)[0]
|
|
596
|
+
assert_equal(ViewModel::Changes.new(changed_nested_children: true), change)
|
|
597
|
+
|
|
482
598
|
assert_edited(vm.nested[0], changed_attributes: [:member])
|
|
483
599
|
end
|
|
484
600
|
|
|
485
601
|
it 'can append a nested value' do
|
|
486
|
-
|
|
602
|
+
subject_view['nested'] << view_base.merge('_type' => 'Nested', 'member' => 'member3')
|
|
487
603
|
|
|
488
|
-
vm = viewmodel_class.deserialize_from_view(
|
|
604
|
+
vm = viewmodel_class.deserialize_from_view(subject_view, deserialize_context: update_context)
|
|
489
605
|
|
|
490
|
-
assert(
|
|
606
|
+
assert(subject_model.equal?(vm.model), 'returned model was not the same')
|
|
491
607
|
assert_equal(3, vm.model.nested.size)
|
|
492
|
-
assert(
|
|
493
|
-
assert(
|
|
608
|
+
assert(subject_nested_model_1.equal?(vm.model.nested[0]))
|
|
609
|
+
assert(subject_nested_model_2.equal?(vm.model.nested[1]))
|
|
494
610
|
|
|
495
611
|
vm.model.nested.each_with_index do |nvm, i|
|
|
496
612
|
assert_equal("member#{i + 1}", nvm.member)
|
|
497
613
|
end
|
|
498
614
|
|
|
499
|
-
assert_edited(vm, changed_attributes: [:nested])
|
|
615
|
+
assert_edited(vm, changed_attributes: [:nested], changed_nested_children: true)
|
|
500
616
|
assert_edited(vm.nested[2], new: true, changed_attributes: [:member])
|
|
501
617
|
end
|
|
502
618
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: iknow_view_models
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- iKnow Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2021-
|
|
11
|
+
date: 2021-12-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|