Skip to content

Commit 5fc64cc

Browse files
committed
Fix #save! and raise RecordNotSaved when a callback throws :abort and terminates the process
1 parent b6a9e3a commit 5fc64cc

5 files changed

Lines changed: 315 additions & 95 deletions

File tree

lib/dynamoid/persistence.rb

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,8 @@ def persisted?
519519
# If a model is new and hash key (+id+ by default) is not assigned yet
520520
# it was assigned implicitly with random UUID value.
521521
#
522-
# If +lock_version+ attribute is declared it will be incremented. If it's blank then it will be initialized with 1.
522+
# If +lock_version+ attribute is declared it will be incremented. If it's
523+
# blank then it will be initialized with 1.
523524
#
524525
# +save+ method call raises +Dynamoid::Errors::RecordNotUnique+ exception
525526
# if primary key (hash key + optional range key) already exists in a
@@ -553,6 +554,80 @@ def save(options = {})
553554
end
554555
end
555556

557+
# Create new model or persist changes.
558+
#
559+
# Run the validation and callbacks. Raises
560+
# +Dynamoid::Errors::DocumentNotValid+ is validation fails.
561+
#
562+
# user = User.create
563+
#
564+
# user.age = 26
565+
# user.save! # => user
566+
#
567+
# Validation can be skipped with +validate: false+ option:
568+
#
569+
# user = User.new(age: -1)
570+
# user.save!(validate: false) # => user
571+
#
572+
# +save!+ by default sets timestamps attributes - +created_at+ and
573+
# +updated_at+ when creates new model and updates +updated_at+ attribute
574+
# when updates already existing one.
575+
#
576+
# Changing +updated_at+ attribute at updating a model can be skipped with
577+
# +touch: false+ option:
578+
#
579+
# user.save!(touch: false)
580+
#
581+
# If a model is new and hash key (+id+ by default) is not assigned yet
582+
# it was assigned implicitly with random UUID value.
583+
#
584+
# If +lock_version+ attribute is declared it will be incremented. If it's
585+
# blank then it will be initialized with 1.
586+
#
587+
# +save!+ method call raises +Dynamoid::Errors::RecordNotUnique+ exception
588+
# if primary key (hash key + optional range key) already exists in a
589+
# table.
590+
#
591+
# +save!+ method call raises +Dynamoid::Errors::StaleObjectError+ exception
592+
# if there is +lock_version+ attribute and the document in a table was
593+
# already changed concurrently and +lock_version+ was consequently
594+
# increased.
595+
#
596+
# +save!+ method call raises +Dynamoid::Errors::RecordNotSaved+ exception
597+
# if some callback aborted execution.
598+
#
599+
# When a table is not created yet the first +save!+ method call will create
600+
# a table. It's useful in test environment to avoid explicit table
601+
# creation.
602+
#
603+
# @param options [Hash] (optional)
604+
# @option options [true|false] :validate validate a model or not - +true+ by default (optional)
605+
# @option options [true|false] :touch update tiemstamps fields or not - +true+ by default (optional)
606+
# @return [true|false] Whether saving successful or not
607+
def save!(options = {})
608+
# validation is handled in the Validation module
609+
610+
if Dynamoid.config.create_table_on_save
611+
self.class.create_table(sync: true)
612+
end
613+
614+
create_or_update = new_record? ? :create : :update
615+
aborted = true
616+
617+
run_callbacks(:save) do
618+
run_callbacks(create_or_update) do
619+
aborted = false
620+
Save.call(self, touch: options[:touch])
621+
end
622+
end
623+
624+
if aborted
625+
raise Dynamoid::Errors::RecordNotSaved, self
626+
end
627+
628+
nil
629+
end
630+
556631
# Update multiple attributes at once, saving the object once the updates
557632
# are complete.
558633
#

lib/dynamoid/transaction_write.rb

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ def rollback
178178
# When a model is not persisted - its id should have unique value.
179179
# Otherwise a transaction will be rolled back.
180180
#
181+
# There are the following differences between transactional and
182+
# non-transactional +#save!+:
183+
# - transactional +#save!+ doesn't support the +:touch+ option
184+
# - transactional +#save!+ doesn't support +lock_version+ attribute so it
185+
# will not be incremented and will not be checked to detect concurrent
186+
# modification of a model and +Dynamoid::Errors::StaleObjectError+
187+
# exception will not be raised
188+
# - transactional +#save!+ doesn't raise +Dynamoid::Errors::RecordNotUnique+
189+
# at saving new model when primary key is already used. A generic
190+
# +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised instead.
191+
# - a table isn't created lazily if it doesn't exist yet
192+
#
181193
# @param model [Dynamoid::Document] a model
182194
# @param options [Hash] (optional)
183195
# @option options [true|false] :validate validate a model or not - +true+ by default (optional)
@@ -216,6 +228,18 @@ def save!(model, **options)
216228
# When a model is not persisted - its id should have unique value.
217229
# Otherwise a transaction will be rolled back.
218230
#
231+
# There are the following differences between transactional and
232+
# non-transactional +#save+:
233+
# - transactional +#save+ doesn't support the +:touch+ option
234+
# - transactional +#save+ doesn't support +lock_version+ attribute so it
235+
# will not be incremented and will not be checked to detect concurrent
236+
# modification of a model and +Dynamoid::Errors::StaleObjectError+
237+
# exception will not be raised
238+
# - transactional +#save+ doesn't raise +Dynamoid::Errors::RecordNotUnique+
239+
# at saving new model when primary key is already used. A generic
240+
# +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised instead.
241+
# - a table isn't created lazily if it doesn't exist yet
242+
#
219243
# @param model [Dynamoid::Document] a model
220244
# @param options [Hash] (optional)
221245
# @option options [true|false] :validate validate a model or not - +true+ by default (optional)
@@ -248,6 +272,17 @@ def save(model, **options)
248272
#
249273
# Validates model and runs callbacks.
250274
#
275+
# There are the following differences between transactional and
276+
# non-transactional +#create+:
277+
# - transactional +#create!+ doesn't support +lock_version+ attribute so it
278+
# will not be incremented and will not be checked to detect concurrent
279+
# modification of a model and +Dynamoid::Errors::StaleObjectError+
280+
# exception will not be raised
281+
# - transactional +#create!+ doesn't raise +Dynamoid::Errors::RecordNotUnique+
282+
# at saving new model when primary key is already used. A generic
283+
# +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised instead.
284+
# - a table isn't created lazily if it doesn't exist yet
285+
#
251286
# @param model_class [Class] a model class which should be instantiated
252287
# @param attributes [Hash|Array<Hash>] attributes of a model
253288
# @param block [Proc] a block to process a model after initialization
@@ -287,6 +322,17 @@ def create!(model_class, attributes = {}, &block)
287322
#
288323
# Validates model and runs callbacks.
289324
#
325+
# There are the following differences between transactional and
326+
# non-transactional +#create+:
327+
# - transactional +#create+ doesn't support +lock_version+ attribute so it
328+
# will not be incremented and will not be checked to detect concurrent
329+
# modification of a model and +Dynamoid::Errors::StaleObjectError+
330+
# exception will not be raised
331+
# - transactional +#create+ doesn't raise +Dynamoid::Errors::RecordNotUnique+
332+
# at saving new model when primary key is already used. A generic
333+
# +Aws::DynamoDB::Errors::TransactionCanceledException+ is raised instead.
334+
# - a table isn't created lazily if it doesn't exist yet
335+
#
290336
# @param model_class [Class] a model class which should be instantiated
291337
# @param attributes [Hash|Array<Hash>] attributes of a model
292338
# @param block [Proc] a block to process a model after initialization

