class Statesman::Adapters::ActiveRecord

Constants

JSON_COLUMN_TYPES

Attributes

parent_model[R]
transition_class[R]
transition_table[R]

Public Class Methods

adapter_name() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 19
def self.adapter_name
  ::ActiveRecord::Base.connection.adapter_name.downcase
end
database_supports_partial_indexes?() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 10
def self.database_supports_partial_indexes?
  # Rails 3 doesn't implement `supports_partial_index?`
  if ::ActiveRecord::Base.connection.respond_to?(:supports_partial_index?)
    ::ActiveRecord::Base.connection.supports_partial_index?
  else
    ::ActiveRecord::Base.connection.adapter_name == "PostgreSQL"
  end
end
new(transition_class, parent_model, observer, options = {}) click to toggle source
# File lib/statesman/adapters/active_record.rb, line 23
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

Public Instance Methods

create(from, to, metadata = {}) click to toggle source
# File lib/statesman/adapters/active_record.rb, line 42
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
  @last_transition = nil
end
history(force_reload: false) click to toggle source
# File lib/statesman/adapters/active_record.rb, line 58
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
last(force_reload: false) click to toggle source

rubocop:disable Naming/MemoizedInstanceVariableName

# File lib/statesman/adapters/active_record.rb, line 69
def last(force_reload: false)
  if force_reload
    @last_transition = history(force_reload: true).last
  else
    @last_transition ||= history.last
  end
end
reset() click to toggle source

rubocop:enable Naming/MemoizedInstanceVariableName

# File lib/statesman/adapters/active_record.rb, line 78
def reset
  @last_transition = nil
end

Private Instance Methods

add_after_commit_callback(from, to, transition) click to toggle source
# File lib/statesman/adapters/active_record.rb, line 132
def add_after_commit_callback(from, to, transition)
  ::ActiveRecord::Base.connection.add_transaction_record(
    ActiveRecordAfterCommitWrap.new do
      @observer.execute(:after_commit, from, to, transition)
    end,
  )
end
association_join_primary_key(association) click to toggle source
# File lib/statesman/adapters/active_record.rb, line 276
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
build_arel_manager(manager) click to toggle source

Provide a wrapper for constructing an update manager which handles a breaking API change in Arel as we move into Rails >6.0.

github.com/rails/rails/commit/7508284800f67b4611c767bff9eae7045674b66f

# File lib/statesman/adapters/active_record.rb, line 225
def build_arel_manager(manager)
  if manager.instance_method(:initialize).arity.zero?
    manager.new
  else
    manager.new(::ActiveRecord::Base)
  end
end
build_most_recents_update_all_values(most_recent_id = nil) click to toggle source

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 = '...'
   ...
# File lib/statesman/adapters/active_record.rb, line 191
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
create_transition(from, to, metadata) click to toggle source

rubocop:disable Metrics/MethodLength

# File lib/statesman/adapters/active_record.rb, line 85
def create_transition(from, to, metadata)
  transition = transitions_for_parent.build(
    default_transition_attributes(to, metadata),
  )

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

    if mysql_gaplock_protection?
      # 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
db_false() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 319
def db_false
  ::ActiveRecord::Base.connection.quote(type_cast(false))
end
db_null() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 323
def db_null
  Arel::Nodes::SqlLiteral.new("NULL")
end
db_true() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 315
def db_true
  ::ActiveRecord::Base.connection.quote(type_cast(true))
end
default_transition_attributes(to, metadata) click to toggle source

rubocop:enable Metrics/MethodLength

# File lib/statesman/adapters/active_record.rb, line 123
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
most_recent_transitions(most_recent_id = nil) click to toggle source
# File lib/statesman/adapters/active_record.rb, line 160
def most_recent_transitions(most_recent_id = nil)
  if most_recent_id
    transitions_of_parent.and(
      transition_table[:id].eq(most_recent_id).or(
        transition_table[:most_recent].eq(true),
      ),
    )
  else
    transitions_of_parent.and(transition_table[:most_recent].eq(true))
  end
end
most_recent_value(most_recent_id) click to toggle source
# File lib/statesman/adapters/active_record.rb, line 211
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
  else
    Arel::Nodes::SqlLiteral.new(not_most_recent_value)
  end
end
mysql_gaplock_protection?() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 311
def mysql_gaplock_protection?
  Statesman.mysql_gaplock_protection?
end
next_sort_key() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 233
def next_sort_key
  (last && last.sort_key + 10) || 10
end
not_most_recent_value(db_cast: true) click to toggle source

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.

# File lib/statesman/adapters/active_record.rb, line 340
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
parent_join_foreign_key() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 267
def parent_join_foreign_key
  association =
    parent_model.class.
      reflect_on_all_associations(:has_many).
      find { |r| r.name.to_s == @association_name.to_s }

  association_join_primary_key(association)
end
serialized?(transition_class) click to toggle source
# File lib/statesman/adapters/active_record.rb, line 237
def serialized?(transition_class)
  if ::ActiveRecord.respond_to?(:gem_version) &&
      ::ActiveRecord.gem_version >= Gem::Version.new("4.2.0.a")
    transition_class.type_for_attribute("metadata").
      is_a?(::ActiveRecord::Type::Serialized)
  else
    transition_class.serialized_attributes.include?("metadata")
  end
end
transition_conflict_error?(err) click to toggle source
# File lib/statesman/adapters/active_record.rb, line 247
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
transitions_for_parent() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 140
def transitions_for_parent
  parent_model.send(@association_name)
end
transitions_of_parent() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 172
def transitions_of_parent
  transition_table[parent_join_foreign_key.to_sym].eq(parent_model.id)
end
type_cast(value) click to toggle source

Type casting against a column is deprecated and will be removed in Rails 6.2. See github.com/rails/arel/commit/6160bfbda1d1781c3b08a33ec4955f170e95be11

# File lib/statesman/adapters/active_record.rb, line 329
def type_cast(value)
  ::ActiveRecord::Base.connection.type_cast(value)
end
unique_indexes() click to toggle source
# File lib/statesman/adapters/active_record.rb, line 254
def unique_indexes
  ::ActiveRecord::Base.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
update_most_recents(most_recent_id = nil) click to toggle source

Sets the given transition most_recent = t while unsetting the most_recent of any previous transitions.

# File lib/statesman/adapters/active_record.rb, line 146
def update_most_recents(most_recent_id = nil)
  update = build_arel_manager(::Arel::UpdateManager)
  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.
  update.order(transition_table[:most_recent].desc) if mysql_gaplock_protection?

  ::ActiveRecord::Base.connection.update(update.to_sql)
end
updated_column_and_timestamp() click to toggle source

updated_column_and_timestamp should return [column_name, value]

# File lib/statesman/adapters/active_record.rb, line 289
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, ::ActiveRecord::Base.default_timezone == :utc ? Time.now.utc : Time.now
  ]
end