railsware-workflow 0.8.1

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.
@@ -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
+