Build finite state machines in a backend-agnostic, class-centric way.
The basic features will work with any PORO, and more features and callbacks are available when used with an ORM like ActiveRecord
and/or ActiveModel::Dirty
.
After exploring several of the existing Ruby state machine options, they all seem too tightly coupled to an ORM models and tend to pollute the object model's code far too much. The goal of this gem is to provide a clean, testable interface for working with a state machine that decouples as much as possible from the model object itself.
CHANGELOG.md will contain changes that need to be made at each version
Ruby 2.0+
class BlogPost
attr_accessor :state, :title, :body, :author
def initialize
self.state = :draft
end
end
class PublishingWorkflow
include FlowMachine::Workflow
state DraftState
state PublishedState
end
class DraftState < FlowMachine::WorkflowState
event :publish do
transition to: :published
end
end
class PublishedState < FlowMachine::WorkflowState
on_enter :notify_email_author
on_exit :clear_published_at
def notify_email_author
# Send an email
end
def clear_published_at
object.published_at = nil
end
end
blog_post = BlogPost.new
blog_post.author = "[email protected]"
workflow = PublishingWorkflow.new(blog_post)
workflow.publish # notify_email_author is called (returns true if successful)
workflow.published? # => true
blog_post.state # => :published
If you are using the workflow around an ORM model like ActiveRecord, calling the bang version of the transition will perform the transition and call save
on the object. This method will return the value returned by save
(true
/false
for ActiveRecord) or false
if any of the guards fail.
E.g. workflow.publish!
will transition the object to published
and call save
on the object.
Guards are used to allow or prevent event
s from being called. :guard
accepts a single symbol or an array of symbols representing methods. The method may be on the state, the workflow, or the object itself, and the method will be searched for in that order.
Best practice Use predicate methods that return a simple true/false. All guard methods are called, so avoid side affects in these methods.
Calling the transition with a failing guard will result in the object not being transitioned and returning false
. If using the bang version, save
will not be called.
workflow.may_publish?
will call all the guard methods and return false
if any of the guard methods return false
. It will also return false
if you are not in a state that has a defined event (e.g. published_workflow.may_publish?
will always return false
)
After calling may_xxx?
, the workflow will have an array of the guard methods that failed. To avoid additional dependencies, the developer is responsible for converting these to human readable messages (using I18n or the like). This may include :invalid_event
in the case where a transition from the current state is not defined.
class DraftState < FlowMachine::WorkflowState
event :publish, guard: [:content_present?, :can_publish?]
transition to: :published
end
def can_publish?
false
end
end
class BlogPost
def content_present?
content.present? # assuming you have ActiveSupport loaded
end
end
workflow.may_publish? # => false
workfow.guard_errors # => [:can_publish?]
workflow.publish # => false
State and Workflow callbacks accept if
and unless
options. They may be a symbol or array of symbols (looking for the method in the state, workflow, and object in that order) or a Proc.
Declared in the WorkflowState
class.
on_exit
Called after the object has transitioned out of the state.on_enter
Called after the object has transitioned into the state. Triggered after the previous state'son_exit
.
The following are available when Workflow#save
is used (workflow.save
or workflow.transition!
) Not called if you call save
directly on the decorated model.
after_enter
Called when the object has transitioned into the state and the object has been saved eitherworkflow.save
orworkflow.transition!
has been called.before_change
Useful when watching for changes to a model, but only when in a certain state. Will be called if anything exists in theobject#changes
hash (if it exists), often provided byActiveModel#dirty
.after_change
Useful when watching for changes to a model in a certain state, but you only want to trigger when the save is successful (e.g. the model isvalid?
)
Declared in the Workflow
class.
after_transition
Called anytime a transition takes place
The following are available when Workflow#save
is used:
before_save
Called whenWorkflow#save
is called, but beforeobject#save
is calledafter_save
Called afterobject#save
has returnedtrue
Declared as an option to the transition
method inside an event
block.
after
Will be called after the transition has happened successfully including persistance (if applicable). Useful when you only want something to trigger when moving from a specific state to another.
transition to: :published, after: :send_mailing_list_email
You can easily access the workflow for your particular object, class, or collection of objects.
Examples:
blog = BlogPost.new
FlowMachine::Workflow.for(blog) # => BlogPostWorkflow
FlowMachine::Workflow.for(BlogPost) # => BlogPostWorkflow
You can also create an collection of workflow objects via:
blog_posts = BlogPost.all
FlowMachine::Workflow.collection_for(blog_posts) # => [BlogPostWorkfow.new(blog_post[0]), ..., BlogPostWorkflow.new(blog_post[n])
If you want scopes and predicate methods defined on your model, use the following:
PublishingWorkflow.create_scopes_on(self)
within the model.
Assuming BlogPost is an ActiveRecord model, this will create BlogPost.draft
and BlogPost.published
scopes as well as the BlogPost#draft?
and BlogPost#published?
methods.
If you don't want to use state
as your field for storing state, simply declare state_attribute :status
in the Workflow class.
PublishingWorkflow.state_names` # => ['draft', 'published']
Especially useful in an ActiveModel validation:
validates :state, presence: true, inclusion: { in: PublishingWorkflow.state_names }
You can pass an options hash into the workflow which is available at any time while using the workflow. A prime example is tracking the user who performed an action.
class PublishedState < FlowMachine::WorkflowState
on_enter :update_published_by
def update_published_by
object.published_by = options[:current_user]
end
end
workflow = PublishingWorkflow.new(blog_post, current_user: User.find(123))
workflow.publish
blog_post.published_by # => User #123