Workflow
Ruby finite-state-machine-inspired API for modeling workflow
Install / Use
/learn @geekq/WorkflowREADME
:doctype: book :toc: macro :toclevels: 1 :sectlinks: :idprefix:
Workflow
image:https://img.shields.io/gem/v/workflow.svg[link=https://rubygems.org/gems/workflow] image:https://github.com/geekq/workflow/actions/workflows/test.yml/badge.svg[link=https://github.com/geekq/workflow/actions/workflows/test.yml] image:https://codeclimate.com/github/geekq/workflow/badges/gpa.svg[link=https://codeclimate.com/github/geekq/workflow] image:https://codeclimate.com/github/geekq/workflow/badges/coverage.svg[link=https://codeclimate.com/github/geekq/workflow/coverage]
Note: you can find documentation for specific workflow rubygem versions at http://rubygems.org/gems/workflow : select a version (optional, default is latest release), click "Documentation" link. When reading on github.com, the README refers to the upcoming release.
toc::[]
What is workflow?
Workflow is a finite-state-machine-inspired API for modeling and interacting with what we tend to refer to as 'workflow'.
A lot of business modeling tends to involve workflow-like concepts, and the aim of this library is to make the expression of these concepts as clear as possible, using similar terminology as found in state machine theory.
So, a workflow has a state. It can only be in one state at a time. When a workflow changes state, we call that a transition. Transitions occur on an event, so events cause transitions to occur. Additionally, when an event fires, other arbitrary code can be executed, we call those actions. So any given state has a bunch of events, any event in a state causes a transition to another state and potentially causes code to be executed (an action). We can hook into states when they are entered, and exited from, and we can cause transitions to fail (guards), and we can hook in to every transition that occurs ever for whatever reason we can come up with.
Now, all that's a mouthful, but we'll demonstrate the API bit by bit with a real-ish world example.
Let's say we're modeling article submission from journalists. An article is written, then submitted. When it's submitted, it's awaiting review. Someone reviews the article, and then either accepts or rejects it. Here is the expression of this workflow using the API:
class Article
include Workflow
workflow do
state :new do
event :submit, :transitions_to => :awaiting_review
end
state :awaiting_review do
event :review, :transitions_to => :being_reviewed
end
state :being_reviewed do
event :accept, :transitions_to => :accepted
event :reject, :transitions_to => :rejected
end
state :accepted
state :rejected
end
end
Nice, isn't it!
Note: the first state in the definition (:new in the example, but you
can name it as you wish) is used as the initial state - newly created
objects start their life cycle in that state.
Let's create an article instance and check in which state it is:
article = Article.new
article.accepted? # => false
article.new? # => true
You can also access the whole current_state object including the list
of possible events and other meta information:
article.current_state
=> #<Workflow::State:0x7f1e3d6731f0 @events={
:submit=>#<Workflow::Event:0x7f1e3d6730d8 @action=nil,
@transitions_to=:awaiting_review, @name=:submit, @meta={}>},
name:new, meta{}
You can also check, whether a state comes before or after another state (by the order they were defined):
article.current_state # => being_reviewed
article.current_state < :accepted # => true
article.current_state >= :accepted # => false
article.current_state.between? :awaiting_review, :rejected # => true
Now we can call the submit event, which transitions to the
:awaiting_review state:
article.submit!
article.awaiting_review? # => true
Events are actually instance methods on a workflow, and depending on the state you're in, you'll have a different set of events used to transition to other states.
It is also easy to check, if a certain transition is possible from the
current state . article.can_submit? checks if there is a :submit
event (transition) defined for the current state.
Getting started
=== Installation
gem install workflow
Important: If you're interested in graphing your workflow state machine, you will also need to
install the activesupport and ruby-graphviz gems.
Versions up to and including 1.0.0 are also available as a single file download - link:https://github.com/geekq/workflow/blob/v1.0.0/lib/workflow.rb[lib/workflow.rb file].
=== Examples
After installation or downloading the library you can easily try out all the example code from this README in irb.
$ irb
require 'rubygems'
require 'workflow'
Now just copy and paste the source code from the beginning of this README file snippet by snippet and observe the output.
Transition event handler
The best way is to use convention over configuration and to define a method with the same name as the event. Then it is automatically invoked when event is raised. For the Article workflow defined earlier it would be:
class Article
def reject
puts 'sending email to the author explaining the reason...'
end
end
article.review!; article.reject! will cause state transition to
being_reviewed state, persist the new state (if integrated with
ActiveRecord), invoke this user defined reject method and finally
persist the rejected state.
Note: on successful transition from one state to another the workflow
gem immediately persists the new workflow state with update_column(),
bypassing any ActiveRecord callbacks including updated_at update.
This way it is possible to deal with the validation and to save the
pending changes to a record at some later point instead of the moment
when transition occurs.
You can also define event handler accepting/requiring additional arguments:
class Article
def review(reviewer = '')
puts "[#{reviewer}] is now reviewing the article"
end
end
article2 = Article.new
article2.submit!
article2.review!('Homer Simpson') # => [Homer Simpson] is now reviewing the article
Alternative way is to use a block (only recommended for short event implementation without further code nesting):
event :review, :transitions_to => :being_reviewed do |reviewer|
# store the reviewer
end
We've noticed, that mixing the list of events and states with the blocks invoked for particular transitions leads to a bumpy and poorly readable code due to a deep nesting. We tried (and dismissed) lambdas for this. Eventually we decided to invoke an optional user defined callback method with the same name as the event (convention over configuration) as explained before.
State persistence
=== ActiveRecord
Note: Workflow 2.0 is a major refactoring for the worklow library.
If your application suddenly breaks after the workflow 2.0 release, you've
probably got your Gemfile wrong ;-). workflow uses
https://guides.rubygems.org/patterns/#semantic-versioning[semantic versioning].
For highest compatibility please reference the desired major+minor version.
Note on ActiveRecord/Rails 4.*, 5.* Support:
Since integration with ActiveRecord makes over 90% of the issues and maintenance effort, and also to allow for an independent (faster) release cycle for Rails support, starting with workflow version 2.0 in January 2019 the support for ActiveRecord (4.*, 5.* and newer) has been extracted into a separate gem. Read at https://github.com/geekq/workflow-activerecord[workflow-activerecord], how to include the right gem.
To use legacy built-in ActiveRecord 2.3 - 4.* support, reference Workflow 1.2 in your Gemfile:
gem 'workflow', '~> 1.2'
=== Custom workflow state persistence
If you do not use a relational database and ActiveRecord, you can still
integrate the workflow very easily. To implement persistence you just
need to override load_workflow_state and
persist_workflow_state(new_value) methods. Next section contains an example for
using CouchDB, a document oriented database.
http://tim.lossen.de/[Tim Lossen] implemented support for http://github.com/tlossen/remodel[remodel] / http://github.com/antirez/redis[redis] key-value store.
=== Integration with CouchDB
We are using the compact http://github.com/geekq/couchtiny[couchtiny library] here. But the implementation would look similar for the popular couchrest library.
require 'couchtiny'
require 'couchtiny/document'
require 'workflow'
class User < CouchTiny::Document
include Workflow
workflow do
state :submitted do
event :activate_via_link, :transitions_to => :proved_email
end
state :proved_email
end
def load_workflow_state
self[:workflow_state]
end
def persist_workflow_state(new_value)
self[:workflow_state] = new_value
save!
end
end
Please also have a look at http://github.com/geekq/workflow/blob/develop/test/couchtiny_example.rb[the full source code].
=== Adapters to support other databases
I get a lot of requests to integrate persistence support for different databases, object-relational adapters, column stores, document databases.
To enable highest possible quality, avoid too many dependencies and to
avoid unneeded maintenance burden on the workflow core it is best to
implement such support as a separate gem.
Only support for the ActiveRecord will remain for the foreseeable
future. So Rails beginners can expect workflow to work with Rails out
of the box. Other already included adapters stay for a while but should
be extracted to separate gems.
If you want to implement support for your favorite ORM mapper or your
favorite NoSQL database, you just need to implement a module which
overrides the persistence methods load_workflow_state and
persist_workflow_state. Example:
module Workflow
module SuperCoolDb
module InstanceMethods
def load_workf
