rails-workflow 1.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.travis.yml +27 -0
- data/.yardopts +2 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +426 -0
- data/Rakefile +30 -0
- data/asdf +18 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/gemfiles/Gemfile.rails-3.x +11 -0
- data/gemfiles/Gemfile.rails-4.0 +14 -0
- data/gemfiles/Gemfile.rails-5.0 +13 -0
- data/gemfiles/Gemfile.rails-edge +13 -0
- data/lib/workflow.rb +295 -0
- data/lib/workflow/adapters/active_record.rb +78 -0
- data/lib/workflow/adapters/active_record_validations.rb +110 -0
- data/lib/workflow/adapters/remodel.rb +15 -0
- data/lib/workflow/callbacks.rb +274 -0
- data/lib/workflow/configuration.rb +10 -0
- data/lib/workflow/draw.rb +79 -0
- data/lib/workflow/errors.rb +29 -0
- data/lib/workflow/event.rb +129 -0
- data/lib/workflow/specification.rb +137 -0
- data/lib/workflow/state.rb +88 -0
- data/lib/workflow/transition_context.rb +54 -0
- data/lib/workflow/version.rb +3 -0
- data/orders_workflow.png +0 -0
- data/rails-workflow.gemspec +51 -0
- metadata +258 -0
@@ -0,0 +1,29 @@
|
|
1
|
+
module Workflow
|
2
|
+
module Errors
|
3
|
+
class TransitionHaltedError < StandardError
|
4
|
+
|
5
|
+
attr_reader :halted_because
|
6
|
+
|
7
|
+
def initialize(msg = nil)
|
8
|
+
@halted_because = msg
|
9
|
+
super msg
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
13
|
+
|
14
|
+
class NoMatchingTransitionError < StandardError
|
15
|
+
end
|
16
|
+
|
17
|
+
class NoTransitionAllowed < StandardError
|
18
|
+
end
|
19
|
+
|
20
|
+
class WorkflowError < StandardError
|
21
|
+
end
|
22
|
+
|
23
|
+
class CallbackArityError < StandardError
|
24
|
+
end
|
25
|
+
|
26
|
+
class WorkflowDefinitionError < StandardError
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
module Workflow
|
2
|
+
class Event
|
3
|
+
attr_reader :name, :transitions, :meta
|
4
|
+
|
5
|
+
def initialize(name, meta)
|
6
|
+
@name = name.to_sym
|
7
|
+
@transitions = []
|
8
|
+
@meta = meta || {}
|
9
|
+
end
|
10
|
+
|
11
|
+
def inspect
|
12
|
+
"<Event name=#{name.inspect} transitions(#{transitions.length})=#{transitions.inspect}>"
|
13
|
+
end
|
14
|
+
|
15
|
+
def evaluate(target)
|
16
|
+
transition = transitions.find{|t| t.apply? target}
|
17
|
+
if transition
|
18
|
+
return transition.target_state
|
19
|
+
else
|
20
|
+
nil
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def to(target_state, **conditions_def, &block)
|
25
|
+
conditions = Conditions.new &&conditions_def, block
|
26
|
+
self.transitions << Transition.new(target_state, conditions_def, &block)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
class Transition
|
31
|
+
attr_accessor :target_state, :conditions
|
32
|
+
def apply?(target)
|
33
|
+
conditions.apply?(target)
|
34
|
+
end
|
35
|
+
# delegate :apply?, to: :conditions
|
36
|
+
def initialize(target_state, conditions_def, &block)
|
37
|
+
@target_state = target_state
|
38
|
+
@conditions = Conditions.new conditions_def, &block
|
39
|
+
end
|
40
|
+
|
41
|
+
def inspect
|
42
|
+
"<to=#{target_state.inspect} conditions=#{conditions.inspect}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
class Conditions #:nodoc:#
|
47
|
+
|
48
|
+
def initialize(**options, &block)
|
49
|
+
@if = Array(options[:if])
|
50
|
+
@unless = Array(options[:unless])
|
51
|
+
@if << block if block_given?
|
52
|
+
@conditions_lambdas = conditions_lambdas
|
53
|
+
end
|
54
|
+
|
55
|
+
def inspect
|
56
|
+
"if: #{@if}, unless: #{@unless}"
|
57
|
+
end
|
58
|
+
|
59
|
+
def apply?(target)
|
60
|
+
# TODO: Remove the second parameter from the conditions below.
|
61
|
+
@conditions_lambdas.all?{|l| l.call(target, ->(){})}
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
# Copied from https://github.com/rails/rails/blob/bca2e69b785fa3cdbe148b0d2dd5d3b58f6daf53/activesupport/lib/active_support/callbacks.rb#L366
|
67
|
+
def invert_lambda(l)
|
68
|
+
lambda { |*args, &blk| !l.call(*args, &blk) }
|
69
|
+
end
|
70
|
+
|
71
|
+
# Filters support:
|
72
|
+
#
|
73
|
+
# Symbols:: A method to call.
|
74
|
+
# Strings:: Some content to evaluate.
|
75
|
+
# Procs:: A proc to call with the object.
|
76
|
+
# Objects:: An object with a <tt>before_foo</tt> method on it to call.
|
77
|
+
#
|
78
|
+
# All of these objects are converted into a lambda and handled
|
79
|
+
# the same after this point.
|
80
|
+
# Copied from https://github.com/rails/rails/blob/bca2e69b785fa3cdbe148b0d2dd5d3b58f6daf53/activesupport/lib/active_support/callbacks.rb#L379
|
81
|
+
def make_lambda(filter)
|
82
|
+
case filter
|
83
|
+
when Symbol
|
84
|
+
lambda { |target, _, &blk| target.send filter, &blk }
|
85
|
+
when String
|
86
|
+
l = eval "lambda { |value| #{filter} }"
|
87
|
+
lambda { |target, value| target.instance_exec(value, &l) }
|
88
|
+
# when Conditionals::Value then filter
|
89
|
+
when ::Proc
|
90
|
+
if filter.arity > 1
|
91
|
+
return lambda { |target, _, &block|
|
92
|
+
raise ArgumentError unless block
|
93
|
+
target.instance_exec(target, block, &filter)
|
94
|
+
}
|
95
|
+
end
|
96
|
+
|
97
|
+
if filter.arity <= 0
|
98
|
+
lambda { |target, _| target.instance_exec(&filter) }
|
99
|
+
else
|
100
|
+
lambda { |target, _| target.instance_exec(target, &filter) }
|
101
|
+
end
|
102
|
+
else
|
103
|
+
scopes = Array(chain_config[:scope])
|
104
|
+
method_to_call = scopes.map{ |s| public_send(s) }.join("_")
|
105
|
+
|
106
|
+
lambda { |target, _, &blk|
|
107
|
+
filter.public_send method_to_call, target, &blk
|
108
|
+
}
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# From https://github.com/rails/rails/blob/bca2e69b785fa3cdbe148b0d2dd5d3b58f6daf53/activesupport/lib/active_support/callbacks.rb#L410
|
113
|
+
def compute_identifier(filter)
|
114
|
+
case filter
|
115
|
+
when String, ::Proc
|
116
|
+
filter.object_id
|
117
|
+
else
|
118
|
+
filter
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# From https://github.com/rails/rails/blob/bca2e69b785fa3cdbe148b0d2dd5d3b58f6daf53/activesupport/lib/active_support/callbacks.rb#L419
|
123
|
+
def conditions_lambdas
|
124
|
+
@if.map { |c| make_lambda c } +
|
125
|
+
@unless.map { |c| invert_lambda make_lambda c }
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
@@ -0,0 +1,137 @@
|
|
1
|
+
require 'workflow/state'
|
2
|
+
require 'workflow/event'
|
3
|
+
require 'workflow/errors'
|
4
|
+
require 'active_support/callbacks'
|
5
|
+
|
6
|
+
module Workflow
|
7
|
+
# Metadata object describing available states and state transitions.
|
8
|
+
class Specification
|
9
|
+
include ActiveSupport::Callbacks
|
10
|
+
|
11
|
+
# The state objects defined for this specification, keyed by name
|
12
|
+
# @return [Hash]
|
13
|
+
attr_reader :states
|
14
|
+
|
15
|
+
# State object to be given to newly created objects under this workflow.
|
16
|
+
# @return [State]
|
17
|
+
attr_reader :initial_state
|
18
|
+
|
19
|
+
# Optional metadata stored with this workflow specification
|
20
|
+
# @return [Hash]
|
21
|
+
attr_reader :meta
|
22
|
+
|
23
|
+
# List of symbols, for attribute accessors to be added to {TransitionContext} object
|
24
|
+
# @return [Array]
|
25
|
+
attr_reader :named_arguments
|
26
|
+
|
27
|
+
define_callbacks :spec_definition
|
28
|
+
set_callback(:spec_definition, :after, if: :define_revert_events?) do |spec|
|
29
|
+
spec.states.each do |state|
|
30
|
+
state.events.reject{ |e|
|
31
|
+
e.name.to_s =~ /^revert_/
|
32
|
+
}.select{|e| e.transitions.length == 1}.each do |event|
|
33
|
+
revert_event_name = "revert_#{event.name}".to_sym
|
34
|
+
from_state_for_revert = event.transitions.first.target_state
|
35
|
+
from_state_for_revert.on revert_event_name, to: state
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
set_callback(:spec_definition, :after) do |spec|
|
41
|
+
spec.states.each do |state|
|
42
|
+
state.events.each do |event|
|
43
|
+
event.transitions.each do |transition|
|
44
|
+
target_state = spec.find_state(transition.target_state)
|
45
|
+
unless target_state.present?
|
46
|
+
raise Workflow::Errors::WorkflowDefinitionError.new("Event #{event.name} transitions to #{transition.target_state} but there is no such state.")
|
47
|
+
end
|
48
|
+
transition.target_state = target_state
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def find_state(name)
|
55
|
+
states.find{|t| t.name == name.to_sym}
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
|
60
|
+
|
61
|
+
# @api private
|
62
|
+
#
|
63
|
+
# @param [Hash] meta Metadata
|
64
|
+
# @yield [] Block for workflow definition
|
65
|
+
# @return [Specification]
|
66
|
+
def initialize(meta = {}, &specification)
|
67
|
+
@states = []
|
68
|
+
@meta = meta
|
69
|
+
run_callbacks :spec_definition do
|
70
|
+
instance_eval(&specification)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# Define a new state named [name]
|
75
|
+
#
|
76
|
+
# @param [Symbol] name name of state
|
77
|
+
# @param [Hash] meta Metadata to be stored with the state within the {Specification} object
|
78
|
+
# @yield [] block defining events for this state.
|
79
|
+
# @return [nil]
|
80
|
+
def state(name, meta: {}, &events)
|
81
|
+
name = name.to_sym
|
82
|
+
new_state = Workflow::State.new(name, self, meta)
|
83
|
+
@initial_state ||= new_state
|
84
|
+
@states << new_state
|
85
|
+
new_state.instance_eval(&events) if block_given?
|
86
|
+
end
|
87
|
+
|
88
|
+
|
89
|
+
|
90
|
+
# Specify attributes to make available on the {TransitionContext} object
|
91
|
+
# during transitions taking place in this specification.
|
92
|
+
# The attributes' values will be taken in order from the arguments passed to
|
93
|
+
# the event transit method call.
|
94
|
+
#
|
95
|
+
# @param [Array] names A list of symbols
|
96
|
+
# @return [nil]
|
97
|
+
def event_args(*names)
|
98
|
+
@named_arguments = names
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
# Also create additional event transitions that will move each configured transition
|
103
|
+
# in the reverse direction.
|
104
|
+
#
|
105
|
+
# @return [nil]
|
106
|
+
#
|
107
|
+
#```ruby
|
108
|
+
# class Article
|
109
|
+
# include Workflow
|
110
|
+
# workflow do
|
111
|
+
# define_revert_events!
|
112
|
+
# state :foo do
|
113
|
+
# event :bar, transitions_to: :bax
|
114
|
+
# end
|
115
|
+
# state :bax
|
116
|
+
# end
|
117
|
+
# end
|
118
|
+
#
|
119
|
+
# a = Article.new
|
120
|
+
# a.process_event! :foo
|
121
|
+
# a.current_state.name # => :bax
|
122
|
+
# a.process_event! :revert_bar
|
123
|
+
# a.current_state.name # => :foo
|
124
|
+
#```
|
125
|
+
def define_revert_events!
|
126
|
+
@define_revert_events = true
|
127
|
+
end
|
128
|
+
|
129
|
+
private
|
130
|
+
|
131
|
+
|
132
|
+
def define_revert_events?
|
133
|
+
!!@define_revert_events
|
134
|
+
end
|
135
|
+
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Workflow
|
2
|
+
class State
|
3
|
+
include Comparable
|
4
|
+
|
5
|
+
attr_accessor :name, :events, :meta, :on_entry, :on_exit
|
6
|
+
attr_reader :sequence
|
7
|
+
|
8
|
+
def initialize(name, sequence, **meta)
|
9
|
+
@name, @sequence, @events, @meta = name.to_sym, sequence, [], meta
|
10
|
+
end
|
11
|
+
|
12
|
+
def find_event(name)
|
13
|
+
events.find{|t| t.name == name}
|
14
|
+
end
|
15
|
+
|
16
|
+
# Define an event on this specification.
|
17
|
+
# Must be called within the scope of the block within a call to {#state}.
|
18
|
+
#
|
19
|
+
# @param [Symbol] name The name of the event
|
20
|
+
# @param [Hash] args
|
21
|
+
# @option args [Symbol] :transitions_to The state this event transitions to.
|
22
|
+
# @option args [Symbol] :if optional instance method name or [Proc] that will receive the object when called.
|
23
|
+
# @option args [Hash] :meta Optional metadata to be stored on the event object
|
24
|
+
# @return [nil]
|
25
|
+
#
|
26
|
+
#```ruby
|
27
|
+
#workflow do
|
28
|
+
# state :new do
|
29
|
+
# on :review, to: :being_reviewed
|
30
|
+
#
|
31
|
+
# on :submit do
|
32
|
+
# to :submitted,
|
33
|
+
# if: [ "name == 'The Dude'", :abides?, -> (rug) {rug.tied_the_room_together?}],
|
34
|
+
# unless: :nihilist?
|
35
|
+
#
|
36
|
+
# to :trash, unless: :body?
|
37
|
+
# to :another_place do |article|
|
38
|
+
# article.foo?
|
39
|
+
# end
|
40
|
+
# end
|
41
|
+
# end
|
42
|
+
#
|
43
|
+
# state :kitchen
|
44
|
+
# state :the_bar
|
45
|
+
# state :the_diner
|
46
|
+
#end
|
47
|
+
#```
|
48
|
+
def on(name, to: nil, meta: nil, &transitions)
|
49
|
+
if to && block_given?
|
50
|
+
raise Errors::WorkflowDefinitionError.new("Event target can only be received in the method call or the block, not both.")
|
51
|
+
end
|
52
|
+
|
53
|
+
unless to || block_given?
|
54
|
+
raise Errors::WorkflowDefinitionError.new("No event target given for event #{name}")
|
55
|
+
end
|
56
|
+
|
57
|
+
if find_event(name)
|
58
|
+
raise Errors::WorkflowDefinitionError.new("Already defined an event [#{name}] for state[#{self.name}]")
|
59
|
+
end
|
60
|
+
|
61
|
+
event = Workflow::Event.new(name, meta)
|
62
|
+
|
63
|
+
if to
|
64
|
+
event.to to
|
65
|
+
else
|
66
|
+
event.instance_eval(&transitions)
|
67
|
+
end
|
68
|
+
|
69
|
+
if event.transitions.empty?
|
70
|
+
raise Errors::WorkflowDefinitionError.new("No transitions defined for event [#{name}] on state [#{self.name}]")
|
71
|
+
end
|
72
|
+
|
73
|
+
events << event
|
74
|
+
nil
|
75
|
+
end
|
76
|
+
|
77
|
+
def inspect
|
78
|
+
"<State name=#{name.inspect} events(#{events.length})=#{events.inspect}>"
|
79
|
+
end
|
80
|
+
|
81
|
+
def <=>(other_state)
|
82
|
+
unless other_state.is_a?(State)
|
83
|
+
raise StandardError.new "Other State #{other_state} is a #{other_state.class}. I can only be compared with a Workflow::State."
|
84
|
+
end
|
85
|
+
self.sequence <=> other_state.sequence
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module Workflow
|
2
|
+
# During transitions, an instance of this class can be found
|
3
|
+
# on the object as `transition_context`.
|
4
|
+
# Contains metadata related to the current transition underway.
|
5
|
+
#
|
6
|
+
# == To name parameters:
|
7
|
+
#
|
8
|
+
# During workflow definition, do the following:
|
9
|
+
#
|
10
|
+
# before_transition :transition_handler
|
11
|
+
#
|
12
|
+
# def transition_handler
|
13
|
+
# transition_context.name1 # will equal 1
|
14
|
+
# transition_context.name2 # will equal 2
|
15
|
+
# transition_context.name3 # will equal 3
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# workflow do
|
19
|
+
# event_args :name1, :name2, :name3
|
20
|
+
# state :foo do
|
21
|
+
# event :bar, transitions_to: :bax
|
22
|
+
# end
|
23
|
+
# end
|
24
|
+
#
|
25
|
+
# Then later call:
|
26
|
+
#
|
27
|
+
# my_obj.submit! 1, 2, 3
|
28
|
+
#
|
29
|
+
# The entire list of passed parameters will still be available on +event_args+.
|
30
|
+
# If you pass fewer parameters, the later ones will simply be nil.
|
31
|
+
class TransitionContext
|
32
|
+
attr_reader :from, :to, :event, :event_args, :attributes, :named_arguments
|
33
|
+
def initialize(from:, to:, event:, event_args:, attributes:, named_arguments: [])
|
34
|
+
@from = from
|
35
|
+
@to = to
|
36
|
+
@event = event
|
37
|
+
@event_args = event_args
|
38
|
+
@attributes = attributes
|
39
|
+
@named_arguments = (named_arguments || []).zip(event_args).to_h
|
40
|
+
end
|
41
|
+
|
42
|
+
def values
|
43
|
+
[from, to, event, event_args]
|
44
|
+
end
|
45
|
+
|
46
|
+
def method_missing(method, *args)
|
47
|
+
if named_arguments.key?(method)
|
48
|
+
named_arguments[method]
|
49
|
+
else
|
50
|
+
super
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|