iknow_view_models 3.13.0 → 3.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf46376eaf22e04e38fb4b841c10ee273422c4927a1402b4cd2ca4d53f548cb7
4
- data.tar.gz: eb83782759ccdc0259c25bf15eca93469272a947b0177c03cb739f478be0733b
3
+ metadata.gz: b6b63efe5e196fe293c68497b57f53356366632e08f3b2ed97b2240ceb1e2f96
4
+ data.tar.gz: e8c8d932be65e4f811ac1f80b45338a66681ad6c9b3e5e2957ce123ccff4b70c
5
5
  SHA512:
6
- metadata.gz: 6edef87440a300016dd2044330e25d1927d161a7706abb4fab86cfd1cffde93e980ef978097006364f7fa4330c4b669342c04f3c3768645832faa0b7f8742f99
7
- data.tar.gz: a44050a5c1aebc470ab8c96e027935463b6dd05f3f796f828be33b40ad3af9cd9148fcddbc69aeadb00b1466b802b049c51378f05e63af2c67143838c74a8308
6
+ metadata.gz: 7e050339a0ac0d06d70e55edc30a2f7a8afe2a58d64f933cc48de18de9c7f98a1fc21b0fe50666b459cf3971cc89b32c75b72fb76c30e6cf3c3f9294e7a864b4
7
+ data.tar.gz: 8c7fb549e253196df7ab6ca2e56f0659cff104e0e3e9c58806d3681b67ef8c69ecfdae8a781110768249f738ff69e81a0b86360c66f45fb7536ef9bf9ac3fced
@@ -26,12 +26,8 @@ jobs:
26
26
  strategy:
27
27
  fail-fast: false
28
28
  matrix:
29
- ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3']
29
+ ruby-version: ['3.1', '3.2', '3.3']
30
30
  include:
31
- - ruby-version: '2.7'
32
- bundle-gemfile: gemfiles/rails_5_2.gemfile
33
- - ruby-version: '3.0'
34
- bundle-gemfile: gemfiles/rails_6_0.gemfile
35
31
  - ruby-version: '3.1'
36
32
  bundle-gemfile: gemfiles/rails_6_1.gemfile
37
33
  - ruby-version: '3.2'
@@ -53,7 +49,7 @@ jobs:
53
49
  - name: Run tests
54
50
  run: bundle exec rake test
55
51
  - name: Upload result
56
- uses: actions/upload-artifact@v3
52
+ uses: actions/upload-artifact@v4
57
53
  if: always()
58
54
  with:
59
55
  path: test/reports/
@@ -21,9 +21,9 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  spec.required_ruby_version = '>= 2.7.3'
23
23
 
24
- spec.add_dependency 'actionpack', '>= 5.0'
25
- spec.add_dependency 'activerecord', '>= 5.0'
26
- spec.add_dependency 'activesupport', '>= 5.0'
24
+ spec.add_dependency 'actionpack', '>= 6.1'
25
+ spec.add_dependency 'activerecord', '>= 6.1'
26
+ spec.add_dependency 'activesupport', '>= 6.1'
27
27
 
28
28
  spec.add_dependency 'acts_as_manual_list'
29
29
  spec.add_dependency 'deep_preloader', '>= 1.0.2'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IknowViewModels
4
- VERSION = '3.13.0'
4
+ VERSION = '3.14.0'
5
5
  end
@@ -5,6 +5,8 @@
5
5
  # the mechanism by which it is applied to models.
6
6
  class ViewModel::ActiveRecord
7
7
  class UpdateContext
8
+ include ViewModel::ErrorWrapping
9
+
8
10
  ReleaseEntry = Struct.new(:viewmodel, :association_data) do
9
11
  def initialize(*)
10
12
  super
@@ -276,10 +278,10 @@ class ViewModel::ActiveRecord
276
278
  # human-readable error details.
277
279
  def check_deferred_constraints!(model_class)
278
280
  if model_class.connection.adapter_name == 'PostgreSQL'
