workflow-rails4 1.1.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 +21 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +663 -0
- data/Rakefile +31 -0
- data/gemfiles/Gemfile.rails-2.3.x +11 -0
- data/gemfiles/Gemfile.rails-3.x +11 -0
- data/gemfiles/Gemfile.rails-edge +12 -0
- data/lib/workflow.rb +277 -0
- data/lib/workflow/adapters/active_record.rb +66 -0
- data/lib/workflow/adapters/remodel.rb +15 -0
- data/lib/workflow/draw.rb +79 -0
- data/lib/workflow/errors.rb +18 -0
- data/lib/workflow/event.rb +18 -0
- data/lib/workflow/specification.rb +64 -0
- data/lib/workflow/state.rb +44 -0
- data/lib/workflow/version.rb +3 -0
- data/orders_workflow.png +0 -0
- data/test/active_record_scopes_test.rb +49 -0
- data/test/advanced_examples_test.rb +82 -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/inheritance_test.rb +60 -0
- data/test/main_test.rb +544 -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/readme_example.rb +37 -0
- data/test/test_helper.rb +39 -0
- data/test/without_active_record_test.rb +54 -0
- data/workflow.gemspec +32 -0
- metadata +202 -0
data/Rakefile
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake/testtask'
|
3
|
+
require 'rdoc/task'
|
4
|
+
|
5
|
+
require 'bundler'
|
6
|
+
Bundler.setup
|
7
|
+
Bundler::GemHelper.install_tasks
|
8
|
+
|
9
|
+
task :default => [:test]
|
10
|
+
|
11
|
+
require 'rake'
|
12
|
+
Rake::TestTask.new do |t|
|
13
|
+
t.libs << 'test'
|
14
|
+
t.verbose = true
|
15
|
+
t.warning = true
|
16
|
+
t.test_files = FileList['test/*_test.rb'] + FileList['test/new_versions/*_test.rb']
|
17
|
+
end
|
18
|
+
|
19
|
+
Rake::TestTask.new do |t|
|
20
|
+
t.name = 'test_without_new_versions'
|
21
|
+
t.libs << 'test'
|
22
|
+
t.verbose = true
|
23
|
+
t.warning = true
|
24
|
+
t.pattern = 'test/*_test.rb'
|
25
|
+
end
|
26
|
+
|
27
|
+
Rake::RDocTask.new do |rdoc|
|
28
|
+
rdoc.rdoc_files.include("lib/**/*.rb")
|
29
|
+
rdoc.options << "-S"
|
30
|
+
end
|
31
|
+
|
data/lib/workflow.rb
ADDED
@@ -0,0 +1,277 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
|
3
|
+
require 'workflow/specification'
|
4
|
+
require 'workflow/adapters/active_record'
|
5
|
+
require 'workflow/adapters/remodel'
|
6
|
+
|
7
|
+
# See also README.markdown for documentation
|
8
|
+
module Workflow
|
9
|
+
module ClassMethods
|
10
|
+
attr_reader :workflow_spec
|
11
|
+
|
12
|
+
def workflow_column(column_name=nil)
|
13
|
+
if column_name
|
14
|
+
@workflow_state_column_name = column_name.to_sym
|
15
|
+
end
|
16
|
+
if !@workflow_state_column_name && superclass.respond_to?(:workflow_column)
|
17
|
+
@workflow_state_column_name = superclass.workflow_column
|
18
|
+
end
|
19
|
+
@workflow_state_column_name ||= :workflow_state
|
20
|
+
end
|
21
|
+
|
22
|
+
def workflow(&specification)
|
23
|
+
assign_workflow Specification.new(Hash.new, &specification)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
# Creates the convinience methods like `my_transition!`
|
29
|
+
def assign_workflow(specification_object)
|
30
|
+
|
31
|
+
# Merging two workflow specifications can **not** be done automically, so
|
32
|
+
# just make the latest specification win. Same for inheritance -
|
33
|
+
# definition in the subclass wins.
|
34
|
+
if respond_to? :inherited_workflow_spec # undefine methods defined by the old workflow_spec
|
35
|
+
inherited_workflow_spec.states.values.each do |state|
|
36
|
+
state_name = state.name
|
37
|
+
module_eval do
|
38
|
+
undef_method "#{state_name}?"
|
39
|
+
end
|
40
|
+
|
41
|
+
state.events.values.each do |event|
|
42
|
+
event_name = event.name
|
43
|
+
module_eval do
|
44
|
+
undef_method "#{event_name}!".to_sym
|
45
|
+
undef_method "can_#{event_name}?"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
@workflow_spec = specification_object
|
52
|
+
@workflow_spec.states.values.each do |state|
|
53
|
+
state_name = state.name
|
54
|
+
module_eval do
|
55
|
+
define_method "#{state_name}?" do
|
56
|
+
state_name == current_state.name
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
state.events.values.each do |event|
|
61
|
+
event_name = event.name
|
62
|
+
module_eval do
|
63
|
+
define_method "#{event_name}!".to_sym do |*args|
|
64
|
+
process_event!(event_name, *args)
|
65
|
+
end
|
66
|
+
|
67
|
+
define_method "can_#{event_name}?" do
|
68
|
+
return self.current_state.events.include?(event_name)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
module InstanceMethods
|
77
|
+
|
78
|
+
def current_state
|
79
|
+
loaded_state = load_workflow_state
|
80
|
+
res = spec.states[loaded_state.to_sym] if loaded_state
|
81
|
+
res || spec.initial_state
|
82
|
+
end
|
83
|
+
|
84
|
+
# See the 'Guards' section in the README
|
85
|
+
# @return true if the last transition was halted by one of the transition callbacks.
|
86
|
+
def halted?
|
87
|
+
@halted
|
88
|
+
end
|
89
|
+
|
90
|
+
# @return the reason of the last transition abort as set by the previous
|
91
|
+
# call of `halt` or `halt!` method.
|
92
|
+
def halted_because
|
93
|
+
@halted_because
|
94
|
+
end
|
95
|
+
|
96
|
+
def process_event!(name, *args)
|
97
|
+
event = current_state.events[name.to_sym]
|
98
|
+
raise NoTransitionAllowed.new(
|
99
|
+
"There is no event #{name.to_sym} defined for the #{current_state} state") \
|
100
|
+
if event.nil?
|
101
|
+
@halted_because = nil
|
102
|
+
@halted = false
|
103
|
+
|
104
|
+
check_transition(event)
|
105
|
+
|
106
|
+
from = current_state
|
107
|
+
to = spec.states[event.transitions_to]
|
108
|
+
|
109
|
+
run_before_transition(from, to, name, *args)
|
110
|
+
return false if @halted
|
111
|
+
|
112
|
+
begin
|
113
|
+
return_value = run_action(event.action, *args) || run_action_callback(event.name, *args)
|
114
|
+
rescue Exception => e
|
115
|
+
run_on_error(e, from, to, name, *args)
|
116
|
+
end
|
117
|
+
|
118
|
+
return false if @halted
|
119
|
+
|
120
|
+
run_on_transition(from, to, name, *args)
|
121
|
+
|
122
|
+
run_on_exit(from, to, name, *args)
|
123
|
+
|
124
|
+
transition_value = persist_workflow_state to.to_s
|
125
|
+
|
126
|
+
run_on_entry(to, from, name, *args)
|
127
|
+
|
128
|
+
run_after_transition(from, to, name, *args)
|
129
|
+
|
130
|
+
return_value.nil? ? transition_value : return_value
|
131
|
+
end
|
132
|
+
|
133
|
+
def halt(reason = nil)
|
134
|
+
@halted_because = reason
|
135
|
+
@halted = true
|
136
|
+
end
|
137
|
+
|
138
|
+
def halt!(reason = nil)
|
139
|
+
@halted_because = reason
|
140
|
+
@halted = true
|
141
|
+
raise TransitionHalted.new(reason)
|
142
|
+
end
|
143
|
+
|
144
|
+
def spec
|
145
|
+
# check the singleton class first
|
146
|
+
class << self
|
147
|
+
return workflow_spec if workflow_spec
|
148
|
+
end
|
149
|
+
|
150
|
+
c = self.class
|
151
|
+
# using a simple loop instead of class_inheritable_accessor to avoid
|
152
|
+
# dependency on Rails' ActiveSupport
|
153
|
+
until c.workflow_spec || !(c.include? Workflow)
|
154
|
+
c = c.superclass
|
155
|
+
end
|
156
|
+
c.workflow_spec
|
157
|
+
end
|
158
|
+
|
159
|
+
private
|
160
|
+
|
161
|
+
def check_transition(event)
|
162
|
+
# Create a meaningful error message instead of
|
163
|
+
# "undefined method `on_entry' for nil:NilClass"
|
164
|
+
# Reported by Kyle Burton
|
165
|
+
if !spec.states[event.transitions_to]
|
166
|
+
raise WorkflowError.new("Event[#{event.name}]'s " +
|
167
|
+
"transitions_to[#{event.transitions_to}] is not a declared state.")
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def run_before_transition(from, to, event, *args)
|
172
|
+
instance_exec(from.name, to.name, event, *args, &spec.before_transition_proc) if
|
173
|
+
spec.before_transition_proc
|
174
|
+
end
|
175
|
+
|
176
|
+
def run_on_error(error, from, to, event, *args)
|
177
|
+
if spec.on_error_proc
|
178
|
+
instance_exec(error, from.name, to.name, event, *args, &spec.on_error_proc)
|
179
|
+
halt(error.message)
|
180
|
+
else
|
181
|
+
raise error
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
def run_on_transition(from, to, event, *args)
|
186
|
+
instance_exec(from.name, to.name, event, *args, &spec.on_transition_proc) if spec.on_transition_proc
|
187
|
+
end
|
188
|
+
|
189
|
+
def run_after_transition(from, to, event, *args)
|
190
|
+
instance_exec(from.name, to.name, event, *args, &spec.after_transition_proc) if
|
191
|
+
spec.after_transition_proc
|
192
|
+
end
|
193
|
+
|
194
|
+
def run_action(action, *args)
|
195
|
+
instance_exec(*args, &action) if action
|
196
|
+
end
|
197
|
+
|
198
|
+
def has_callback?(action)
|
199
|
+
# 1. public callback method or
|
200
|
+
# 2. protected method somewhere in the class hierarchy or
|
201
|
+
# 3. private in the immediate class (parent classes ignored)
|
202
|
+
self.respond_to?(action) or
|
203
|
+
self.class.protected_method_defined?(action) or
|
204
|
+
self.private_methods(false).map(&:to_sym).include?(action)
|
205
|
+
end
|
206
|
+
|
207
|
+
def run_action_callback(action_name, *args)
|
208
|
+
action = action_name.to_sym
|
209
|
+
self.send(action, *args) if has_callback?(action)
|
210
|
+
end
|
211
|
+
|
212
|
+
def run_on_entry(state, prior_state, triggering_event, *args)
|
213
|
+
if state.on_entry
|
214
|
+
instance_exec(prior_state.name, triggering_event, *args, &state.on_entry)
|
215
|
+
else
|
216
|
+
hook_name = "on_#{state}_entry"
|
217
|
+
self.send hook_name, prior_state, triggering_event, *args if self.respond_to? hook_name
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
def run_on_exit(state, new_state, triggering_event, *args)
|
222
|
+
if state
|
223
|
+
if state.on_exit
|
224
|
+
instance_exec(new_state.name, triggering_event, *args, &state.on_exit)
|
225
|
+
else
|
226
|
+
hook_name = "on_#{state}_exit"
|
227
|
+
self.send hook_name, new_state, triggering_event, *args if self.respond_to? hook_name
|
228
|
+
end
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
# load_workflow_state and persist_workflow_state
|
233
|
+
# can be overriden to handle the persistence of the workflow state.
|
234
|
+
#
|
235
|
+
# Default (non ActiveRecord) implementation stores the current state
|
236
|
+
# in a variable.
|
237
|
+
#
|
238
|
+
# Default ActiveRecord implementation uses a 'workflow_state' database column.
|
239
|
+
def load_workflow_state
|
240
|
+
@workflow_state if instance_variable_defined? :@workflow_state
|
241
|
+
end
|
242
|
+
|
243
|
+
def persist_workflow_state(new_value)
|
244
|
+
@workflow_state = new_value
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
def self.included(klass)
|
249
|
+
klass.send :include, InstanceMethods
|
250
|
+
|
251
|
+
# backup the parent workflow spec, making accessible through #inherited_workflow_spec
|
252
|
+
if klass.superclass.respond_to?(:workflow_spec, true)
|
253
|
+
klass.module_eval do
|
254
|
+
# see http://stackoverflow.com/a/2495650/111995 for implementation explanation
|
255
|
+
pro = Proc.new { klass.superclass.workflow_spec }
|
256
|
+
singleton_class = class << self; self; end
|
257
|
+
singleton_class.send(:define_method, :inherited_workflow_spec) do
|
258
|
+
pro.call
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
klass.extend ClassMethods
|
264
|
+
|
265
|
+
if Object.const_defined?(:ActiveRecord)
|
266
|
+
if klass < ActiveRecord::Base
|
267
|
+
klass.send :include, Adapter::ActiveRecord::InstanceMethods
|
268
|
+
klass.send :extend, Adapter::ActiveRecord::Scopes
|
269
|
+
klass.before_validation :write_initial_state
|
270
|
+
end
|
271
|
+
elsif Object.const_defined?(:Remodel)
|
272
|
+
if klass < Adapter::Remodel::Entity
|
273
|
+
klass.send :include, Remodel::InstanceMethods
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Workflow
|
2
|
+
module Adapter
|
3
|
+
module ActiveRecord
|
4
|
+
module InstanceMethods
|
5
|
+
def load_workflow_state
|
6
|
+
read_attribute(self.class.workflow_column)
|
7
|
+
end
|
8
|
+
|
9
|
+
# On transition the new workflow state is immediately saved in the
|
10
|
+
# database.
|
11
|
+
def persist_workflow_state(new_value)
|
12
|
+
if self.respond_to? :update_column
|
13
|
+
# Rails 3.1 or newer
|
14
|
+
update_column self.class.workflow_column, new_value
|
15
|
+
else
|
16
|
+
# older Rails; beware of side effect: other (pending) attribute changes will be persisted too
|
17
|
+
update_attribute self.class.workflow_column, new_value
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
# Motivation: even if NULL is stored in the workflow_state database column,
|
24
|
+
# the current_state is correctly recognized in the Ruby code. The problem
|
25
|
+
# arises when you want to SELECT records filtering by the value of initial
|
26
|
+
# state. That's why it is important to save the string with the name of the
|
27
|
+
# initial state in all the new records.
|
28
|
+
def write_initial_state
|
29
|
+
write_attribute self.class.workflow_column, current_state.to_s
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# This module will automatically generate ActiveRecord scopes based on workflow states.
|
34
|
+
# The name of each generated scope will be something like `with_<state_name>_state`
|
35
|
+
#
|
36
|
+
# Examples:
|
37
|
+
#
|
38
|
+
# Article.with_pending_state # => ActiveRecord::Relation
|
39
|
+
#
|
40
|
+
# Example above just adds `where(:state_column_name => 'pending')` to AR query and returns
|
41
|
+
# ActiveRecord::Relation.
|
42
|
+
module Scopes
|
43
|
+
def self.extended(object)
|
44
|
+
class << object
|
45
|
+
alias_method :workflow_without_scopes, :workflow unless method_defined?(:workflow_without_scopes)
|
46
|
+
alias_method :workflow, :workflow_with_scopes
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def workflow_with_scopes(&specification)
|
51
|
+
workflow_without_scopes(&specification)
|
52
|
+
states = workflow_spec.states.values
|
53
|
+
eigenclass = class << self; self; end
|
54
|
+
|
55
|
+
states.each do |state|
|
56
|
+
# Use eigenclass instead of `define_singleton_method`
|
57
|
+
# to be compatible with Ruby 1.8+
|
58
|
+
eigenclass.send(:define_method, "with_#{state}_state") do
|
59
|
+
where("#{table_name}.#{self.workflow_column.to_sym} = ?", state.to_s)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
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'
|
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.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
|