gocardless/statesman

View on GitHub
lib/statesman/adapters/active_record.rb

Summary

Maintainability
C
7 hrs
Test Coverage
# frozen_string_literal: true

require_relative "../exceptions"

module Statesman
  module Adapters
    class ActiveRecord
      JSON_COLUMN_TYPES = %w[json jsonb].freeze

      def self.database_supports_partial_indexes?(model)
        # Rails 3 doesn't implement `supports_partial_index?`
        if model.connection.respond_to?(:supports_partial_index?)
          model.connection.supports_partial_index?
        else
          model.connection.adapter_name.casecmp("postgresql").zero?
        end
      end

      def initialize(transition_class, parent_model, observer, options = {})
        serialized = serialized?(transition_class)
        column_type = transition_class.columns_hash["metadata"].sql_type
        if !serialized && !JSON_COLUMN_TYPES.include?(column_type)
          raise UnserializedMetadataError, transition_class.name
        elsif serialized && JSON_COLUMN_TYPES.include?(column_type)
          raise IncompatibleSerializationError, transition_class.name
        end

        @transition_class = transition_class
        @transition_table = transition_class.arel_table
        @parent_model = parent_model
        @observer = observer
        @association_name =
          options[:association_name] || @transition_class.table_name
      end

      attr_reader :transition_class, :transition_table, :parent_model

      def create(from, to, metadata = {})
        create_transition(from.to_s, to.to_s, metadata)
      rescue ::ActiveRecord::RecordNotUnique => e
        if transition_conflict_error? e
          # The history has the invalid transition on the end of it, which means
          # `current_state` would then be incorrect. We force a reload of the history to
          # avoid this.
          transitions_for_parent.reload
          raise TransitionConflictError, e.message
        end

        raise
      ensure
        reset
      end

      def history(force_reload: false)
        if transitions_for_parent.loaded? && !force_reload
          # Workaround for Rails bug which causes infinite loop when sorting
          # already loaded result set. Introduced in rails/rails@b097ebe
          transitions_for_parent.to_a.sort_by(&:sort_key)
        else
          transitions_for_parent.order(:sort_key)
        end
      end

      def last(force_reload: false)
        if force_reload
          @last_transition = history(force_reload: true).last
        elsif instance_variable_defined?(:@last_transition)
          @last_transition
        else
          @last_transition = history.last
        end
      end

      def reset
        if instance_variable_defined?(:@last_transition)
          remove_instance_variable(:@last_transition)
        end
      end

      private

      def create_transition(from, to, metadata)
        transition = transitions_for_parent.build(
          default_transition_attributes(to, metadata),
        )

        transition_class.transaction(requires_new: true) do
          @observer.execute(:before, from, to, transition)

          if mysql_gaplock_protection?(transition_class.connection)
            # We save the transition first with most_recent falsy, then mark most_recent
            # true after to avoid letting MySQL acquire a next-key lock which can cause
            # deadlocks.
            #
            # To avoid an additional query, we manually adjust the most_recent attribute
            # on our transition assuming that update_most_recents will have set it to true

            transition.save!

            unless update_most_recents(transition.id).positive?
              raise ActiveRecord::Rollback, "failed to update most_recent"
            end

            transition.assign_attributes(most_recent: true)
          else
            update_most_recents
            transition.assign_attributes(most_recent: true)
            transition.save!
          end

          @last_transition = transition
          @observer.execute(:after, from, to, transition)
          add_after_commit_callback(from, to, transition)
        end

        transition
      end

      def default_transition_attributes(to, metadata)
        {
          to_state: to,
          sort_key: next_sort_key,
          metadata: metadata,
          most_recent: not_most_recent_value(db_cast: false),
        }
      end

      def add_after_commit_callback(from, to, transition)
        transition_class.connection.add_transaction_record(
          ActiveRecordAfterCommitWrap.new(transition_class.connection) do
            @observer.execute(:after_commit, from, to, transition)
          end,
        )
      end

      def transitions_for_parent
        parent_model.send(@association_name)
      end

      # Sets the given transition most_recent = t while unsetting the most_recent of any
      # previous transitions.
      def update_most_recents(most_recent_id = nil)
        update = build_arel_manager(::Arel::UpdateManager, transition_class)
        update.table(transition_table)
        update.where(most_recent_transitions(most_recent_id))
        update.set(build_most_recents_update_all_values(most_recent_id))

        # MySQL will validate index constraints across the intermediate result of an
        # update. This means we must order our update to deactivate the previous
        # most_recent before setting the new row to be true.
        if mysql_gaplock_protection?(transition_class.connection)
          update.order(transition_table[:most_recent].desc)
        end

        transition_class.connection.update(update.to_sql(transition_class))
      end

      def most_recent_transitions(most_recent_id = nil)
        if most_recent_id
          concrete_transitions_of_parent.and(
            transition_table[:id].eq(most_recent_id).or(
              transition_table[:most_recent].eq(true),
            ),
          )
        else
          concrete_transitions_of_parent.and(transition_table[:most_recent].eq(true))
        end
      end

      def concrete_transitions_of_parent
        if transition_sti?
          transitions_of_parent.and(
            transition_table[transition_class.inheritance_column].
              eq(transition_class.name),
          )
        else
          transitions_of_parent
        end
      end

      def transitions_of_parent
        transition_table[parent_join_foreign_key.to_sym].eq(parent_model.id)
      end

      # Generates update_all Arel values that will touch the updated timestamp (if valid
      # for this model) and set most_recent to true only for the transition with a
      # matching most_recent ID.
      #
      # This is quite nasty, but combines two updates (set all most_recent = f, set
      # current most_recent = t) into one, which helps improve transition performance
      # especially when database latency is significant.
      #
      # The SQL this can help produce looks like:
      #
      #   update transitions
      #      set most_recent = (case when id = 'PA123' then TRUE else FALSE end)
      #        , updated_at = '...'
      #      ...
      #
      def build_most_recents_update_all_values(most_recent_id = nil)
        [
          [
            transition_table[:most_recent],
            Arel::Nodes::SqlLiteral.new(most_recent_value(most_recent_id)),
          ],
        ].tap do |values|
          # Only if we support the updated at timestamps should we add this column to the
          # update
          updated_column, updated_at = updated_column_and_timestamp

          if updated_column
            values << [
              transition_table[updated_column.to_sym],
              updated_at,
            ]
          end
        end
      end

      def most_recent_value(most_recent_id)
        if most_recent_id
          Arel::Nodes::Case.new.
            when(transition_table[:id].eq(most_recent_id)).then(db_true).
            else(not_most_recent_value).to_sql(transition_class)
        else
          Arel::Nodes::SqlLiteral.new(not_most_recent_value)
        end
      end

      # Provide a wrapper for constructing an update manager which handles a breaking API
      # change in Arel as we move into Rails >6.0.
      #
      # https://github.com/rails/rails/commit/7508284800f67b4611c767bff9eae7045674b66f
      def build_arel_manager(manager, engine)
        if manager.instance_method(:initialize).arity.zero?
          manager.new
        else
          manager.new(engine)
        end
      end

      def next_sort_key
        (last && (last.sort_key + 10)) || 10
      end

      def serialized?(transition_class)
        transition_class.type_for_attribute("metadata").
          is_a?(::ActiveRecord::Type::Serialized)
      end

      def transition_conflict_error?(err)
        return true if unique_indexes.any? { |i| err.message.include?(i.name) }

        err.message.include?(transition_class.table_name) &&
          (err.message.include?("sort_key") || err.message.include?("most_recent"))
      end

      def unique_indexes
        transition_class.connection.
          indexes(transition_class.table_name).
          select do |index|
            next unless index.unique

            # We care about the columns used in the index, but not necessarily
            # the order, which is why we sort both sides of the comparison here
            index.columns.sort == [parent_join_foreign_key, "sort_key"].sort ||
              index.columns.sort == [parent_join_foreign_key, "most_recent"].sort
          end
      end

      def transition_sti?
        transition_class.column_names.include?(transition_class.inheritance_column)
      end

      def parent_association
        parent_model.class.
          reflect_on_all_associations(:has_many).
          find { |r| r.name.to_s == @association_name.to_s }
      end

      def parent_join_foreign_key
        association_join_primary_key(parent_association)
      end

      def association_join_primary_key(association)
        if association.respond_to?(:join_primary_key)
          association.join_primary_key
        elsif association.method(:join_keys).arity.zero?
          # Support for Rails 5.1
          association.join_keys.key
        else
          # Support for Rails < 5.1
          association.join_keys(transition_class).key
        end
      end

      # updated_column_and_timestamp should return [column_name, value]
      def updated_column_and_timestamp
        # TODO: Once we've set expectations that transition classes should conform to
        # the interface of Adapters::ActiveRecordTransition as a breaking change in the
        # next major version, we can stop calling `#respond_to?` first and instead
        # assume that there is a `.updated_timestamp_column` method we can call.
        #
        # At the moment, most transition classes will include the module, but not all,
        # not least because it doesn't work with PostgreSQL JSON columns for metadata.
        column = if transition_class.respond_to?(:updated_timestamp_column)
                   transition_class.updated_timestamp_column
                 else
                   ActiveRecordTransition::DEFAULT_UPDATED_TIMESTAMP_COLUMN
                 end

        # No updated timestamp column, don't return anything
        return nil if column.nil?

        [
          column, default_timezone == :utc ? Time.now.utc : Time.now
        ]
      end

      def default_timezone
        # Rails 7 deprecates ActiveRecord::Base.default_timezone
        # in favour of ActiveRecord.default_timezone
        if ::ActiveRecord.respond_to?(:default_timezone)
          return ::ActiveRecord.default_timezone
        end

        ::ActiveRecord::Base.default_timezone
      end

      def mysql_gaplock_protection?(connection)
        Statesman.mysql_gaplock_protection?(connection)
      end

      def db_true
        transition_class.connection.quote(type_cast(true))
      end

      def db_false
        transition_class.connection.quote(type_cast(false))
      end

      def db_null
        Arel::Nodes::SqlLiteral.new("NULL")
      end

      # Type casting against a column is deprecated and will be removed in Rails 6.2.
      # See https://github.com/rails/arel/commit/6160bfbda1d1781c3b08a33ec4955f170e95be11
      def type_cast(value)
        transition_class.connection.type_cast(value)
      end

      # Check whether the `most_recent` column allows null values. If it doesn't, set old
      # records to `false`, otherwise, set them to `NULL`.
      #
      # Some conditioning here is required to support databases that don't support partial
      # indexes. By doing the conditioning on the column, rather than Rails' opinion of
      # whether the database supports partial indexes, we're robust to DBs later adding
      # support for partial indexes.
      def not_most_recent_value(db_cast: true)
        if transition_class.columns_hash["most_recent"].null == false
          return db_cast ? db_false : false
        end

        db_cast ? db_null : nil
      end
    end

    class ActiveRecordAfterCommitWrap
      def initialize(connection, &block)
        @callback = block
        @connection = connection
      end

      def self.trigger_transactional_callbacks?
        true
      end

      def trigger_transactional_callbacks?
        true
      end

      # rubocop: disable Naming/PredicateName
      def has_transactional_callbacks?
        true
      end
      # rubocop: enable Naming/PredicateName

      def committed!(*)
        @callback.call
      end

      def before_committed!(*); end

      def rolledback!(*); end

      # Required for +transaction(requires_new: true)+
      def add_to_transaction(*)
        @connection.add_transaction_record(self)
      end
    end
  end
end