279
- model_class.connection.execute('SET CONSTRAINTS ALL IMMEDIATE')
281
+ wrap_active_record_errors([]) do
282
+ model_class.connection.execute('SET CONSTRAINTS ALL IMMEDIATE')
283
+ end
280
284
  end
281
- rescue ::ActiveRecord::StatementInvalid => ex
282
- raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(ex)
283
285
  end
284
286
  end
285
287
  end
@@ -9,6 +9,7 @@ class ViewModel::ActiveRecord
9
9
  class UpdateOperation
10
10
  # inverse association and record to update a change in parent from a child
11
11
  ParentData = Struct.new(:association_reflection, :viewmodel)
12
+ include ViewModel::ErrorWrapping
12
13
 
13
14
  enum :RunState, [:Pending, :Running, :Run]
14
15
 
@@ -68,152 +69,148 @@ class ViewModel::ActiveRecord
68
69
 
69
70
  model.class.transaction do
70
71
  # Run context and viewmodel hooks
71
- ViewModel::Callbacks.wrap_deserialize(viewmodel, deserialize_context: deserialize_context) do |hook_control|
72
- # update parent association
73
- if reparent_to.present?
74
- debug "-> #{debug_name}: Updating parent pointer to '#{reparent_to.viewmodel.class.view_name}:#{reparent_to.viewmodel.id}'"
75
- association = model.association(reparent_to.association_reflection.name)
76
- association.writer(reparent_to.viewmodel.model)
77
- debug "<- #{debug_name}: Updated parent pointer"
78
- end
79
-
80
- # update position
81
- if reposition_to.present?
82
- debug "-> #{debug_name}: Updating position to #{reposition_to}"
83
- viewmodel._list_attribute = reposition_to
84
- end
85
-
86
- # Visit attributes and associations as much as possible in the order
87
- # that they're declared in the view. We can visit attributes and
88
- # points-to associations before save, but points-from associations
89
- # must be visited after save.
90
- pre_save_members, post_save_members = viewmodel.class._members.values.partition do |member_data|
91
- !member_data.association? || member_data.pointer_location == :local
92
- end
93
-
94
- pre_save_members.each do |member_data|
95
- if member_data.association?
96
- next unless association_updates.include?(member_data)
97
-
98
- child_operation = association_updates[member_data]
72
+ wrap_active_record_errors(self.blame_reference) do
73
+ ViewModel::Callbacks.wrap_deserialize(viewmodel, deserialize_context: deserialize_context) do |hook_control|
74
+ # update parent association
75
+ if reparent_to.present?
76
+ debug "-> #{debug_name}: Updating parent pointer to '#{reparent_to.viewmodel.class.view_name}:#{reparent_to.viewmodel.id}'"
77
+ association = model.association(reparent_to.association_reflection.name)
78
+ association.writer(reparent_to.viewmodel.model)
79
+ debug "<- #{debug_name}: Updated parent pointer"
80
+ end
99
81
 
100
- reflection = member_data.direct_reflection
101
- debug "-> #{debug_name}: Updating points-to association '#{reflection.name}'"
82
+ # update position
83
+ if reposition_to.present?
84
+ debug "-> #{debug_name}: Updating position to #{reposition_to}"
85
+ viewmodel._list_attribute = reposition_to
86
+ end
102
87
 
103
- association = model.association(reflection.name)
104
- new_target =
105
- if child_operation
106
- child_ctx = viewmodel.context_for_child(member_data.association_name, context: deserialize_context)
107
- child_viewmodel = child_operation.run!(deserialize_context: child_ctx)
108
- propagate_tree_changes(member_data, child_viewmodel.previous_changes)
88
+ # Visit attributes and associations as much as possible in the order
89
+ # that they're declared in the view. We can visit attributes and
90
+ # points-to associations before save, but points-from associations
91
+ # must be visited after save.
92
+ pre_save_members, post_save_members = viewmodel.class._members.values.partition do |member_data|
93
+ !member_data.association? || member_data.pointer_location == :local
94
+ end
109
95
 
