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,110 @@
|
|
1
|
+
require 'active_support/concern'
|
2
|
+
|
3
|
+
module Workflow
|
4
|
+
module Adapter
|
5
|
+
module ActiveRecordValidations
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
###
|
9
|
+
#
|
10
|
+
# Captures instance method calls of the form `:transitioning_from_<state_name>`
|
11
|
+
# and `:transitioning_to_<state_name>`.
|
12
|
+
#
|
13
|
+
# For use with validators, e.g. `validates :foobar, presence: true, if: :transitioning_to_some_state?`
|
14
|
+
#
|
15
|
+
def method_missing(method, *args, &block)
|
16
|
+
if method.to_s =~ /^transitioning_(from|to|via_event)_([\w_-]+)\?$/
|
17
|
+
class_eval "
|
18
|
+
def #{method}
|
19
|
+
transitioning? direction: '#{$~[1]}', state: '#{$~[2]}'
|
20
|
+
end
|
21
|
+
"
|
22
|
+
send method
|
23
|
+
else
|
24
|
+
super
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def valid?(context=nil)
|
29
|
+
if errors.any?
|
30
|
+
false
|
31
|
+
else
|
32
|
+
super
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def can_transition?(event_id)
|
37
|
+
event = current_state.find_event(event_id)
|
38
|
+
return false unless event
|
39
|
+
|
40
|
+
from = current_state.name
|
41
|
+
to = event.evaluate(self)
|
42
|
+
|
43
|
+
return false unless to
|
44
|
+
|
45
|
+
within_transition(from, to, event_id) do
|
46
|
+
valid?
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
###
|
51
|
+
#
|
52
|
+
# Executes the given block within a context that is able to give
|
53
|
+
# correct answers to the questions, `:transitioning_from_<old_state>?`.
|
54
|
+
# `:transitioning_to_<new_state>`, `:transitioning_via_event_<event_name>?`
|
55
|
+
#
|
56
|
+
# For use with validators, e.g. `validates :foobar, presence: true, if: :transitioning_to_some_state?`
|
57
|
+
#
|
58
|
+
# = Example:
|
59
|
+
#
|
60
|
+
# before_transition do |from, to, name, *args|
|
61
|
+
# @halted = !within_transition from, to, name do
|
62
|
+
# valid?
|
63
|
+
# end
|
64
|
+
# end
|
65
|
+
#
|
66
|
+
def within_transition(from, to, event, &block)
|
67
|
+
begin
|
68
|
+
@transition_context = TransitionContext.new \
|
69
|
+
from: from,
|
70
|
+
to: to,
|
71
|
+
event: event,
|
72
|
+
attributes: {},
|
73
|
+
event_args: []
|
74
|
+
|
75
|
+
return block.call()
|
76
|
+
ensure
|
77
|
+
@transition_context = nil
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
module ClassMethods
|
82
|
+
def halt_transition_unless_valid!
|
83
|
+
before_transition unless: :valid? do |model|
|
84
|
+
throw :abort
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def wrap_transition_in_transaction!
|
89
|
+
around_transition do |model, transition|
|
90
|
+
model.with_lock do
|
91
|
+
transition.call
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
private
|
98
|
+
|
99
|
+
def transitioning?(direction:, state:)
|
100
|
+
state = state.to_sym
|
101
|
+
return false unless transition_context
|
102
|
+
case direction
|
103
|
+
when 'from' then transition_context.from == state
|
104
|
+
when 'to' then transition_context.to == state
|
105
|
+
else transition_context.event == state
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
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,274 @@
|
|
1
|
+
module Workflow
|
2
|
+
module Callbacks
|
3
|
+
|
4
|
+
extend ActiveSupport::Concern
|
5
|
+
include ActiveSupport::Callbacks
|
6
|
+
|
7
|
+
CALLBACK_MAP = {
|
8
|
+
transition: :event,
|
9
|
+
exit: :from,
|
10
|
+
enter: :to
|
11
|
+
}.freeze
|
12
|
+
|
13
|
+
# @!attribute [r] transition_context
|
14
|
+
# During state transition events, contains a {TransitionContext} representing the transition underway.
|
15
|
+
# @return [TransitionContext] representation of current state transition
|
16
|
+
#
|
17
|
+
included do
|
18
|
+
attr_reader :transition_context
|
19
|
+
CALLBACK_MAP.keys.each do |type|
|
20
|
+
define_callbacks type,
|
21
|
+
skip_after_callbacks_if_terminated: true
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
module ClassMethods
|
26
|
+
##
|
27
|
+
# @!method before_transition
|
28
|
+
#
|
29
|
+
# :call-seq:
|
30
|
+
# before_transition(*instance_method_names, options={})
|
31
|
+
# before_transition(options={}, &block)
|
32
|
+
#
|
33
|
+
# Append a callback before transition.
|
34
|
+
# Instance methods used for `before` and `after` transitions
|
35
|
+
# receive no parameters. Instance methods for `around` transitions will be given a block,
|
36
|
+
# which must be yielded/called in order for the sequence to continue.
|
37
|
+
#
|
38
|
+
# Using a block notation, the first parameter will be an instance of the object
|
39
|
+
# under transition, while the second parameter (`around` transition only) will be
|
40
|
+
# the block which should be called for the sequence to continue.
|
41
|
+
#
|
42
|
+
# == Transition Metadata
|
43
|
+
#
|
44
|
+
# Within the callback you can access the `transition_context` instance variable,
|
45
|
+
# which will give you metadata and arguments passed to the transition.
|
46
|
+
# See Workflow::TransitionContext
|
47
|
+
#
|
48
|
+
# == Options
|
49
|
+
#
|
50
|
+
# === If / Unless
|
51
|
+
#
|
52
|
+
# The callback will run `if` or `unless` the named method returns a truthy value.
|
53
|
+
#
|
54
|
+
# # Assuming some_instance_method returns a boolean,
|
55
|
+
# before_transition :do_something, if: :some_instance_method
|
56
|
+
# before_transition :do_something_else, unless: :some_instance_method
|
57
|
+
#
|
58
|
+
# === Only / Except
|
59
|
+
#
|
60
|
+
# The callback will run `if` or `unless` the event being processed is in the list given
|
61
|
+
#
|
62
|
+
# # Run this callback only on the `accept` and `publish` events.
|
63
|
+
# before_transition :do_something, only: [:accept, :publish]
|
64
|
+
# # Run this callback on events other than the `accept` and `publish` events.
|
65
|
+
# before_transition :do_something_else, except: [:accept, :publish]
|
66
|
+
#
|
67
|
+
|
68
|
+
##
|
69
|
+
# @!method prepend_before_transition(*instance_method_names, options={})
|
70
|
+
#
|
71
|
+
# Something Interesting
|
72
|
+
#
|
73
|
+
# @overload prepend_before_transition(options={}, &block)
|
74
|
+
#
|
75
|
+
# Prepend a callback before transition, making it the first before transition called.
|
76
|
+
# Options are the same as for the standard #before_transition method.
|
77
|
+
|
78
|
+
##
|
79
|
+
# @!method skip_before_transition
|
80
|
+
#
|
81
|
+
# :call-seq: skip_before_transition(names)
|
82
|
+
#
|
83
|
+
# Skip a callback before transition.
|
84
|
+
# Options are the same as for the standard #before_transition method.
|
85
|
+
|
86
|
+
##
|
87
|
+
# @!method after_transition
|
88
|
+
#
|
89
|
+
# :call-seq:
|
90
|
+
# after_transition(*instance_method_names, options={})
|
91
|
+
# after_transition(options={}, &block)
|
92
|
+
#
|
93
|
+
# Append a callback after transition.
|
94
|
+
# Instance methods used for `before` and `after` transitions
|
95
|
+
# receive no parameters. Instance methods for `around` transitions will be given a block,
|
96
|
+
# which must be yielded/called in order for the sequence to continue.
|
97
|
+
#
|
98
|
+
# Using a block notation, the first parameter will be an instance of the object
|
99
|
+
# under transition, while the second parameter (`around` transition only) will be
|
100
|
+
# the block which should be called for the sequence to continue.
|
101
|
+
#
|
102
|
+
# == Transition Metadata
|
103
|
+
#
|
104
|
+
# Within the callback you can access the `transition_context` instance variable,
|
105
|
+
# which will give you metadata and arguments passed to the transition.
|
106
|
+
# See Workflow::TransitionContext
|
107
|
+
#
|
108
|
+
# == Options
|
109
|
+
#
|
110
|
+
# === If / Unless
|
111
|
+
#
|
112
|
+
# The callback will run `if` or `unless` the named method returns a truthy value.
|
113
|
+
#
|
114
|
+
# # Assuming some_instance_method returns a boolean,
|
115
|
+
# after_transition :do_something, if: :some_instance_method
|
116
|
+
# after_transition :do_something_else, unless: :some_instance_method
|
117
|
+
#
|
118
|
+
# === Only / Except
|
119
|
+
#
|
120
|
+
# The callback will run `if` or `unless` the event being processed is in the list given
|
121
|
+
#
|
122
|
+
# # Run this callback only on the `accept` and `publish` events.
|
123
|
+
# after_transition :do_something, only: [:accept, :publish]
|
124
|
+
# # Run this callback on events other than the `accept` and `publish` events.
|
125
|
+
# after_transition :do_something_else, except: [:accept, :publish]
|
126
|
+
#
|
127
|
+
|
128
|
+
##
|
129
|
+
# @!method prepend_after_transition(*instance_method_names, options={})
|
130
|
+
#
|
131
|
+
# Something Interesting
|
132
|
+
#
|
133
|
+
# @overload prepend_after_transition(options={}, &block)
|
134
|
+
#
|
135
|
+
# Prepend a callback after transition, making it the first after transition called.
|
136
|
+
# Options are the same as for the standard #after_transition method.
|
137
|
+
|
138
|
+
##
|
139
|
+
# @!method skip_after_transition
|
140
|
+
#
|
141
|
+
# :call-seq: skip_after_transition(names)
|
142
|
+
#
|
143
|
+
# Skip a callback after transition.
|
144
|
+
# Options are the same as for the standard #after_transition method.
|
145
|
+
|
146
|
+
##
|
147
|
+
# @!method around_transition
|
148
|
+
#
|
149
|
+
# :call-seq:
|
150
|
+
# around_transition(*instance_method_names, options={})
|
151
|
+
# around_transition(options={}, &block)
|
152
|
+
#
|
153
|
+
# Append a callback around transition.
|
154
|
+
# Instance methods used for `before` and `after` transitions
|
155
|
+
# receive no parameters. Instance methods for `around` transitions will be given a block,
|
156
|
+
# which must be yielded/called in order for the sequence to continue.
|
157
|
+
#
|
158
|
+
# Using a block notation, the first parameter will be an instance of the object
|
159
|
+
# under transition, while the second parameter (`around` transition only) will be
|
160
|
+
# the block which should be called for the sequence to continue.
|
161
|
+
#
|
162
|
+
# == Transition Metadata
|
163
|
+
#
|
164
|
+
# Within the callback you can access the `transition_context` instance variable,
|
165
|
+
# which will give you metadata and arguments passed to the transition.
|
166
|
+
# See Workflow::TransitionContext
|
167
|
+
#
|
168
|
+
# == Options
|
169
|
+
#
|
170
|
+
# === If / Unless
|
171
|
+
#
|
172
|
+
# The callback will run `if` or `unless` the named method returns a truthy value.
|
173
|
+
#
|
174
|
+
# # Assuming some_instance_method returns a boolean,
|
175
|
+
# around_transition :do_something, if: :some_instance_method
|
176
|
+
# around_transition :do_something_else, unless: :some_instance_method
|
177
|
+
#
|
178
|
+
# === Only / Except
|
179
|
+
#
|
180
|
+
# The callback will run `if` or `unless` the event being processed is in the list given
|
181
|
+
#
|
182
|
+
# # Run this callback only on the `accept` and `publish` events.
|
183
|
+
# around_transition :do_something, only: [:accept, :publish]
|
184
|
+
# # Run this callback on events other than the `accept` and `publish` events.
|
185
|
+
# around_transition :do_something_else, except: [:accept, :publish]
|
186
|
+
#
|
187
|
+
|
188
|
+
##
|
189
|
+
# @!method prepend_around_transition(*instance_method_names, options={})
|
190
|
+
#
|
191
|
+
# Something Interesting
|
192
|
+
#
|
193
|
+
# @overload prepend_around_transition(options={}, &block)
|
194
|
+
#
|
195
|
+
# Prepend a callback around transition, making it the first around transition called.
|
196
|
+
# Options are the same as for the standard #around_transition method.
|
197
|
+
|
198
|
+
##
|
199
|
+
# @!method skip_around_transition
|
200
|
+
#
|
201
|
+
# :call-seq: skip_around_transition(names)
|
202
|
+
#
|
203
|
+
# Skip a callback around transition.
|
204
|
+
# Options are the same as for the standard #around_transition method.
|
205
|
+
|
206
|
+
[:before, :after, :around].each do |callback|
|
207
|
+
CALLBACK_MAP.each do |type, context_attribute|
|
208
|
+
define_method "#{callback}_#{type}" do |*names, &blk|
|
209
|
+
_insert_callbacks(names, context_attribute, blk) do |name, options|
|
210
|
+
set_callback(type, callback, name, options)
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
define_method "prepend_#{callback}_#{type}" do |*names, &blk|
|
215
|
+
_insert_callbacks(names, context_attribute, blk) do |name, options|
|
216
|
+
set_callback(type, callback, name, options.merge(prepend: true))
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
# Skip a before, after or around callback. See _insert_callbacks
|
221
|
+
# for details on the allowed parameters.
|
222
|
+
define_method "skip_#{callback}_#{type}" do |*names|
|
223
|
+
_insert_callbacks(names, context_attribute) do |name, options|
|
224
|
+
skip_callback(type, callback, name, options)
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# *_action is the same as append_*_action
|
229
|
+
alias_method :"append_#{callback}_#{type}", :"#{callback}_#{type}"
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
private
|
234
|
+
def _insert_callbacks(callbacks, context_attribute, block = nil)
|
235
|
+
options = callbacks.extract_options!
|
236
|
+
_normalize_callback_options(options, context_attribute)
|
237
|
+
callbacks.push(block) if block
|
238
|
+
callbacks.each do |callback|
|
239
|
+
yield callback, options
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
def _normalize_callback_options(options, context_attribute)
|
244
|
+
_normalize_callback_option(options, context_attribute, :only, :if)
|
245
|
+
_normalize_callback_option(options, context_attribute, :except, :unless)
|
246
|
+
end
|
247
|
+
|
248
|
+
def _normalize_callback_option(options, context_attribute, from, to) # :nodoc:
|
249
|
+
if from = options[from]
|
250
|
+
_from = Array(from).map(&:to_sym).to_set
|
251
|
+
from = proc { |record|
|
252
|
+
_from.include? record.transition_context.send(context_attribute).to_sym
|
253
|
+
}
|
254
|
+
options[to] = Array(options[to]).unshift(from)
|
255
|
+
end
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
private
|
260
|
+
# TODO: Do something here.
|
261
|
+
def halted_callback_hook(filter)
|
262
|
+
end
|
263
|
+
|
264
|
+
def run_all_callbacks(&block)
|
265
|
+
catch(:abort) do
|
266
|
+
run_callbacks :transition do
|
267
|
+
throw(:abort) if false == run_callbacks(:exit) do
|
268
|
+
throw(:abort) if false == run_callbacks(:enter, &block)
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
end
|
273
|
+
end
|
274
|
+
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.uniq_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
|