require 'active_model' require 'active_support/core_ext/hash/keys' require 'active_support/core_ext/module/attribute_accessors.rb' require 'state_machines' require 'state_machines/integrations/base' require 'state_machines/integrations/active_model/version' module StateMachines module Integrations #:nodoc: # Adds support for integrating state machines with ActiveModel classes. # # == Examples # # If using ActiveModel directly within your class, then any one of the # following features need to be included in order for the integration to be # detected: # * ActiveModel::Validations # # Below is an example of a simple state machine defined within an # ActiveModel class: # # class Vehicle # include ActiveModel::Validations # # attr_accessor :state # define_attribute_methods [:state] # # state_machine :initial => :parked do # event :ignite do # transition :parked => :idling # end # end # end # # The examples in the sections below will use the above class as a # reference. # # == Actions # # By default, no action will be invoked when a state is transitioned. This # means that if you want to save changes when transitioning, you must # define the action yourself like so: # # class Vehicle # include ActiveModel::Validations # attr_accessor :state # # state_machine :action => :save do # ... # end # # def save # # Save changes # end # end # # == Validations # # As mentioned in StateMachine::Machine#state, you can define behaviors, # like validations, that only execute for certain states. One *important* # caveat here is that, due to a constraint in ActiveModel's validation # framework, custom validators will not work as expected when defined to run # in multiple states. For example: # # class Vehicle # include ActiveModel::Validations # # state_machine do # ... # state :first_gear, :second_gear do # validate :speed_is_legal # end # end # end # # In this case, the :speed_is_legal validation will only get run # for the :second_gear state. To avoid this, you can define your # custom validation like so: # # class Vehicle # include ActiveModel::Validations # # state_machine do # ... # state :first_gear, :second_gear do # validate {|vehicle| vehicle.speed_is_legal} # end # end # end # # == Validation errors # # In order to hook in validation support for your model, the # ActiveModel::Validations feature must be included. If this is included # and an event fails to successfully fire because there are no matching # transitions for the object, a validation error is added to the object's # state attribute to help in determining why it failed. # # For example, # # vehicle = Vehicle.new # vehicle.ignite # => false # vehicle.errors.full_messages # => ["State cannot transition via \"ignite\""] # # In addition, if you're using the ignite! version of the event, # then the failure reason (such as the current validation errors) will be # included in the exception that gets raised when the event fails. For # example, assuming there's a validation on a field called +name+ on the class: # # vehicle = Vehicle.new # vehicle.ignite! # => StateMachine::InvalidTransition: Cannot transition state via :ignite from :parked (Reason(s): Name cannot be blank) # # === Security implications # # Beware that public event attributes mean that events can be fired # whenever mass-assignment is being used. If you want to prevent malicious # users from tampering with events through URLs / forms, the attribute # should be protected like so: # # class Vehicle # include ActiveModel::MassAssignmentSecurity # attr_accessor :state # # attr_protected :state_event # # attr_accessible ... # Alternative technique # # state_machine do # ... # end # end # # If you want to only have *some* events be able to fire via mass-assignment, # you can build two state machines (one public and one protected) like so: # # class Vehicle # attr_accessor :state # # attr_protected :state_event # Prevent access to events in the first machine # # state_machine do # # Define private events here # end # # # Public machine targets the same state as the private machine # state_machine :public_state, :attribute => :state do # # Define public events here # end # end # # == Callbacks # # All before/after transition callbacks defined for ActiveModel models # behave in the same way that other ActiveSupport callbacks behave. The # object involved in the transition is passed in as an argument. # # For example, # # class Vehicle # include ActiveModel::Validations # attr_accessor :state # # state_machine :initial => :parked do # before_transition any => :idling do |vehicle| # vehicle.put_on_seatbelt # end # # before_transition do |vehicle, transition| # # log message # end # # event :ignite do # transition :parked => :idling # end # end # # def put_on_seatbelt # ... # end # end # # Note, also, that the transition can be accessed by simply defining # additional arguments in the callback block. # # == Observers # # In order to hook in observer support for your application, the # ActiveModel::Observing feature must be included. Because of the way # ActiveModel observers are designed, there is less flexibility around the # specific transitions that can be hooked in. However, a large number of # hooks *are* supported. For example, if a transition for a object's # +state+ attribute changes the state from +parked+ to +idling+ via the # +ignite+ event, the following observer methods are supported: # * before/after/after_failure_to-_ignite_from_parked_to_idling # * before/after/after_failure_to-_ignite_from_parked # * before/after/after_failure_to-_ignite_to_idling # * before/after/after_failure_to-_ignite # * before/after/after_failure_to-_transition_state_from_parked_to_idling # * before/after/after_failure_to-_transition_state_from_parked # * before/after/after_failure_to-_transition_state_to_idling # * before/after/after_failure_to-_transition_state # * before/after/after_failure_to-_transition # # The following class shows an example of some of these hooks: # # class VehicleObserver < ActiveModel::Observer # # Callback for :ignite event *before* the transition is performed # def before_ignite(vehicle, transition) # # log message # end # # # Callback for :ignite event *after* the transition has been performed # def after_ignite(vehicle, transition) # # put on seatbelt # end # # # Generic transition callback *before* the transition is performed # def after_transition(vehicle, transition) # Audit.log(vehicle, transition) # end # # def after_failure_to_transition(vehicle, transition) # Audit.error(vehicle, transition) # end # end # # More flexible transition callbacks can be defined directly within the # model as described in StateMachine::Machine#before_transition # and StateMachine::Machine#after_transition. # # To define a single observer for multiple state machines: # # class StateMachineObserver < ActiveModel::Observer # observe Vehicle, Switch, Project # # def after_transition(object, transition) # Audit.log(object, transition) # end # end # # == Internationalization # # Any error message that is generated from performing invalid transitions # can be localized. The following default translations are used: # # en: # activemodel: # errors: # messages: # invalid: "is invalid" # # %{value} = attribute value, %{state} = Human state name # invalid_event: "cannot transition when %{state}" # # %{value} = attribute value, %{event} = Human event name, %{state} = Human current state name # invalid_transition: "cannot transition via %{event}" # # You can override these for a specific model like so: # # en: # activemodel: # errors: # models: # user: # invalid: "is not valid" # # In addition to the above, you can also provide translations for the # various states / events in each state machine. Using the Vehicle example, # state translations will be looked for using the following keys, where # +model_name+ = "vehicle", +machine_name+ = "state" and +state_name+ = "parked": # * activemodel.state_machines.#{model_name}.#{machine_name}.states.#{state_name} # * activemodel.state_machines.#{model_name}.states.#{state_name} # * activemodel.state_machines.#{machine_name}.states.#{state_name} # * activemodel.state_machines.states.#{state_name} # # Event translations will be looked for using the following keys, where # +model_name+ = "vehicle", +machine_name+ = "state" and +event_name+ = "ignite": # * activemodel.state_machines.#{model_name}.#{machine_name}.events.#{event_name} # * activemodel.state_machines.#{model_name}.events.#{event_name} # * activemodel.state_machines.#{machine_name}.events.#{event_name} # * activemodel.state_machines.events.#{event_name} # # An example translation configuration might look like so: # # es: # activemodel: # state_machines: # states: # parked: 'estacionado' # events: # park: 'estacionarse' # # == Dirty Attribute Tracking # # When using the ActiveModel::Dirty extension, your model will keep track of # any changes that are made to attributes. Depending on your ORM, an object # will only be saved when there are attributes that have changed on the # object. When integrating with state_machine, typically the +state+ field # will be marked as dirty after a transition occurs. In some situations, # however, this isn't the case. # # If you define loopback transitions in your state machine, the value for # the machine's attribute (e.g. state) will not change. Unless you explicitly # indicate so, this means that your object won't persist anything on a # loopback. For example: # # class Vehicle # include ActiveModel::Validations # include ActiveModel::Dirty # attr_accessor :state # # state_machine :initial => :parked do # event :park do # transition :parked => :parked, ... # end # end # end # # If, instead, you'd like your object to always persist regardless of # whether the value actually changed, you can do so by using the # #{attribute}_will_change! helpers or defining a +before_transition+ # callback that actually changes an attribute on the model. For example: # # class Vehicle # ... # state_machine :initial => :parked do # before_transition all => same do |vehicle| # vehicle.state_will_change! # # # Alternative solution, updating timestamp # # vehicle.updated_at = Time.current # end # end # end # # == Creating new integrations # # If you want to integrate state_machine with an ORM that implements parts # or all of the ActiveModel API, only the machine defaults need to be # specified. Otherwise, the implementation is similar to any other # integration. # # For example, # # module StateMachine::Integrations::MyORM # include ActiveModel # # mattr_accessor(:defaults) { :action => :persist } # # def self.matches?(klass) # defined?(::MyORM::Base) && klass <= ::MyORM::Base # end # # protected # # def runs_validations_on_action? # action == :persist # end # end # # If you wish to implement other features, such as attribute initialization # with protected attributes, named scopes, or database transactions, you # must add these independent of the ActiveModel integration. See the # ActiveRecord implementation for examples of these customizations. module ActiveModel include Base @defaults = {} # Classes that include ActiveModel::Validations # will automatically use the ActiveModel integration. def self.matching_ancestors %w(ActiveModel ActiveModel::Validations) end # Adds a validation error to the given object def invalidate(object, attribute, message, values = []) if supports_validations? attribute = self.attribute(attribute) options = values.reduce({}) do |h, (key, value)| h[key] = value h end default_options = default_error_message_options(object, attribute, message) object.errors.add(attribute, message, options.merge(default_options)) end end # Describes the current validation errors on the given object. If none # are specific, then the default error is interpeted as a "halt". def errors_for(object) object.errors.empty? ? 'Transition halted' : object.errors.full_messages * ', ' end # Resets any errors previously added when invalidating the given object def reset(object) object.errors.clear if supports_validations? end # Runs state events around the object's validation process def around_validation(object) object.class.state_machines.transitions(object, action, after: false).perform { yield } end protected # Whether validations are supported in the integration. Only true if # the ActiveModel feature is enabled on the owner class. def supports_validations? defined?(::ActiveModel::Validations) && owner_class <= ::ActiveModel::Validations end # Do validations run when the action configured this machine is # invoked? This is used to determine whether to fire off attribute-based # event transitions when the action is run. def runs_validations_on_action? false end # Gets the terminator to use for callbacks def callback_terminator @terminator ||= ->(result) { result == false } end # Determines the base scope to use when looking up translations def i18n_scope(klass) klass.i18n_scope end # The default options to use when generating messages for validation # errors def default_error_message_options(_object, _attribute, message) { message: @messages[message] } end # Translates the given key / value combo. Translation keys are looked # up in the following order: # * #{i18n_scope}.state_machines.#{model_name}.#{machine_name}.#{plural_key}.#{value} # * #{i18n_scope}.state_machines.#{model_name}.#{plural_key}.#{value} # * #{i18n_scope}.state_machines.#{machine_name}.#{plural_key}.#{value} # * #{i18n_scope}.state_machines.#{plural_key}.#{value} # # If no keys are found, then the humanized value will be the fallback. def translate(klass, key, value) ancestors = ancestors_for(klass) group = key.to_s.pluralize value = value ? value.to_s : 'nil' # Generate all possible translation keys translations = ancestors.map { |ancestor| :"#{ancestor.model_name.to_s.underscore}.#{name}.#{group}.#{value}" } translations.concat(ancestors.map { |ancestor| :"#{ancestor.model_name.to_s.underscore}.#{group}.#{value}" }) translations.concat([:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.humanize.downcase]) I18n.translate(translations.shift, default: translations, scope: [i18n_scope(klass), :state_machines]) end # Build a list of ancestors for the given class to use when # determining which localization key to use for a particular string. def ancestors_for(klass) klass.lookup_ancestors end # Initializes class-level extensions and defaults for this machine def after_initialize super() load_locale end # Loads any locale files needed for translating validation errors def load_locale I18n.load_path.unshift(locale_path) unless I18n.load_path.include?(locale_path) end def locale_path "#{File.dirname(__FILE__)}/active_model/locale.rb" end # Skips defining reader/writer methods since this is done automatically def define_state_accessor name = self.name owner_class.validates_each(attribute) do |object| machine = object.class.state_machine(name) machine.invalidate(object, :state, :invalid) unless machine.states.match(object) end if supports_validations? end # Adds hooks into validation for automatically firing events def define_action_helpers super define_validation_hook if runs_validations_on_action? end # Hooks into validations by defining around callbacks for the # :validation event def define_validation_hook owner_class.set_callback(:validation, :around, self, prepend: true) end # Creates a new callback in the callback chain, always inserting it # before the default Observer callbacks that were created after # initialization. def add_callback(type, options, &block) options[:terminator] = callback_terminator super end # Configures new states with the built-in humanize scheme def add_states(*) super.each do |new_state| new_state.human_name = ->(state, klass) { translate(klass, :state, state.name) } end end # Configures new event with the built-in humanize scheme def add_events(*) super.each do |new_event| new_event.human_name = ->(event, klass) { translate(klass, :event, event.name) } end end end register(ActiveModel) end end