iknow_view_models 3.7.7 → 3.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 849a8c0a38a21b8d8d8c706aae9800c2d5b009a81e59432d501cc0cef8329db3
4
- data.tar.gz: b9cfa60ace9c9f6af4c711f7ae11fd8aa76934bcef7d6e7b360b749fe667d4b4
3
+ metadata.gz: 10ac2bba9965a5eb683cdbb972d1a7c8b31a6fd86304b1e52816cdd5649818d9
4
+ data.tar.gz: dcd13cba67c3b27beb4491b23d6a487ff30a3a3b930a8d2188a5503f77298c89
5
5
  SHA512:
6
- metadata.gz: 6e510f3a65791a27df5be2eabb889bd5a5addb84ee17f4e1b2f1ff7b76dfb1630978e3fc1b90bc1c26b36a83d9bb6892e9c3eb7e235b7f762946ab0c3b9b4ab4
7
- data.tar.gz: 67f12f88ce24394ddcaa639008a66ea0120fa5e062463fed5c4d30e0f20a75e7dda7823f025304b3cf4d1cda9e5640cbe281f459aab22f84ed5a4d920eb7f9b6
6
+ metadata.gz: a0faccad196615ad236d93016b3268fe50c3eac86bfc52750445a53655fc24d24ff068442071d4ea0a6d1d19e86cdaba8ae24ad9cba5d4feedfbb007333217e9
7
+ data.tar.gz: 253dea949f2c1a79360969fbd662a93d072ef93ff43554b5b08bc65344e2cc96c58b0eea2a71ff734cd8d3507c07b2569638be4d483c116d7b498ea98aca5fd5
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IknowViewModels
4
- VERSION = '3.7.7'
4
+ VERSION = '3.8.0'
5
5
  end
@@ -134,7 +134,15 @@ class ViewModel::ActiveRecord
134
134
 
135
135
  updates.each do |ref, update_data|
136
136
  viewmodel =
137
- if update_data.new?
137
+ if update_data.auto_child_update?
138
+ raise ViewModel::DeserializationError::InvalidStructure.new(
139
+ 'Cannot make an automatic child update to a root node',
140
+ ViewModel::Reference.new(update_data.viewmodel_class, nil))
141
+ elsif update_data.child_update?
142
+ raise ViewModel::DeserializationError::InvalidStructure.new(
143
+ 'Cannot update an existing root node without a specified id',
144
+ ViewModel::Reference.new(update_data.viewmodel_class, nil))
145
+ elsif update_data.new?
138
146
  viewmodel_class.for_new_model(id: update_data.id)
139
147
  else
140
148
  viewmodel_class.new(existing_models[update_data.id])
@@ -520,9 +520,7 @@ class ViewModel::ActiveRecord
520
520
  end
521
521
  end
522
522
 
523
- def new?
524
- id.nil? || metadata.new?
525
- end
523
+ delegate :new?, :child_update?, :auto_child_update?, to: :metadata
526
524
 
527
525
  def self.parse_hashes(root_subtree_hashes, referenced_subtree_hashes = {})
528
526
  valid_reference_keys = referenced_subtree_hashes.keys.to_set
@@ -300,6 +300,26 @@ class ViewModel::ActiveRecord
300
300
  case
301
301
  when update_data.new?
302
302
  child_viewmodel_class.for_new_model(id: update_data.id)
303
+ when update_data.child_update?
304
+ if association_data.collection?
305
+ raise ViewModel::DeserializationError::InvalidStructure.new(
306
+ 'Cannot update existing children of a collection association without specified ids',
307
+ ViewModel::Reference.new(update_data.viewmodel_class, nil))
308
+ end
309
+
310
+ child = previous_child_viewmodels[0]
311
+
312
+ if child.nil?
313
+ unless update_data.auto_child_update?
314
+ raise ViewModel::DeserializationError::PreviousChildNotFound.new(
315
+ association_data.association_name.to_s,
316
+ self.blame_reference)
317
+ end
318
+
319
+ child = child_viewmodel_class.for_new_model
320
+ end
321
+
322
+ child
303
323
  when existing_child = previous_by_key[key]