110
- child_viewmodel.model
111
- end
112
- association.writer(new_target)
113
- debug "<- #{debug_name}: Updated points-to association '#{reflection.name}'"
114
- else
115
- attr_name = member_data.name
116
- next unless attributes.include?(attr_name)
117
-
118
- serialized_value = attributes[attr_name]
119
- # Note that the VM::AR deserialization tree asserts ownership over any
120
- # references it's provided, and so they're intentionally not passed on
121
- # to attribute deserialization for use by their `using:` viewmodels. A
122
- # (better?) alternative would be to provide them as reference-only
123
- # hashes, to indicate that no modification can be permitted.
124
- viewmodel.public_send("deserialize_#{attr_name}", serialized_value,
125
- references: {},
126
- deserialize_context: deserialize_context)
96
+ pre_save_members.each do |member_data|
97
+ if member_data.association?
98
+ next unless association_updates.include?(member_data)
99
+
100
+ child_operation = association_updates[member_data]
101
+
102
+ reflection = member_data.direct_reflection
103
+ debug "-> #{debug_name}: Updating points-to association '#{reflection.name}'"
104
+
105
+ association = model.association(reflection.name)
106
+ new_target =
107
+ if child_operation
108
+ child_ctx = viewmodel.context_for_child(member_data.association_name, context: deserialize_context)
109
+ child_viewmodel = child_operation.run!(deserialize_context: child_ctx)
110
+ propagate_tree_changes(member_data, child_viewmodel.previous_changes)
111
+
112
+ child_viewmodel.model
113
+ end
114
+ association.writer(new_target)
115
+ debug "<- #{debug_name}: Updated points-to association '#{reflection.name}'"
116
+ else
117
+ attr_name = member_data.name
118
+ next unless attributes.include?(attr_name)
119
+
120
+ serialized_value = attributes[attr_name]
121
+ # Note that the VM::AR deserialization tree asserts ownership over any
122
+ # references it's provided, and so they're intentionally not passed on
123
+ # to attribute deserialization for use by their `using:` viewmodels. A
124
+ # (better?) alternative would be to provide them as reference-only
125
+ # hashes, to indicate that no modification can be permitted.
126
+ viewmodel.public_send("deserialize_#{attr_name}", serialized_value,
127
+ references: {},
128
+ deserialize_context: deserialize_context)
129
+ end
127
130
  end
128
- end
129
131
 
130
- # If a request makes no assertions about the model, we don't demand
131
- # that the current state of the model is valid. This permits making
132
- # edits to other models that refer to this model when this model is
133
- # invalid.
134
- unless reference_only? && !viewmodel.new_model?
135
- deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, viewmodel)
136
- viewmodel.validate!
137
- end
132
+ # If a request makes no assertions about the model, we don't demand
133
+ # that the current state of the model is valid. This permits making
134
+ # edits to other models that refer to this model when this model is
135
+ # invalid.
136
+ unless reference_only? && !viewmodel.new_model?
137
+ deserialize_context.run_callback(ViewModel::Callbacks::Hook::BeforeValidate, viewmodel)
138
+ viewmodel.validate!
139
+ end
138
140
 
139
- # Save if the model has been altered. Covers not only models with
140
- # view changes but also lock version assertions.
141
- if viewmodel.model.changed? || viewmodel.model.new_record?
142
- debug "-> #{debug_name}: Saving"
143
- begin
141
+ # Save if the model has been altered. Covers not only models with
142
+ # view changes but also lock version assertions.
143
+ if viewmodel.model.changed? || viewmodel.model.new_record?
144
+ debug "-> #{debug_name}: Saving"
144
145
  model.save!
145
- rescue ::ActiveRecord::RecordInvalid => ex
146
- raise ViewModel::DeserializationError::Validation.from_active_model(ex.errors, blame_reference)
147
- rescue ::ActiveRecord::StaleObjectError => _ex
148
- raise ViewModel::DeserializationError::LockFailure.new(blame_reference)
146
+ debug "<- #{debug_name}: Saved"
149
147
  end
150
- debug "<- #{debug_name}: Saved"
151
- end
152
148
 
