railsware-workflow 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,44 @@
1
+ require 'rubygems'
2
+ require 'rake/gempackagetask'
3
+ require 'rake/testtask'
4
+ require 'rake/rdoctask'
5
+
6
+ task :default => [:test]
7
+
8
+ Rake::TestTask.new do |t|
9
+ t.verbose = true
10
+ t.warning = true
11
+ t.pattern = 'test/*_test.rb'
12
+ end
13
+
14
+ Rake::RDocTask.new do |rdoc|
15
+ rdoc.rdoc_files.include("lib/**/*.rb")
16
+ rdoc.options << "-S"
17
+ end
18
+
19
+ begin
20
+ require 'jeweler'
21
+ Jeweler::Tasks.new do |gemspec|
22
+ gemspec.name = "workflow"
23
+ gemspec.rubyforge_project = 'workflow'
24
+ gemspec.email = "vladimir@geekq.net"
25
+ gemspec.homepage = "http://www.geekq.net/workflow/"
26
+ gemspec.authors = ["Vladimir Dobriakov"]
27
+ gemspec.summary = "A replacement for acts_as_state_machine."
28
+ gemspec.description = <<-EOS
29
+ Workflow is a finite-state-machine-inspired API for modeling and interacting
30
+ with what we tend to refer to as 'workflow'.
31
+
32
+ * nice DSL to describe your states, events and transitions
33
+ * robust integration with ActiveRecord and non relational data stores
34
+ * various hooks for single transitions, entering state etc.
35
+ * convenient access to the workflow specification: list states, possible events
36
+ for particular state
37
+ EOS
38
+
39
+ Jeweler::GemcutterTasks.new
40
+ end
41
+ rescue LoadError
42
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
43
+ end
44
+
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.8.0
@@ -0,0 +1,400 @@
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,
9
+ :on_transition_proc, :before_transition_proc, :after_transition_proc
10
+
11
+ def initialize(meta = {}, &specification)
12
+ @states = Hash.new
13
+ @meta = meta
14
+ instance_eval(&specification)
15
+ end
16
+
17
+ def state_names
18
+ states.keys
19
+ end
20
+
21
+ private
22
+
23
+ def state(name, meta = {:meta => {}}, &events_and_etc)
24
+ # meta[:meta] to keep the API consistent..., gah
25
+ new_state = Workflow::State.new(name, meta[:meta])
26
+ @initial_state = new_state if @states.empty?
27
+ @states[name.to_sym] = new_state
28
+ @scoped_state = new_state
29
+ instance_eval(&events_and_etc) if events_and_etc
30
+ end
31
+
32
+ def event(name, args = {}, &action)
33
+ target = args[:transitions_to] || args[:transition_to]
34
+ raise WorkflowDefinitionError.new(
35
+ "missing ':transitions_to' in workflow event definition for '#{name}'") \
36
+ if target.nil?
37
+ @scoped_state.events[name.to_sym] =
38
+ Workflow::Event.new(name, target, (args[:meta] or {}), &action)
39
+ end
40
+
41
+ def on_entry(&proc)
42
+ @scoped_state.on_entry = proc
43
+ end
44
+
45
+ def on_exit(&proc)
46
+ @scoped_state.on_exit = proc
47
+ end
48
+
49
+ def after_transition(&proc)
50
+ @after_transition_proc = proc
51
+ end
52
+
53
+ def before_transition(&proc)
54
+ @before_transition_proc = proc
55
+ end
56
+
57
+ def on_transition(&proc)
58
+ @on_transition_proc = proc
59
+ end
60
+ end
61
+
62
+ class TransitionHalted < Exception
63
+
64
+ attr_reader :halted_because
65
+
66
+ def initialize(msg = nil)
67
+ @halted_because = msg
68
+ super msg
69
+ end
70
+
71
+ end
72
+
73
+ class NoTransitionAllowed < Exception; end
74
+
75
+ class WorkflowError < Exception; end
76
+
77
+ class WorkflowDefinitionError < Exception; end
78
+
79
+ class State
80
+
81
+ attr_accessor :name, :events, :meta, :on_entry, :on_exit
82
+
83
+ def initialize(name, meta = {})
84
+ @name, @events, @meta = name, Hash.new, meta
85
+ end
86
+
87
+ def to_s
88
+ "#{name}"
89
+ end
90
+
91
+ def to_sym
92
+ name.to_sym
93
+ end
94
+ end
95
+
96
+ class Event
97
+
98
+ attr_accessor :name, :transitions_to, :meta, :action
99
+
100
+ def initialize(name, transitions_to, meta = {}, &action)
101
+ @name, @transitions_to, @meta, @action = name, transitions_to.to_sym, meta, action
102
+ end
103
+
104
+ end
105
+
106
+ module WorkflowClassMethods
107
+ attr_reader :workflow_spec
108
+
109
+ def workflow_column(column_name=nil)
110
+ if column_name
111
+ @workflow_state_column_name = column_name.to_sym
112
+ end
113
+ if !@workflow_state_column_name && superclass.respond_to?(:workflow_column)
114
+ @workflow_state_column_name = superclass.workflow_column
115
+ end
116
+ @workflow_state_column_name ||= :workflow_state
117
+ end
118
+
119
+ def workflow(&specification)
120
+ @workflow_spec = Specification.new(Hash.new, &specification)
121
+ @workflow_spec.states.values.each do |state|
122
+ state_name = state.name
123
+ module_eval do
124
+ define_method "#{state_name}?" do
125
+ state_name == current_state.name
126
+ end
127
+ end
128
+
129
+ state.events.values.each do |event|
130
+ event_name = event.name
131
+ module_eval do
132
+ define_method "#{event_name}!".to_sym do |*args|
133
+ process_event!(event_name, *args)
134
+ end
135
+
136
+ define_method "can_#{event_name}?" do
137
+ return self.current_state.events.include?(event_name)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ module WorkflowInstanceMethods
146
+ def current_state
147
+ loaded_state = load_workflow_state
148
+ res = spec.states[loaded_state.to_sym] if loaded_state
149
+ res || spec.initial_state
150
+ end
151
+
152
+ # See the 'Guards' section in the README
153
+ # @return true if the last transition was halted by one of the transition callbacks.
154
+ def halted?
155
+ @halted
156
+ end
157
+
158
+ # @return the reason of the last transition abort as set by the previous
159
+ # call of `halt` or `halt!` method.
160
+ def halted_because
161
+ @halted_because
162
+ end
163
+
164
+ def process_event!(name, *args)
165
+ event = current_state.events[name.to_sym]
166
+ raise NoTransitionAllowed.new(
167
+ "There is no event #{name.to_sym} defined for the #{current_state} state") \
168
+ if event.nil?
169
+ @halted_because = nil
170
+ @halted = false
171
+
172
+ check_transition(event)
173
+
174
+ from = current_state
175
+ to = spec.states[event.transitions_to]
176
+
177
+ run_before_transition(current_state, spec.states[event.transitions_to], name, *args)
178
+ return false if @halted
179
+
180
+ return_value = run_action(event.action, *args) || run_action_callback(event.name, *args)
181
+ return false if @halted
182
+
183
+ run_on_transition(from, to, name, *args)
184
+
185
+ run_on_exit(from, to, name, *args)
186
+
187
+ transition_value = persist_workflow_state to.to_s
188
+
189
+ run_on_entry(to, from, name, *args)
190
+
191
+ run_after_transition(from, to, name, *args)
192
+
193
+ return_value.nil? ? transition_value : return_value
194
+ end
195
+
196
+ def halt(reason = nil)
197
+ @halted_because = reason
198
+ @halted = true
199
+ end
200
+
201
+ def halt!(reason = nil)
202
+ @halted_because = reason
203
+ @halted = true
204
+ raise TransitionHalted.new(reason)
205
+ end
206
+
207
+ def spec
208
+ # check the singleton class first
209
+ class << self
210
+ return workflow_spec if workflow_spec
211
+ end
212
+
213
+ c = self.class
214
+ # using a simple loop instead of class_inheritable_accessor to avoid
215
+ # dependency on Rails' ActiveSupport
216
+ until c.workflow_spec || !(c.include? Workflow)
217
+ c = c.superclass
218
+ end
219
+ c.workflow_spec
220
+ end
221
+
222
+ private
223
+
224
+ def check_transition(event)
225
+ # Create a meaningful error message instead of
226
+ # "undefined method `on_entry' for nil:NilClass"
227
+ # Reported by Kyle Burton
228
+ if !spec.states[event.transitions_to]
229
+ raise WorkflowError.new("Event[#{event.name}]'s " +
230
+ "transitions_to[#{event.transitions_to}] is not a declared state.")
231
+ end
232
+ end
233
+
234
+ def run_before_transition(from, to, event, *args)
235
+ instance_exec(from.name, to.name, event, *args, &spec.before_transition_proc) if
236
+ spec.before_transition_proc
237
+ end
238
+
239
+ def run_on_transition(from, to, event, *args)
240
+ instance_exec(from.name, to.name, event, *args, &spec.on_transition_proc) if spec.on_transition_proc
241
+ end
242
+
243
+ def run_after_transition(from, to, event, *args)
244
+ instance_exec(from.name, to.name, event, *args, &spec.after_transition_proc) if
245
+ spec.after_transition_proc
246
+ end
247
+
248
+ def run_action(action, *args)
249
+ instance_exec(*args, &action) if action
250
+ end
251
+
252
+ def run_action_callback(action_name, *args)
253
+ self.send action_name.to_sym, *args if self.respond_to?(action_name.to_sym)
254
+ end
255
+
256
+ def run_on_entry(state, prior_state, triggering_event, *args)
257
+ if state.on_entry
258
+ instance_exec(prior_state.name, triggering_event, *args, &state.on_entry)
259
+ else
260
+ hook_name = "on_#{state}_entry"
261
+ self.send hook_name, prior_state, triggering_event, *args if self.respond_to? hook_name
262
+ end
263
+ end
264
+
265
+ def run_on_exit(state, new_state, triggering_event, *args)
266
+ if state
267
+ if state.on_exit
268
+ instance_exec(new_state.name, triggering_event, *args, &state.on_exit)
269
+ else
270
+ hook_name = "on_#{state}_exit"
271
+ self.send hook_name, new_state, triggering_event, *args if self.respond_to? hook_name
272
+ end
273
+ end
274
+ end
275
+
276
+ # load_workflow_state and persist_workflow_state
277
+ # can be overriden to handle the persistence of the workflow state.
278
+ #
279
+ # Default (non ActiveRecord) implementation stores the current state
280
+ # in a variable.
281
+ #
282
+ # Default ActiveRecord implementation uses a 'workflow_state' database column.
283
+ def load_workflow_state
284
+ @workflow_state if instance_variable_defined? :@workflow_state
285
+ end
286
+
287
+ def persist_workflow_state(new_value)
288
+ @workflow_state = new_value
289
+ end
290
+ end
291
+
292
+ module ActiveRecordInstanceMethods
293
+ def load_workflow_state
294
+ read_attribute(self.class.workflow_column)
295
+ end
296
+
297
+ # On transition the new workflow state is immediately saved in the
298
+ # database.
299
+ def persist_workflow_state(new_value)
300
+ update_attribute self.class.workflow_column, new_value
301
+ end
302
+
303
+ private
304
+
305
+ # Motivation: even if NULL is stored in the workflow_state database column,
306
+ # the current_state is correctly recognized in the Ruby code. The problem
307
+ # arises when you want to SELECT records filtering by the value of initial
308
+ # state. That's why it is important to save the string with the name of the
309
+ # initial state in all the new records.
310
+ def write_initial_state
311
+ write_attribute self.class.workflow_column, current_state.to_s
312
+ end
313
+ end
314
+
315
+ module RemodelInstanceMethods
316
+ def load_workflow_state
317
+ send(self.class.workflow_column)
318
+ end
319
+
320
+ def persist_workflow_state(new_value)
321
+ update(self.class.workflow_column => new_value)
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
+ end
338
+ end
339
+
340
+ # Generates a `dot` graph of the workflow.
341
+ # Prerequisite: the `dot` binary. (Download from http://www.graphviz.org/)
342
+ # You can use this method in your own Rakefile like this:
343
+ #
344
+ # namespace :doc do
345
+ # desc "Generate a graph of the workflow."
346
+ # task :workflow => :environment do # needs access to the Rails environment
347
+ # Workflow::create_workflow_diagram(Order)
348
+ # end
349
+ # end
350
+ #
351
+ # You can influence the placement of nodes by specifying
352
+ # additional meta information in your states and transition descriptions.
353
+ # You can assign higher `doc_weight` value to the typical transitions
354
+ # in your workflow. All other states and transitions will be arranged
355
+ # around that main line. See also `weight` in the graphviz documentation.
356
+ # Example:
357
+ #
358
+ # state :new do
359
+ # event :approve, :transitions_to => :approved, :meta => {:doc_weight => 8}
360
+ # end
361
+ #
362
+ #
363
+ # @param klass A class with the Workflow mixin, for which you wish the graphical workflow representation
364
+ # @param [String] target_dir Directory, where to save the dot and the pdf files
365
+ # @param [String] graph_options You can change graph orientation, size etc. See graphviz documentation
366
+ def self.create_workflow_diagram(klass, target_dir='.', graph_options='rankdir="LR", size="7,11.6", ratio="fill"')
367
+ workflow_name = "#{klass.name.tableize}_workflow".gsub('/', '_')
368
+ fname = File.join(target_dir, "generated_#{workflow_name}")
369
+ File.open("#{fname}.dot", 'w') do |file|
370
+ file.puts %Q|
371
+ digraph #{workflow_name} {
372
+ graph [#{graph_options}];
373
+ node [shape=box];
374
+ edge [len=1];
375
+ |
376
+
377
+ klass.workflow_spec.states.each do |state_name, state|
378
+ file.puts %Q{ #{state.name} [label="#{state.name}"];}
379
+ state.events.each do |event_name, event|
380
+ meta_info = event.meta
381
+ if meta_info[:doc_weight]
382
+ weight_prop = ", weight=#{meta_info[:doc_weight]}"
383
+ else
384
+ weight_prop = ''
385
+ end
386
+ file.puts %Q{ #{state.name} -> #{event.transitions_to} [label="#{event_name.to_s.humanize}" #{weight_prop}];}
387
+ end
388
+ end
389
+ file.puts "}"
390
+ file.puts
391
+ end
392
+ `dot -Tpdf -o'#{fname}.pdf' '#{fname}.dot'`
393
+ puts "
394
+ Please run the following to open the generated file:
395
+
396
+ open '#{fname}.pdf'
397
+
398
+ "
399
+ end
400
+ end
@@ -0,0 +1,118 @@
1
+ require File.join(File.dirname(__FILE__), 'test_helper')
2
+
3
+ $VERBOSE = false
4
+ require 'active_record'
5
+ require 'sqlite3'
6
+ require 'workflow'
7
+
8
+ ActiveRecord::Migration.verbose = false
9
+
10
+ # Transition based validation
11
+ # ---------------------------
12
+ # If you are using ActiveRecord you might want to define different validations
13
+ # for different transitions. There is a `validates_presence_of` hook that let's
14
+ # you specify the attributes that need to be present for an successful transition.
15
+ # If the object is not valid at the end of the transition event the transition
16
+ # is halted and a TransitionHalted exception is thrown.
17
+ #
18
+ # Here is a sample that illustrates how to use the presence validation:
19
+ # (use case suggested by http://github.com/southdesign)
20
+ class Article < ActiveRecord::Base
21
+ include Workflow
22
+ workflow do
23
+ state :new do
24
+ event :accept, :transitions_to => :accepted, :meta => {:validates_presence_of => [:title, :body]}
25
+ event :reject, :transitions_to => :rejected
26
+ end
27
+ state :accepted do
28
+ event :blame, :transitions_to => :blamed, :meta => {:validates_presence_of => [:title, :body, :blame_reason]}
29
+ event :delete, :transitions_to => :deleted
30
+ end
31
+ state :rejected do
32
+ event :delete, :transitions_to => :deleted
33
+ end
34
+ state :blamed do
35
+ event :delete, :transitions_to => :deleted
36
+ end
37
+ state :deleted do
38
+ event :accept, :transitions_to => :accepted
39
+ end
40
+
41
+ on_transition do |from, to, triggering_event, *event_args|
42
+ if self.class.superclass.to_s.split("::").first == "ActiveRecord"
43
+ singleton = class << self; self end
44
+ validations = Proc.new {}
45
+
46
+ meta = Article.workflow_spec.states[from].events[triggering_event].meta
47
+ fields_to_validate = meta[:validates_presence_of]
48
+ if fields_to_validate
49
+ validations = Proc.new {
50
+ errors.add_on_blank(fields_to_validate) if fields_to_validate
51
+ }
52
+ end
53
+
54
+ singleton.send :define_method, :validate, &validations
55
+ halt! "Event[#{triggering_event}]'s transitions_to[#{to}] is not valid." if self.invalid?
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ class AdvancedHooksAndValidationTest < ActiveRecordTestCase
62
+
63
+ def setup
64
+ super
65
+
66
+ ActiveRecord::Schema.define do
67
+ create_table :articles do |t|
68
+ t.string :title
69
+ t.string :body
70
+ t.string :blame_reason
71
+ t.string :reject_reason
72
+ t.string :workflow_state
73
+ end
74
+ end
75
+
76
+ exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('new1', NULL, NULL, NULL, 'new')"
77
+ exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('new2', 'some content', NULL, NULL, 'new')"
78
+ exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('accepted1', 'some content', NULL, NULL, 'accepted')"
79
+
80
+ end
81
+
82
+ def assert_state(title, expected_state, klass = Order)
83
+ o = klass.find_by_title(title)
84
+ assert_equal expected_state, o.read_attribute(klass.workflow_column)
85
+ o
86
+ end
87
+
88
+ test 'deny transition from new to accepted because of the missing presence of the body' do
89
+ a = Article.find_by_title('new1');
90
+ assert_raise Workflow::TransitionHalted do
91
+ a.accept!
92
+ end
93
+ assert_state 'new1', 'new', Article
94
+ end
95
+
96
+ test 'allow transition from new to accepted because body is present this time' do
97
+ a = Article.find_by_title('new2');
98
+ assert a.accept!
99
+ assert_state 'new2', 'accepted', Article
100
+ end
101
+
102
+ test 'allow transition from accepted to blamed because of a blame_reason' do
103
+ a = Article.find_by_title('accepted1');
104
+ a.blame_reason = "Provocant thesis"
105
+ assert a.blame!
106
+ assert_state 'accepted1', 'blamed', Article
107
+ end
108
+
109
+ test 'deny transition from accepted to blamed because of no blame_reason' do
110
+ a = Article.find_by_title('accepted1');
111
+ assert_raise Workflow::TransitionHalted do
112
+ assert a.blame!
113
+ end
114
+ assert_state 'accepted1', 'accepted', Article
115
+ end
116
+
117
+ end
118
+