304
324
  existing_child
305
325
  when taken_child = update_context.try_take_released_viewmodel(key)
@@ -211,6 +211,23 @@ class ViewModel
211
211
  end
212
212
  end
213
213
 
214
+ class PreviousChildNotFound < NotFound
215
+ attr_reader :association
216
+
217
+ def initialize(association, blame_nodes)
218
+ @association = association
219
+ super(blame_nodes)
220
+ end
221
+
222
+ def detail
223
+ "No child currently exists to update for association '#{association}'"
224
+ end
225
+
226
+ def meta
227
+ super.merge(association: association)
228
+ end
229
+ end
230
+
214
231
  class DuplicateNodes < InvalidRequest
215
232
  attr_reader :type
216
233
 
@@ -21,7 +21,7 @@ class ViewModel::Schemas
21
21
  'description' => 'viewmodel update',
22
22
  'properties' => { ViewModel::TYPE_ATTRIBUTE => { 'type' => 'string' },
23
23
  ViewModel::ID_ATTRIBUTE => ID_SCHEMA,
24
- ViewModel::NEW_ATTRIBUTE => { 'type' => 'boolean' },
24
+ ViewModel::NEW_ATTRIBUTE => { 'oneOf' => [{ 'type' => 'boolean' }, { 'type' => 'string', 'enum' => ['auto'] }] },
25
25
  ViewModel::VERSION_ATTRIBUTE => { 'type' => 'integer' } },
26
26
  'required' => [ViewModel::TYPE_ATTRIBUTE],
27
27
  }.freeze
data/lib/view_model.rb CHANGED
@@ -11,6 +11,22 @@ class ViewModel
11
11
  ID_ATTRIBUTE = 'id'
12
12
  TYPE_ATTRIBUTE = '_type'
13
13
  VERSION_ATTRIBUTE = '_version'
14
+
15
+ # The optional _new attribute specifies explicitly whether a deserialization
16
+ # is to an new or existing model. The behaviour of _new is as follows:
17
+ # * true - Always create a new model, using the id if provided, error if
18
+ # a model with that id already exists
19
+ # * false - Never create a new model. If id isn’t provided, it's an error
20
+ # unless the viewmodel is being deserialized in the context of a
21
+ # singular association, in which case it's implicitly referring
22
+ # to the current child of that association, and is an error if
23
+ # that child doesn't exist.
24
+ # * nil - Create a new model if `id` is not specified, otherwise update the
25
+ # existing record with `id`, and error if it doesn't exist.
26
+ # * 'auto' - Can only be used when the viewmodel is being deserialized in the
27
+ # context of a singular association. `id` must not be specified. If
28
+ # the association currently has a child, update it, otherwise
29
+ # create a new one.
14
30
  NEW_ATTRIBUTE = '_new'
15
31
 
16
32
  BULK_UPDATE_TYPE = '_bulk_update'
@@ -20,10 +36,36 @@ class ViewModel
20
36
  # Migrations leave a metadata attribute _migrated on any views that they
21
37
  # alter. This attribute is accessible as metadata when deserializing migrated
22
38
  # input, and is included in the output serialization sent to clients.
23
- MIGRATED_ATTRIBUTE = '_migrated'
39
+ MIGRATED_ATTRIBUTE = '_migrated'
24
40
 
25
41
  Metadata = Struct.new(:id, :view_name, :schema_version, :new, :migrated) do
26
- alias_method :new?, :new
42
+ # Does this metadata describe deserialization to a new model, either by
43
+ # {new: true} or {new: nil, id: nil}
44
+ def new?
45
+ if new.nil?
46
+ id.nil?
47
+ else
48
+ new == true
49
+ end
50
+ end
51
+
52
+ # Does this metadata describe an change to the anonymous child of a singular
53
+ # association
54
+ def child_update?
55
+ explicit_child_update? || auto_child_update?
56
+ end
57
+
58
+ # Does this metadata describe an update to the already-existing anonymous
59
+ # child of a singular association
60
+ def explicit_child_update?
61
+ new == false && id.nil?
62
+ end
63
+
64
+ # Does this child metadata describe an create-or-update to the anonymous
65
+ # child of a singular association
66
+ def auto_child_update?
67
+ new == 'auto'
68
+ end
27
69
  end
