mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
f4fbc2c1f9
[#922 state:resovled] Signed-off-by: José Valim <jose.valim@gmail.com>
602 lines
17 KiB
Ruby
602 lines
17 KiB
Ruby
require "cases/helper"
|
|
require 'models/topic'
|
|
require 'models/reply'
|
|
require 'models/developer'
|
|
require 'models/book'
|
|
require 'models/author'
|
|
require 'models/post'
|
|
|
|
class TransactionTest < ActiveRecord::TestCase
|
|
self.use_transactional_fixtures = false
|
|
fixtures :topics, :developers, :authors, :posts
|
|
|
|
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
|
|
remove_method :commit_db_transaction
|
|
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_update_attributes_should_rollback_on_failure
|
|
author = Author.find(1)
|
|
posts_count = author.posts.size
|
|
assert posts_count > 0
|
|
status = author.update_attributes(:name => nil, :post_ids => [])
|
|
assert !status
|
|
assert_equal posts_count, author.posts(true).size
|
|
end
|
|
|
|
def test_update_attributes_should_rollback_on_failure!
|
|
author = Author.find(1)
|
|
posts_count = author.posts.size
|
|
assert posts_count > 0
|
|
assert_raise(ActiveRecord::RecordInvalid) do
|
|
author.update_attributes!(:name => nil, :post_ids => [])
|
|
end
|
|
assert_equal posts_count, author.posts(true).size
|
|
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
|
|
|
|
def test_invalid_keys_for_transaction
|
|
assert_raise ArgumentError do
|
|
Topic.transaction :nested => true do
|
|
end
|
|
end
|
|
end
|
|
|
|
def test_force_savepoint_in_nested_transaction
|
|
Topic.transaction do
|
|
@first.approved = true
|
|
@second.approved = false
|
|
@first.save!
|
|
@second.save!
|
|
|
|
begin
|
|
Topic.transaction :requires_new => true do
|
|
@first.happy = false
|
|
@first.save!
|
|
raise
|
|
end
|
|
rescue
|
|
end
|
|
end
|
|
|
|
assert @first.reload.approved?
|
|
assert !@second.reload.approved?
|
|
end if Topic.connection.supports_savepoints?
|
|
|
|
def test_no_savepoint_in_nested_transaction_without_force
|
|
Topic.transaction do
|
|
@first.approved = true
|
|
@second.approved = false
|
|
@first.save!
|
|
@second.save!
|
|
|
|
begin
|
|
Topic.transaction do
|
|
@first.approved = false
|
|
@first.save!
|
|
raise
|
|
end
|
|
rescue
|
|
end
|
|
end
|
|
|
|
assert !@first.reload.approved?
|
|
assert !@second.reload.approved?
|
|
end if Topic.connection.supports_savepoints?
|
|
|
|
def test_many_savepoints
|
|
Topic.transaction do
|
|
@first.content = "One"
|
|
@first.save!
|
|
|
|
begin
|
|
Topic.transaction :requires_new => true do
|
|
@first.content = "Two"
|
|
@first.save!
|
|
|
|
begin
|
|
Topic.transaction :requires_new => true do
|
|
@first.content = "Three"
|
|
@first.save!
|
|
|
|
begin
|
|
Topic.transaction :requires_new => true do
|
|
@first.content = "Four"
|
|
@first.save!
|
|
raise
|
|
end
|
|
rescue
|
|
end
|
|
|
|
@three = @first.reload.content
|
|
raise
|
|
end
|
|
rescue
|
|
end
|
|
|
|
@two = @first.reload.content
|
|
raise
|
|
end
|
|
rescue
|
|
end
|
|
|
|
@one = @first.reload.content
|
|
end
|
|
|
|
assert_equal "One", @one
|
|
assert_equal "Two", @two
|
|
assert_equal "Three", @three
|
|
end if Topic.connection.supports_savepoints?
|
|
|
|
def test_rollback_when_commit_raises
|
|
Topic.connection.expects(:begin_db_transaction)
|
|
Topic.connection.expects(:commit_db_transaction).raises('OH NOES')
|
|
Topic.connection.expects(:outside_transaction?).returns(false)
|
|
Topic.connection.expects(:rollback_db_transaction)
|
|
|
|
assert_raise RuntimeError do
|
|
Topic.transaction do
|
|
# do nothing
|
|
end
|
|
end
|
|
end
|
|
|
|
def test_restore_active_record_state_for_all_records_in_a_transaction
|
|
topic_1 = Topic.new(:title => 'test_1')
|
|
topic_2 = Topic.new(:title => 'test_2')
|
|
Topic.transaction do
|
|
assert topic_1.save
|
|
assert topic_2.save
|
|
@first.save
|
|
@second.destroy
|
|
assert_equal false, topic_1.new_record?
|
|
assert_not_nil topic_1.id
|
|
assert_equal false, topic_2.new_record?
|
|
assert_not_nil topic_2.id
|
|
assert_equal false, @first.new_record?
|
|
assert_not_nil @first.id
|
|
assert_equal true, @second.destroyed?
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
|
|
assert_equal true, topic_1.new_record?
|
|
assert_nil topic_1.id
|
|
assert_equal true, topic_2.new_record?
|
|
assert_nil topic_2.id
|
|
assert_equal false, @first.new_record?
|
|
assert_not_nil @first.id
|
|
assert_equal false, @second.destroyed?
|
|
end
|
|
|
|
if current_adapter?(:PostgreSQLAdapter) && defined?(PGconn::PQTRANS_IDLE)
|
|
def test_outside_transaction_works
|
|
assert Topic.connection.outside_transaction?
|
|
Topic.connection.begin_db_transaction
|
|
assert !Topic.connection.outside_transaction?
|
|
Topic.connection.rollback_db_transaction
|
|
assert Topic.connection.outside_transaction?
|
|
end
|
|
|
|
def test_rollback_wont_be_executed_if_no_transaction_active
|
|
assert_raise RuntimeError do
|
|
Topic.transaction do
|
|
Topic.connection.rollback_db_transaction
|
|
Topic.connection.expects(:rollback_db_transaction).never
|
|
raise "Rails doesn't scale!"
|
|
end
|
|
end
|
|
end
|
|
|
|
def test_open_transactions_count_is_reset_to_zero_if_no_transaction_active
|
|
Topic.transaction do
|
|
Topic.transaction do
|
|
Topic.connection.rollback_db_transaction
|
|
end
|
|
assert_equal 0, Topic.connection.open_transactions
|
|
end
|
|
assert_equal 0, Topic.connection.open_transactions
|
|
end
|
|
end
|
|
|
|
def test_sqlite_add_column_in_transaction
|
|
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
|
|
|
|
if Topic.connection.supports_ddl_transactions?
|
|
assert_nothing_raised do
|
|
Topic.transaction { Topic.connection.add_column('topics', 'stuff', :string) }
|
|
end
|
|
else
|
|
Topic.transaction do
|
|
assert_raise(ActiveRecord::StatementInvalid) { Topic.connection.add_column('topics', 'stuff', :string) }
|
|
raise ActiveRecord::Rollback
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
def define_callback_method(callback_method)
|
|
define_method(callback_method) do
|
|
self.history << [callback_method, :method]
|
|
end
|
|
end
|
|
|
|
def add_exception_raising_after_save_callback_to_topic
|
|
Topic.class_eval <<-eoruby, __FILE__, __LINE__ + 1
|
|
remove_method(:after_save_for_transaction)
|
|
def after_save_for_transaction
|
|
raise 'Make the transaction rollback'
|
|
end
|
|
eoruby
|
|
end
|
|
|
|
def remove_exception_raising_after_save_callback_to_topic
|
|
Topic.class_eval <<-eoruby, __FILE__, __LINE__ + 1
|
|
remove_method :after_save_for_transaction
|
|
def after_save_for_transaction; end
|
|
eoruby
|
|
end
|
|
|
|
def add_exception_raising_after_create_callback_to_topic
|
|
Topic.class_eval <<-eoruby, __FILE__, __LINE__ + 1
|
|
remove_method(:after_create_for_transaction)
|
|
def after_create_for_transaction
|
|
raise 'Make the transaction rollback'
|
|
end
|
|
eoruby
|
|
end
|
|
|
|
def remove_exception_raising_after_create_callback_to_topic
|
|
Topic.class_eval <<-eoruby, __FILE__, __LINE__ + 1
|
|
remove_method :after_create_for_transaction
|
|
def after_create_for_transaction; end
|
|
eoruby
|
|
end
|
|
|
|
%w(validation save destroy).each do |filter|
|
|
define_method("add_cancelling_before_#{filter}_with_db_side_effect_to_topic") do
|
|
Topic.class_eval <<-eoruby, __FILE__, __LINE__ + 1
|
|
remove_method :before_#{filter}_for_transaction
|
|
def before_#{filter}_for_transaction
|
|
Book.create
|
|
false
|
|
end
|
|
eoruby
|
|
end
|
|
|
|
define_method("remove_cancelling_before_#{filter}_with_db_side_effect_to_topic") do
|
|
Topic.class_eval <<-eoruby, __FILE__, __LINE__ + 1
|
|
remove_method :before_#{filter}_for_transaction
|
|
def before_#{filter}_for_transaction; end
|
|
eoruby
|
|
end
|
|
end
|
|
end
|
|
|
|
class TransactionsWithTransactionalFixturesTest < ActiveRecord::TestCase
|
|
self.use_transactional_fixtures = true
|
|
fixtures :topics
|
|
|
|
def test_automatic_savepoint_in_outer_transaction
|
|
@first = Topic.find(1)
|
|
|
|
begin
|
|
Topic.transaction do
|
|
@first.approved = true
|
|
@first.save!
|
|
raise
|
|
end
|
|
rescue
|
|
assert !@first.reload.approved?
|
|
end
|
|
end
|
|
|
|
def test_no_automatic_savepoint_for_inner_transaction
|
|
@first = Topic.find(1)
|
|
|
|
Topic.transaction do
|
|
@first.approved = true
|
|
@first.save!
|
|
|
|
begin
|
|
Topic.transaction do
|
|
@first.approved = false
|
|
@first.save!
|
|
raise
|
|
end
|
|
rescue
|
|
end
|
|
end
|
|
|
|
assert !@first.reload.approved?
|
|
end
|
|
end if Topic.connection.supports_savepoints?
|
|
|
|
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
|