class Statesman::Adapters::ActiveRecord
Constants
- JSON_COLUMN_TYPES
Attributes
Public Class Methods
# File lib/statesman/adapters/active_record.rb, line 19 def self.adapter_name ::ActiveRecord::Base.connection.adapter_name.downcase end
# 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
# 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
# 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
# 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
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
rubocop:enable Naming/MemoizedInstanceVariableName
# File lib/statesman/adapters/active_record.rb, line 78 def reset @last_transition = nil end
Private Instance Methods
# 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
# 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
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
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
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
# File lib/statesman/adapters/active_record.rb, line 319 def db_false ::ActiveRecord::Base.connection.quote(type_cast(false)) end
# File lib/statesman/adapters/active_record.rb, line 323 def db_null Arel::Nodes::SqlLiteral.new("NULL") end
# File lib/statesman/adapters/active_record.rb, line 315 def db_true ::ActiveRecord::Base.connection.quote(type_cast(true)) end
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
# 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
# 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
# File lib/statesman/adapters/active_record.rb, line 311 def mysql_gaplock_protection? Statesman.mysql_gaplock_protection? end
# File lib/statesman/adapters/active_record.rb, line 233 def next_sort_key (last && last.sort_key + 10) || 10 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.
# 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
# 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
# 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
# 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
# File lib/statesman/adapters/active_record.rb, line 140 def transitions_for_parent parent_model.send(@association_name) end
# 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 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
# 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
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
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