153
- # Update association cache of pointed-from associations after save: the
154
- # child update will have saved the pointer.
155
- post_save_members.each do |association_data|
156
- next unless association_updates.include?(association_data)
149
+ # Update association cache of pointed-from associations after save: the
150
+ # child update will have saved the pointer.
151
+ post_save_members.each do |association_data|
152
+ next unless association_updates.include?(association_data)
157
153
 
158
- child_operation = association_updates[association_data]
159
- reflection = association_data.direct_reflection
154
+ child_operation = association_updates[association_data]
155
+ reflection = association_data.direct_reflection
160
156
 
161
- debug "-> #{debug_name}: Updating pointed-to association '#{reflection.name}'"
157
+ debug "-> #{debug_name}: Updating pointed-to association '#{reflection.name}'"
162
158
 
163
- association = model.association(reflection.name)
164
- child_ctx = viewmodel.context_for_child(association_data.association_name, context: deserialize_context)
159
+ association = model.association(reflection.name)
160
+ child_ctx = viewmodel.context_for_child(association_data.association_name, context: deserialize_context)
165
161
 
166
- new_target =
167
- if child_operation
168
- ViewModel::Utils.map_one_or_many(child_operation) do |op|
169
- child_viewmodel = op.run!(deserialize_context: child_ctx)
170
- propagate_tree_changes(association_data, child_viewmodel.previous_changes)
162
+ new_target =
163
+ if child_operation
164
+ ViewModel::Utils.map_one_or_many(child_operation) do |op|
165
+ child_viewmodel = op.run!(deserialize_context: child_ctx)
166
+ propagate_tree_changes(association_data, child_viewmodel.previous_changes)
171
167
 
172
- child_viewmodel.model
168
+ child_viewmodel.model
169
+ end
173
170
  end
174
- end
175
171
 
176
- association.target = new_target
172
+ association.target = new_target
177
173
 
178
- debug "<- #{debug_name}: Updated pointed-to association '#{reflection.name}'"
179
- end
174
+ debug "<- #{debug_name}: Updated pointed-to association '#{reflection.name}'"
175
+ end
180
176
 
181
- if self.released_children.present?
182
- # Released children that were not reclaimed by other parents during the
183
- # build phase will be deleted: check access control.
184
- debug "-> #{debug_name}: Checking released children permissions"
185
- self.released_children.reject(&:claimed?).each do |released_child|
186
- debug "-> #{debug_name}: Checking #{released_child.viewmodel.to_reference}"
187
- child_vm = released_child.viewmodel
188
- child_association_data = released_child.association_data
189
- child_ctx = viewmodel.context_for_child(child_association_data.association_name, context: deserialize_context)
190
-
191
- ViewModel::Callbacks.wrap_deserialize(child_vm, deserialize_context: child_ctx) do |child_hook_control|
192
- changes = ViewModel::Changes.new(deleted: true)
193
- child_ctx.run_callback(ViewModel::Callbacks::Hook::OnChange,
194
- child_vm,
195
- changes: changes)
196
- child_hook_control.record_changes(changes)
197
- end
177
+ if self.released_children.present?
178
+ # Released children that were not reclaimed by other parents during the
179
+ # build phase will be deleted: check access control.
180
+ debug "-> #{debug_name}: Checking released children permissions"
181
+ self.released_children.reject(&:claimed?).each do |released_child|
182
+ debug "-> #{debug_name}: Checking #{released_child.viewmodel.to_reference}"
183
+ child_vm = released_child.viewmodel
184
+ child_association_data = released_child.association_data
185
+ child_ctx = viewmodel.context_for_child(child_association_data.association_name, context: deserialize_context)
186
+
187
+ ViewModel::Callbacks.wrap_deserialize(child_vm, deserialize_context: child_ctx) do |child_hook_control|
188
+ changes = ViewModel::Changes.new(deleted: true)
189
+ child_ctx.run_callback(ViewModel::Callbacks::Hook::OnChange,
190
+ child_vm,
191
+ changes: changes)
192
+ child_hook_control.record_changes(changes)
193
+ end
198
194
 
