aboutsummaryrefslogblamecommitdiffstats
path: root/activerecord/test/cases/transactions_test.rb
blob: b12ec36455e14efd913c56bdba27e511f60ea6e3 (plain) (tree)
1
2
3
4
5
6
7
8
9
                      


                          
                     
 
                                              
                                         
                               
 
           
                                                           



                        

                              







                                                                         

                             

                              










                                                              
                                  














                                                                         

                                          

                              






                                                                         
 














                                                                              
 


                                                                         
 
                                                          












                                                             
 















































                                                                               










                                                      
                                                            
                              
 










                                                                                                                 
                                                                            




                                                               
 
                                       

                          

                                







                                                                         
 
                                              
                        



                              
 
                                  



                                                                              
 



                                                                         

                                                          
                                                     
                                                                                                          









                                                                        
 

























                                                                                                               



                                                                                     
 


                                                             
 


                                                                                       
 


                                                               









                                                                                         



                                                   

                              












                                                                                   

             
 

                                   
       
 



























                                                           

             
 







                                                                    

             

                                   

         
                                                            
       
     
   
require "cases/helper"
require 'models/topic'
require 'models/reply'
require 'models/developer'
require 'models/book'

class TransactionTest < ActiveRecord::TestCase
  self.use_transactional_fixtures = false
  fixtures :topics, :developers

  def setup
    @first, @second = Topic.find(1, 2).sort_by { |t| t.id }
  end

  def test_successful
    Topic.transaction do
      @first.approved  = true
      @second.approved = false
      @first.save
      @second.save
    end

    assert Topic.find(1).approved?, "First should have been approved"
    assert !Topic.find(2).approved?, "Second should have been unapproved"
  end

  def transaction_with_return
    Topic.transaction do
      @first.approved  = true
      @second.approved = false
      @first.save
      @second.save
      return
    end
  end

  def test_successful_with_return
    class << Topic.connection
      alias :real_commit_db_transaction :commit_db_transaction
      def commit_db_transaction
        $committed = true
        real_commit_db_transaction
      end
    end

    $committed = false
    transaction_with_return
    assert $committed

    assert Topic.find(1).approved?, "First should have been approved"
    assert !Topic.find(2).approved?, "Second should have been unapproved"
  ensure
    class << Topic.connection
      alias :commit_db_transaction :real_commit_db_transaction rescue nil
    end
  end

  def test_successful_with_instance_method
    @first.transaction do
      @first.approved  = true
      @second.approved = false
      @first.save
      @second.save
    end

    assert Topic.find(1).approved?, "First should have been approved"
    assert !Topic.find(2).approved?, "Second should have been unapproved"
  end

  def test_failing_on_exception
    begin
      Topic.transaction do
        @first.approved  = true
        @second.approved = false
        @first.save
        @second.save
        raise "Bad things!"
      end
    rescue
      # caught it
    end

    assert @first.approved?, "First should still be changed in the objects"
    assert !@second.approved?, "Second should still be changed in the objects"

    assert !Topic.find(1).approved?, "First shouldn't have been approved"
    assert Topic.find(2).approved?, "Second should still be approved"
  end

  def test_raising_exception_in_callback_rollbacks_in_save
    add_exception_raising_after_save_callback_to_topic

    begin
      @first.approved = true
      @first.save
      flunk
    rescue => e
      assert_equal "Make the transaction rollback", e.message
      assert !Topic.find(1).approved?
    ensure
      remove_exception_raising_after_save_callback_to_topic
    end
  end

  def test_cancellation_from_before_destroy_rollbacks_in_destroy
    add_cancelling_before_destroy_with_db_side_effect_to_topic
    begin
      nbooks_before_destroy = Book.count
      status = @first.destroy
      assert !status
      assert_nothing_raised(ActiveRecord::RecordNotFound) { @first.reload }
      assert_equal nbooks_before_destroy, Book.count
    ensure
      remove_cancelling_before_destroy_with_db_side_effect_to_topic
    end
  end

  def test_cancellation_from_before_filters_rollbacks_in_save
    %w(validation save).each do |filter|
      send("add_cancelling_before_#{filter}_with_db_side_effect_to_topic")
      begin
        nbooks_before_save = Book.count
        original_author_name = @first.author_name
        @first.author_name += '_this_should_not_end_up_in_the_db'
        status = @first.save
        assert !status
        assert_equal original_author_name, @first.reload.author_name
        assert_equal nbooks_before_save, Book.count
      ensure
        send("remove_cancelling_before_#{filter}_with_db_side_effect_to_topic")
      end
    end
  end

  def test_cancellation_from_before_filters_rollbacks_in_save!
    %w(validation save).each do |filter|
      send("add_cancelling_before_#{filter}_with_db_side_effect_to_topic")
      begin
        nbooks_before_save = Book.count
        original_author_name = @first.author_name
        @first.author_name += '_this_should_not_end_up_in_the_db'
        @first.save!
        flunk
      rescue => e
        assert_equal original_author_name, @first.reload.author_name
        assert_equal nbooks_before_save, Book.count
      ensure
        send("remove_cancelling_before_#{filter}_with_db_side_effect_to_topic")
      end
    end
  end

  def test_callback_rollback_in_create
    new_topic = Topic.new(
      :title => "A new topic",
      :author_name => "Ben",
      :author_email_address => "ben@example.com",
      :written_on => "2003-07-16t15:28:11.2233+01:00",
      :last_read => "2004-04-15",
      :bonus_time => "2005-01-30t15:28:00.00+01:00",
      :content => "Have a nice day",
      :approved => false)
    new_record_snapshot = new_topic.new_record?
    id_present = new_topic.has_attribute?(Topic.primary_key)
    id_snapshot = new_topic.id

    # Make sure the second save gets the after_create callback called.
    2.times do
      begin
        add_exception_raising_after_create_callback_to_topic
        new_topic.approved = true
        new_topic.save
        flunk
      rescue => e
        assert_equal "Make the transaction rollback", e.message
        assert_equal new_record_snapshot, new_topic.new_record?, "The topic should have its old new_record value"
        assert_equal id_snapshot, new_topic.id, "The topic should have its old id"
        assert_equal id_present, new_topic.has_attribute?(Topic.primary_key)
      ensure
        remove_exception_raising_after_create_callback_to_topic
      end
    end
  end

  def test_nested_explicit_transactions
    Topic.transaction do
      Topic.transaction do
        @first.approved  = true
        @second.approved = false
        @first.save
        @second.save
      end
    end

    assert Topic.find(1).approved?, "First should have been approved"
    assert !Topic.find(2).approved?, "Second should have been unapproved"
  end

  def test_manually_rolling_back_a_transaction
    Topic.transaction do
      @first.approved  = true
      @second.approved = false
      @first.save
      @second.save

      raise ActiveRecord::Rollback
    end

    assert @first.approved?, "First should still be changed in the objects"
    assert !@second.approved?, "Second should still be changed in the objects"

    assert !Topic.find(1).approved?, "First shouldn't have been approved"
    assert Topic.find(2).approved?, "Second should still be approved"
  end

  uses_mocha 'mocking connection.commit_db_transaction' do
    def test_rollback_when_commit_raises
      Topic.connection.expects(:begin_db_transaction)
      Topic.connection.expects(:transaction_active?).returns(true) if current_adapter?(:PostgreSQLAdapter)
      Topic.connection.expects(:commit_db_transaction).raises('OH NOES')
      Topic.connection.expects(:rollback_db_transaction)

      assert_raise RuntimeError do
        Topic.transaction do
          # do nothing
        end
      end
    end
  end

  def test_sqlite_add_column_in_transaction_raises_statement_invalid
    return true unless current_adapter?(:SQLite3Adapter, :SQLiteAdapter)

    # Test first if column creation/deletion works correctly when no
    # transaction is in place.
    #
    # We go back to the connection for the column queries because
    # Topic.columns is cached and won't report changes to the DB
    
    assert_nothing_raised do
      Topic.reset_column_information
      Topic.connection.add_column('topics', 'stuff', :string)
      assert Topic.column_names.include?('stuff')
      
      Topic.reset_column_information
      Topic.connection.remove_column('topics', 'stuff')
      assert !Topic.column_names.include?('stuff')
    end

    # Test now inside a transaction: add_column should raise a StatementInvalid
    Topic.transaction do
      assert_raises(ActiveRecord::StatementInvalid) { Topic.connection.add_column('topics', 'stuff', :string) }
      raise ActiveRecord::Rollback
    end
  end

  private
    def add_exception_raising_after_save_callback_to_topic
      Topic.class_eval { def after_save() raise "Make the transaction rollback" end }
    end

    def remove_exception_raising_after_save_callback_to_topic
      Topic.class_eval { remove_method :after_save }
    end

    def add_exception_raising_after_create_callback_to_topic
      Topic.class_eval { def after_create() raise "Make the transaction rollback" end }
    end

    def remove_exception_raising_after_create_callback_to_topic
      Topic.class_eval { remove_method :after_create }
    end

    %w(validation save destroy).each do |filter|
      define_method("add_cancelling_before_#{filter}_with_db_side_effect_to_topic") do
        Topic.class_eval "def before_#{filter}() Book.create; false end"
      end

      define_method("remove_cancelling_before_#{filter}_with_db_side_effect_to_topic") do
        Topic.class_eval "remove_method :before_#{filter}"
      end
    end
