workflow 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.travis.yml +8 -1
- data/Gemfile +0 -1
- data/README.markdown +73 -27
- data/Rakefile +3 -9
- data/gemfiles/Gemfile.rails-2.3.x +1 -0
- data/gemfiles/Gemfile.rails-3.x +11 -0
- data/gemfiles/Gemfile.rails-edge +12 -0
- data/lib/workflow.rb +52 -227
- data/lib/workflow/adapters/active_record.rb +66 -0
- data/lib/workflow/adapters/remodel.rb +15 -0
- data/lib/workflow/draw.rb +79 -0
- data/lib/workflow/errors.rb +18 -0
- data/lib/workflow/event.rb +18 -0
- data/lib/workflow/specification.rb +64 -0
- data/lib/workflow/state.rb +44 -0
- data/lib/workflow/version.rb +1 -1
- data/orders_workflow.png +0 -0
- data/test/active_record_scopes_test.rb +49 -0
- data/test/advanced_examples_test.rb +21 -0
- data/test/attr_protected_test.rb +3 -0
- data/test/inheritance_test.rb +60 -0
- data/test/main_test.rb +7 -6
- data/test/new_versions/compare_states_test.rb +2 -1
- data/workflow.gemspec +1 -0
- metadata +86 -46
- data/workflow.rb +0 -1
@@ -0,0 +1,66 @@
|
|
1
|
+
module Workflow
|
2
|
+
module Adapter
|
3
|
+
module ActiveRecord
|
4
|
+
module InstanceMethods
|
5
|
+
def load_workflow_state
|
6
|
+
read_attribute(self.class.workflow_column)
|
7
|
+
end
|
8
|
+
|
9
|
+
# On transition the new workflow state is immediately saved in the
|
10
|
+
# database.
|
11
|
+
def persist_workflow_state(new_value)
|
12
|
+
if self.respond_to? :update_column
|
13
|
+
# Rails 3.1 or newer
|
14
|
+
update_column self.class.workflow_column, new_value
|
15
|
+
else
|
16
|
+
# older Rails; beware of side effect: other (pending) attribute changes will be persisted too
|
17
|
+
update_attribute self.class.workflow_column, new_value
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
# Motivation: even if NULL is stored in the workflow_state database column,
|
24
|
+
# the current_state is correctly recognized in the Ruby code. The problem
|
25
|
+
# arises when you want to SELECT records filtering by the value of initial
|
26
|
+
# state. That's why it is important to save the string with the name of the
|
27
|
+
# initial state in all the new records.
|
28
|
+
def write_initial_state
|
29
|
+
write_attribute self.class.workflow_column, current_state.to_s
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# This module will automatically generate ActiveRecord scopes based on workflow states.
|
34
|
+
# The name of each generated scope will be something like `with_<state_name>_state`
|
35
|
+
#
|
36
|
+
# Examples:
|
37
|
+
#
|
38
|
+
# Article.with_pending_state # => ActiveRecord::Relation
|
39
|
+
#
|
40
|
+
# Example above just adds `where(:state_column_name => 'pending')` to AR query and returns
|
41
|
+
# ActiveRecord::Relation.
|
42
|
+
module Scopes
|
43
|
+
def self.extended(object)
|
44
|
+
class << object
|
45
|
+
alias_method :workflow_without_scopes, :workflow unless method_defined?(:workflow_without_scopes)
|
46
|
+
alias_method :workflow, :workflow_with_scopes
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def workflow_with_scopes(&specification)
|
51
|
+
workflow_without_scopes(&specification)
|
52
|
+
states = workflow_spec.states.values
|
53
|
+
eigenclass = class << self; self; end
|
54
|
+
|
55
|
+
states.each do |state|
|
56
|
+
# Use eigenclass instead of `define_singleton_method`
|
57
|
+
# to be compatible with Ruby 1.8+
|
58
|
+
eigenclass.send(:define_method, "with_#{state}_state") do
|
59
|
+
where("#{table_name}.#{self.workflow_column.to_sym} = ?", state.to_s)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Workflow
|
2
|
+
module Adapter
|
3
|
+
module Remodel
|
4
|
+
module InstanceMethods
|
5
|
+
def load_workflow_state
|
6
|
+
send(self.class.workflow_column)
|
7
|
+
end
|
8
|
+
|
9
|
+
def persist_workflow_state(new_value)
|
10
|
+
update(self.class.workflow_column => new_value)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
begin
|
2
|
+
require 'rubygems'
|
3
|
+
|
4
|
+
gem 'ruby-graphviz', '>=1.0'
|
5
|
+
gem 'activesupport'
|
6
|
+
|
7
|
+
require 'graphviz'
|
8
|
+
require 'active_support/inflector'
|
9
|
+
rescue LoadError => e
|
10
|
+
$stderr.puts "Could not load the ruby-graphiz or active_support gems for rendering: #{e.message}"
|
11
|
+
end
|
12
|
+
|
13
|
+
module Workflow
|
14
|
+
module Draw
|
15
|
+
|
16
|
+
# Generates a `dot` graph of the workflow.
|
17
|
+
# Prerequisite: the `dot` binary. (Download from http://www.graphviz.org/)
|
18
|
+
# You can use this method in your own Rakefile like this:
|
19
|
+
#
|
20
|
+
# namespace :doc do
|
21
|
+
# desc "Generate a workflow graph for a model passed e.g. as 'MODEL=Order'."
|
22
|
+
# task :workflow => :environment do
|
23
|
+
# require 'workflow/draw'
|
24
|
+
# Workflow::Draw::workflow_diagram(ENV['MODEL'].constantize)
|
25
|
+
# end
|
26
|
+
# end
|
27
|
+
#
|
28
|
+
# You can influence the placement of nodes by specifying
|
29
|
+
# additional meta information in your states and transition descriptions.
|
30
|
+
# You can assign higher `weight` value to the typical transitions
|
31
|
+
# in your workflow. All other states and transitions will be arranged
|
32
|
+
# around that main line. See also `weight` in the graphviz documentation.
|
33
|
+
# Example:
|
34
|
+
#
|
35
|
+
# state :new do
|
36
|
+
# event :approve, :transitions_to => :approved, :meta => {:weight => 8}
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
#
|
40
|
+
# @param klass A class with the Workflow mixin, for which you wish the graphical workflow representation
|
41
|
+
# @param [String] target_dir Directory, where to save the dot and the pdf files
|
42
|
+
# @param [String] graph_options You can change graph orientation, size etc. See graphviz documentation
|
43
|
+
def self.workflow_diagram(klass, options={})
|
44
|
+
options = {
|
45
|
+
:name => "#{klass.name.tableize}_workflow".gsub('/', '_'),
|
46
|
+
:path => '.',
|
47
|
+
:orientation => "landscape",
|
48
|
+
:ratio => "fill",
|
49
|
+
:format => 'png',
|
50
|
+
:font => 'Helvetica'
|
51
|
+
}.merge options
|
52
|
+
|
53
|
+
graph = ::GraphViz.new('G', :rankdir => options[:orientation] == 'landscape' ? 'LR' : 'TB', :ratio => options[:ratio])
|
54
|
+
|
55
|
+
# Add nodes
|
56
|
+
klass.workflow_spec.states.each do |_, state|
|
57
|
+
node = state.draw(graph)
|
58
|
+
node.fontname = options[:font]
|
59
|
+
|
60
|
+
state.events.each do |_, event|
|
61
|
+
edge = event.draw(graph, state)
|
62
|
+
edge.fontname = options[:font]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Generate the graph
|
67
|
+
filename = File.join(options[:path], "#{options[:name]}.#{options[:format]}")
|
68
|
+
|
69
|
+
graph.output options[:format] => "'#{filename}'"
|
70
|
+
|
71
|
+
puts "
|
72
|
+
Please run the following to open the generated file:
|
73
|
+
|
74
|
+
open '#{filename}'
|
75
|
+
"
|
76
|
+
graph
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Workflow
|
2
|
+
class TransitionHalted < Exception
|
3
|
+
|
4
|
+
attr_reader :halted_because
|
5
|
+
|
6
|
+
def initialize(msg = nil)
|
7
|
+
@halted_because = msg
|
8
|
+
super msg
|
9
|
+
end
|
10
|
+
|
11
|
+
end
|
12
|
+
|
13
|
+
class NoTransitionAllowed < Exception; end
|
14
|
+
|
15
|
+
class WorkflowError < Exception; end
|
16
|
+
|
17
|
+
class WorkflowDefinitionError < Exception; end
|
18
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Workflow
|
2
|
+
class Event
|
3
|
+
|
4
|
+
attr_accessor :name, :transitions_to, :meta, :action
|
5
|
+
|
6
|
+
def initialize(name, transitions_to, meta = {}, &action)
|
7
|
+
@name, @transitions_to, @meta, @action = name, transitions_to.to_sym, meta, action
|
8
|
+
end
|
9
|
+
|
10
|
+
def draw(graph, from_state)
|
11
|
+
graph.add_edges(from_state.name.to_s, transitions_to.to_s, meta.merge(:label => to_s))
|
12
|
+
end
|
13
|
+
|
14
|
+
def to_s
|
15
|
+
@name.to_s
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'workflow/state'
|
2
|
+
require 'workflow/event'
|
3
|
+
require 'workflow/errors'
|
4
|
+
|
5
|
+
module Workflow
|
6
|
+
class Specification
|
7
|
+
attr_accessor :states, :initial_state, :meta,
|
8
|
+
:on_transition_proc, :before_transition_proc, :after_transition_proc, :on_error_proc
|
9
|
+
|
10
|
+
def initialize(meta = {}, &specification)
|
11
|
+
@states = Hash.new
|
12
|
+
@meta = meta
|
13
|
+
instance_eval(&specification)
|
14
|
+
end
|
15
|
+
|
16
|
+
def state_names
|
17
|
+
states.keys
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def state(name, meta = {:meta => {}}, &events_and_etc)
|
23
|
+
# meta[:meta] to keep the API consistent..., gah
|
24
|
+
new_state = Workflow::State.new(name, self, meta[:meta])
|
25
|
+
@initial_state = new_state if @states.empty?
|
26
|
+
@states[name.to_sym] = new_state
|
27
|
+
@scoped_state = new_state
|
28
|
+
instance_eval(&events_and_etc) if events_and_etc
|
29
|
+
end
|
30
|
+
|
31
|
+
def event(name, args = {}, &action)
|
32
|
+
target = args[:transitions_to] || args[:transition_to]
|
33
|
+
raise WorkflowDefinitionError.new(
|
34
|
+
"missing ':transitions_to' in workflow event definition for '#{name}'") \
|
35
|
+
if target.nil?
|
36
|
+
@scoped_state.events[name.to_sym] =
|
37
|
+
Workflow::Event.new(name, target, (args[:meta] or {}), &action)
|
38
|
+
end
|
39
|
+
|
40
|
+
def on_entry(&proc)
|
41
|
+
@scoped_state.on_entry = proc
|
42
|
+
end
|
43
|
+
|
44
|
+
def on_exit(&proc)
|
45
|
+
@scoped_state.on_exit = proc
|
46
|
+
end
|
47
|
+
|
48
|
+
def after_transition(&proc)
|
49
|
+
@after_transition_proc = proc
|
50
|
+
end
|
51
|
+
|
52
|
+
def before_transition(&proc)
|
53
|
+
@before_transition_proc = proc
|
54
|
+
end
|
55
|
+
|
56
|
+
def on_transition(&proc)
|
57
|
+
@on_transition_proc = proc
|
58
|
+
end
|
59
|
+
|
60
|
+
def on_error(&proc)
|
61
|
+
@on_error_proc = proc
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module Workflow
|
2
|
+
class State
|
3
|
+
attr_accessor :name, :events, :meta, :on_entry, :on_exit
|
4
|
+
attr_reader :spec
|
5
|
+
|
6
|
+
def initialize(name, spec, meta = {})
|
7
|
+
@name, @spec, @events, @meta = name, spec, Hash.new, meta
|
8
|
+
end
|
9
|
+
|
10
|
+
def draw(graph)
|
11
|
+
defaults = {
|
12
|
+
:label => to_s,
|
13
|
+
:width => '1',
|
14
|
+
:height => '1',
|
15
|
+
:shape => 'ellipse'
|
16
|
+
}
|
17
|
+
|
18
|
+
node = graph.add_nodes(to_s, defaults.merge(meta))
|
19
|
+
|
20
|
+
# Add open arrow for initial state
|
21
|
+
# graph.add_edge(graph.add_node('starting_state', :shape => 'point'), node) if initial?
|
22
|
+
|
23
|
+
node
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
if RUBY_VERSION >= '1.9'
|
28
|
+
include Comparable
|
29
|
+
def <=>(other_state)
|
30
|
+
states = spec.states.keys
|
31
|
+
raise ArgumentError, "state `#{other_state}' does not exist" unless states.include?(other_state.to_sym)
|
32
|
+
states.index(self.to_sym) <=> states.index(other_state.to_sym)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def to_s
|
37
|
+
"#{name}"
|
38
|
+
end
|
39
|
+
|
40
|
+
def to_sym
|
41
|
+
name.to_sym
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/lib/workflow/version.rb
CHANGED
data/orders_workflow.png
ADDED
Binary file
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
$VERBOSE = false
|
4
|
+
require 'active_record'
|
5
|
+
require 'sqlite3'
|
6
|
+
require 'workflow'
|
7
|
+
|
8
|
+
ActiveRecord::Migration.verbose = false
|
9
|
+
|
10
|
+
class Article < ActiveRecord::Base
|
11
|
+
include Workflow
|
12
|
+
|
13
|
+
workflow do
|
14
|
+
state :new
|
15
|
+
state :accepted
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class ActiveRecordScopesTest < ActiveRecordTestCase
|
20
|
+
|
21
|
+
def setup
|
22
|
+
super
|
23
|
+
|
24
|
+
ActiveRecord::Schema.define do
|
25
|
+
create_table :articles do |t|
|
26
|
+
t.string :title
|
27
|
+
t.string :body
|
28
|
+
t.string :blame_reason
|
29
|
+
t.string :reject_reason
|
30
|
+
t.string :workflow_state
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def assert_state(title, expected_state, klass = Order)
|
36
|
+
o = klass.find_by_title(title)
|
37
|
+
assert_equal expected_state, o.read_attribute(klass.workflow_column)
|
38
|
+
o
|
39
|
+
end
|
40
|
+
|
41
|
+
test 'have "with_new_state" scope' do
|
42
|
+
assert_respond_to Article, :with_new_state
|
43
|
+
end
|
44
|
+
|
45
|
+
test 'have "with_accepted_state" scope' do
|
46
|
+
assert_respond_to Article, :with_accepted_state
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
@@ -58,4 +58,25 @@ class AdvanceExamplesTest < ActiveRecordTestCase
|
|
58
58
|
assert(a.new?, "should now be back in the 'new' state")
|
59
59
|
end
|
60
60
|
|
61
|
+
test '#92 Load workflow specification' do
|
62
|
+
c = Class.new
|
63
|
+
c.class_eval do
|
64
|
+
include Workflow
|
65
|
+
end
|
66
|
+
|
67
|
+
# build a Specification (you can load it from yaml file too)
|
68
|
+
myspec = Workflow::Specification.new do
|
69
|
+
state :one do
|
70
|
+
event :dynamic_transition, :transitions_to => :one_a
|
71
|
+
end
|
72
|
+
state :one_a
|
73
|
+
end
|
74
|
+
|
75
|
+
c.send :assign_workflow, myspec
|
76
|
+
|
77
|
+
a = c.new
|
78
|
+
a.dynamic_transition!(1)
|
79
|
+
assert a.one_a?, 'Expected successful transition to a new state'
|
80
|
+
end
|
81
|
+
|
61
82
|
end
|
data/test/attr_protected_test.rb
CHANGED
@@ -7,6 +7,7 @@ require 'sqlite3'
|
|
7
7
|
require 'workflow'
|
8
8
|
require 'mocha/setup'
|
9
9
|
require 'stringio'
|
10
|
+
require 'protected_attributes' if ActiveRecord::VERSION::MAJOR >= 4
|
10
11
|
|
11
12
|
ActiveRecord::Migration.verbose = false
|
12
13
|
|
@@ -60,7 +61,9 @@ class AttrProtectedTest < ActiveRecordTestCase
|
|
60
61
|
test 'cannot mass-assign workflow_state if attr_protected' do
|
61
62
|
o = AttrProtectedTestOrder.find_by_title('order1')
|
62
63
|
assert_equal 'submitted', o.read_attribute(:workflow_state)
|
64
|
+
AttrProtectedTestOrder.logger.level = Logger::ERROR # ignore warnings
|
63
65
|
o.update_attributes :workflow_state => 'some_bad_value'
|
66
|
+
AttrProtectedTestOrder.logger.level = Logger::WARN
|
64
67
|
assert_equal 'submitted', o.read_attribute(:workflow_state)
|
65
68
|
o.update_attribute :workflow_state, 'some_overridden_value'
|
66
69
|
assert_equal 'some_overridden_value', o.read_attribute(:workflow_state)
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
require 'workflow'
|
3
|
+
class InheritanceTest < ActiveRecordTestCase
|
4
|
+
|
5
|
+
test '#69 inheritance' do
|
6
|
+
class Animal
|
7
|
+
include Workflow
|
8
|
+
|
9
|
+
workflow do
|
10
|
+
|
11
|
+
state :conceived do
|
12
|
+
event :birth, :transition_to => :born
|
13
|
+
end
|
14
|
+
|
15
|
+
state :born do
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class Cat < Animal
|
22
|
+
include Workflow
|
23
|
+
workflow do
|
24
|
+
|
25
|
+
state :upset do
|
26
|
+
event :scratch, :transition_to => :hiding
|
27
|
+
end
|
28
|
+
|
29
|
+
state :hiding do
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
assert_equal [:born, :conceived] , sort_sym_array(Animal.workflow_spec.states.keys)
|
36
|
+
assert_equal [:hiding, :upset], sort_sym_array(Cat.workflow_spec.states.keys), "Workflow definitions are not inherited"
|
37
|
+
|
38
|
+
animal = Animal.new
|
39
|
+
cat = Cat.new
|
40
|
+
|
41
|
+
animal.birth!
|
42
|
+
|
43
|
+
assert_raise NoMethodError, 'Methods defined by the old workflow spec should have be gone away' do
|
44
|
+
cat.birth!
|
45
|
+
end
|
46
|
+
|
47
|
+
assert_equal [:birth!, :halt!, :process_event!], bang_methods(animal)
|
48
|
+
assert_equal [:halt!, :process_event!, :scratch!], bang_methods(cat)
|
49
|
+
end
|
50
|
+
|
51
|
+
def sort_sym_array(a)
|
52
|
+
a.sort { |a, b| a.to_s <=> b.to_s } # workaround for Ruby 1.8.7
|
53
|
+
end
|
54
|
+
|
55
|
+
def bang_methods(obj)
|
56
|
+
non_trivial_methods = obj.public_methods-Object.public_methods
|
57
|
+
methods_with_bang = non_trivial_methods.select {|m| m =~ /!$/}
|
58
|
+
sort_sym_array(methods_with_bang).map {|m| m.to_sym}
|
59
|
+
end
|
60
|
+
end
|