199
- if child_association_data.nested?
200
- viewmodel.nested_children_changed!
201
- elsif child_association_data.owned?
202
- viewmodel.referenced_children_changed!
195
+ if child_association_data.nested?
196
+ viewmodel.nested_children_changed!
197
+ elsif child_association_data.owned?
198
+ viewmodel.referenced_children_changed!
199
+ end
203
200
  end
201
+ debug "<- #{debug_name}: Finished checking released children permissions"
204
202
  end
205
- debug "<- #{debug_name}: Finished checking released children permissions"
206
- end
207
203
 
208
- final_changes = viewmodel.clear_changes!
204
+ final_changes = viewmodel.clear_changes!
209
205
 
210
- if final_changes.changed?
211
- # Now that the change has been fully attempted, call the OnChange
212
- # hook if local changes were made
213
- deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, viewmodel, changes: final_changes)
214
- end
206
+ if final_changes.changed?
207
+ # Now that the change has been fully attempted, call the OnChange
208
+ # hook if local changes were made
209
+ deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, viewmodel, changes: final_changes)
210
+ end
215
211
 
216
- hook_control.record_changes(final_changes)
212
+ hook_control.record_changes(final_changes)
213
+ end
217
214
  end
218
215
  end
219
216
 
@@ -221,8 +218,6 @@ class ViewModel::ActiveRecord
221
218
 
222
219
  @run_state = RunState::Run
223
220
  viewmodel
224
- rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::InvalidForeignKey, ::ActiveRecord::RecordNotSaved => ex
225
- raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(ex, blame_reference)
226
221
  end
227
222
 
228
223
  def propagate_tree_changes(association_data, child_changes)
@@ -20,6 +20,7 @@ class ViewModel::ActiveRecord < ViewModel::Record
20
20
  AFTER_ATTRIBUTE = 'after'
21
21
 
22
22
  require 'view_model/utils/collections'
23
+ require 'view_model/error_wrapping'
23
24
  require 'view_model/active_record/association_data'
24
25
  require 'view_model/active_record/update_data'
25
26
  require 'view_model/active_record/update_context'
@@ -30,6 +31,7 @@ class ViewModel::ActiveRecord < ViewModel::Record
30
31
  require 'view_model/active_record/association_manipulation'
31
32
 
32
33
  include AssociationManipulation
34
+ include ViewModel::ErrorWrapping
33
35
 
34
36
  attr_reader :changed_associations
35
37
 
@@ -285,14 +287,14 @@ class ViewModel::ActiveRecord < ViewModel::Record
285
287
 
286
288
  def destroy!(deserialize_context: self.class.new_deserialize_context)
287
289
  model_class.transaction do
288
- ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control|
289
- changes = ViewModel::Changes.new(deleted: true)
290
- deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: changes)
291
- hook_control.record_changes(changes)
292
- model.destroy!
290
+ wrap_active_record_errors(self.blame_reference) do
291
+ ViewModel::Callbacks.wrap_deserialize(self, deserialize_context: deserialize_context) do |hook_control|
292
+ changes = ViewModel::Changes.new(deleted: true)
293
+ deserialize_context.run_callback(ViewModel::Callbacks::Hook::OnChange, self, changes: changes)
294
+ hook_control.record_changes(changes)
295
+ model.destroy!
296
+ end
293
297
  end
294
- rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::InvalidForeignKey, ::ActiveRecord::RecordNotSaved => e
295
- raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(e, self.blame_reference)
296
298
  end
297
299
  end
298
300
 
@@ -371,14 +373,8 @@ class ViewModel::ActiveRecord < ViewModel::Record
371
373
  end
372
374
  end
373
375
 
374
- # Rails 6.1 introduced "previously_new_record?", but this library still
375
- # supports activerecord >= 5.0. This is an approximation.
376
376
  def self.model_previously_new?(model)
377
- if (id_changes = model.saved_change_to_id)
378
- old_id, _new_id = id_changes
379
- return true if old_id.nil?
380
- end
381
- false
377
+ model.previously_new_record?
382
378
  end
