validating-workflow 0.7.2
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.
- data/.gitignore +7 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +550 -0
- data/Rakefile +44 -0
- data/VERSION +1 -0
- data/lib/workflow/state_dependent_validations.rb +44 -0
- data/lib/workflow.rb +405 -0
- data/test/couchtiny_example.rb +46 -0
- data/test/main_test.rb +483 -0
- data/test/multiple_workflows_test.rb +84 -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.rb +1 -0
- metadata +98 -0
data/lib/workflow.rb
ADDED
@@ -0,0 +1,405 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
|
3
|
+
# See also README.markdown for documentation
|
4
|
+
module Workflow
|
5
|
+
|
6
|
+
class Specification
|
7
|
+
|
8
|
+
attr_accessor :states, :initial_state, :meta, :on_transition_proc
|
9
|
+
|
10
|
+
def initialize(meta = {}, &specification)
|
11
|
+
@states = Hash.new
|
12
|
+
@meta = meta
|
13
|
+
instance_eval(&specification)
|
14
|
+
end
|
15
|
+
|
16
|
+
def state_names
|
17
|
+
states.keys
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def state(name, meta = {:meta => {}}, &events_and_etc)
|
23
|
+
# meta[:meta] to keep the API consistent..., gah
|
24
|
+
new_state = Workflow::State.new(name, meta[:meta])
|
25
|
+
@initial_state = new_state if @states.empty?
|
26
|
+
@states[name.to_sym] = new_state
|
27
|
+
@scoped_state = new_state
|
28
|
+
instance_eval(&events_and_etc) if events_and_etc
|
29
|
+
end
|
30
|
+
|
31
|
+
def event(name, args = {}, &action)
|
32
|
+
target = args[:transitions_to] || args[:transition_to]
|
33
|
+
raise WorkflowDefinitionError.new(
|
34
|
+
"missing ':transitions_to' in workflow event definition for '#{name}'") \
|
35
|
+
if target.nil?
|
36
|
+
@scoped_state.events[name.to_sym] =
|
37
|
+
Workflow::Event.new(name, target, (args[:meta] or {}), &action)
|
38
|
+
end
|
39
|
+
|
40
|
+
def on_entry(&proc)
|
41
|
+
@scoped_state.on_entry = proc
|
42
|
+
end
|
43
|
+
|
44
|
+
def on_exit(&proc)
|
45
|
+
@scoped_state.on_exit = proc
|
46
|
+
end
|
47
|
+
|
48
|
+
def on_transition(&proc)
|
49
|
+
@on_transition_proc = proc
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
class TransitionHalted < Exception
|
54
|
+
|
55
|
+
attr_reader :halted_because
|
56
|
+
|
57
|
+
def initialize(msg = nil)
|
58
|
+
@halted_because = msg
|
59
|
+
super msg
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
class NoTransitionAllowed < Exception; end
|
65
|
+
|
66
|
+
class WorkflowError < Exception; end
|
67
|
+
|
68
|
+
class WorkflowDefinitionError < Exception; end
|
69
|
+
|
70
|
+
class State
|
71
|
+
|
72
|
+
attr_accessor :name, :events, :meta, :on_entry, :on_exit
|
73
|
+
|
74
|
+
def initialize(name, meta = {})
|
75
|
+
@name, @events, @meta = name, Hash.new, meta
|
76
|
+
end
|
77
|
+
|
78
|
+
def to_s
|
79
|
+
"#{name}"
|
80
|
+
end
|
81
|
+
|
82
|
+
def to_sym
|
83
|
+
name.to_sym
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
class Event
|
88
|
+
|
89
|
+
attr_accessor :name, :transitions_to, :meta, :action
|
90
|
+
|
91
|
+
def initialize(name, transitions_to, meta = {}, &action)
|
92
|
+
@name, @transitions_to, @meta, @action = name, transitions_to.to_sym, meta, action
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
|
97
|
+
module WorkflowClassMethods
|
98
|
+
attr_reader :workflow_spec
|
99
|
+
|
100
|
+
def workflow_column(column_name=nil)
|
101
|
+
if column_name
|
102
|
+
@workflow_state_column_name = column_name.to_sym
|
103
|
+
end
|
104
|
+
if !@workflow_state_column_name && superclass.respond_to?(:workflow_column)
|
105
|
+
@workflow_state_column_name = superclass.workflow_column
|
106
|
+
end
|
107
|
+
@workflow_state_column_name ||= :workflow_state
|
108
|
+
end
|
109
|
+
|
110
|
+
def workflow(&specification)
|
111
|
+
@workflow_spec = Specification.new(Hash.new, &specification)
|
112
|
+
@workflow_spec.states.values.each do |state|
|
113
|
+
state_name = state.name
|
114
|
+
module_eval do
|
115
|
+
define_method "#{state_name}?" do
|
116
|
+
state_name == current_state.name
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
state.events.values.each do |event|
|
121
|
+
event_name = event.name
|
122
|
+
module_eval do
|
123
|
+
define_method "#{event_name}!".to_sym do |*args|
|
124
|
+
process_event!(event_name, *args)
|
125
|
+
end
|
126
|
+
|
127
|
+
define_method "can_#{event_name}?" do
|
128
|
+
return self.current_state.events.include? event_name
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
module WorkflowInstanceMethods
|
137
|
+
def current_state
|
138
|
+
loaded_state = load_workflow_state
|
139
|
+
res = spec.states[loaded_state.to_sym] if loaded_state
|
140
|
+
res || spec.initial_state
|
141
|
+
end
|
142
|
+
|
143
|
+
# See the 'Guards' section in the README
|
144
|
+
# @return true if the last transition was halted by one of the transition callbacks.
|
145
|
+
def halted?
|
146
|
+
@halted
|
147
|
+
end
|
148
|
+
|
149
|
+
# @return the reason of the last transition abort as set by the previous
|
150
|
+
# call of `halt` or `halt!` method.
|
151
|
+
def halted_because
|
152
|
+
@halted_because
|
153
|
+
end
|
154
|
+
|
155
|
+
def process_event!(name, *args)
|
156
|
+
event = current_state.events[name.to_sym]
|
157
|
+
if event.nil?
|
158
|
+
raise NoTransitionAllowed.new \
|
159
|
+
"There is no event #{name.to_sym} defined for the #{current_state} state"
|
160
|
+
end
|
161
|
+
@halted_because = nil
|
162
|
+
@halted = false
|
163
|
+
return_value = run_action(event.action, *args) || run_action_callback(event.name, *args)
|
164
|
+
if @halted
|
165
|
+
return false
|
166
|
+
else
|
167
|
+
target_state = spec.states[event.transitions_to]
|
168
|
+
check_transition(event)
|
169
|
+
set_validation_triggers(current_state, target_state, name)
|
170
|
+
# TODO: shift this down by one line?!
|
171
|
+
# ... or possibly validate twice!
|
172
|
+
run_on_transition(current_state, target_state, name, *args) # if valid?
|
173
|
+
if valid?
|
174
|
+
transition(current_state, target_state, name, *args)
|
175
|
+
else
|
176
|
+
@halted_because = 'Validation for transition failed: %{errors}' % {:errors => self.errors.full_messages.join(', ')}
|
177
|
+
@halted = true
|
178
|
+
return false
|
179
|
+
end
|
180
|
+
return_value.nil? ? true : return_value
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def set_validation_triggers(current_state, target_state, event_name)
|
185
|
+
self.instance_variable_set "@validate_on_#{current_state}_exit", true
|
186
|
+
self.instance_variable_set "@validate_on_#{target_state}_entry", true
|
187
|
+
self.instance_variable_set "@validate_on_#{event_name}", true
|
188
|
+
end
|
189
|
+
|
190
|
+
def halt(reason = nil)
|
191
|
+
@halted_because = reason
|
192
|
+
@halted = true
|
193
|
+
end
|
194
|
+
|
195
|
+
def halt!(reason = nil)
|
196
|
+
@halted_because = reason
|
197
|
+
@halted = true
|
198
|
+
raise TransitionHalted.new(reason)
|
199
|
+
end
|
200
|
+
|
201
|
+
def spec
|
202
|
+
# check the singleton class first
|
203
|
+
class << self
|
204
|
+
return workflow_spec if workflow_spec
|
205
|
+
end
|
206
|
+
|
207
|
+
c = self.class
|
208
|
+
# using a simple loop instead of class_inheritable_accessor to avoid
|
209
|
+
# dependency on Rails' ActiveSupport
|
210
|
+
until c.workflow_spec || !(c.include? Workflow)
|
211
|
+
c = c.superclass
|
212
|
+
end
|
213
|
+
c.workflow_spec
|
214
|
+
end
|
215
|
+
|
216
|
+
private
|
217
|
+
|
218
|
+
def check_transition(event)
|
219
|
+
# Create a meaningful error message instead of
|
220
|
+
# "undefined method `on_entry' for nil:NilClass"
|
221
|
+
# Reported by Kyle Burton
|
222
|
+
if !spec.states[event.transitions_to]
|
223
|
+
raise WorkflowError.new("Event[#{event.name}]'s " +
|
224
|
+
"transitions_to[#{event.transitions_to}] is not a declared state.")
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def transition(from, to, name, *args)
|
229
|
+
run_on_exit(from, to, name, *args)
|
230
|
+
val = persist_workflow_state to.to_s
|
231
|
+
run_on_entry(to, from, name, *args)
|
232
|
+
val
|
233
|
+
end
|
234
|
+
|
235
|
+
def run_on_transition(from, to, event, *args)
|
236
|
+
instance_exec(from.name, to.name, event, *args, &spec.on_transition_proc) if spec.on_transition_proc
|
237
|
+
end
|
238
|
+
|
239
|
+
def run_action(action, *args)
|
240
|
+
instance_exec(*args, &action) if action
|
241
|
+
end
|
242
|
+
|
243
|
+
def run_action_callback(action_name, *args)
|
244
|
+
self.send action_name.to_sym, *args if self.respond_to?(action_name.to_sym)
|
245
|
+
end
|
246
|
+
|
247
|
+
def run_on_entry(state, prior_state, triggering_event, *args)
|
248
|
+
if state.on_entry
|
249
|
+
instance_exec(prior_state.name, triggering_event, *args, &state.on_entry)
|
250
|
+
else
|
251
|
+
hook_name = "on_#{state}_entry"
|
252
|
+
self.send hook_name, prior_state, triggering_event, *args if self.respond_to? hook_name
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
def run_on_exit(state, new_state, triggering_event, *args)
|
257
|
+
if state
|
258
|
+
if state.on_exit
|
259
|
+
instance_exec(new_state.name, triggering_event, *args, &state.on_exit)
|
260
|
+
else
|
261
|
+
hook_name = "on_#{state}_exit"
|
262
|
+
self.send hook_name, new_state, triggering_event, *args if self.respond_to? hook_name
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
# load_workflow_state and persist_workflow_state
|
268
|
+
# can be overriden to handle the persistence of the workflow state.
|
269
|
+
#
|
270
|
+
# Default (non ActiveRecord) implementation stores the current state
|
271
|
+
# in a variable.
|
272
|
+
#
|
273
|
+
# Default ActiveRecord implementation uses a 'workflow_state' database column.
|
274
|
+
def load_workflow_state
|
275
|
+
@workflow_state if instance_variable_defined? :@workflow_state
|
276
|
+
end
|
277
|
+
|
278
|
+
def persist_workflow_state(new_value)
|
279
|
+
@workflow_state = new_value
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
module ActiveRecordInstanceMethods
|
284
|
+
def load_workflow_state
|
285
|
+
read_attribute(self.class.workflow_column)
|
286
|
+
end
|
287
|
+
|
288
|
+
# On transition the new workflow state is immediately saved in the
|
289
|
+
# database.
|
290
|
+
def persist_workflow_state(new_value)
|
291
|
+
update_attribute self.class.workflow_column, new_value
|
292
|
+
end
|
293
|
+
|
294
|
+
private
|
295
|
+
|
296
|
+
# Motivation: even if NULL is stored in the workflow_state database column,
|
297
|
+
# the current_state is correctly recognized in the Ruby code. The problem
|
298
|
+
# arises when you want to SELECT records filtering by the value of initial
|
299
|
+
# state. That's why it is important to save the string with the name of the
|
300
|
+
# initial state in all the new records.
|
301
|
+
def write_initial_state
|
302
|
+
write_attribute self.class.workflow_column, current_state.to_s
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
module RemodelInstanceMethods
|
307
|
+
def load_workflow_state
|
308
|
+
send(self.class.workflow_column)
|
309
|
+
end
|
310
|
+
|
311
|
+
def persist_workflow_state(new_value)
|
312
|
+
update(self.class.workflow_column => new_value)
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
module MongoidInstanceMethods
|
317
|
+
include ActiveRecordInstanceMethods
|
318
|
+
# implementation of abstract method: saves new workflow state to DB
|
319
|
+
def persist_workflow_state(new_value)
|
320
|
+
self.write_attribute(self.class.workflow_column, new_value.to_s)
|
321
|
+
self.save! :validate => false
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
def self.included(klass)
|
326
|
+
klass.send :include, WorkflowInstanceMethods
|
327
|
+
klass.extend WorkflowClassMethods
|
328
|
+
if Object.const_defined?(:ActiveRecord)
|
329
|
+
if klass < ActiveRecord::Base
|
330
|
+
klass.send :include, ActiveRecordInstanceMethods
|
331
|
+
klass.before_validation :write_initial_state
|
332
|
+
end
|
333
|
+
elsif Object.const_defined?(:Remodel)
|
334
|
+
if klass < Remodel::Entity
|
335
|
+
klass.send :include, RemodelInstanceMethods
|
336
|
+
end
|
337
|
+
elsif Object.const_defined?(:Mongoid)
|
338
|
+
if klass.include? Mongoid::Document
|
339
|
+
klass.send :include, MongoidInstanceMethods
|
340
|
+
klass.after_initialize :write_initial_state
|
341
|
+
end
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
# Generates a `dot` graph of the workflow.
|
346
|
+
# Prerequisite: the `dot` binary. (Download from http://www.graphviz.org/)
|
347
|
+
# You can use this method in your own Rakefile like this:
|
348
|
+
#
|
349
|
+
# namespace :doc do
|
350
|
+
# desc "Generate a graph of the workflow."
|
351
|
+
# task :workflow => :environment do # needs access to the Rails environment
|
352
|
+
# Workflow::create_workflow_diagram(Order)
|
353
|
+
# end
|
354
|
+
# end
|
355
|
+
#
|
356
|
+
# You can influence the placement of nodes by specifying
|
357
|
+
# additional meta information in your states and transition descriptions.
|
358
|
+
# You can assign higher `doc_weight` value to the typical transitions
|
359
|
+
# in your workflow. All other states and transitions will be arranged
|
360
|
+
# around that main line. See also `weight` in the graphviz documentation.
|
361
|
+
# Example:
|
362
|
+
#
|
363
|
+
# state :new do
|
364
|
+
# event :approve, :transitions_to => :approved, :meta => {:doc_weight => 8}
|
365
|
+
# end
|
366
|
+
#
|
367
|
+
#
|
368
|
+
# @param klass A class with the Workflow mixin, for which you wish the graphical workflow representation
|
369
|
+
# @param [String] target_dir Directory, where to save the dot and the pdf files
|
370
|
+
# @param [String] graph_options You can change graph orientation, size etc. See graphviz documentation
|
371
|
+
def self.create_workflow_diagram(klass, target_dir='.', graph_options='rankdir="LR", size="7,11.6", ratio="fill"')
|
372
|
+
workflow_name = "#{klass.name.tableize}_workflow".gsub('/', '_')
|
373
|
+
fname = File.join(target_dir, "generated_#{workflow_name}")
|
374
|
+
File.open("#{fname}.dot", 'w') do |file|
|
375
|
+
file.puts %Q|
|
376
|
+
digraph #{workflow_name} {
|
377
|
+
graph [#{graph_options}];
|
378
|
+
node [shape=box];
|
379
|
+
edge [len=1];
|
380
|
+
|
|
381
|
+
|
382
|
+
klass.workflow_spec.states.each do |state_name, state|
|
383
|
+
file.puts %Q{ #{state.name} [label="#{state.name}"];}
|
384
|
+
state.events.each do |event_name, event|
|
385
|
+
meta_info = event.meta
|
386
|
+
if meta_info[:doc_weight]
|
387
|
+
weight_prop = ", weight=#{meta_info[:doc_weight]}"
|
388
|
+
else
|
389
|
+
weight_prop = ''
|
390
|
+
end
|
391
|
+
file.puts %Q{ #{state.name} -> #{event.transitions_to} [label="#{event_name.to_s.humanize}" #{weight_prop}];}
|
392
|
+
end
|
393
|
+
end
|
394
|
+
file.puts "}"
|
395
|
+
file.puts
|
396
|
+
end
|
397
|
+
`dot -Tpdf -o'#{fname}.pdf' '#{fname}.dot'`
|
398
|
+
puts "
|
399
|
+
Please run the following to open the generated file:
|
400
|
+
|
401
|
+
open '#{fname}.pdf'
|
402
|
+
|
403
|
+
"
|
404
|
+
end
|
405
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
require 'couchtiny'
|
3
|
+
require 'couchtiny/document'
|
4
|
+
require 'workflow'
|
5
|
+
|
6
|
+
class User < CouchTiny::Document
|
7
|
+
include Workflow
|
8
|
+
workflow do
|
9
|
+
state :submitted do
|
10
|
+
event :activate_via_link, :transitions_to => :proved_email
|
11
|
+
end
|
12
|
+
state :proved_email
|
13
|
+
end
|
14
|
+
|
15
|
+
def load_workflow_state
|
16
|
+
self[:workflow_state]
|
17
|
+
end
|
18
|
+
|
19
|
+
def persist_workflow_state(new_value)
|
20
|
+
self[:workflow_state] = new_value
|
21
|
+
save!
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
class CouchtinyExample < Test::Unit::TestCase
|
27
|
+
|
28
|
+
def setup
|
29
|
+
db = CouchTiny::Database.url("http://127.0.0.1:5984/test-workflow")
|
30
|
+
db.delete_database! rescue nil
|
31
|
+
db.create_database!
|
32
|
+
User.use_database db
|
33
|
+
end
|
34
|
+
|
35
|
+
test 'CouchDB persistence' do
|
36
|
+
user = User.new :email => 'manya@example.com'
|
37
|
+
user.save!
|
38
|
+
assert user.submitted?
|
39
|
+
user.activate_via_link!
|
40
|
+
assert user.proved_email?
|
41
|
+
|
42
|
+
reloaded_user = User.get user.id
|
43
|
+
puts reloaded_user.inspect
|
44
|
+
assert reloaded_user.proved_email?, 'Reloaded user should have the desired workflow state'
|
45
|
+
end
|
46
|
+
end
|