lib/dynamoid/validations.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ def valid?(context = nil)
3131
#
3232
# @private
3333
# @since 0.2.0
34-
def save!
35-
raise Dynamoid::Errors::DocumentNotValid, self unless valid?
34+
def save!(options = {})
35+
unless valid?
36+
raise Dynamoid::Errors::DocumentNotValid, self
37+
end
3638

37-
save(validate: false)
38-
self
39+
super
3940
end
4041

4142
def update_attribute(attribute, value)

spec/dynamoid/persistence/save_spec.rb

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,53 @@ def around_save_callback
619619
end
620620
end
621621

622+
context "when a callback aborts saving" do
623+
it 'aborts creation if callback throws :abort' do
624+
if ActiveSupport.version < Gem::Version.new('5.0')
625+
skip "Rails 4.x and below don't support aborting with `throw :abort`"
626+
end
627+
628+
klass = new_class do
629+
field :name
630+
before_create { throw :abort }
631+
end
632+
klass.create_table
633+
obj = klass.new(name: 'Alex')
634+
635+
result = nil
636+
expect {
637+
result = obj.save
638+
}.not_to change { klass.count }
639+
640+
expect(result).to eql false
641+
expect(obj).not_to be_persisted
642+
expect(obj).to be_changed
643+
end
644+
645+
it 'aborts updating if callback throws :abort' do
646+
if ActiveSupport.version < Gem::Version.new('5.0')
647+
skip "Rails 4.x and below don't support aborting with `throw :abort`"
648+
end
649+
650+
klass = new_class do
651+
field :name
652+
before_update { throw :abort }
653+
end
654+
655+
obj = klass.create!(name: 'Alex')
656+
obj.name = 'Alex [Updated]'
657+
658+
result = nil
659+
expect {
660+
result = obj.save
661+
}.not_to change { klass.find(obj.id).name }
662+
663+
expect(result).to eql false
664+
expect(obj).to be_persisted
665+
expect(obj).to be_changed
666+
end
667+
end
668+
622669
context 'not unique primary key' do
623670
context 'composite key' do
624671
let(:klass_with_composite_key) do
@@ -848,4 +895,53 @@ def around_save_callback
848895
end
849896
end
850897
end
898+
899+
describe "#save!" do
900+
context "when a callback aborts saving" do
901+
it 'aborts creation and raises RecordNotSaved if callback throws :abort' do
902+
if ActiveSupport.version < Gem::Version.new('5.0')
903+
skip "Rails 4.x and below don't support aborting with `throw :abort`"
904+
end
905+
906+
klass = new_class do
907+
field :name
908+
before_create { throw :abort }
909+
end
910+
klass.create_table
911+
obj = klass.new(name: 'Alex')
912+
913+
expect {
914+
expect {
915+
obj.save!
916+
}.to raise_error(Dynamoid::Errors::RecordNotSaved)
917+
}.not_to change { klass.count }
918+
919+
expect(obj).not_to be_persisted
920+
expect(obj).to be_changed
921+
end
922+
923+
it 'aborts updating and raises RecordNotSaved if callback throws :abort' do
924+
if ActiveSupport.version < Gem::Version.new('5.0')
925+
skip "Rails 4.x and below don't support aborting with `throw :abort`"
926+
end
927+
928+
klass = new_class do
929+
field :name
930+
before_update { throw :abort }
931+
end
932+
933+
obj = klass.create!(name: 'Alex')
934+
obj.name = 'Alex [Updated]'
935+
936+
expect {
937+
expect {
938+
obj.save!
939+
}.to raise_error(Dynamoid::Errors::RecordNotSaved)
940+
}.not_to change { klass.count }
941+
942+
expect(obj).to be_persisted
943+
expect(obj).to be_changed
944+
end
945+
end
946+
end
851947
end

0 commit comments

Comments
 (0)