383
379
 
384
380
  # Helper to return entities that were part of the last deserialization. The
@@ -350,6 +350,16 @@ class ViewModel
350
350
  end
351
351
  end
352
352
 
353
+ class TransientDatabaseError < DeserializationError
354
+ status 502
355
+ attr_reader :detail
356
+
357
+ def initialize(detail, nodes = [])
358
+ @detail = detail
359
+ super(nodes)
360
+ end
361
+ end
362
+
353
363
  class DatabaseConstraint < DeserializationError
354
364
  status 400
355
365
  attr_reader :detail
@@ -359,9 +369,9 @@ class ViewModel
359
369
  super(nodes)
360
370
  end
361
371
 
362
- # Database constraint errors are pretty opaque and stringly typed. We can
363
- # do our best to parse out what metadata we can from the error, and fall
364
- # back when we can't.
372
+ # Database constraint errors that come from Postgres are pretty opaque and
373
+ # stringly typed. We can do our best to parse out what metadata we can
374
+ # from the error, and fall back when we can't.
365
375
  def self.from_exception(exception, nodes = [])
366
376
  case exception.cause
367
377
  when PG::UniqueViolation, PG::ExclusionViolation
@@ -478,7 +488,7 @@ class ViewModel
478
488
  @attribute = attribute
479
489
  @reason = reason
480
490
  @details = details
481
- super([node])
491
+ super(Array.wrap(node))
482
492
  end
483
493
 
484
494
  def detail
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ViewModel
4
+ module ErrorWrapping
5
+ extend ActiveSupport::Concern
6
+
7
+ # Catch and translate ActiveRecord errors that map to standard ViewModel
8
+ # errors. Blame may be either a single VM::Reference or an array of them, or
9
+ # an empty array if there is no specific node that the error may be attached
10
+ # to.
11
+ def wrap_active_record_errors(blame)
12
+ yield
13
+ rescue ::ActiveRecord::RecordInvalid => e
14
+ raise ViewModel::DeserializationError::Validation.from_active_model(e.record.errors, Array.wrap(blame))
15
+ rescue ::ActiveRecord::StaleObjectError => _e
16
+ raise ViewModel::DeserializationError::LockFailure.new(Array.wrap(blame))
17
+ rescue ::ActiveRecord::QueryAborted, ::ActiveRecord::PreparedStatementCacheExpired, ::ActiveRecord::TransactionRollbackError => e
18
+ raise ViewModel::DeserializationError::TransientDatabaseError.new(e.message, Array.wrap(blame))
19
+ rescue ::ActiveRecord::StatementInvalid, ::ActiveRecord::InvalidForeignKey, ::ActiveRecord::RecordNotSaved => e
20
+ raise ViewModel::DeserializationError::DatabaseConstraint.from_exception(e, Array.wrap(blame))
21
+ end
22
+ end
23
+ end
data/nix/dependencies.nix CHANGED
@@ -1,5 +1,4 @@
1
1
  {pkgs}:
2
2
  {
3
- ruby = pkgs.ruby_3_2;
4
- postgresql = pkgs.postgresql_14;
3
+ ruby = pkgs.ruby_3_3;
5
4
  }
data/shell.nix CHANGED
@@ -8,8 +8,6 @@ in
8
8
  (bundlerEnv {
9
9
  name = "iknow-view-models-shell";
10
10
  gemdir = ./nix/gem;
11
-
12
- gemConfig = (defaultGemConfig.override { inherit (dependencies) postgresql; });
13
-
11
+ gemConfig = defaultGemConfig;
14
12
  inherit (dependencies) ruby;
15
13
  }).env
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'logger'
3
4
  require 'active_support'
4
5
  require 'minitest/hooks'
