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/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