end

if current_adapter?(:PostgreSQLAdapter)
  class ConcurrentTransactionTest < TransactionTest
    use_concurrent_connections

    # This will cause transactions to overlap and fail unless they are performed on
    # separate database connections.
    def test_transaction_per_thread
      assert_nothing_raised do
        threads = (1..3).map do
          Thread.new do
            Topic.transaction do
              topic = Topic.find(1)
              topic.approved = !topic.approved?
              topic.save!
              topic.approved = !topic.approved?
              topic.save!
            end
          end
        end

        threads.each { |t| t.join }
      end
    end

    # Test for dirty reads among simultaneous transactions.
    def test_transaction_isolation__read_committed
      # Should be invariant.
      original_salary = Developer.find(1).salary
      temporary_salary = 200000

      assert_nothing_raised do
        threads = (1..3).map do
          Thread.new do
            Developer.transaction do
              # Expect original salary.
              dev = Developer.find(1)
              assert_equal original_salary, dev.salary

              dev.salary = temporary_salary
              dev.save!

              # Expect temporary salary.
              dev = Developer.find(1)
              assert_equal temporary_salary, dev.salary

              dev.salary = original_salary
              dev.save!

              # Expect original salary.
              dev = Developer.find(1)
              assert_equal original_salary, dev.salary
            end
          end
        end

        # Keep our eyes peeled.
        threads << Thread.new do
          10.times do
            sleep 0.05
            Developer.transaction do
              # Always expect original salary.
              assert_equal original_salary, Developer.find(1).salary
            end
          end
        end

        threads.each { |t| t.join }
      end

      assert_equal original_salary, Developer.find(1).salary
    end
  end
end