validating-workflow 0.7.2

Sign up to get free protection for your applications and to get access to all the features.
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