rails-workflow 1.4.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 +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
|