28
70
 
29
71
  class << self
@@ -119,9 +161,14 @@ class ViewModel
119
161
  id = hash.delete(ViewModel::ID_ATTRIBUTE)
120
162
  type_name = hash.delete(ViewModel::TYPE_ATTRIBUTE)
121
163
  schema_version = hash.delete(ViewModel::VERSION_ATTRIBUTE)
122
- new = hash.delete(ViewModel::NEW_ATTRIBUTE) { false }
164
+ new = hash.delete(ViewModel::NEW_ATTRIBUTE)
123
165
  migrated = hash.delete(ViewModel::MIGRATED_ATTRIBUTE) { false }
124
166
 
167
+ if id && new == 'auto'
168
+ raise ViewModel::DeserializationError::InvalidSyntax.new(
169
+ "An explicit id must not be specified with an 'auto' child update")
170
+ end
171
+
125
172
  Metadata.new(id, type_name, schema_version, new, migrated)
126
173
  end
127
174
 
@@ -85,7 +85,7 @@ class ViewModel::ActiveRecord::BelongsToTest < ActiveSupport::TestCase
85
85
  end
86
86
  end
87
87
 
88
- def test_belongs_to_create
88
+ def test_belongs_to_create_implicit_new
89
89
  @model1.update(child: nil)
90
90
 
91
91
  alter_by_view!(ModelView, @model1) do |view, _refs|
@@ -95,6 +95,108 @@ class ViewModel::ActiveRecord::BelongsToTest < ActiveSupport::TestCase
95
95
  assert_equal('cheese', @model1.child.name)
96
96
  end
97
97
 
98
+ def test_belongs_to_create
99
+ @model1.update(child: nil)
100
+
101
+ alter_by_view!(ModelView, @model1) do |view, _refs|
102
+ view['child'] = { '_type' => 'Child', '_new' => true, 'name' => 'cheese' }
103
+ end
104
+
105
+ assert_equal('cheese', @model1.child.name)
106
+ end
107
+
108
+ def test_belongs_to_create_with_id
109
+ @model1.update(child: nil)
110
+
111
+ alter_by_view!(ModelView, @model1) do |view, _refs|
112
+ view['child'] = { '_type' => 'Child', 'id' => 9999, '_new' => true, 'name' => 'cheese' }
113
+ end
114
+
115
+ assert_equal('cheese', @model1.child.name)
116
+ assert_equal(9999, @model1.child.id)
117
+ end
118
+
119
+ def test_belongs_to_create_with_colliding_id
120
+ child_id = @model1.child_id
121
+ @model1.update(child: nil)
122
+
123
+ ex = assert_raises(ViewModel::DeserializationError::UniqueViolation) do
124
+ alter_by_view!(ModelView, @model1) do |view, _refs|
125
+ view['child'] = { '_type' => 'Child', 'id' => child_id, '_new' => true, 'name' => 'cheese' }
126
+ end
127
+ end
128
+
129
+ assert_equal(['id'], ex.columns)
130
+ assert_equal([ViewModel::Reference.new(ChildView, child_id)], ex.nodes)
131
+ end
132
+
133
+ def test_belongs_to_create_child_with_auto
134
+ @model1.update(child: nil)
135
+
136
+ alter_by_view!(ModelView, @model1) do |view, _refs|
137
+ view['child'] = { '_type' => 'Child', '_new' => 'auto', 'name' => 'cheese' }
138
+ end
139
+
140
+ assert_equal('cheese', @model1.child.name)
141
+ end
142
+
143
+ def test_belongs_to_edit_implicit_new
144
+ child_id = @model1.child_id
145
+
146
+ alter_by_view!(ModelView, @model1) do |view, _refs|
147
+ view['child'] = { '_type' => 'Child', 'id' => child_id, 'name' => 'cheese' }
148
+ end
149
+
150
+ assert_equal('cheese', @model1.child.name)
151
+ assert_equal(child_id, @model1.child.id)
152
+ end
153
+
154
+ def test_belongs_to_edit
155
+ child_id = @model1.child_id
156
+
157
+ alter_by_view!(ModelView, @model1) do |view, _refs|
158
+ view['child'] = { '_type' => 'Child', 'id' => child_id, '_new' => false, 'name' => 'cheese' }
159
+ end
160
+
161
+ assert_equal('cheese', @model1.child.name)
162
+ assert_equal(child_id, @model1.child.id)
163
+ end
164
+
165
+ def test_belongs_to_edit_child_without_id
166
+ child_id = @model1.child_id
167
+
168
+ alter_by_view!(ModelView, @model1) do |view, _refs|
169
+ view['child'] = { '_type' => 'Child', '_new' => false, 'name' => 'cheese' }
170
+ end
171
+
172
+ assert_equal('cheese', @model1.child.name)
173
+ assert_equal(child_id, @model1.child.id)
174
+ end
175
+
176
+ def test_belongs_to_one_edit_without_id_no_child
177
+ @model1.update(child: nil)
178
+
179
+ ex = assert_raises(ViewModel::DeserializationError::PreviousChildNotFound) do
180
+ alter_by_view!(ModelView, @model1) do |view, _refs|
181
+ view['child'] = { '_type' => 'Child', '_new' => false, 'name' => 'cheese' }
182
+ end
183
+ end
184
+
185
+ assert_equal('child', ex.association)
186
+ assert_equal([ViewModel::Reference.new(ModelView, @model1.id)], ex.nodes)
187
+ end
188
+
189
+ def test_belongs_to_edit_child_with_auto
190
+ child_id = @model1.child_id
191
+
192
+ alter_by_view!(ModelView, @model1) do |view, _refs|
193
+ view['child'] = { '_type' => 'Child', '_new' => 'auto', 'name' => 'cheese' }
194
+ end
195
+
196
+ assert_equal('cheese', @model1.child.name)
197
+ assert_equal(child_id, @model1.child.id)
198
+ end
199
+
98
200
  def test_belongs_to_replace
