module Clear::SQL::Transaction # Represents the differents levels of transactions # as described in https://www.postgresql.org/docs/9.5/transaction-iso.html # # ReadUncommited is voluntarly ommited as it fallback to ReadCommited in PostgreSQL enum Level ReadCommitted RepeatableRead Serializable # :nodoc: def to_begin_operation case self when ReadCommitted "BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED" when RepeatableRead "BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ" else # Serializable is the default "BEGIN" end end end @@savepoint_uid : UInt64 = 0_u64 @@commit_callbacks = Hash(DB::Connection, Array(DB::Connection ->)).new { [] of DB::Connection -> } # Check whether the current pair of fiber/connection is in transaction # block or not. def in_transaction?(connection : String = "default") Clear::SQL::ConnectionPool.with_connection(connection, &._clear_in_transaction?) end # Enter new transaction block for the current connection/fiber pair. # # Example: # ``` # Clear::SQL.transaction do # # do something # Clear::SQL.transaction do # Technically, this block do nothing, since we already are in transaction # rollback # < Rollback the up-most `transaction` block. # end # end # ``` # see #with_savepoint to use a stackable version using savepoints. # def transaction(connection : String = "default", level : Level = Level::Serializable, &block) Clear::SQL::ConnectionPool.with_connection(connection) do |cnx| has_rollback = false if cnx._clear_in_transaction? return yield(cnx) # In case we already are in transaction, we just ignore else cnx._clear_in_transaction = true execute(level.to_begin_operation) begin return yield(cnx) rescue e has_rollback = true is_rollback_error = e.is_a?(RollbackError) || e.is_a?(CancelTransactionError) execute("ROLLBACK --" + (is_rollback_error ? "normal" : "program error")) rescue nil raise e unless is_rollback_error ensure cnx._clear_in_transaction = false unless has_rollback execute("COMMIT") @@commit_callbacks[cnx].each(&.call(cnx)) end @@commit_callbacks.delete(cnx) end end end end # Register a callback function which will be fired once when SQL `COMMIT` # operation is called # # This can be used for example to send email, or perform others tasks # when you want to be sure the data is secured in the database. # # ``` # transaction do # @user = User.find(1) # @user.subscribe! # Clear::SQL.after_commit { Email.deliver(ConfirmationMail.new(@user)) } # end # ``` # # In case the transaction fail and eventually rollback, the code won't be called. # def after_commit(connection : String = "default", &block : DB::Connection -> Nil) Clear::SQL::ConnectionPool.with_connection(connection) do |cnx| if cnx._clear_in_transaction? @@commit_callbacks[cnx] <<= block else raise Clear::SQL::Error.new("you need to be in transaction to add after_commit callback") end end end # Create a transaction, but this one is stackable # using savepoints. # # Example: # ``` # Clear::SQL.with_savepoint do # # do something # Clear::SQL.with_savepoint do # rollback # < Rollback only the last `with_savepoint` block # end # end # ``` def with_savepoint(sp_name : Symbolic? = nil, connection_name : String = "default", &block) transaction do |cnx| sp_name ||= "sp_#{@@savepoint_uid += 1}" execute(connection_name, "SAVEPOINT #{sp_name}") yield execute(connection_name, "RELEASE SAVEPOINT #{sp_name}") if cnx._clear_in_transaction? rescue e : RollbackError if cnx._clear_in_transaction? execute(connection_name, "ROLLBACK TO SAVEPOINT #{sp_name}") raise e if e.savepoint_id.try &.!=(sp_name) end end end # Rollback a transaction or return to the previous savepoint in case of a # with_savepoint block. # The params `to` offer def rollback(to = nil) raise RollbackError.new(to) end # Rollback the transaction. In case the call is made inside a savepoint block # rollback everything. def rollback_transaction raise CancelTransactionError.new end end