rails-workflow 1.4.5.4 → 1.4.6.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/.rubocop.yml +23 -0
- data/Gemfile +2 -1
- data/Rakefile +4 -4
- data/bin/console +3 -3
- data/lib/active_support/overloads.rb +13 -6
- data/lib/workflow.rb +12 -279
- data/lib/workflow/adapters/active_record.rb +57 -50
- data/lib/workflow/adapters/active_record_validations.rb +25 -19
- data/lib/workflow/adapters/adapter.rb +23 -0
- data/lib/workflow/adapters/remodel.rb +8 -9
- data/lib/workflow/callbacks.rb +60 -45
- data/lib/workflow/callbacks/callback.rb +23 -37
- data/lib/workflow/callbacks/method_callback.rb +12 -0
- data/lib/workflow/callbacks/proc_callback.rb +23 -0
- data/lib/workflow/callbacks/string_callback.rb +12 -0
- data/lib/workflow/callbacks/transition_callback.rb +88 -78
- data/lib/workflow/callbacks/transition_callbacks/method_caller.rb +53 -0
- data/lib/workflow/callbacks/transition_callbacks/proc_caller.rb +60 -0
- data/lib/workflow/configuration.rb +1 -0
- data/lib/workflow/definition.rb +73 -0
- data/lib/workflow/errors.rb +37 -6
- data/lib/workflow/event.rb +30 -15
- data/lib/workflow/helper_method_configurator.rb +100 -0
- data/lib/workflow/specification.rb +45 -22
- data/lib/workflow/state.rb +45 -36
- data/lib/workflow/transition_context.rb +5 -4
- data/lib/workflow/transitions.rb +94 -0
- data/lib/workflow/version.rb +2 -1
- data/rails-workflow.gemspec +18 -18
- data/tags.markdown +31 -0
- metadata +13 -5
- data/lib/workflow/callbacks/transition_callbacks/method_wrapper.rb +0 -102
- data/lib/workflow/callbacks/transition_callbacks/proc_wrapper.rb +0 -48
- data/lib/workflow/draw.rb +0 -79
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Workflow
|
3
|
+
module Definition
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
|
6
|
+
# Returns a state object representing the current workflow state.
|
7
|
+
#
|
8
|
+
# @return [State] Current workflow state
|
9
|
+
def current_state
|
10
|
+
loaded_state = load_workflow_state
|
11
|
+
res = workflow_spec.states.find { |t| t.name == loaded_state.to_sym } if loaded_state
|
12
|
+
res || workflow_spec.initial_state
|
13
|
+
end
|
14
|
+
|
15
|
+
# The specification for this object.
|
16
|
+
# Could be set on a singleton for the object, on the object's class,
|
17
|
+
# Or else on a superclass of the object.
|
18
|
+
# @return [Specification] The Specification that applies to this object.
|
19
|
+
def workflow_spec
|
20
|
+
# check the singleton class first
|
21
|
+
class << self
|
22
|
+
return workflow_spec if workflow_spec
|
23
|
+
end
|
24
|
+
|
25
|
+
c = self.class
|
26
|
+
# using a simple loop instead of class_inheritable_accessor to avoid
|
27
|
+
# dependency on Rails' ActiveSupport
|
28
|
+
c = c.superclass until c.workflow_spec || !(c.include? Workflow)
|
29
|
+
c.workflow_spec
|
30
|
+
end
|
31
|
+
|
32
|
+
module ClassMethods
|
33
|
+
attr_reader :workflow_spec
|
34
|
+
|
35
|
+
##
|
36
|
+
# Define workflow for the class.
|
37
|
+
#
|
38
|
+
# @yield [] Specification of workflow. Example below and in README.markdown
|
39
|
+
# @return [nil]
|
40
|
+
#
|
41
|
+
# Workflow definition takes place inside the yielded block.
|
42
|
+
# @see Specification::state
|
43
|
+
# @see Specification::event
|
44
|
+
#
|
45
|
+
# ~~~ruby
|
46
|
+
#
|
47
|
+
# class Article
|
48
|
+
# include Workflow
|
49
|
+
# workflow do
|
50
|
+
# state :new do
|
51
|
+
# event :submit, :transitions_to => :awaiting_review
|
52
|
+
# end
|
53
|
+
# state :awaiting_review do
|
54
|
+
# event :review, :transitions_to => :being_reviewed
|
55
|
+
# end
|
56
|
+
# state :being_reviewed do
|
57
|
+
# event :accept, :transitions_to => :accepted
|
58
|
+
# event :reject, :transitions_to => :rejected
|
59
|
+
# end
|
60
|
+
# state :accepted
|
61
|
+
# state :rejected
|
62
|
+
# end
|
63
|
+
# end
|
64
|
+
#
|
65
|
+
# ~~~
|
66
|
+
#
|
67
|
+
def workflow(&specification)
|
68
|
+
@workflow_spec = Specification.new({}, &specification)
|
69
|
+
HelperMethodConfigurator.new(@workflow_spec, self).configure!
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
data/lib/workflow/errors.rb
CHANGED
@@ -1,29 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
module Workflow
|
2
3
|
module Errors
|
3
4
|
class TransitionHaltedError < StandardError
|
4
|
-
|
5
5
|
attr_reader :halted_because
|
6
6
|
|
7
7
|
def initialize(msg = nil)
|
8
8
|
@halted_because = msg
|
9
9
|
super msg
|
10
10
|
end
|
11
|
-
|
12
11
|
end
|
13
12
|
|
14
13
|
class NoMatchingTransitionError < StandardError
|
15
14
|
end
|
16
15
|
|
17
|
-
class
|
16
|
+
class WorkflowDefinitionError < StandardError
|
18
17
|
end
|
19
18
|
|
20
|
-
class
|
19
|
+
class NoTransitionsDefinedError < WorkflowDefinitionError
|
20
|
+
def initialize(state, event)
|
21
|
+
super("No transitions defined for event [#{event.name}] on state [#{state.name}]")
|
22
|
+
end
|
21
23
|
end
|
22
24
|
|
23
|
-
class
|
25
|
+
class DualEventDefinitionError < WorkflowDefinitionError
|
26
|
+
def initialize
|
27
|
+
super('Event target can only be received in the method call or the block, not both.')
|
28
|
+
end
|
24
29
|
end
|
25
30
|
|
26
|
-
class
|
31
|
+
class EventNameCollisionError < WorkflowDefinitionError
|
32
|
+
def initialize(state, event_name)
|
33
|
+
super("Already defined an event [#{event_name}] for state[#{state.name}]")
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
class StateComparisonError < StandardError
|
38
|
+
def initialize(state)
|
39
|
+
super("Other State #{state} is a #{state.class}.
|
40
|
+
I can only be compared with a Workflow::State.".squish)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
class NoSuchStateError < WorkflowDefinitionError
|
45
|
+
def initialize(event, transition)
|
46
|
+
super("Event #{event.name} transitions to
|
47
|
+
#{transition.target_state} but there is no such state.".squish)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
class NoTransitionAllowed < StandardError
|
52
|
+
def initialize(state, event_name)
|
53
|
+
super("There is no event #{event_name} defined for the #{state.name} state")
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
class WorkflowError < StandardError
|
27
58
|
end
|
28
59
|
end
|
29
60
|
end
|
data/lib/workflow/event.rb
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
module Workflow
|
2
3
|
class Event
|
3
4
|
# @!attribute [r] name
|
@@ -11,13 +12,17 @@ module Workflow
|
|
11
12
|
# @api private
|
12
13
|
# See {Workflow::State#on} for creating objects of this class.
|
13
14
|
# @param [Symbol] name The name of the event to create.
|
14
|
-
# @param [Hash] meta
|
15
|
+
# @param [Hash] meta Optional Metadata for this object.
|
15
16
|
def initialize(name, meta: {})
|
16
17
|
@name = name.to_sym
|
17
18
|
@transitions = []
|
18
19
|
@meta = meta || {}
|
19
20
|
end
|
20
21
|
|
22
|
+
def valid?
|
23
|
+
transitions.any?
|
24
|
+
end
|
25
|
+
|
21
26
|
def inspect
|
22
27
|
"<Event name=#{name.inspect} transitions(#{transitions.length})=#{transitions.inspect}>"
|
23
28
|
end
|
@@ -28,28 +33,37 @@ module Workflow
|
|
28
33
|
# @param [Object] target An object of the class that this event was defined on.
|
29
34
|
# @return [Workflow::State] The first applicable destination state, or nil if none.
|
30
35
|
def evaluate(target)
|
31
|
-
transitions.find
|
36
|
+
transitions.find do |transition|
|
32
37
|
transition.matches? target
|
33
|
-
|
38
|
+
end&.target_state
|
39
|
+
end
|
40
|
+
|
41
|
+
def evaluate!(target)
|
42
|
+
state_name = evaluate(target)
|
43
|
+
unless state_name
|
44
|
+
raise NoMatchingTransitionError, "No matching transition found on #{name}
|
45
|
+
for target #{target}. Consider adding a catchall transition.".squish
|
46
|
+
end
|
47
|
+
state_name
|
34
48
|
end
|
35
49
|
|
36
50
|
# Add a {Workflow::Transition} to the possible {#transitions} for this event.
|
37
51
|
#
|
38
52
|
# @param [Symbol] target_state the name of the state target state if this transition matches.
|
39
53
|
# @option conditions_def [Symbol] :if Name of instance method to evaluate. e.g. `:valid?`
|
40
|
-
# @option conditions_def [Array] :if Mixed array of Symbol, String or Proc conditions.
|
41
|
-
#
|
42
|
-
# @option conditions_def [
|
43
|
-
#
|
54
|
+
# @option conditions_def [Array] :if Mixed array of Symbol, String or Proc conditions.
|
55
|
+
# All must match for the transition to apply.
|
56
|
+
# @option conditions_def [String] :if A string to evaluate on the target.
|
57
|
+
# e.g. `"self.foo == :bar"`
|
58
|
+
# @option conditions_def [Proc] :if A proc which will be evaluated on the object
|
59
|
+
# e.g. `->{self.foo == :bar}`
|
60
|
+
# @option conditions_def [Symbol] :unless Same Like `:if` but all conditions must **not** match
|
44
61
|
# @yield [] Optional block which, if provided, becomes an `:if` condition for the transition.
|
45
62
|
# @return [nil]
|
46
63
|
def to(target_state, **conditions_def, &block)
|
47
|
-
|
48
|
-
self.transitions << Transition.new(target_state, conditions_def, &block)
|
64
|
+
transitions << Transition.new(target_state, conditions_def, &block)
|
49
65
|
end
|
50
66
|
|
51
|
-
private
|
52
|
-
|
53
67
|
# @api private
|
54
68
|
# Represents a possible transition via the event on which it is defined.
|
55
69
|
class Transition
|
@@ -77,6 +91,7 @@ module Workflow
|
|
77
91
|
end
|
78
92
|
|
79
93
|
private
|
94
|
+
|
80
95
|
# @!attribute [r] conditions
|
81
96
|
# @return [Workflow::Event::Conditions] Conditions for this transition.
|
82
97
|
attr_reader :conditions
|
@@ -91,7 +106,7 @@ module Workflow
|
|
91
106
|
def initialize(**options, &block)
|
92
107
|
@if = Array(options[:if])
|
93
108
|
@unless = Array(options[:unless])
|
94
|
-
@if
|
109
|
+
@if << block if block_given?
|
95
110
|
@conditions_lambdas = conditions_lambdas
|
96
111
|
end
|
97
112
|
|
@@ -100,14 +115,14 @@ module Workflow
|
|
100
115
|
end
|
101
116
|
|
102
117
|
def apply?(target)
|
103
|
-
@conditions_lambdas.all?{|l| l.call(target)}
|
118
|
+
@conditions_lambdas.all? { |l| l.call(target) }
|
104
119
|
end
|
105
120
|
|
106
121
|
private
|
107
122
|
|
108
123
|
def conditions_lambdas
|
109
|
-
@if.map { |c| Callbacks::Callback.
|
110
|
-
@unless.map { |c| Callbacks::Callback.
|
124
|
+
@if.map { |c| Callbacks::Callback.build c } +
|
125
|
+
@unless.map { |c| Callbacks::Callback.inverted c }
|
111
126
|
end
|
112
127
|
end
|
113
128
|
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module Workflow
|
3
|
+
class HelperMethodConfigurator
|
4
|
+
attr_reader :workflow_spec, :workflow_class
|
5
|
+
|
6
|
+
def initialize(workflow_spec, workflow_class)
|
7
|
+
@workflow_spec = workflow_spec
|
8
|
+
@workflow_class = workflow_class
|
9
|
+
end
|
10
|
+
|
11
|
+
def configure!
|
12
|
+
undefine_methods_defined_by_workflow_spec if inherited_workflow_spec?
|
13
|
+
define_revert_events if workflow_spec.define_revert_events?
|
14
|
+
create_instance_methods
|
15
|
+
end
|
16
|
+
|
17
|
+
def create_instance_methods
|
18
|
+
workflow_spec.states.each do |state|
|
19
|
+
state_name = state.name
|
20
|
+
workflow_class.module_eval do
|
21
|
+
define_method "#{state_name}?" do
|
22
|
+
state_name == current_state.name
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
state.events.each do |event|
|
27
|
+
define_method_for_event(event) unless event_method?(event)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def define_revert_events
|
35
|
+
workflow_spec.states.each do |state|
|
36
|
+
reversible_events(state).each do |event|
|
37
|
+
revert_event_name = "revert_#{event.name}".to_sym
|
38
|
+
from_state_for_revert = event.transitions.first.target_state
|
39
|
+
from_state_for_revert.on revert_event_name, to: state
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def reversible_events(state)
|
45
|
+
state.events.select do |e|
|
46
|
+
e.name !~ /^revert_/ && e.transitions.length == 1
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def event_method?(event)
|
51
|
+
workflow_class.instance_methods.include?(event_method_name(event))
|
52
|
+
end
|
53
|
+
|
54
|
+
def event_method_name(event)
|
55
|
+
"#{event.name}!".to_sym
|
56
|
+
end
|
57
|
+
|
58
|
+
def define_method_for_event(event)
|
59
|
+
workflow_class.module_eval do
|
60
|
+
define_method "#{event.name}!".to_sym do |*args|
|
61
|
+
transition!(event.name, *args)
|
62
|
+
end
|
63
|
+
|
64
|
+
define_method "can_#{event.name}?" do
|
65
|
+
current_state.find_event(event.name)&.evaluate(self)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def inherited_workflow_spec?
|
71
|
+
workflow_class.superclass.respond_to?(:workflow_spec, true) &&
|
72
|
+
workflow_class.superclass.workflow_spec
|
73
|
+
end
|
74
|
+
|
75
|
+
def undefine_methods_defined_by_workflow_spec
|
76
|
+
superclass_workflow_spec.states.each do |state|
|
77
|
+
workflow_class.class_exec(state.name, &undef_state_method_proc)
|
78
|
+
|
79
|
+
state.events.each do |event|
|
80
|
+
workflow_class.class_exec(event.name, &undef_event_method_procs)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def superclass_workflow_spec
|
86
|
+
workflow_class.superclass.workflow_spec
|
87
|
+
end
|
88
|
+
|
89
|
+
def undef_state_method_proc
|
90
|
+
-> (state) { undef_method "#{state}?" }
|
91
|
+
end
|
92
|
+
|
93
|
+
def undef_event_method_procs
|
94
|
+
lambda do |event_name|
|
95
|
+
undef_method "#{event_name}!"
|
96
|
+
undef_method "can_#{event_name}?"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -1,3 +1,4 @@
|
|
1
|
+
# frozen_string_literal: true
|
1
2
|
require 'workflow/state'
|
2
3
|
require 'workflow/event'
|
3
4
|
require 'workflow/errors'
|
@@ -24,17 +25,6 @@ module Workflow
|
|
24
25
|
attr_reader :named_arguments
|
25
26
|
|
26
27
|
define_callbacks :spec_definition
|
27
|
-
set_callback(:spec_definition, :after, if: :define_revert_events?) do |spec|
|
28
|
-
spec.states.each do |state|
|
29
|
-
state.events.reject{ |e|
|
30
|
-
e.name.to_s =~ /^revert_/
|
31
|
-
}.select{|e| e.transitions.length == 1}.each do |event|
|
32
|
-
revert_event_name = "revert_#{event.name}".to_sym
|
33
|
-
from_state_for_revert = event.transitions.first.target_state
|
34
|
-
from_state_for_revert.on revert_event_name, to: state
|
35
|
-
end
|
36
|
-
end
|
37
|
-
end
|
38
28
|
|
39
29
|
set_callback(:spec_definition, :after) do |spec|
|
40
30
|
spec.states.each do |state|
|
@@ -42,7 +32,7 @@ module Workflow
|
|
42
32
|
event.transitions.each do |transition|
|
43
33
|
target_state = spec.find_state(transition.target_state)
|
44
34
|
if target_state.nil?
|
45
|
-
raise
|
35
|
+
raise Errors::NoSuchStateError.new(event, transition)
|
46
36
|
end
|
47
37
|
transition.target_state = target_state
|
48
38
|
end
|
@@ -50,12 +40,14 @@ module Workflow
|
|
50
40
|
end
|
51
41
|
end
|
52
42
|
|
43
|
+
set_callback(:spec_definition, :after, :define_tag_methods)
|
44
|
+
|
53
45
|
# Find the state with the given name.
|
54
46
|
#
|
55
47
|
# @param [Symbol] name Name of state to find.
|
56
48
|
# @return [Workflow::State] The state with the given name.
|
57
49
|
def find_state(name)
|
58
|
-
states.find{|t| t.name == name.to_sym}
|
50
|
+
states.find { |t| t.name == name.to_sym }
|
59
51
|
end
|
60
52
|
|
61
53
|
# @api private
|
@@ -75,18 +67,17 @@ module Workflow
|
|
75
67
|
#
|
76
68
|
# @param [Symbol] name name of state
|
77
69
|
# @param [Hash] meta Metadata to be stored with the state within the {Specification} object
|
70
|
+
# @param [Array] tags Tags to apply to the {Workflow::State} object
|
78
71
|
# @yield [] block defining events for this state.
|
79
72
|
# @return [nil]
|
80
|
-
def state(name, meta: {}, &events)
|
73
|
+
def state(name, tags: [], meta: {}, &events)
|
81
74
|
name = name.to_sym
|
82
|
-
new_state = Workflow::State.new(name, @states.length, meta: meta)
|
75
|
+
new_state = Workflow::State.new(name, @states.length, tags: tags, meta: meta)
|
83
76
|
@initial_state ||= new_state
|
84
77
|
@states << new_state
|
85
78
|
new_state.instance_eval(&events) if block_given?
|
86
79
|
end
|
87
80
|
|
88
|
-
|
89
|
-
|
90
81
|
# Specify attributes to make available on the {TransitionContext} object
|
91
82
|
# during transitions taking place in this specification.
|
92
83
|
# The attributes' values will be taken in order from the arguments passed to
|
@@ -98,13 +89,12 @@ module Workflow
|
|
98
89
|
@named_arguments = names
|
99
90
|
end
|
100
91
|
|
101
|
-
|
102
92
|
# Also create additional event transitions that will move each configured transition
|
103
93
|
# in the reverse direction.
|
104
94
|
#
|
105
95
|
# @return [nil]
|
106
96
|
#
|
107
|
-
|
97
|
+
# ```ruby
|
108
98
|
# class Article
|
109
99
|
# include Workflow
|
110
100
|
# workflow do
|
@@ -121,17 +111,50 @@ module Workflow
|
|
121
111
|
# a.current_state.name # => :bax
|
122
112
|
# a.transition! :revert_bar
|
123
113
|
# a.current_state.name # => :foo
|
124
|
-
|
114
|
+
# ```
|
125
115
|
def define_revert_events!
|
126
116
|
@define_revert_events = true
|
127
117
|
end
|
128
118
|
|
119
|
+
def unique_event_names
|
120
|
+
states.collect(&:events).flatten.collect(&:name).flatten.uniq
|
121
|
+
end
|
122
|
+
|
123
|
+
def define_revert_events?
|
124
|
+
@define_revert_events
|
125
|
+
end
|
126
|
+
|
129
127
|
private
|
130
128
|
|
129
|
+
module TagHelpers
|
130
|
+
def initial?
|
131
|
+
sequence.zero?
|
132
|
+
end
|
131
133
|
|
132
|
-
|
133
|
-
|
134
|
+
def terminal?
|
135
|
+
events.empty?
|
136
|
+
end
|
134
137
|
end
|
135
138
|
|
139
|
+
def define_tag_methods
|
140
|
+
tags = states.map(&:tags).flatten.uniq
|
141
|
+
tag_method_module = build_tag_method_module(tags)
|
142
|
+
states.each do |state|
|
143
|
+
state.send :extend, tag_method_module
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def build_tag_method_module(tags)
|
148
|
+
tag_method_module = Module.new
|
149
|
+
tag_method_module.send :include, TagHelpers
|
150
|
+
tag_method_module.class_eval do
|
151
|
+
tags.each do |tag|
|
152
|
+
define_method "#{tag}?" do
|
153
|
+
self.tags.include?(tag)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
tag_method_module
|
158
|
+
end
|
136
159
|
end
|
137
160
|
end
|