99
201
  old_child = @model1.child
100
202
 
@@ -107,7 +107,7 @@ class ViewModel::ActiveRecord::HasManyTest < ActiveSupport::TestCase
107
107
  assert(pv.model.children.blank?)
108
108
  end
109
109
 
110
- def test_create_has_many
110
+ def test_create_has_many_implicit_new
111
111
  view = { '_type' => 'Model',
112
112
  'name' => 'p',
113
113
  'children' => [{ '_type' => 'Child', 'name' => 'c1' },
@@ -123,6 +123,64 @@ class ViewModel::ActiveRecord::HasManyTest < ActiveSupport::TestCase
123
123
  assert_equal(%w[c1 c2], pv.model.children.map(&:name))
124
124
  end
125
125
 
126
+ def test_create_has_many
127
+ view = { '_type' => 'Model',
128
+ 'name' => 'p',
129
+ 'children' => [{ '_type' => 'Child', '_new' => true, 'name' => 'c1' },
130
+ { '_type' => 'Child', '_new' => true, 'name' => 'c2' },] }
131
+
132
+ context = viewmodel_class.new_deserialize_context
133
+ pv = viewmodel_class.deserialize_from_view(view, deserialize_context: context)
134
+
135
+ assert_contains_exactly(
136
+ [pv.to_reference, pv.children[0].to_reference, pv.children[1].to_reference],
137
+ context.valid_edit_refs)
138
+
139
+ assert_equal(%w[c1 c2], pv.model.children.map(&:name))
140
+ end
141
+
142
+ def test_create_has_many_with_id
143
+ view = { '_type' => 'Model',
144
+ 'name' => 'p',
145
+ 'children' => [{ '_type' => 'Child', 'id' => 9998, '_new' => true, 'name' => 'c1' },
146
+ { '_type' => 'Child', 'id' => 9999, '_new' => true, 'name' => 'c2' },] }
147
+
148
+ context = viewmodel_class.new_deserialize_context
149
+ pv = viewmodel_class.deserialize_from_view(view, deserialize_context: context)
150
+
151
+ assert_contains_exactly(
152
+ [pv.to_reference, pv.children[0].to_reference, pv.children[1].to_reference],
153
+ context.valid_edit_refs)
154
+
155
+ assert_equal(%w[c1 c2], pv.model.children.map(&:name))
156
+ end
157
+
158
+ def test_create_has_many_auto
159
+ view = { '_type' => 'Model',
160
+ 'name' => 'p',
161
+ 'children' => [{ '_type' => 'Child', '_new' => 'auto', 'name' => 'c1' }] }
162
+
163
+ context = viewmodel_class.new_deserialize_context
164
+ ex = assert_raises(ViewModel::DeserializationError::InvalidStructure) do
165
+ viewmodel_class.deserialize_from_view(view, deserialize_context: context)
166
+ end
167
+ assert_match(/existing children of a collection association without specified ids/, ex.message)
168
+ assert_equal([ViewModel::Reference.new(ChildView, nil)], ex.nodes)
169
+ end
170
+
171
+ def test_update_has_many_without_id
172
+ view = { '_type' => 'Model',
173
+ 'name' => 'p',
174
+ 'children' => [{ '_type' => 'Child', '_new' => false, 'name' => 'c1' }] }
175
+
176
+ context = viewmodel_class.new_deserialize_context
177
+ ex = assert_raises(ViewModel::DeserializationError::InvalidStructure) do
178
+ viewmodel_class.deserialize_from_view(view, deserialize_context: context)
179
+ end
180
+ assert_match(/existing children of a collection association without specified ids/, ex.message)
181
+ assert_equal([ViewModel::Reference.new(ChildView, nil)], ex.nodes)
182
+ end
183
+
126
184
  def test_nil_multiple_association
127
185
  view = {
128
186
  '_type' => 'Model',
@@ -98,7 +98,7 @@ class ViewModel::ActiveRecord::HasOneTest < ActiveSupport::TestCase
98
98
  assert_nil(pv.model.child)
99
99
  end
100
100
 
101
- def test_has_one_create
101
+ def test_has_one_create_implicit_new
102
102
  @model1.update(child: nil)
103
103
 
104
104
  alter_by_view!(ModelView, @model1) do |view, _refs|
@@ -108,12 +108,106 @@ class ViewModel::ActiveRecord::HasOneTest < ActiveSupport::TestCase
108
108
  assert_equal('t', @model1.child.name)
109
109
  end
110
110
 
111
- def test_has_one_update
111
+ def test_has_one_create
112
+ @model1.update(child: nil)
113
+
114
+ alter_by_view!(ModelView, @model1) do |view, _refs|
115
+ view['child'] = { '_type' => 'Child', '_new' => true, 'name' => 't' }
116
+ end
117
+
118
+ assert_equal('t', @model1.child.name)
119
+ end
120
+
121
+ def test_has_one_create_with_id
122
+ @model1.update(child: nil)
123
+
124
+ alter_by_view!(ModelView, @model1) do |view, _refs|
125
+ view['child'] = { '_type' => 'Child', 'id' => 9999, '_new' => true, 'name' => 't' }
126
+ end
127
+
128
+ assert_equal('t', @model1.child.name)
129
+ assert_equal(9999, @model1.child.id)
130
+ end
131
+
132
+ def test_has_one_create_with_colliding_id
133
+ child_id = @model2.child.id
134
+ @model1.update(child: nil)
135
+
136
+ ex = assert_raises(ViewModel::DeserializationError::UniqueViolation) do
137
+ alter_by_view!(ModelView, @model1) do |view, _refs|
138
+ view['child'] = { '_type' => 'Child', 'id' => child_id, '_new' => true, 'name' => 'cheese' }
139
+ end
140
+ end
141
+
142
+ assert_equal(['id'], ex.columns)
143
+ assert_equal([ViewModel::Reference.new(ChildView, child_id)], ex.nodes)
144
+ end
145
+
146
+ def test_has_one_create_child_with_auto
147
+ @model1.update(child: nil)
148
+
149
+ alter_by_view!(ModelView, @model1) do |view, _refs|
150
+ view['child'] = { '_type' => 'Child', '_new' => 'auto', 'name' => 'cheese' }
151
+ end
152
+
153
+ assert_equal('cheese', @model1.child.name)
154
+ end
155
+
156
+ def test_has_one_edit_implicit_new
157
+ child_id = @model1.child.id
158
+
159
+ alter_by_view!(ModelView, @model1) do |view, _refs|
160
+ view['child'] = { '_type' => 'Child', 'id' => child_id, 'name' => 'cheese' }
161
+ end
162
+
163
+ assert_equal('cheese', @model1.child.name)
164
+ assert_equal(child_id, @model1.child.id)
165
+ end
166
+
167
+ def test_has_one_edit
168
+ child_id = @model1.child.id
169
+
170
+ alter_by_view!(ModelView, @model1) do |view, _refs|
171
+ view['child'] = { '_type' => 'Child', 'id' => child_id, '_new' => false, 'name' => 'cheese' }
172
+ end
173
+
174
+ assert_equal('cheese', @model1.child.name)
175
+ assert_equal(child_id, @model1.child.id)
176
+ end
177
+
178
+ def test_has_one_edit_without_id
179
+ child_id = @model1.child.id
180
+
181
+ alter_by_view!(ModelView, @model1) do |view, _refs|
182
+ view['child'] = { '_type' => 'Child', '_new' => false, 'name' => 'cheese' }
183
+ end
184
+
185
+ assert_equal('cheese', @model1.child.name)
186
+ assert_equal(child_id, @model1.child.id)
187
+ end
188
+
189
+ def test_has_one_edit_without_id_no_child
190
+ @model1.update(child: nil)
191
+
192
+ ex = assert_raises(ViewModel::DeserializationError::PreviousChildNotFound) do
193
+ alter_by_view!(ModelView, @model1) do |view, _refs|
194
+ view['child'] = { '_type' => 'Child', '_new' => false, 'name' => 'cheese' }
195
+ end
196
+ end
197
+
198
+ assert_equal('child', ex.association)
199
+ assert_equal([ViewModel::Reference.new(ModelView, @model1.id)], ex.nodes)
200
+ end
201
+
202
+ def test_belongs_to_edit_child_with_auto
203
+ child_id = @model1.child.id
204
+
112
205
  alter_by_view!(ModelView, @model1) do |view, _refs|
113
- view['child']['name'] = 'hello'
206
+ view['child'] = { '_type' => 'Child', '_new' => 'auto', 'name' => 'cheese' }
114
207
  end
115
208
 
116
- assert_equal('hello', @model1.child.name)
209
+ assert_equal('cheese', @model1.child.name)
210
+ assert_equal(child_id, @model1.child.id)
117
211
  end
118
212
 
119
213
  def test_has_one_destroy
@@ -127,6 +127,30 @@ class ViewModel::ActiveRecordTest < ActiveSupport::TestCase
127
127
  assert_equal([ViewModel::Reference.new(ParentView, 9999)], ex.nodes)
128
128
  end
129
129
 
130
+ def test_create_implicit_id_raises
131
+ view = {
132
+ '_type' => 'Parent',
133
+ '_new' => false,
134
+ }
135
+ ex = assert_raises(ViewModel::DeserializationError::InvalidStructure) do
136
+ ParentView.deserialize_from_view(view)
137
+ end
138
+ assert_match(/existing root node without a specified id/, ex.message)
139
+ assert_equal([ViewModel::Reference.new(ParentView, nil)], ex.nodes)
140
+ end
141
+
142
+ def test_create_auto_raises
143
+ view = {
144
+ '_type' => 'Parent',
145
+ '_new' => 'auto',
146
+ }
147
+ ex = assert_raises(ViewModel::DeserializationError::InvalidStructure) do
148
+ ParentView.deserialize_from_view(view)
149
+ end
150
+ assert_match(/automatic child update to a root node/, ex.message)
151
+ assert_equal([ViewModel::Reference.new(ParentView, nil)], ex.nodes)
152
+ end
153
+
130
154
  def test_read_only_raises_with_id
131
155
  view = {
132
156
  '_type' => 'Parent',
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.7.7
4
+ version: 3.8.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: 2024-01-15 00:00:00.000000000 Z
11
+ date: 2024-01-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack