diff --git a/test/test_orm_model_transactions.rb b/test/test_orm_model_transactions.rb new file mode 100644 index 0000000..3505db0 --- /dev/null +++ b/test/test_orm_model_transactions.rb @@ -0,0 +1,221 @@ +require 'minitest/autorun' +require 'prism' +require 'tmpdir' +require 'fileutils' + +class OrmModelTransactionsTest < Minitest::Test + def setup + @dir = Dir.mktmpdir('prism-model-tx') + @store = Prism::Orm::Store.new(File.join(@dir, 'test.pstore')) + @store.create_table(:accounts, %w[id name balance]) + @store.create_table(:authors, %w[id name]) + @store.create_table(:posts, %w[id title author_id]) + + account_class = Class.new(Prism::Orm::Model) { table_name :accounts } + author_class = Class.new(Prism::Orm::Model) do + table_name :authors + has_many :posts, class_name: 'Post', foreign_key: 'author_id', dependent: :destroy + end + post_class = Class.new(Prism::Orm::Model) do + table_name :posts + belongs_to :author, class_name: 'Author', foreign_key: 'author_id' + end + [account_class, author_class, post_class].each { |klass| klass.store = @store } + Object.const_set(:Account, account_class) + Object.const_set(:Author, author_class) + Object.const_set(:Post, post_class) + end + + def teardown + FileUtils.remove_entry(@dir) + %i[Account Author Post].each { |name| Object.send(:remove_const, name) if Object.const_defined?(name) } + end + + def test_transaction_commits_every_write_made_by_multiple_records + Account.transaction do + Account.create!(name: 'alice', balance: 10) + Account.create!(name: 'bob', balance: 20) + end + assert_equal [10, 20], Account.all.map(&:balance) + end + + def test_exception_inside_transaction_rolls_back_every_write + assert_raises(RuntimeError) do + Account.transaction do + Account.create!(name: 'alice', balance: 10) + Account.create!(name: 'bob', balance: 20) + raise 'transfer failed midway' + end + end + assert_empty Account.all.to_a + end + + def test_rollback_exception_aborts_silently_without_propagating + result = Account.transaction do + Account.create!(name: 'alice', balance: 10) + raise Prism::Orm::Rollback + end + assert_nil result + assert_empty Account.all.to_a + end + + def test_transaction_can_be_entered_from_an_instance_and_from_a_different_model_class + alice = Account.create!(name: 'alice', balance: 0) + assert_raises(RuntimeError) do + alice.transaction do + alice.update!(balance: 100) + Author.create!(name: 'ghost') + raise 'boom' + end + end + assert_equal 0, alice.reload.balance + assert_empty Author.all.to_a + end + + def test_after_commit_fires_once_for_a_standalone_save_with_no_explicit_transaction + log = [] + Account.after_commit { log << [:commit, name] } + Account.after_rollback { log << [:rollback, name] } + Account.create!(name: 'alice', balance: 5) + assert_equal [[:commit, 'alice']], log + end + + def test_after_commit_fires_exactly_once_for_all_participants_of_an_explicit_transaction + log = [] + Account.after_commit { log << name } + Account.transaction do + Account.create!(name: 'alice', balance: 1) + Account.create!(name: 'bob', balance: 2) + end + assert_equal %w[alice bob], log.sort + end + + def test_after_rollback_fires_instead_of_after_commit_when_the_transaction_raises + committed = [] + rolled_back = [] + Account.after_commit { committed << name } + Account.after_rollback { rolled_back << name } + assert_raises(RuntimeError) do + Account.transaction do + Account.create!(name: 'alice', balance: 1) + raise 'boom' + end + end + assert_empty committed + assert_equal ['alice'], rolled_back + end + + def test_after_commit_on_filters_by_operation + creates = [] + updates = [] + Account.after_commit(on: :create) { creates << name } + Account.after_commit(on: :update) { updates << name } + account = Account.create!(name: 'alice', balance: 1) + account.update!(balance: 2) + assert_equal ['alice'], creates + assert_equal ['alice'], updates + end + + def test_cascaded_dependent_destroy_shares_one_transaction_with_the_parent + author = Author.create!(name: 'foksiu') + Post.create!(title: 'a', author_id: author.id) + Post.create!(title: 'b', author_id: author.id) + + Author.after_destroy { raise 'boom after destroy' } + assert_raises(RuntimeError) { author.destroy } + + assert Author.find(author.id), 'the author row must survive the aborted transaction' + assert_equal 2, Post.where(author_id: author.id).count, + 'cascaded child deletes must roll back together with the parent' + end + + def test_before_destroy_abort_never_reaches_the_store + Author.before_destroy { throw :abort } + author = Author.create!(name: 'foksiu') + author.destroy + refute author.destroyed? + assert Author.find(author.id) + end + + def test_plain_nested_transaction_still_flattens_and_rolls_back_everything + assert_raises(RuntimeError) do + Account.transaction do + Account.create!(name: 'alice', balance: 10) + Account.transaction do + Account.create!(name: 'bob', balance: 20) + raise 'boom' + end + end + end + assert_empty Account.all.to_a, 'a plain nested transaction is not a real savepoint' + end + + def test_requires_new_rolls_back_only_the_inner_writes_and_lets_the_outer_commit + Account.transaction do + Account.create!(name: 'alice', balance: 10) + assert_raises(RuntimeError) do + Account.transaction(requires_new: true) do + Account.create!(name: 'doomed', balance: 999) + raise 'inner failure' + end + end + Account.create!(name: 'bob', balance: 20) + end + assert_equal %w[alice bob], Account.all.map(&:name).sort + end + + def test_requires_new_with_explicit_rollback_is_swallowed_and_outer_keeps_going + Account.transaction do + Account.create!(name: 'alice', balance: 10) + result = Account.transaction(requires_new: true) do + Account.create!(name: 'doomed', balance: 999) + raise Prism::Orm::Rollback + end + assert_nil result + Account.create!(name: 'bob', balance: 20) + end + assert_equal %w[alice bob], Account.all.map(&:name).sort + end + + def test_requires_new_fires_after_rollback_immediately_without_waiting_for_the_outer_transaction + log = [] + Account.after_commit { log << [:commit, name] } + Account.after_rollback { log << [:rollback, name] } + + Account.transaction do + Account.create!(name: 'alice', balance: 10) + assert_raises(RuntimeError) do + Account.transaction(requires_new: true) do + Account.create!(name: 'doomed', balance: 999) + raise 'inner failure' + end + end + assert_equal [[:rollback, 'doomed']], log, + 'the savepoint must have rolled back already, before the outer transaction has even finished' + Account.create!(name: 'bob', balance: 20) + end + assert_equal [[:rollback, 'doomed'], [:commit, 'alice'], [:commit, 'bob']], log + end + + def test_requires_new_without_a_surrounding_transaction_behaves_like_a_plain_transaction + assert_raises(RuntimeError) do + Account.transaction(requires_new: true) do + Account.create!(name: 'alice', balance: 10) + raise 'boom' + end + end + assert_empty Account.all.to_a + end + + def test_outer_rollback_after_a_committed_savepoint_still_undoes_the_savepoint_writes + assert_raises(RuntimeError) do + Account.transaction do + Account.transaction(requires_new: true) do + Account.create!(name: 'alice', balance: 10) + end + raise 'outer failure' + end + end + assert_empty Account.all.to_a, 'a released savepoint is not a commit point on its own' + end +end