require 'state_machines' require 'state_machines-activemodel' require 'mongoid' module StateMachines module Integrations #:nodoc: # Adds support for integrating state machines with Mongoid models. # # == Examples # # Below is an example of a simple state machine defined within a # Mongoid model: # # class Vehicle # include Mongoid::Document # # 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, the action that will be invoked when a state is transitioned # is the +save+ action. This will cause the record to save the changes # made to the state machine's attribute. *Note* that if any other changes # were made to the record prior to transition, then those changes will # be saved as well. # # For example, # # vehicle = Vehicle.create # => # # vehicle.name = 'Ford Explorer' # vehicle.ignite # => true # vehicle.reload # => # # # == Events # # As described in StateMachines::InstanceMethods#state_machine, event # attributes are created for every machine that allow transitions to be # performed automatically when the object's action (in this case, :save) # is called. # # In Mongoid, these automated events are run in the following order: # * before validation - Run before callbacks and persist new states, then validate # * before save - If validation was skipped, run before callbacks and persist new states, then save # * after save - Run after callbacks # # For example, # # vehicle = Vehicle.create # => # # vehicle.state_event # => nil # vehicle.state_event = 'invalid' # vehicle.valid? # => false # vehicle.errors.full_messages # => ["State event is invalid"] # # vehicle.state_event = 'ignite' # vehicle.valid? # => true # vehicle.save # => true # vehicle.state # => "idling" # vehicle.state_event # => nil # # Note that this can also be done on a mass-assignment basis: # # vehicle = Vehicle.create(:state_event => 'ignite') # => # # vehicle.state # => "idling" # # This technique is always used for transitioning states when the +save+ # action (which is the default) is configured for the machine. # # === 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 Mongoid::Document # # 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 # include Mongoid::Document # # 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 # # == Validations # # As mentioned in StateMachines::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 Mongoid's validation # framework, custom validators will not work as expected when defined to run # in multiple states. For example: # # class Vehicle # include Mongoid::Document # # 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 Mongoid::Document # # state_machine do # ... # state :first_gear, :second_gear do # validate {|vehicle| vehicle.speed_is_legal} # end # end # end # # == Validation errors # # If an event fails to successfully fire because there are no matching # transitions for the current record, a validation error is added to the # record's state attribute to help in determining why it failed and for # reporting via the UI. # # For example, # # vehicle = Vehicle.create(:state => 'idling') # => # # vehicle.ignite # => false # vehicle.errors.full_messages # => ["State cannot transition via \"ignite\""] # # If an event fails to fire because of a validation error on the record and # *not* because a matching transition was not available, no error messages # will be added to the state attribute. # # 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! # => StateMachines::InvalidTransition: Cannot transition state via :ignite from :parked (Reason(s): Name cannot be blank) # # == Scopes # # To assist in filtering models with specific states, a series of basic # scopes are defined on the model for finding records with or without a # particular set of states. # # These scopes are essentially the functional equivalent of the following # definitions: # # class Vehicle # include Mongoid::Document # # scope :with_states, lambda {|*states| where(:state => {'$in' => states})} # # with_states also aliased to with_state # # scope :without_states, lambda {|*states| where(:state => {'$nin' => states})} # # without_states also aliased to without_state # end # # *Note*, however, that the states are converted to their stored values # before being passed into the query. # # Because of the way named scopes work in Mongoid, they *cannot* be # chained. # # Note that states can also be referenced by the string version of their # name: # # Vehicle.with_state('parked') # # == Callbacks # # All before/after transition callbacks defined for Mongoid models # behave in the same way that other Mongoid callbacks behave. The # object involved in the transition is passed in as an argument. # # For example, # # class Vehicle # include Mongoid::Document # # 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 # # Have been removed in Rails 4 and therefore are no longer supported. # # == Internationalization # # Any error message that is generated from performing invalid transitions # can be localized. The following default translations are used: # # en: # mongoid: # 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: # mongoid: # 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": # * mongoid.state_machines.#{model_name}.#{machine_name}.states.#{state_name} # * mongoid.state_machines.#{model_name}.states.#{state_name} # * mongoid.state_machines.#{machine_name}.states.#{state_name} # * mongoid.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": # * mongoid.state_machines.#{model_name}.#{machine_name}.events.#{event_name} # * mongoid.state_machines.#{model_name}.events.#{event_name} # * mongoid.state_machines.#{machine_name}.events.#{event_name} # * mongoid.state_machines.events.#{event_name} # # An example translation configuration might look like so: # # es: # mongoid: # state_machines: # states: # parked: 'estacionado' # events: # park: 'estacionarse' module Mongoid include Base include ActiveModel # The default options to use for state machines using this integration @defaults = { action: :save, use_transactions: false } # Classes that include Mongoid::Document will automatically use the # Mongoid integration. def self.matching_ancestors [::Mongoid::Document] end def self.locale_path "#{File.dirname(__FILE__)}/mongoid/locale.rb" end protected # Only runs validations on the action if using :save def runs_validations_on_action? action == :save end # Gets the db default for the machine's attribute def owner_class_attribute_default attribute_field && attribute_field.default_val end # Gets the field for this machine's attribute (if it exists) def attribute_field owner_class.fields[attribute.to_s] || owner_class.fields[owner_class.aliased_fields[attribute.to_s]] end # Defines an initialization hook into the owner class for setting the # initial state of the machine *before* any attributes are set on the # object def define_state_initializer define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1 def initialize(*) super do |*args| self.class.state_machines.initialize_states(self, :static => false) yield(*args) if block_given? end end def apply_pre_processed_defaults defaults = {} self.class.state_machines.initialize_states(self, :static => :force, :dynamic => false, :to => defaults) defaults.each do |attr, value| send(:"\#{attr}=", value) unless attributes.include?(attr) end super end end_eval end # Skips defining reader/writer methods since this is done automatically def define_state_accessor owner_class.field(attribute, type: String) unless attribute_field super end # Uses around callbacks to run state events if using the :save hook def define_action_hook if action_hook == :save define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1 def insert(*) self.class.state_machine(#{name.inspect}).send(:around_save, self) { super.persisted? } self end def update(*) self.class.state_machine(#{name.inspect}).send(:around_save, self) { super } end def update_document(*) self.class.state_machine(#{name.inspect}).send(:around_save, self) { super } end def upsert(*) self.class.state_machine(#{name.inspect}).send(:around_save, self) { super } end end_eval else super end end # Runs state events around the machine's :save action def around_save(object) object.class.state_machines.transitions(object, action).perform { yield } end # Creates a scope for finding records *with* a particular state or # states for the attribute def create_with_scope(name) define_scope(name, lambda { |values| { attribute => { '$in' => values } } }) end # Creates a scope for finding records *without* a particular state or # states for the attribute def create_without_scope(name) define_scope(name, lambda { |values| { attribute => { '$nin' => values } } }) end # Defines a new scope with the given name def define_scope(_name, scope) lambda { |model, values| model.criteria.where(scope.call(values)) } end def locale_path "#{File.dirname(__FILE__)}/mongoid/locale.rb" end end register(Mongoid) end end