workflow-orchestrator 1.3.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/.gitignore +20 -0
- data/.travis.yml +36 -0
- data/CHANGELOG.md +133 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +22 -0
- data/README.md +707 -0
- data/Rakefile +30 -0
- data/gemfiles/Gemfile.rails-3.x +12 -0
- data/gemfiles/Gemfile.rails-4.0 +14 -0
- data/gemfiles/Gemfile.rails-4.1 +14 -0
- data/gemfiles/Gemfile.rails-4.2 +14 -0
- data/gemfiles/Gemfile.rails-edge +14 -0
- data/lib/workflow/adapters/active_record.rb +75 -0
- data/lib/workflow/adapters/remodel.rb +15 -0
- data/lib/workflow/draw.rb +79 -0
- data/lib/workflow/errors.rb +20 -0
- data/lib/workflow/event.rb +38 -0
- data/lib/workflow/event_collection.rb +36 -0
- data/lib/workflow/specification.rb +83 -0
- data/lib/workflow/state.rb +44 -0
- data/lib/workflow/version.rb +3 -0
- data/lib/workflow.rb +307 -0
- data/orders_workflow.png +0 -0
- data/test/active_record_scopes_test.rb +56 -0
- data/test/active_record_scopes_with_values_test.rb +79 -0
- data/test/adapter_hook_test.rb +52 -0
- data/test/advanced_examples_test.rb +84 -0
- data/test/advanced_hooks_and_validation_test.rb +119 -0
- data/test/attr_protected_test.rb +107 -0
- data/test/before_transition_test.rb +36 -0
- data/test/couchtiny_example.rb +46 -0
- data/test/enum_values_in_memory_test.rb +23 -0
- data/test/enum_values_test.rb +30 -0
- data/test/incline_column_test.rb +54 -0
- data/test/inheritance_test.rb +56 -0
- data/test/main_test.rb +588 -0
- data/test/multiple_workflows_test.rb +84 -0
- data/test/new_versions/compare_states_test.rb +32 -0
- data/test/new_versions/persistence_test.rb +62 -0
- data/test/on_error_test.rb +52 -0
- data/test/on_unavailable_transition_test.rb +85 -0
- data/test/readme_example.rb +37 -0
- data/test/test_helper.rb +39 -0
- data/test/without_active_record_test.rb +54 -0
- data/workflow-orchestrator.gemspec +42 -0
- metadata +267 -0
data/Rakefile
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake/testtask'
|
3
|
+
require 'rdoc/task'
|
4
|
+
|
5
|
+
require 'bundler'
|
6
|
+
Bundler.setup
|
7
|
+
|
8
|
+
task :default => [:test]
|
9
|
+
|
10
|
+
require 'rake'
|
11
|
+
Rake::TestTask.new do |t|
|
12
|
+
t.libs << 'test'
|
13
|
+
t.verbose = true
|
14
|
+
t.warning = true
|
15
|
+
t.test_files = FileList['test/*_test.rb'] + FileList['test/new_versions/*_test.rb']
|
16
|
+
end
|
17
|
+
|
18
|
+
Rake::TestTask.new do |t|
|
19
|
+
t.name = 'test_without_new_versions'
|
20
|
+
t.libs << 'test'
|
21
|
+
t.verbose = true
|
22
|
+
t.warning = true
|
23
|
+
t.pattern = 'test/*_test.rb'
|
24
|
+
end
|
25
|
+
|
26
|
+
Rake::RDocTask.new do |rdoc|
|
27
|
+
rdoc.rdoc_files.include("lib/**/*.rb")
|
28
|
+
rdoc.options << "-S"
|
29
|
+
end
|
30
|
+
|
@@ -0,0 +1,14 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
|
3
|
+
group :development do
|
4
|
+
gem "minitest", "~> 5.1.0"
|
5
|
+
gem "rdoc", ">= 3.12"
|
6
|
+
gem "bundler", ">= 1.0.0"
|
7
|
+
gem "activerecord", "~>4.1"
|
8
|
+
gem 'protected_attributes'
|
9
|
+
gem "sqlite3"
|
10
|
+
gem "mocha"
|
11
|
+
gem "rake"
|
12
|
+
gem "ruby-graphviz", "~> 1.0.0"
|
13
|
+
gem "test-unit"
|
14
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
|
3
|
+
group :development do
|
4
|
+
gem "minitest", "~> 5.1.0"
|
5
|
+
gem "rdoc", ">= 3.12"
|
6
|
+
gem "bundler", ">= 1.0.0"
|
7
|
+
gem "activerecord", "~>4.0"
|
8
|
+
gem "protected_attributes"
|
9
|
+
gem "sqlite3"
|
10
|
+
gem "mocha"
|
11
|
+
gem "rake"
|
12
|
+
gem "ruby-graphviz", "~> 1.0.0"
|
13
|
+
gem "test-unit"
|
14
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
|
3
|
+
group :development do
|
4
|
+
gem "minitest", "~> 5.1.0"
|
5
|
+
gem "rdoc", ">= 3.12"
|
6
|
+
gem "bundler", ">= 1.0.0"
|
7
|
+
gem "activerecord", "~>4.2"
|
8
|
+
gem "protected_attributes"
|
9
|
+
gem "sqlite3"
|
10
|
+
gem "mocha"
|
11
|
+
gem "rake"
|
12
|
+
gem "ruby-graphviz", "~> 1.0.0"
|
13
|
+
gem "test-unit"
|
14
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
source "http://rubygems.org"
|
2
|
+
|
3
|
+
group :development do
|
4
|
+
gem "minitest", "~> 5.1.0"
|
5
|
+
gem "rdoc", ">= 3.12"
|
6
|
+
gem "bundler", ">= 1.0.0"
|
7
|
+
gem "activerecord"
|
8
|
+
gem "sqlite3"
|
9
|
+
gem "mocha"
|
10
|
+
gem "rake"
|
11
|
+
gem "ruby-graphviz", "~> 1.0.0"
|
12
|
+
gem "protected_attributes"
|
13
|
+
gem "test-unit"
|
14
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module Workflow
|
2
|
+
module Adapter
|
3
|
+
module ActiveRecord
|
4
|
+
def self.included(klass)
|
5
|
+
klass.send :include, Adapter::ActiveRecord::InstanceMethods
|
6
|
+
klass.send :extend, Adapter::ActiveRecord::Scopes
|
7
|
+
klass.before_validation :write_initial_state
|
8
|
+
end
|
9
|
+
|
10
|
+
module InstanceMethods
|
11
|
+
def load_workflow_state
|
12
|
+
read_attribute(self.class.workflow_column)
|
13
|
+
end
|
14
|
+
|
15
|
+
# On transition the new workflow state is immediately saved in the
|
16
|
+
# database.
|
17
|
+
def persist_workflow_state(new_value)
|
18
|
+
# Rails 3.1 or newer
|
19
|
+
update_column self.class.workflow_column, new_value
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
# Motivation: even if NULL is stored in the workflow_state database column,
|
25
|
+
# the current_state is correctly recognized in the Ruby code. The problem
|
26
|
+
# arises when you want to SELECT records filtering by the value of initial
|
27
|
+
# state. That's why it is important to save the string with the value of the
|
28
|
+
# initial state in all the new records.
|
29
|
+
def write_initial_state
|
30
|
+
write_attribute self.class.workflow_column, current_state.value
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# This module will automatically generate ActiveRecord scopes based on workflow states.
|
35
|
+
# The name of each generated scope will be something like `with_<state_name>_state`
|
36
|
+
#
|
37
|
+
# Examples:
|
38
|
+
#
|
39
|
+
# Article.with_pending_state # => ActiveRecord::Relation
|
40
|
+
# Payment.without_refunded_state # => ActiveRecord::Relation
|
41
|
+
#`
|
42
|
+
# Example above just adds `where(:state_column_name => 'pending')` or
|
43
|
+
# `where.not(:state_column_name => 'pending')` to AR query and returns
|
44
|
+
# ActiveRecord::Relation.
|
45
|
+
module Scopes
|
46
|
+
def self.extended(object)
|
47
|
+
class << object
|
48
|
+
alias_method :workflow_without_scopes, :workflow unless method_defined?(:workflow_without_scopes)
|
49
|
+
alias_method :workflow, :workflow_with_scopes
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def workflow_with_scopes(column=nil, &specification)
|
54
|
+
workflow_without_scopes(column, &specification)
|
55
|
+
states = workflow_spec.states.values
|
56
|
+
|
57
|
+
states.each do |state|
|
58
|
+
define_singleton_method("with_#{state}_state") do
|
59
|
+
where("#{table_name}.#{self.workflow_column.to_sym} = ?", state.value)
|
60
|
+
end
|
61
|
+
|
62
|
+
define_singleton_method("without_#{state}_state") do
|
63
|
+
where("#{table_name}.#{self.workflow_column.to_sym} != ?", state.value)
|
64
|
+
end
|
65
|
+
|
66
|
+
define_method("without_#{state}_state") do
|
67
|
+
where("#{table_name}.#{self.workflow_column.to_sym} <> ?", state.to_s)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
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.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.flat.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,20 @@
|
|
1
|
+
module Workflow
|
2
|
+
class Error < StandardError; end
|
3
|
+
|
4
|
+
class TransitionHalted < Error
|
5
|
+
|
6
|
+
attr_reader :halted_because
|
7
|
+
|
8
|
+
def initialize(msg = nil)
|
9
|
+
@halted_because = msg
|
10
|
+
super msg
|
11
|
+
end
|
12
|
+
|
13
|
+
end
|
14
|
+
|
15
|
+
class NoTransitionAllowed < Error; end
|
16
|
+
|
17
|
+
class WorkflowError < Error; end
|
18
|
+
|
19
|
+
class WorkflowDefinitionError < Error; end
|
20
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Workflow
|
2
|
+
class Event
|
3
|
+
|
4
|
+
attr_accessor :name, :transitions_to, :meta, :action, :condition
|
5
|
+
|
6
|
+
def initialize(name, transitions_to, condition = nil, meta = {}, &action)
|
7
|
+
@name = name
|
8
|
+
@transitions_to = transitions_to.to_sym
|
9
|
+
@meta = meta
|
10
|
+
@action = action
|
11
|
+
@condition = if condition.nil? || condition.is_a?(Symbol) || condition.respond_to?(:call)
|
12
|
+
condition
|
13
|
+
else
|
14
|
+
raise TypeError, 'condition must be nil, an instance method name symbol or a callable (eg. a proc or lambda)'
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def condition_applicable?(object)
|
19
|
+
if condition
|
20
|
+
if condition.is_a?(Symbol)
|
21
|
+
object.send(condition)
|
22
|
+
else
|
23
|
+
condition.call(object)
|
24
|
+
end
|
25
|
+
else
|
26
|
+
true
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def draw(graph, from_state)
|
31
|
+
graph.add_edges(from_state.name.to_s, transitions_to.to_s, meta.merge(:label => to_s))
|
32
|
+
end
|
33
|
+
|
34
|
+
def to_s
|
35
|
+
@name.to_s
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Workflow
|
2
|
+
class EventCollection < Hash
|
3
|
+
|
4
|
+
def [](name)
|
5
|
+
super name.to_sym # Normalize to symbol
|
6
|
+
end
|
7
|
+
|
8
|
+
def push(name, event)
|
9
|
+
key = name.to_sym
|
10
|
+
self[key] ||= []
|
11
|
+
self[key] << event
|
12
|
+
end
|
13
|
+
|
14
|
+
def flat
|
15
|
+
self.values.flatten.uniq do |event|
|
16
|
+
[:name, :transitions_to, :meta, :action].map { |m| event.send(m) }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def include?(name_or_obj)
|
21
|
+
case name_or_obj
|
22
|
+
when Event
|
23
|
+
flat.include? name_or_obj
|
24
|
+
else
|
25
|
+
!(self[name_or_obj].nil?)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def first_applicable(name, object_context)
|
30
|
+
(self[name] || []).detect do |event|
|
31
|
+
event.condition_applicable?(object_context) && event
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'workflow/state'
|
2
|
+
require 'workflow/event'
|
3
|
+
require 'workflow/event_collection'
|
4
|
+
require 'workflow/errors'
|
5
|
+
|
6
|
+
module Workflow
|
7
|
+
class Specification
|
8
|
+
|
9
|
+
attr_accessor :states, :initial_state, :meta,
|
10
|
+
:on_transition_proc, :before_transition_proc,
|
11
|
+
:after_transition_proc, :on_error_proc, :on_unavailable_transition_proc
|
12
|
+
|
13
|
+
def initialize(meta = {}, &specification)
|
14
|
+
@states = Hash.new
|
15
|
+
@meta = meta
|
16
|
+
instance_eval(&specification)
|
17
|
+
end
|
18
|
+
|
19
|
+
def state_names
|
20
|
+
states.keys
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def state(name, value=nil, options=nil, &events_and_etc)
|
26
|
+
|
27
|
+
if value.is_a? Hash
|
28
|
+
value = nil
|
29
|
+
options = value
|
30
|
+
end
|
31
|
+
|
32
|
+
value ||= name
|
33
|
+
options ||= {}
|
34
|
+
meta.reverse_merge! meta: {}
|
35
|
+
|
36
|
+
new_state = Workflow::State.new(name, value, self, options[:meta])
|
37
|
+
@initial_state = new_state if @states.empty?
|
38
|
+
@states[name.to_sym] = new_state
|
39
|
+
@scoped_state = new_state
|
40
|
+
instance_eval(&events_and_etc) if events_and_etc
|
41
|
+
end
|
42
|
+
|
43
|
+
def event(name, args = {}, &action)
|
44
|
+
target = args[:transitions_to] || args[:transition_to]
|
45
|
+
condition = args[:if]
|
46
|
+
raise WorkflowDefinitionError.new(
|
47
|
+
"missing ':transitions_to' in workflow event definition for '#{name}'") \
|
48
|
+
if target.nil?
|
49
|
+
@scoped_state.events.push(
|
50
|
+
name, Workflow::Event.new(name, target, condition, (args[:meta] or {}), &action)
|
51
|
+
)
|
52
|
+
end
|
53
|
+
|
54
|
+
def on_entry(&proc)
|
55
|
+
@scoped_state.on_entry = proc
|
56
|
+
end
|
57
|
+
|
58
|
+
def on_exit(&proc)
|
59
|
+
@scoped_state.on_exit = proc
|
60
|
+
end
|
61
|
+
|
62
|
+
def after_transition(&proc)
|
63
|
+
@after_transition_proc = proc
|
64
|
+
end
|
65
|
+
|
66
|
+
def before_transition(&proc)
|
67
|
+
@before_transition_proc = proc
|
68
|
+
end
|
69
|
+
|
70
|
+
def on_transition(&proc)
|
71
|
+
@on_transition_proc = proc
|
72
|
+
end
|
73
|
+
|
74
|
+
def on_error(&proc)
|
75
|
+
@on_error_proc = proc
|
76
|
+
end
|
77
|
+
|
78
|
+
def on_unavailable_transition(&proc)
|
79
|
+
@on_unavailable_transition_proc = proc
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module Workflow
|
2
|
+
class State
|
3
|
+
attr_accessor :name, :value, :events, :meta, :on_entry, :on_exit
|
4
|
+
attr_reader :spec
|
5
|
+
|
6
|
+
def initialize(name, value, spec, meta = {})
|
7
|
+
@name, @value, @spec, @events, @meta = name, value, spec, EventCollection.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)
|
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
|