# frozen_string_literal: true require "active_record" require "active_support/core_ext/module/delegation" require "after_commit_everywhere/version" require "after_commit_everywhere/wrap" # Module allowing to use ActiveRecord transactional callbacks outside of # ActiveRecord models, literally everywhere in your application. # # Include it to your classes (e.g. your base service object class or whatever) module AfterCommitEverywhere class NotInTransaction < RuntimeError; end delegate :after_commit, :before_commit, :after_rollback, to: AfterCommitEverywhere delegate :in_transaction?, :in_transaction, to: AfterCommitEverywhere # Causes {before_commit} and {after_commit} to raise an exception when # called outside a transaction. RAISE = :raise # Causes {before_commit} and {after_commit} to execute the given callback # immediately when called outside a transaction. EXECUTE = :execute # Causes {before_commit} and {after_commit} to log a warning before calling # the given callback immediately when called outside a transaction. WARN_AND_EXECUTE = :warn_and_execute class << self # Runs +callback+ after successful commit of outermost transaction for # database +connection+. # # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection to operate in. Defaults to +ActiveRecord::Base.connection+ # @param without_tx [Symbol] Determines the behavior of this function when # called without an open transaction. # # Must be one of: {RAISE}, {EXECUTE}, or {WARN_AND_EXECUTE}. # # @param callback [#call] Callback to be executed # @return void def after_commit( prepend: false, connection: nil, without_tx: EXECUTE, &callback ) register_callback( prepend: prepend, connection: connection, name: __method__, callback: callback, without_tx: without_tx, ) end # Runs +callback+ before committing of outermost transaction for +connection+. # # Available only since Ruby on Rails 5.0. See https://github.com/rails/rails/pull/18936 # # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection to operate in. Defaults to +ActiveRecord::Base.connection+ # @param without_tx [Symbol] Determines the behavior of this function when # called without an open transaction. # # Must be one of: {RAISE}, {EXECUTE}, or {WARN_AND_EXECUTE}. # # @param callback [#call] Callback to be executed # @return void def before_commit( prepend: false, connection: nil, without_tx: WARN_AND_EXECUTE, &callback ) if ActiveRecord::VERSION::MAJOR < 5 raise NotImplementedError, "#{__method__} works only with Rails 5.0+" end register_callback( prepend: prepend, connection: connection, name: __method__, callback: callback, without_tx: without_tx, ) end # Runs +callback+ after rolling back of transaction or savepoint (if declared # in nested transaction) for database +connection+. # # Caveat: do not raise +ActivRecord::Rollback+ in nested transaction block! # See http://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#module-ActiveRecord::Transactions::ClassMethods-label-Nested+transactions # # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection to operate in. Defaults to +ActiveRecord::Base.connection+ # @param callback [#call] Callback to be executed # @return void # @raise [NotInTransaction] if called outside transaction. def after_rollback(prepend: false, connection: nil, &callback) register_callback( prepend: prepend, connection: connection, name: __method__, callback: callback, without_tx: RAISE, ) end # @api private def register_callback(prepend:, connection: nil, name:, without_tx:, callback:) raise ArgumentError, "Provide callback to #{name}" unless callback unless in_transaction?(connection) case without_tx when WARN_AND_EXECUTE warn "#{name}: No transaction open. Executing callback immediately." return callback.call when EXECUTE return callback.call when RAISE raise NotInTransaction, "#{name} is useless outside transaction" else raise ArgumentError, "Invalid \"without_tx\": \"#{without_tx}\"" end end connection ||= default_connection wrap = Wrap.new(connection: connection, "#{name}": callback) if prepend # Hacking ActiveRecord's transaction internals to prepend our callback # See https://github.com/rails/rails/blob/f0d433bb46ac233ec7fd7fae48f458978908d905/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L148-L156 records = connection.current_transaction.instance_variable_get(:@records) records = connection.current_transaction.instance_variable_set(:@records, []) if records.nil? records.unshift(wrap) else connection.add_transaction_record(wrap) end end # Helper method to determine whether we're currently in transaction or not def in_transaction?(connection = nil) # Don't establish new connection if not connected: we apparently not in transaction return false unless connection || ActiveRecord::Base.connection_pool.active_connection? connection ||= default_connection # service transactions (tests and database_cleaner) are not joinable connection.transaction_open? && connection.current_transaction.joinable? end # Makes sure the provided block runs in a transaction. If we are not currently in a transaction, a new transaction is started. # # It mimics the ActiveRecord's +transaction+ method's API and actually uses it under the hood. # # However, the main difference is that it doesn't swallow +ActiveRecord::Rollback+ exception in case when there is no transaction open. # # @see https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-transaction # # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection to operate in. Defaults to +ActiveRecord::Base.connection+ # @param requires_new [Boolean] Forces creation of new subtransaction (savepoint) even if transaction is already opened. # @param new_tx_options [Hash] Options to be passed to +connection.transaction+ on new transaction creation # @return void def in_transaction(connection = default_connection, requires_new: false, **new_tx_options) if in_transaction?(connection) && !requires_new yield else connection.transaction(requires_new: requires_new, **new_tx_options) { yield } end end private def default_connection ActiveRecord::Base.connection end end end