# 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? # 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 def self.adapter_name ::ActiveRecord::Base.connection.adapter_name.downcase 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 remove_instance_variable(:@last_transition) 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 remove_instance_variable(:@last_transition) end private 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 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) ::ActiveRecord::Base.connection.add_transaction_record( ActiveRecordAfterCommitWrap.new 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) 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 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 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 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) if manager.instance_method(:initialize).arity.zero? manager.new else manager.new(::ActiveRecord::Base) end end def next_sort_key (last && (last.sort_key + 10)) || 10 end 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 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 ::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 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 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? Statesman.mysql_gaplock_protection? end def db_true ::ActiveRecord::Base.connection.quote(type_cast(true)) end def db_false ::ActiveRecord::Base.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) ::ActiveRecord::Base.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(&block) @callback = block @connection = ::ActiveRecord::Base.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