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