# frozen_string_literal: true require "cases/helper" require "models/owner" require "models/pet" require "models/topic" class TransactionCallbacksTest < ActiveRecord::TestCase fixtures :topics, :owners, :pets class ReplyWithCallbacks < ActiveRecord::Base self.table_name = :topics belongs_to :topic, foreign_key: "parent_id" validates_presence_of :content after_commit :do_after_commit, on: :create attr_accessor :save_on_after_create after_create do save! if save_on_after_create end def history @history ||= [] end def do_after_commit history << :commit_on_create end end class TopicWithCallbacks < ActiveRecord::Base self.table_name = :topics has_many :replies, class_name: "ReplyWithCallbacks", foreign_key: "parent_id" before_destroy { self.class.find(id).touch if persisted? } before_commit { |record| record.do_before_commit(nil) } after_commit { |record| record.do_after_commit(nil) } after_save_commit { |record| record.do_after_commit(:save) } after_create_commit { |record| record.do_after_commit(:create) } after_update_commit { |record| record.do_after_commit(:update) } after_destroy_commit { |record| record.do_after_commit(:destroy) } after_rollback { |record| record.do_after_rollback(nil) } after_rollback(on: :create) { |record| record.do_after_rollback(:create) } after_rollback(on: :update) { |record| record.do_after_rollback(:update) } after_rollback(on: :destroy) { |record| record.do_after_rollback(:destroy) } def history @history ||= [] end def before_commit_block(on = nil, &block) @before_commit ||= {} @before_commit[on] ||= [] @before_commit[on] << block end def after_commit_block(on = nil, &block) @after_commit ||= {} @after_commit[on] ||= [] @after_commit[on] << block end def after_rollback_block(on = nil, &block) @after_rollback ||= {} @after_rollback[on] ||= [] @after_rollback[on] << block end def do_before_commit(on) blocks = @before_commit[on] if defined?(@before_commit) blocks.each { |b| b.call(self) } if blocks end def do_after_commit(on) blocks = @after_commit[on] if defined?(@after_commit) blocks.each { |b| b.call(self) } if blocks end def do_after_rollback(on) blocks = @after_rollback[on] if defined?(@after_rollback) blocks.each { |b| b.call(self) } if blocks end end def setup @first = TopicWithCallbacks.find(1) end # FIXME: Test behavior, not implementation. def test_before_commit_exception_should_pop_transaction_stack @first.before_commit_block { raise "better pop this txn from the stack!" } original_txn = @first.class.connection.current_transaction begin @first.save! fail rescue assert_equal original_txn, @first.class.connection.current_transaction end end def test_call_after_commit_after_transaction_commits @first.after_commit_block { |r| r.history << :after_commit } @first.after_rollback_block { |r| r.history << :after_rollback } @first.save! assert_equal [:after_commit], @first.history end def test_dont_call_any_callbacks_after_transaction_commits_for_invalid_record @first.after_commit_block { |r| r.history << :after_commit } @first.after_rollback_block { |r| r.history << :after_rollback } def @first.valid?(*) false end assert_not @first.save assert_equal [], @first.history end def test_dont_call_any_callbacks_after_explicit_transaction_commits_for_invalid_record @first.after_commit_block { |r| r.history << :after_commit } @first.after_rollback_block { |r| r.history << :after_rollback } def @first.valid?(*) false end @first.transaction do assert_not @first.save end assert_equal [], @first.history end def test_only_call_after_commit_on_save_after_transaction_commits_for_saving_record record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) record.after_commit_block(:save) { |r| r.history << :after_save } record.save! assert_equal [:after_save], record.history record.update!(title: "Another topic") assert_equal [:after_save, :after_save], record.history end def test_only_call_after_commit_on_update_after_transaction_commits_for_existing_record add_transaction_execution_blocks @first @first.save! assert_equal [:commit_on_update], @first.history end def test_only_call_after_commit_on_destroy_after_transaction_commits_for_destroyed_record add_transaction_execution_blocks @first @first.destroy assert_equal [:commit_on_destroy], @first.history end def test_only_call_after_commit_on_create_after_transaction_commits_for_new_record new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) add_transaction_execution_blocks new_record new_record.save! assert_equal [:commit_on_create], new_record.history end def test_only_call_after_commit_on_create_after_transaction_commits_for_new_record_if_create_succeeds_creating_through_association topic = TopicWithCallbacks.create!(title: "New topic", written_on: Date.today) reply = topic.replies.create assert_equal [], reply.history end def test_only_call_after_commit_on_destroy_after_transaction_commits_for_destroyed_new_record new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) add_transaction_execution_blocks new_record new_record.destroy assert_equal [:commit_on_destroy], new_record.history end def test_save_in_after_create_commit_wont_invoke_extra_after_create_commit new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) add_transaction_execution_blocks new_record new_record.after_commit_block(:create) { |r| r.save! } new_record.save! assert_equal [:commit_on_create, :commit_on_update], new_record.history end def test_only_call_after_commit_on_create_and_doesnt_leaky r = ReplyWithCallbacks.new(content: "foo") r.save_on_after_create = true r.save! r.content = "bar" r.save! r.save! assert_equal [:commit_on_create], r.history end def test_only_call_after_commit_on_update_after_transaction_commits_for_existing_record_on_touch add_transaction_execution_blocks @first @first.touch assert_equal [:commit_on_update], @first.history end def test_only_call_after_commit_on_top_level_transactions @first.after_commit_block { |r| r.history << :after_commit } assert_empty @first.history @first.transaction do @first.transaction(requires_new: true) do @first.touch end assert_empty @first.history end assert_equal [:after_commit], @first.history end def test_call_after_rollback_after_transaction_rollsback @first.after_commit_block { |r| r.history << :after_commit } @first.after_rollback_block { |r| r.history << :after_rollback } Topic.transaction do @first.save! raise ActiveRecord::Rollback end assert_equal [:after_rollback], @first.history end def test_only_call_after_rollback_on_update_after_transaction_rollsback_for_existing_record add_transaction_execution_blocks @first Topic.transaction do @first.save! raise ActiveRecord::Rollback end assert_equal [:rollback_on_update], @first.history end def test_only_call_after_rollback_on_update_after_transaction_rollsback_for_existing_record_on_touch add_transaction_execution_blocks @first Topic.transaction do @first.touch raise ActiveRecord::Rollback end assert_equal [:rollback_on_update], @first.history end def test_only_call_after_rollback_on_destroy_after_transaction_rollsback_for_destroyed_record add_transaction_execution_blocks @first Topic.transaction do @first.destroy raise ActiveRecord::Rollback end assert_equal [:rollback_on_destroy], @first.history end def test_only_call_after_rollback_on_create_after_transaction_rollsback_for_new_record new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) add_transaction_execution_blocks new_record Topic.transaction do new_record.save! raise ActiveRecord::Rollback end assert_equal [:rollback_on_create], new_record.history end def test_call_after_rollback_when_commit_fails @first.after_commit_block { |r| r.history << :after_commit } @first.after_rollback_block { |r| r.history << :after_rollback } assert_raises RuntimeError do @first.transaction do tx = @first.class.connection.transaction_manager.current_transaction def tx.commit raise end @first.save end end assert_equal [:after_rollback], @first.history end def test_only_call_after_rollback_on_records_rolled_back_to_a_savepoint def @first.rollbacks(i = 0); @rollbacks ||= 0; @rollbacks += i if i; end def @first.commits(i = 0); @commits ||= 0; @commits += i if i; end @first.after_rollback_block { |r| r.rollbacks(1) } @first.after_commit_block { |r| r.commits(1) } second = TopicWithCallbacks.find(3) def second.rollbacks(i = 0); @rollbacks ||= 0; @rollbacks += i if i; end def second.commits(i = 0); @commits ||= 0; @commits += i if i; end second.after_rollback_block { |r| r.rollbacks(1) } second.after_commit_block { |r| r.commits(1) } Topic.transaction do @first.save! Topic.transaction(requires_new: true) do second.save! raise ActiveRecord::Rollback end end assert_equal 1, @first.commits assert_equal 0, @first.rollbacks assert_equal 0, second.commits assert_equal 1, second.rollbacks end def test_only_call_after_rollback_on_records_rolled_back_to_a_savepoint_when_release_savepoint_fails def @first.rollbacks(i = 0); @rollbacks ||= 0; @rollbacks += i if i; end def @first.commits(i = 0); @commits ||= 0; @commits += i if i; end @first.after_rollback_block { |r| r.rollbacks(1) } @first.after_commit_block { |r| r.commits(1) } Topic.transaction do @first.save Topic.transaction(requires_new: true) do @first.save! raise ActiveRecord::Rollback end Topic.transaction(requires_new: true) do @first.save! raise ActiveRecord::Rollback end end assert_equal 1, @first.commits assert_equal 2, @first.rollbacks end def test_after_commit_callback_should_not_swallow_errors @first.after_commit_block { fail "boom" } assert_raises(RuntimeError) do Topic.transaction do @first.save! end end end def test_after_commit_callback_when_raise_should_not_restore_state first = TopicWithCallbacks.new second = TopicWithCallbacks.new first.after_commit_block { fail "boom" } second.after_commit_block { fail "boom" } begin Topic.transaction do first.save! assert_not_nil first.id second.save! assert_not_nil second.id end rescue end assert_not_nil first.id assert_not_nil second.id assert first.reload end def test_after_rollback_callback_should_not_swallow_errors_when_set_to_raise error_class = Class.new(StandardError) @first.after_rollback_block { raise error_class } assert_raises(error_class) do Topic.transaction do @first.save! raise ActiveRecord::Rollback end end end def test_after_rollback_callback_when_raise_should_restore_state error_class = Class.new(StandardError) first = TopicWithCallbacks.new second = TopicWithCallbacks.new first.after_rollback_block { raise error_class } second.after_rollback_block { raise error_class } begin Topic.transaction do first.save! assert_not_nil first.id second.save! assert_not_nil second.id raise ActiveRecord::Rollback end rescue error_class end assert_nil first.id assert_nil second.id end def test_after_rollback_callbacks_should_validate_on_condition assert_raise(ArgumentError) { Topic.after_rollback(on: :save) } e = assert_raise(ArgumentError) { Topic.after_rollback(on: "create") } assert_match(/:on conditions for after_commit and after_rollback callbacks have to be one of \[:create, :destroy, :update\]/, e.message) end def test_after_commit_callbacks_should_validate_on_condition assert_raise(ArgumentError) { Topic.after_commit(on: :save) } e = assert_raise(ArgumentError) { Topic.after_commit(on: "create") } assert_match(/:on conditions for after_commit and after_rollback callbacks have to be one of \[:create, :destroy, :update\]/, e.message) end def test_after_commit_chain_not_called_on_errors record_1 = TopicWithCallbacks.create! record_2 = TopicWithCallbacks.create! record_3 = TopicWithCallbacks.create! callbacks = [] record_1.after_commit_block { raise } record_2.after_commit_block { callbacks << record_2.id } record_3.after_commit_block { callbacks << record_3.id } begin TopicWithCallbacks.transaction do record_1.save! record_2.save! record_3.save! end rescue # From record_1.after_commit end assert_equal [], callbacks end def test_saving_a_record_with_a_belongs_to_that_specifies_touching_the_parent_should_call_callbacks_on_the_parent_object pet = Pet.first owner = pet.owner flag = false owner.on_after_commit do flag = true end pet.name = "Fluffy the Third" pet.save assert flag end private def add_transaction_execution_blocks(record) record.after_commit_block(:create) { |r| r.history << :commit_on_create } record.after_commit_block(:update) { |r| r.history << :commit_on_update } record.after_commit_block(:destroy) { |r| r.history << :commit_on_destroy } record.after_rollback_block(:create) { |r| r.history << :rollback_on_create } record.after_rollback_block(:update) { |r| r.history << :rollback_on_update } record.after_rollback_block(:destroy) { |r| r.history << :rollback_on_destroy } end end class TransactionAfterCommitCallbacksWithOptimisticLockingTest < ActiveRecord::TestCase class PersonWithCallbacks < ActiveRecord::Base self.table_name = :people after_create_commit { |record| record.history << :commit_on_create } after_update_commit { |record| record.history << :commit_on_update } after_destroy_commit { |record| record.history << :commit_on_destroy } def history @history ||= [] end end def test_after_commit_callbacks_with_optimistic_locking person = PersonWithCallbacks.create!(first_name: "first name") person.update!(first_name: "another name") person.destroy assert_equal [:commit_on_create, :commit_on_update, :commit_on_destroy], person.history end end class CallbacksOnMultipleActionsTest < ActiveRecord::TestCase self.use_transactional_tests = false class TopicWithCallbacksOnMultipleActions < ActiveRecord::Base self.table_name = :topics after_commit(on: [:create, :destroy]) { |record| record.history << :create_and_destroy } after_commit(on: [:create, :update]) { |record| record.history << :create_and_update } after_commit(on: [:update, :destroy]) { |record| record.history << :update_and_destroy } before_commit(if: :save_before_commit_history) { |record| record.history << :before_commit } before_commit(if: :update_title) { |record| record.update(title: "before commit title") } def clear_history @history = [] end def history @history ||= [] end attr_accessor :save_before_commit_history, :update_title end def test_after_commit_on_multiple_actions topic = TopicWithCallbacksOnMultipleActions.new topic.save assert_equal [:create_and_update, :create_and_destroy], topic.history topic.clear_history topic.approved = true topic.save assert_equal [:update_and_destroy, :create_and_update], topic.history topic.clear_history topic.destroy assert_equal [:update_and_destroy, :create_and_destroy], topic.history end def test_before_commit_actions topic = TopicWithCallbacksOnMultipleActions.new topic.save_before_commit_history = true topic.save assert_equal [:before_commit, :create_and_update, :create_and_destroy], topic.history end def test_before_commit_update_in_same_transaction topic = TopicWithCallbacksOnMultipleActions.new topic.update_title = true topic.save assert_equal "before commit title", topic.title assert_equal "before commit title", topic.reload.title end end class CallbacksOnDestroyUpdateActionRaceTest < ActiveRecord::TestCase self.use_transactional_tests = false class TopicWithHistory < ActiveRecord::Base self.table_name = :topics def self.clear_history @@history = [] end def self.history @@history ||= [] end end class TopicWithCallbacksOnDestroy < TopicWithHistory after_commit(on: :destroy) { |record| record.class.history << :commit_on_destroy } after_rollback(on: :destroy) { |record| record.class.history << :rollback_on_destroy } before_destroy :before_destroy_for_transaction private def before_destroy_for_transaction; end end class TopicWithCallbacksOnUpdate < TopicWithHistory after_commit(on: :update) { |record| record.class.history << :commit_on_update } before_save :before_save_for_transaction private def before_save_for_transaction; end end def test_trigger_once_on_multiple_deletions TopicWithCallbacksOnDestroy.clear_history topic = TopicWithCallbacksOnDestroy.new topic.save topic_clone = TopicWithCallbacksOnDestroy.find(topic.id) topic.define_singleton_method(:before_destroy_for_transaction) do topic_clone.destroy end topic.destroy assert_equal [:commit_on_destroy], TopicWithCallbacksOnDestroy.history end def test_rollback_on_multiple_deletions TopicWithCallbacksOnDestroy.clear_history topic = TopicWithCallbacksOnDestroy.new topic.save topic_clone = TopicWithCallbacksOnDestroy.find(topic.id) topic.define_singleton_method(:before_destroy_for_transaction) do topic_clone.update!(author_name: "Test Author Clone") topic_clone.destroy end TopicWithCallbacksOnDestroy.transaction do topic.update!(author_name: "Test Author") topic.destroy raise ActiveRecord::Rollback end assert_not_predicate topic, :destroyed? assert_not_predicate topic_clone, :destroyed? assert_equal [nil, "Test Author"], topic.author_name_change_to_be_saved assert_equal [nil, "Test Author Clone"], topic_clone.author_name_change_to_be_saved assert_equal [:rollback_on_destroy], TopicWithCallbacksOnDestroy.history end def test_trigger_on_update_where_row_was_deleted TopicWithCallbacksOnUpdate.clear_history topic = TopicWithCallbacksOnUpdate.new topic.save topic_clone = TopicWithCallbacksOnUpdate.find(topic.id) topic_clone.define_singleton_method(:before_save_for_transaction) do topic.destroy end topic_clone.author_name = "Test Author" topic_clone.save assert_equal [], TopicWithCallbacksOnUpdate.history end end class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase class TopicWithoutTransactionalEnrollmentCallbacks < ActiveRecord::Base self.table_name = :topics before_commit_without_transaction_enrollment { |r| r.history << :before_commit } after_commit_without_transaction_enrollment { |r| r.history << :after_commit } after_rollback_without_transaction_enrollment { |r| r.history << :rollback } def history @history ||= [] end end def setup @topic = TopicWithoutTransactionalEnrollmentCallbacks.create! end def test_commit_does_not_run_transactions_callbacks_without_enrollment @topic.transaction do @topic.content = "foo" @topic.save! end assert_empty @topic.history end def test_commit_run_transactions_callbacks_with_explicit_enrollment @topic.transaction do 2.times do @topic.content = "foo" @topic.save! end @topic.send(:add_to_transaction) end assert_equal [:before_commit, :after_commit], @topic.history end def test_commit_run_transactions_callbacks_with_nested_transactions @topic.transaction do @topic.transaction(requires_new: true) do @topic.content = "foo" @topic.save! @topic.send(:add_to_transaction) end end assert_equal [:before_commit, :after_commit], @topic.history end def test_rollback_does_not_run_transactions_callbacks_without_enrollment @topic.transaction do @topic.content = "foo" @topic.save! raise ActiveRecord::Rollback end assert_empty @topic.history end def test_rollback_run_transactions_callbacks_with_explicit_enrollment @topic.transaction do 2.times do @topic.content = "foo" @topic.save! end @topic.send(:add_to_transaction) raise ActiveRecord::Rollback end assert_equal [:rollback], @topic.history end end class CallbacksOnActionAndConditionTest < ActiveRecord::TestCase self.use_transactional_tests = false class TopicWithCallbacksOnActionAndCondition < ActiveRecord::Base self.table_name = :topics after_commit(on: [:create, :update], if: :run_callback?) { |record| record.history << :create_or_update } def clear_history @history = [] end def history @history ||= [] end def run_callback? self.history << :run_callback? true end attr_accessor :save_before_commit_history, :update_title end def test_callback_on_action_with_condition topic = TopicWithCallbacksOnActionAndCondition.new topic.save assert_equal [:run_callback?, :create_or_update], topic.history topic.clear_history topic.approved = true topic.save assert_equal [:run_callback?, :create_or_update], topic.history topic.clear_history topic.destroy assert_equal [], topic.history end end