5
6
 
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ require 'minitest/unit'
5
+ require 'minitest/hooks'
6
+ require 'rspec/expectations/minitest_integration'
7
+
8
+ require_relative '../../helpers/arvm_test_utilities'
9
+ require_relative '../../helpers/arvm_test_models'
10
+ require_relative '../../helpers/viewmodel_spec_helpers'
11
+
12
+ require 'view_model'
13
+ require 'view_model/active_record'
14
+
15
+ class ViewModel::ErrorWrappingTest < ActiveSupport::TestCase
16
+ include ARVMTestUtilities
17
+ extend Minitest::Spec::DSL
18
+
19
+ class Subject
20
+ include ViewModel::ErrorWrapping
21
+ end
22
+
23
+ let(:subject) { Subject.new }
24
+
25
+ describe 'wrap_active_record_errors' do
26
+ include ViewModelSpecHelpers::ParentAndBelongsToChild
27
+
28
+ let(:blame) { ViewModel::Reference.new(viewmodel_class, nil) }
29
+
30
+ describe 'RecordInvalid' do
31
+ before do
32
+ model_class.send(:validates, :name, inclusion: { in: ['cat'] })
33
+ end
34
+
35
+ it 'wraps the error' do
36
+ ex = assert_raises(ViewModel::DeserializationError::Validation) do
37
+ subject.wrap_active_record_errors(blame) do
38
+ model_class.create!(name: 'a')
39
+ end
40
+ end
41
+
42
+ expect(ex.attribute).to eq('name')
43
+ expect(ex.reason).to match(/not included/)
44
+ expect(ex.details).to include(error: :inclusion)
45
+ end
46
+ end
47
+
48
+ describe 'StaleObjectError' do
49
+ def model_attributes
50
+ super.merge(
51
+ schema: ->(t) { t.integer :lock_version, default: 0, null: false },
52
+ )
53
+ end
54
+
55
+ it 'wraps the error' do
56
+ model = model_class.create!(name: 'a')
57
+ assert_raises(ViewModel::DeserializationError::LockFailure) do
58
+ subject.wrap_active_record_errors(blame) do
59
+ model.name = 'yes'
60
+ model.lock_version = 10
61
+ model.save!
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ describe 'transient' do
68
+ def with_timeout(timeout, conn = ActiveRecord::Base.connection, &block)
69
+ original_timeout = conn.select_value('SHOW statement_timeout')
70
+ conn.execute("SET SESSION statement_timeout = #{conn.quote(timeout)}")
71
+ block.call(conn)
72
+ ensure
73
+ begin
74
+ conn.execute("SET SESSION statement_timeout = #{conn.quote(original_timeout)}")
75
+ rescue ActiveRecord::StatementInvalid => e
76
+ raise unless e.cause.is_a?(PG::InFailedSqlTransaction)
77
+ end
78
+ end
79
+
80
+ it 'wraps the error' do
81
+ assert_raises(ViewModel::DeserializationError::TransientDatabaseError) do
82
+ subject.wrap_active_record_errors(blame) do
83
+ with_timeout(1) do
84
+ ActiveRecord::Base.connection.execute('SELECT pg_sleep(10)')
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ describe 'check constraint' do
92
+ def model_attributes
93
+ super.merge(
94
+ schema: ->(t) { t.check_constraint "name = 'cat'" },
95
+ )
96
+ end
97
+
98
+ it 'wraps the error' do
99
+ ex = assert_raises(ViewModel::DeserializationError::DatabaseConstraint) do
100
+ subject.wrap_active_record_errors(blame) do
101
+ model_class.create!(name: 'a')
102
+ end
103
+ end
104
+
105
+ expect(ex.message).to match(/violates check constraint/)
106
+ end
107
+ end
108
+
109
+ describe 'unique constraint' do
110
+ def model_attributes
111
+ super.merge(
112
+ schema: ->(t) { t.index :name, unique: true },
113
+ )
114
+ end
115
+
116
+ it 'wraps the error' do
117
+ model_class.create!(name: 'a')
118
+
119
+ ex = assert_raises(ViewModel::DeserializationError::UniqueViolation) do
120
+ subject.wrap_active_record_errors(blame) do
121
+ model_class.create!(name: 'a')
122
+ end
123
+ end
124
+
125
+ expect(ex.columns).to eq(['name'])
126
+ end
127
+ end
128
+ end
129
+ 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.13.0
4
+ version: 3.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - iKnow Team
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-12-17 00:00:00.000000000 Z
11
+ date: 2025-06-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack
@@ -16,42 +16,42 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '5.0'
19
+ version: '6.1'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '5.0'
26
+ version: '6.1'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: activerecord
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
32
32
  - !ruby/object:Gem::Version
