nimboids-workflow 0.8.0

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 = "nimboids-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,376 @@
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
+ raise NoTransitionAllowed.new(
158
+ "There is no event #{name.to_sym} defined for the #{current_state} state") \
159
+ if event.nil?
160
+ @halted_because = nil
161
+ @halted = false
162
+ return_value = run_action(event.action, *args) || run_action_callback(event.name, *args)
163
+ if @halted
164
+ return false
165
+ else
166
+ check_transition(event)
167
+ run_on_transition(current_state, spec.states[event.transitions_to], name, *args)
168
+ transition_value = transition(
169
+ current_state, spec.states[event.transitions_to], name, *args
170
+ )
171
+ return_value.nil? ? transition_value : return_value
172
+ end
173
+ end
174
+
175
+ def halt(reason = nil)
176
+ @halted_because = reason
177
+ @halted = true
178
+ end
179
+
180
+ def halt!(reason = nil)
181
+ @halted_because = reason
182
+ @halted = true
183
+ raise TransitionHalted.new(reason)
184
+ end
185
+
186
+ def spec
187
+ # check the singleton class first
188
+ class << self
189
+ return workflow_spec if workflow_spec
190
+ end
191
+
192
+ c = self.class
193
+ # using a simple loop instead of class_inheritable_accessor to avoid
194
+ # dependency on Rails' ActiveSupport
195
+ until c.workflow_spec || !(c.include? Workflow)
196
+ c = c.superclass
197
+ end
198
+ c.workflow_spec
199
+ end
200
+
201
+ private
202
+
203
+ def check_transition(event)
204
+ # Create a meaningful error message instead of
205
+ # "undefined method `on_entry' for nil:NilClass"
206
+ # Reported by Kyle Burton
207
+ if !spec.states[event.transitions_to]
208
+ raise WorkflowError.new("Event[#{event.name}]'s " +
209
+ "transitions_to[#{event.transitions_to}] is not a declared state.")
210
+ end
211
+ end
212
+
213
+ def transition(from, to, name, *args)
214
+ run_on_exit(from, to, name, *args)
215
+ val = persist_workflow_state to.to_s
216
+ run_on_entry(to, from, name, *args)
217
+ val
218
+ end
219
+
220
+ def run_on_transition(from, to, event, *args)
221
+ instance_exec(from.name, to.name, event, *args, &spec.on_transition_proc) if spec.on_transition_proc
222
+ end
223
+
224
+ def run_action(action, *args)
225
+ instance_exec(*args, &action) if action
226
+ end
227
+
228
+ def run_action_callback(action_name, *args)
229
+ self.send action_name.to_sym, *args if self.respond_to?(action_name.to_sym)
230
+ end
231
+
232
+ def run_on_entry(state, prior_state, triggering_event, *args)
233
+ if state.on_entry
234
+ instance_exec(prior_state.name, triggering_event, *args, &state.on_entry)
235
+ else
236
+ hook_name = "on_#{state}_entry"
237
+ self.send hook_name, prior_state, triggering_event, *args if self.respond_to? hook_name
238
+ end
239
+ end
240
+
241
+ def run_on_exit(state, new_state, triggering_event, *args)
242
+ if state
243
+ if state.on_exit
244
+ instance_exec(new_state.name, triggering_event, *args, &state.on_exit)
245
+ else
246
+ hook_name = "on_#{state}_exit"
247
+ self.send hook_name, new_state, triggering_event, *args if self.respond_to? hook_name
248
+ end
249
+ end
250
+ end
251
+
252
+ # load_workflow_state and persist_workflow_state
253
+ # can be overriden to handle the persistence of the workflow state.
254
+ #
255
+ # Default (non ActiveRecord) implementation stores the current state
256
+ # in a variable.
257
+ #
258
+ # Default ActiveRecord implementation uses a 'workflow_state' database column.
259
+ def load_workflow_state
260
+ @workflow_state if instance_variable_defined? :@workflow_state
261
+ end
262
+
263
+ def persist_workflow_state(new_value)
264
+ @workflow_state = new_value
265
+ end
266
+ end
267
+
268
+ module ActiveRecordInstanceMethods
269
+ def load_workflow_state
270
+ read_attribute(self.class.workflow_column)
271
+ end
272
+
273
+ # On transition the new workflow state is immediately saved in the
274
+ # database.
275
+ def persist_workflow_state(new_value)
276
+ update_attribute self.class.workflow_column, new_value
277
+ end
278
+
279
+ private
280
+
281
+ # Motivation: even if NULL is stored in the workflow_state database column,
282
+ # the current_state is correctly recognized in the Ruby code. The problem
283
+ # arises when you want to SELECT records filtering by the value of initial
284
+ # state. That's why it is important to save the string with the name of the
285
+ # initial state in all the new records.
286
+ def write_initial_state
287
+ write_attribute self.class.workflow_column, current_state.to_s
288
+ end
289
+ end
290
+
291
+ module RemodelInstanceMethods
292
+ def load_workflow_state
293
+ send(self.class.workflow_column)
294
+ end
295
+
296
+ def persist_workflow_state(new_value)
297
+ update(self.class.workflow_column => new_value)
298
+ end
299
+ end
300
+
301
+ def self.included(klass)
302
+ klass.send :include, WorkflowInstanceMethods
303
+ klass.extend WorkflowClassMethods
304
+ if Object.const_defined?(:ActiveRecord)
305
+ if klass < ActiveRecord::Base
306
+ klass.send :include, ActiveRecordInstanceMethods
307
+ klass.before_validation :write_initial_state
308
+ end
309
+ elsif Object.const_defined?(:Remodel)
310
+ if klass < Remodel::Entity
311
+ klass.send :include, RemodelInstanceMethods
312
+ end
313
+ end
314
+ end
315
+
316
+ # Generates a `dot` graph of the workflow.
317
+ # Prerequisite: the `dot` binary. (Download from http://www.graphviz.org/)
318
+ # You can use this method in your own Rakefile like this:
319
+ #
320
+ # namespace :doc do
321
+ # desc "Generate a graph of the workflow."
322
+ # task :workflow => :environment do # needs access to the Rails environment
323
+ # Workflow::create_workflow_diagram(Order)
324
+ # end
325
+ # end
326
+ #
327
+ # You can influence the placement of nodes by specifying
328
+ # additional meta information in your states and transition descriptions.
329
+ # You can assign higher `doc_weight` value to the typical transitions
330
+ # in your workflow. All other states and transitions will be arranged
331
+ # around that main line. See also `weight` in the graphviz documentation.
332
+ # Example:
333
+ #
334
+ # state :new do
335
+ # event :approve, :transitions_to => :approved, :meta => {:doc_weight => 8}
336
+ # end
337
+ #
338
+ #
339
+ # @param klass A class with the Workflow mixin, for which you wish the graphical workflow representation
340
+ # @param [String] target_dir Directory, where to save the dot and the pdf files
341
+ # @param [String] graph_options You can change graph orientation, size etc. See graphviz documentation
342
+ def self.create_workflow_diagram(klass, target_dir='.', graph_options='rankdir="LR", size="7,11.6", ratio="fill"')
343
+ workflow_name = "#{klass.name.tableize}_workflow".gsub('/', '_')
344
+ fname = File.join(target_dir, "generated_#{workflow_name}")
345
+ File.open("#{fname}.dot", 'w') do |file|
346
+ file.puts %Q|
347
+ digraph #{workflow_name} {
348
+ graph [#{graph_options}];
349
+ node [shape=box];
350
+ edge [len=1];
351
+ |
352
+
353
+ klass.workflow_spec.states.each do |state_name, state|
354
+ file.puts %Q{ #{state.name} [label="#{state.name}"];}
355
+ state.events.each do |event_name, event|
356
+ meta_info = event.meta
357
+ if meta_info[:doc_weight]
358
+ weight_prop = ", weight=#{meta_info[:doc_weight]}"
359
+ else
360
+ weight_prop = ''
361
+ end
362
+ file.puts %Q{ #{state.name} -> #{event.transitions_to} [label="#{event_name.to_s.humanize}" #{weight_prop}];}
363
+ end
364
+ end
365
+ file.puts "}"
366
+ file.puts
367
+ end
368
+ `dot -Tpdf -o'#{fname}.pdf' '#{fname}.dot'`
369
+ puts "
370
+ Please run the following to open the generated file:
371
+
372
+ open '#{fname}.pdf'
373
+
374
+ "
375
+ end
376
+ 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
+