33
- version: '5.0'
33
+ version: '6.1'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
- version: '5.0'
40
+ version: '6.1'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: activesupport
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - ">="
46
46
  - !ruby/object:Gem::Version
47
- version: '5.0'
47
+ version: '6.1'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
- version: '5.0'
54
+ version: '6.1'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: acts_as_manual_list
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -392,8 +392,6 @@ files:
392
392
  - LICENSE.txt
393
393
  - README.md
394
394
  - Rakefile
395
- - gemfiles/rails_5_2.gemfile
396
- - gemfiles/rails_6_0.gemfile
397
395
  - gemfiles/rails_6_1.gemfile
398
396
  - gemfiles/rails_7_0.gemfile
399
397
  - gemfiles/rails_7_1.gemfile
@@ -433,6 +431,7 @@ files:
433
431
  - lib/view_model/deserialize_context.rb
434
432
  - lib/view_model/error.rb
435
433
  - lib/view_model/error_view.rb
434
+ - lib/view_model/error_wrapping.rb
436
435
  - lib/view_model/garbage_collection.rb
437
436
  - lib/view_model/migratable_view.rb
438
437
  - lib/view_model/migration.rb
@@ -488,6 +487,7 @@ files:
488
487
  - test/unit/view_model/callbacks_test.rb
489
488
  - test/unit/view_model/controller_test.rb
490
489
  - test/unit/view_model/deserialization_error/unique_violation_test.rb
490
+ - test/unit/view_model/error_wrapping_test.rb
491
491
  - test/unit/view_model/garbage_collection_test.rb
492
492
  - test/unit/view_model/record_test.rb
493
493
  - test/unit/view_model/registry_test.rb
@@ -497,7 +497,7 @@ homepage: https://github.com/iknow/cerego_view_models
497
497
  licenses:
498
498
  - MIT
499
499
  metadata: {}
500
- post_install_message:
500
+ post_install_message:
501
501
  rdoc_options: []
502
502
  require_paths:
503
503
  - lib
@@ -513,7 +513,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
513
513
  version: '0'
514
514
  requirements: []
515
515
  rubygems_version: 3.1.6
516
- signing_key:
516
+ signing_key:
517
517
  specification_version: 4
518
518
  summary: ViewModels provide a means of encapsulating a collection of related data
519
519
  and specifying its JSON serialization.
@@ -550,6 +550,7 @@ test_files:
550
550
  - test/unit/view_model/callbacks_test.rb
551
551
  - test/unit/view_model/controller_test.rb
552
552
  - test/unit/view_model/deserialization_error/unique_violation_test.rb
553
+ - test/unit/view_model/error_wrapping_test.rb
553
554
  - test/unit/view_model/garbage_collection_test.rb
554
555
  - test/unit/view_model/record_test.rb
555
556
  - test/unit/view_model/registry_test.rb
@@ -1,10 +0,0 @@
1
- # This file was generated by Appraisal
2
-
3
- source 'https://rubygems.org'
4
-
5
- gem 'minitest-ci'
6
- gem 'activerecord', '~> 5.2.0'
7
- gem 'activesupport', '~> 5.2.0'
8
- gem 'actionpack', '~> 5.2.0'
9
-
10
- gemspec path: '../'
@@ -1,10 +0,0 @@
1
- # This file was generated by Appraisal
2
-
3
- source 'https://rubygems.org'
4
-
5
- gem 'minitest-ci'
6
- gem 'activerecord', '~> 6.0.0'
7
- gem 'activesupport', '~> 6.0.0'
8
- gem 'actionpack', '~> 6.0.0'
9
-
10
- gemspec path: '../'