workflow 0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (6) hide show
  1. data/LICENSE +19 -0
  2. data/README.rdoc +377 -0
  3. data/Rakefile +43 -0
  4. data/lib/workflow.rb +238 -0
  5. data/test/test_workflow.rb +218 -0
  6. metadata +59 -0
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2007-2008 Ryan Allen, FlashDen Pty Ltd
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,377 @@
1
+ = Motivation for the fork
2
+
3
+ Why to overflow the world with yet another fork of the workflow library?
4
+
5
+ Well, while the workflow definition API of the original library is nice,
6
+ the implementation of the ActiveRecord integration and the remaining API
7
+ are very problematic.
8
+
9
+ == API improvements
10
+
11
+ * Fixed fuzzy API. For example, the states() function returned a state object
12
+ or an array of strings, depending on function parameters!
13
+ While somebody could find the usage like `states(states.first)` funny,
14
+ it leads to a maintanence nightmare and the usage is difficult to explain
15
+ to the new team members.
16
+
17
+ * When we activate the state transition, we typically do not make any other
18
+ changes to the attributes of the entity. So by default the event
19
+ invokation now immediately saves the new state to the database.
20
+ `update_attribute` is used for implementation. This can be overriden
21
+ with the `persist_workflow_state` method.
22
+
23
+ * We've noticed, that mixing the list of events and states with the blocks
24
+ invoked for particular transitions leads to a bumpy and poorly readable code
25
+ due to a deep nesting. We tried (and dismissed) lambdas for this. Eventually
26
+ we desided to invoke an optional user defined callback method with the same
27
+ name as the event (convention over configuration)
28
+
29
+ event :my_transition, :transitions_to => other_state
30
+
31
+ defines a transition method `my_transition!` and invokes user defined callback
32
+ function `my_transition` (with no exclamation mark). The old way - using
33
+ a block or a combination of both is also possible.
34
+
35
+ * In the API the workflow specification is clearly separated from the object
36
+ state. The former is accessible through the class method
37
+ `MyEntityClass.workflow_spec` (including all the meta data) and the latter
38
+ is integrated in the instance of the my_entity, e.g. my_entity.current_state
39
+ or my_entity.my_event!
40
+
41
+
42
+ == Implementation improvements
43
+
44
+ * Replaced the extensive usage of method_missing with a simple generation
45
+ of needed functions like `my_state?` and `my_event`. Advantages:
46
+
47
+ * shorter and more meaningful stack during debugging
48
+ * public_methods shows the methods
49
+ * autocompletion in irb works
50
+
51
+ * Do not use ActiveRecord hooks leading to the divergence of `workflow_state`
52
+ table attribute and wokflow.current_state.
53
+
54
+ * Fixed all the warnings and usage of obsolete API.
55
+
56
+ * The messy and fuzzy API is probably partially caused by RSpec driven
57
+ development. While RSpec can be useful for gathering and mapping business
58
+ requirements, it is IMHO totally unsuitable for driving a clean, explicit,
59
+ orthogonal API. If I say `state` in the natural language or in a RSpec, it is
60
+ not clear, what I mean - a string, a symbol, an object of class State, a hash?
61
+ This(?) led to this fuzzy API.
62
+ So I switched to the plain old unit tests.
63
+
64
+ * Eliminated bidirectional connection between the model
65
+ class and Workflow::Instance - the bind_to, @context, @workflow.
66
+
67
+ * Only one file with 240 lines of code and no interdependencies between classes.
68
+ As little meta programming as needed - not as much as possible. ;-)
69
+ So you can easily extend or modify it to suit your needs.
70
+
71
+
72
+ == Installation
73
+
74
+ You can just download the lib/workflow.rb and put it in the lib folder of your
75
+ Rails application.
76
+
77
+ Later I'll probable create a gem.
78
+
79
+
80
+ == About
81
+
82
+ Author: Vladimir Dobriakov, http://www.innoq.com/blog/vd, http://blog.geekq.net/
83
+
84
+ Parts copyright 2009 Vodafone
85
+
86
+ Based on the work of Ryan Allen and Scott Barron
87
+
88
+
89
+ = Original readme
90
+
91
+ === New Mailing List!
92
+
93
+ Hi! We've now got a mailing list to talk about Workflow, and that's good! Come visit and post your problems or ideas or anything!!!
94
+
95
+ http://groups.google.com/group/ruby-workflow
96
+
97
+ See you there!
98
+
99
+ === What is workflow?
100
+
101
+ Workflow is a finite-state-machine-inspired API for modeling and interacting with what we tend to refer to as 'workflow'.
102
+
103
+ A lot of business modeling tends to involve workflow-like concepts, and the aim of this library is to make the expression of these concepts as clear as possible, using similar terminology as found in state machine theory.
104
+
105
+ So, a workflow has a state. It can only be in one state at a time. When a workflow changes state, we call that a transition. Transitions occur on an event, so events cause transitions to occur. Additionally, when an event fires, other random code can be executed, we call those actions. So any given state has a bunch of events, any event in a state causes a transition to another state and potentially causes code to be executed (an action). We can hook into states when they are entered, and exited from, and we can cause transitions to fail (guards), and we can hook in to every transition that occurs ever for whatever reason we can come up with.
106
+
107
+ Now, all that's a mouthful, but we'll demonstrate the API bit by bit with a real-ish world example.
108
+
109
+ Let's say we're modeling article submission from journalists. An article is written, then submitted. When it's submitted, it's awaiting review. Someone reviews the article, and then either accepts or rejects it. Explaining all that is a pain in the arse. Here is the expression of this workflow using the API:
110
+
111
+ Workflow.specify 'Article Workflow' do
112
+ state :new do
113
+ event :submit, :transitions_to => :awaiting_review
114
+ end
115
+ state :awaiting_review do
116
+ event :review, :transitions_to => :being_reviewed
117
+ end
118
+ state :being_reviewed do
119
+ event :accept, :transitions_to => :accepted
120
+ event :reject, :transitions_to => :rejected
121
+ end
122
+ state :accepted
123
+ state :rejected
124
+ end
125
+
126
+ Much better, isn't it!
127
+
128
+ The initial state is <tt>:new</tt> – in this example that's somewhat meaningless. (?) However, the <tt>:submit</tt> event <tt>:transitions_to => :being_reviewed</tt>. So, lets instantiate an instance of this Workflow:
129
+
130
+ workflow = Workflow.new('Article Workflow')
131
+ workflow.state # => :new
132
+
133
+ Now we can call the submit event, which transitions to the <tt>:awaiting_review</tt> state:
134
+
135
+ workflow.submit
136
+ workflow.state # => :awaiting_review
137
+
138
+ Events are actually instance methods on a workflow, and depending on the state you're in, you'll have a different set of events used to transition to other states.
139
+
140
+ Given this workflow is now <tt>:awaiting_approval</tt>, we have a <tt>:review</tt> event, that we call when someone begins to review the article, which puts the workflow into the <tt>:being_reviewed</tt> state.
141
+
142
+ States can also be queried via predicates for convenience like so:
143
+
144
+ workflow = Workflow.new('Article Workflow')
145
+ workflow.new? # => true
146
+ workflow.awaiting_review? # => false
147
+ workflow.submit
148
+ workflow.new? # => false
149
+ workflow.awaiting_review? # => true
150
+
151
+ Lets say that the business rule is that only one person can review an article at a time – having a state <tt>:being_reviewed</tt> allows for doing things like checking which articles are being reviewed, and being able to select from a pool of articles that are awaiting review, etc. (rewrite?)
152
+
153
+ Now lets say another business rule is that we need to keep track of who is currently reviewing what, how do we do this? We'll now introduce the concept of an action by rewriting our <tt>:review</tt> event.
154
+
155
+ event :review, :transitions_to => :being_reviewed do |reviewer|
156
+ # store the reviewer somewhere for later
157
+ end
158
+
159
+ By using Ruby blocks we've now introduced extra code to be fired when an event is called. The block parameters are treated as method arguments on the event, so, given we have a reference to the reviewer, the event call becomes:
160
+
161
+ # we gots a reviewer
162
+ workflow.reivew(reviewer)
163
+
164
+ OK, so how do we store the reviewer? What is the scope inside that block? Ah, we'll get to that in a bit. An instance of a workflow isn't as useful as a workflow bound to an instance of another class. We'll introduce you to plain old Class integration and ActiveRecord integration later in this document.
165
+
166
+ So we've covered events, states, transitions and actions (as Ruby blocks). Now we're going to go over some hooks you have access to in a workflow. These are on_exit, on_entry and on_transition.
167
+
168
+ When states transition, they are entered into, and exited out of, we can hook into this and do fancy junk.
169
+
170
+ state :being_reviewed do
171
+ event :accept, :transitions_to => :accepted
172
+ event :reject, :transitions_to => :rejected
173
+ on_exit do |new_state, triggering_event, *event_args|
174
+ # do something related to coming out of :being_reviewed
175
+ end
176
+ end
177
+
178
+ state :accepted do
179
+ on_entry do |prior_state, triggering_event, *event_args|
180
+ # do something relevant to coming in to :accepted
181
+ end
182
+ end
183
+
184
+ Now why don't we just put this code into an action block? Well, you might not have only one event that transitions into a state, you may have multiple events that transition to a particular state, so by using the on_entry and on_exit hooks you're guaranteeing that a certain bit of code is executed, regardless what event fires the transition.
185
+
186
+ Billy Bob the Manager comes to you and says "I need to know EVERYTHING THAT HAPPENS EVERYWHERE AT ANY TIME FOR EVERYTHING". For whatever reasons you have to record the history of the entire workflow. That's easy using on_transition.
187
+
188
+ on_transition do |from, to, triggering_event, *event_args|
189
+ # record everything, or something
190
+ end
191
+
192
+ Workflow doesn't try to tell you how to store your log messages, (but we'd suggest using a *splat and storing that somewhere, and keep your log messages flexible).
193
+
194
+ Finite state machines have the concept of a guard. The idea is that if a certain set of arbitrary conditions are not fulfilled, it will halt the transition from one state to another. We haven't really figured out how to do this, and we don't like the idea of going <tt>:guard => Proc.new {}</tt>, coz that's a bit lame, so instead we have <tt>halt!</tt>
195
+
196
+ The <tt>halt!</tt> method is the implementation of the guard concept. Let's take a look.
197
+
198
+ state :being_reviewed do
199
+ event :accept, :transitions_to => :accepted do
200
+ halt if true # does not transition to :accepted
201
+ end
202
+ end
203
+
204
+ Inline with how ActiveRecord does things, <tt>halt!</tt> also can be called via <tt>halt</tt>, which makes the event return false, so you can trap it with if workflow.event instead of using a rescue block. Using halt returns false.
205
+
206
+ # using halt
207
+ workflow.state # => :being_reviewed
208
+ workflow.accept # => false
209
+ workflow.halted? # => true
210
+ workflow.state # => :being_reviewed
211
+
212
+ # using halt!
213
+ workflow.state # => :being_reviewed
214
+ begin
215
+ workflow.accept
216
+ rescue Workflow::Halted => e
217
+ # we gots an exception
218
+ end
219
+ workflow.halted? # => true
220
+ workflow.state # => :being_reviewed
221
+
222
+ Furthermore, <tt>halt!</tt> and <tt>halt</tt> accept an argument, which is the message why the workflow was halted.
223
+
224
+ state :being_reviewed do
225
+ event :accept, :transitions_to => :accepted do
226
+ halt 'coz I said so!' if true # does not transition to :accepted
227
+ end
228
+ end
229
+
230
+ And the API for, like, getting this message, with both <tt>halt</tt> and <tt>halt!</tt>:
231
+
232
+ # using halt
233
+ workflow.state # => :being_reviewed
234
+ workflow.accept # => false
235
+ workflow.halted? # => true
236
+ workflow.halted_because # => 'coz I said so!'
237
+ workflow.state # => :being_reviewed
238
+
239
+ # using halt!
240
+ workflow.state # => :being_reviewed
241
+ begin
242
+ workflow.accept
243
+ rescue Workflow::Halted => e
244
+ e.halted_because # => 'coz I said so!'
245
+ end
246
+ workflow.halted? # => true
247
+ workflow.state # => :being_reviewed
248
+
249
+ We can reflect off the workflow to (attempt) to automate as much as we can. There are two types of reflection in Workflow - reflection and meta-reflection. We'll explain the former first.
250
+
251
+ workflow.states # => [:new, :awaiting_review, :being_reviewed, :accepted, :rejected]
252
+ workflow.states(:new).events # => [:submit]
253
+ workflow.states(:being_reviewed).events # => [:accept, :reject]
254
+ workflow.states(:being_reviewed).events(:accept).transitions_to # => :accepted
255
+
256
+ Meta-reflection allows you to add further information to your states, events in order to allow you to build whatever interface/controller/etc you require for your application. If reflection were Batman then meta-reflection is Robin, always there to lend a helping hand when Batman just isn't enough.
257
+
258
+ state :new, :meta => :ui_widget => :radio_buttons do
259
+ event :submit, :meta => :label => 'Upload...'
260
+ end
261
+
262
+ And as per the last example, getting yo meta is very similar:
263
+
264
+ workflow.states(:new).meta # => {:ui_widget => :radio_buttons}
265
+ workflow.states(:new).meta[:ui_widget] # => :radio_buttons
266
+ workflow.states(:new).meta.ui_widget # => :radio_buttons
267
+
268
+ workflow.states(:new).events(:submit).meta # => {:label => 'Upload...'}
269
+ workflow.states(:new).events(:submit).meta[:label] # => 'Upload...'
270
+ workflow.states(:new).events(:submit).meta.label # => 'Upload...'
271
+
272
+ Thankfully, meta responds to each so you can iterate over your values if you're so inclined.
273
+
274
+ workflow.states(:new).meta.each { |key, value| puts key, value }
275
+
276
+ The order of which things are fired when an event are as follows:
277
+
278
+ * action
279
+ * on_transition (if action didn't halt)
280
+ * on_exit
281
+ * WORKFLOW STATE CHANGES, i.e. transition
282
+ * on_entry
283
+
284
+ Note that any event arguments are passed by reference, so if you modify action arguments in the action, or any of the hooks, it may affect hooked fired later.
285
+
286
+ We promised that we'd show you how to integrate workflow with your existing classes and instances, let look.
287
+
288
+ class Article
289
+ include Workflow
290
+ workflow do
291
+ state :new do
292
+ event :submit, :transitions_to => :awaiting_review
293
+ end
294
+ state :awaiting_review do
295
+ event :approve, :transitions_to => :approved
296
+ end
297
+ state :approved
298
+ # ...
299
+ end
300
+ end
301
+
302
+ article = Article.new
303
+ article.state # => :new
304
+ article.submit
305
+ article.state # => :awaiting_review
306
+ article.approve
307
+ article.state # => :approved
308
+
309
+ And as ActiveRecord is all the rage these days, all you need is a string field on the table called "workflow_state", which is used to store the current state. Workflow handles auto-setting of a state after a find, yet it doesn't save a record after a transition (though you could make it do this in on_transition).
310
+
311
+ class Article < ActiveRecord::Base
312
+ include Workflow
313
+ workflow do
314
+ # ...
315
+ end
316
+ end
317
+
318
+ When integrating with other classes, behind the scenes, Workflow sets up a Proxy to method missing. A probable common error would be to call an event that doesn't exist, so we catch +NoMethodError+'s and helpfully let you know what available events exist:
319
+
320
+ class Article
321
+ include Workflow
322
+ workflow do
323
+ state :new do
324
+ event :submit, :transitions_to => :awaiting_review
325
+ end
326
+ state :awaiting_review do
327
+ event :approve, :transitions_to => :approved
328
+ end
329
+ state :approved
330
+ # ...
331
+ end
332
+ end
333
+
334
+ article = Article.new
335
+ article.aaaa
336
+ NoMethodError: undefined method `aaaa' for #<Article:0xe4e8>, conversely, if you were looking to call an event for its workflow, you're in the :new state, and the available events are [:submit]
337
+
338
+ So just incase you screw something up (like I did while testing this library), it'll give you a useful message.
339
+
340
+ You can blatter existing workflows, by simply opening them up again (similar to how Ruby works!).
341
+
342
+ Workflow.specify 'Blatter' do
343
+ state :opened do
344
+ event :close, :transitions_to => :closed
345
+ end
346
+ state :closed
347
+ end
348
+
349
+ workflow = Workflow.new('Blatter')
350
+ workflow.close
351
+ workflow.state # => :closed
352
+ workflow.open # => raises a (nice) NoMethodError exception!
353
+
354
+ Workflow.specify 'Blatter' do
355
+ state :closed do
356
+ event :open, :transitions_to => :opened
357
+ end
358
+ end
359
+
360
+ workflow.open
361
+ workflow.state # => :opened
362
+
363
+ Workflow.specify 'Blatter' do
364
+ state :open do
365
+ event :close, :transitions_to => :jammed # the door is now faulty :)
366
+ end
367
+ state :jammed
368
+ end
369
+
370
+ workflow.close
371
+ workflow.state # => :jammed
372
+
373
+ Why can we do this? Well, we needed it for our production app, so there.
374
+
375
+ And that's about it. A update to the implementation may allow multiple workflows per instance of a class or ActiveRecord, but we haven't figured out if that's required or appropriate.
376
+
377
+ Ryan Allen, March 2008.
data/Rakefile ADDED
@@ -0,0 +1,43 @@
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
+ end
12
+
13
+ PKG_VERSION = "0.1"
14
+ PKG_FILES = FileList[
15
+ 'LICENSE',
16
+ 'README.rdoc',
17
+ 'Rakefile',
18
+ 'lib/**/*.rb',
19
+ 'test/**/test_*.rb'
20
+ ]
21
+
22
+ spec = Gem::Specification.new do |s|
23
+ s.name = "workflow"
24
+ s.version = PKG_VERSION
25
+ s.author = "Vladimir Dobriakov"
26
+ s.email = "vladimir@geekq.net"
27
+ s.homepage = "http://blog.geekQ.net/"
28
+ s.platform = Gem::Platform::RUBY
29
+ s.summary = "A replacement for acts_as_state_machine."
30
+ s.files = PKG_FILES.to_a
31
+ s.require_path = "lib"
32
+ end
33
+
34
+ Rake::RDocTask.new do |rdoc|
35
+ rdoc.main = "README"
36
+ rdoc.rdoc_files.include("README", "lib/**/*.rb")
37
+ rdoc.options << "-S"
38
+ end
39
+
40
+ package_task = Rake::GemPackageTask.new(spec) do |pkg|
41
+ pkg.need_zip = true
42
+ pkg.need_tar_gz = true
43
+ end
data/lib/workflow.rb ADDED
@@ -0,0 +1,238 @@
1
+ require 'rubygems'
2
+ require 'active_support'
3
+
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
+ private
17
+
18
+ def state(name, meta = {:meta => {}}, &events_and_etc)
19
+ # meta[:meta] to keep the API consistent..., gah
20
+ new_state = State.new(name, meta[:meta])
21
+ @initial_state = new_state if @states.empty?
22
+ @states[name.to_sym] = new_state
23
+ @scoped_state = new_state
24
+ instance_eval(&events_and_etc) if events_and_etc
25
+ end
26
+
27
+ def event(name, args = {}, &action)
28
+ @scoped_state.events[name.to_sym] =
29
+ Event.new(name, args[:transitions_to], (args[:meta] or {}), &action)
30
+ end
31
+
32
+ def on_entry(&proc)
33
+ @scoped_state.on_entry = proc
34
+ end
35
+
36
+ def on_exit(&proc)
37
+ @scoped_state.on_exit = proc
38
+ end
39
+
40
+ def on_transition(&proc)
41
+ @on_transition_proc = proc
42
+ end
43
+ end
44
+
45
+ class TransitionHalted < Exception
46
+
47
+ attr_reader :halted_because
48
+
49
+ def initialize(msg = nil)
50
+ @halted_because = msg
51
+ super msg
52
+ end
53
+
54
+ end
55
+
56
+ class NoTransitionAllowed < Exception; end
57
+
58
+ class State
59
+
60
+ attr_accessor :name, :events, :meta, :on_entry, :on_exit
61
+
62
+ def initialize(name, meta = {})
63
+ @name, @events, @meta = name, Hash.new, meta
64
+ end
65
+
66
+ def to_s
67
+ "#{name}"
68
+ end
69
+
70
+ def to_sym
71
+ name.to_sym
72
+ end
73
+ end
74
+
75
+ class Event
76
+
77
+ attr_accessor :name, :transitions_to, :meta, :action
78
+
79
+ def initialize(name, transitions_to, meta = {}, &action)
80
+ @name, @transitions_to, @meta, @action = name, transitions_to.to_sym, meta, action
81
+ end
82
+
83
+ end
84
+
85
+ module WorkflowClassMethods
86
+ attr_reader :workflow_spec
87
+
88
+ def workflow(&specification)
89
+ @workflow_spec = Specification.new(Hash.new, &specification)
90
+ @workflow_spec.states.values.each do |state|
91
+ state_name = state.name
92
+ module_eval do
93
+ define_method "#{state_name}?" do
94
+ state_name == current_state.name
95
+ end
96
+ end
97
+
98
+ state.events.values.each do |event|
99
+ event_name = event.name
100
+ module_eval do
101
+ define_method "#{event_name}!".to_sym do |*args|
102
+ process_event!(event_name, *args)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ module WorkflowInstanceMethods
111
+ def current_state
112
+ loaded_state = load_workflow_state
113
+ res = spec.states[loaded_state.to_sym] if loaded_state
114
+ res || spec.initial_state
115
+ end
116
+
117
+ def halted?
118
+ @halted
119
+ end
120
+
121
+ def halted_because
122
+ @halted_because
123
+ end
124
+
125
+ def process_event!(name, *args)
126
+ event = current_state.events[name.to_sym]
127
+ raise NoTransitionAllowed.new(
128
+ "There is no event #{name.to_sym} defined for the #{current_state} state") \
129
+ if event.nil?
130
+ @halted_because = nil
131
+ @halted = false
132
+ @raise_exception_on_halt = false
133
+ return_value = run_action(event.action, *args) || run_action_callback(event.name, *args)
134
+ if @halted
135
+ if @raise_exception_on_halt
136
+ raise TransitionHalted.new(@halted_because)
137
+ else
138
+ false
139
+ end
140
+ else
141
+ run_on_transition(current_state, spec.states[event.transitions_to], name, *args)
142
+ transition(current_state, spec.states[event.transitions_to], name, *args)
143
+ return_value
144
+ end
145
+ end
146
+
147
+ private
148
+
149
+ def spec
150
+ self.class.workflow_spec
151
+ end
152
+
153
+ def halt(reason = nil)
154
+ @halted_because = reason
155
+ @halted = true
156
+ @raise_exception_on_halt = false
157
+ end
158
+
159
+ def halt!(reason = nil)
160
+ @halted_because = reason
161
+ @halted = true
162
+ @raise_exception_on_halt = true
163
+ end
164
+
165
+ def transition(from, to, name, *args)
166
+ run_on_exit(from, to, name, *args)
167
+ persist_workflow_state to.to_s
168
+ run_on_entry(to, from, name, *args)
169
+ end
170
+
171
+ def run_on_transition(from, to, event, *args)
172
+ instance_exec(from.name, to.name, event, *args, &spec.on_transition_proc) if spec.on_transition_proc
173
+ end
174
+
175
+ def run_action(action, *args)
176
+ instance_exec(*args, &action) if action
177
+ end
178
+
179
+ def run_action_callback(action_name, *args)
180
+ self.send action_name.to_sym, *args if self.respond_to?(action_name.to_sym)
181
+ end
182
+
183
+ def run_on_entry(state, prior_state, triggering_event, *args)
184
+ instance_exec(prior_state.name, triggering_event, *args, &state.on_entry) if state.on_entry
185
+ end
186
+
187
+ def run_on_exit(state, new_state, triggering_event, *args)
188
+ instance_exec(new_state.name, triggering_event, *args, &state.on_exit) if state and state.on_exit
189
+ end
190
+
191
+ # load_workflow_state and persist_workflow_state
192
+ # can be overriden to handle the persistence of the workflow state.
193
+ #
194
+ # Default (non ActiveRecord) implementation stores the current state
195
+ # in a variable.
196
+ #
197
+ # Default ActiveRecord implementation uses a 'workflow_state' database column.
198
+ def load_workflow_state
199
+ @workflow_state if instance_variable_defined? :@workflow_state
200
+ end
201
+
202
+ def persist_workflow_state(new_value)
203
+ @workflow_state = new_value
204
+ end
205
+ end
206
+
207
+ module ActiveRecordInstanceMethods
208
+ def load_workflow_state
209
+ read_attribute(:workflow_state)
210
+ end
211
+
212
+ # On transition the new workflow state is immediately saved in the
213
+ # database.
214
+ def persist_workflow_state(new_value)
215
+ update_attribute :workflow_state, new_value
216
+ end
217
+
218
+ private
219
+
220
+ # Motivation: even if NULL is stored in the workflow_state database column,
221
+ # the current_state is correctly recognized in the Ruby code. The problem
222
+ # arises when you want to SELECT records filtering by the value of initial
223
+ # state. That's why it is important to save the string with the name of the
224
+ # initial state in all the new records.
225
+ def write_initial_state
226
+ write_attribute :workflow_state, current_state.to_s
227
+ end
228
+ end
229
+
230
+ def self.included(klass)
231
+ klass.send :include, WorkflowInstanceMethods
232
+ klass.extend WorkflowClassMethods
233
+ if klass < ActiveRecord::Base
234
+ klass.send :include, ActiveRecordInstanceMethods
235
+ klass.before_validation :write_initial_state
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,218 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ old_verbose, $VERBOSE = $VERBOSE, nil
4
+ require 'active_record'
5
+ require 'sqlite3'
6
+ $VERBOSE = old_verbose
7
+ require 'workflow'
8
+ require 'mocha'
9
+ #require 'ruby-debug'
10
+
11
+ ActiveRecord::Migration.verbose = false
12
+
13
+ class << Test::Unit::TestCase
14
+ def test(name, &block)
15
+ test_name = :"test_#{name.gsub(' ','_')}"
16
+ raise ArgumentError, "#{test_name} is already defined" if self.instance_methods.include? test_name.to_s
17
+ if block
18
+ define_method test_name, &block
19
+ else
20
+ puts "PENDING: #{name}"
21
+ end
22
+ end
23
+ end
24
+
25
+ class Order < ActiveRecord::Base
26
+ include Workflow
27
+ workflow do
28
+ state :submitted do
29
+ event :accept, :transitions_to => :accepted, :meta => {:doc_weight => 8} do |reviewer, args|
30
+ end
31
+ end
32
+ state :accepted do
33
+ event :ship, :transitions_to => :shipped
34
+ end
35
+ state :shipped
36
+ end
37
+
38
+ end
39
+
40
+ class WorkflowTest < Test::Unit::TestCase
41
+
42
+ def exec(sql)
43
+ ActiveRecord::Base.connection.execute sql
44
+ end
45
+
46
+ def setup
47
+ old_verbose, $VERBOSE = $VERBOSE, nil # eliminate sqlite3 warning. TODO: delete as soon as sqlite-ruby is fixed
48
+ ActiveRecord::Base.establish_connection(
49
+ :adapter => "sqlite3",
50
+ :database => ":memory:" #"tmp/test"
51
+ )
52
+ ActiveRecord::Base.connection.reconnect! # eliminate ActiveRecord warning. TODO: delete as soon as ActiveRecord is fixed
53
+
54
+ ActiveRecord::Schema.define do
55
+ create_table :orders do |t|
56
+ t.string :title, :null => false
57
+ t.string :workflow_state
58
+ end
59
+ end
60
+
61
+ exec "INSERT INTO orders(title, workflow_state) VALUES('some order', 'accepted')"
62
+ $VERBOSE = old_verbose
63
+ end
64
+
65
+ def teardown
66
+ ActiveRecord::Base.connection.disconnect!
67
+ end
68
+
69
+ def assert_state(title, expected_state)
70
+ o = Order.find_by_title(title)
71
+ assert_equal expected_state, o.read_attribute(:workflow_state)
72
+ o
73
+ end
74
+
75
+ test 'immediatly save the new workflow_state on state machine transition' do
76
+ o = assert_state 'some order', 'accepted'
77
+ o.ship!
78
+ assert_state 'some order', 'shipped'
79
+ end
80
+
81
+ test 'persist workflow_state in the db and reload' do
82
+ o = assert_state 'some order', 'accepted'
83
+ assert_equal :accepted, o.current_state.name
84
+ o.ship!
85
+ o.save!
86
+
87
+ assert_state 'some order', 'shipped'
88
+
89
+ o.reload
90
+ assert_equal 'shipped', o.read_attribute(:workflow_state)
91
+ end
92
+
93
+ test 'access workflow specification' do
94
+ assert_equal 3, Order.workflow_spec.states.length
95
+ end
96
+
97
+ test 'current state object' do
98
+ o = assert_state 'some order', 'accepted'
99
+ assert_equal 'accepted', o.current_state.to_s
100
+ assert_equal 1, o.current_state.events.length
101
+ end
102
+
103
+ test 'on_entry and on_exit invoked' do
104
+ c = Class.new
105
+ callbacks = mock()
106
+ callbacks.expects(:my_on_exit_new).once
107
+ callbacks.expects(:my_on_entry_old).once
108
+ c.class_eval do
109
+ include Workflow
110
+ workflow do
111
+ state :new do
112
+ event :age, :transitions_to => :old
113
+ end
114
+ on_exit do
115
+ callbacks.my_on_exit_new
116
+ end
117
+ state :old
118
+ on_entry do
119
+ callbacks.my_on_entry_old
120
+ end
121
+ on_exit do
122
+ fail "wrong on_exit executed"
123
+ end
124
+ end
125
+ end
126
+
127
+ o = c.new
128
+ assert_equal 'new', o.current_state.to_s
129
+ o.age!
130
+ end
131
+
132
+ test 'on_transition invoked' do
133
+ callbacks = mock()
134
+ callbacks.expects(:on_tran).once # this is validated at the end
135
+ c = Class.new
136
+ c.class_eval do
137
+ include Workflow
138
+ workflow do
139
+ state :one do
140
+ event :increment, :transitions_to => :two
141
+ end
142
+ state :two
143
+ on_transition do |from, to, triggering_event, *event_args|
144
+ callbacks.on_tran
145
+ end
146
+ end
147
+ end
148
+ assert_not_nil c.workflow_spec.on_transition_proc
149
+ c.new.increment!
150
+ end
151
+
152
+ test 'access event meta information' do
153
+ c = Class.new
154
+ c.class_eval do
155
+ include Workflow
156
+ workflow do
157
+ state :main, :meta => {:importance => 8}
158
+ state :supplemental, :meta => {:importance => 1}
159
+ end
160
+ end
161
+ assert_equal 1, c.workflow_spec.states[:supplemental].meta[:importance]
162
+ end
163
+
164
+ test 'initial state' do
165
+ c = Class.new
166
+ c.class_eval do
167
+ include Workflow
168
+ workflow { state :one; state :two }
169
+ end
170
+ assert_equal 'one', c.new.current_state.to_s
171
+ end
172
+
173
+ test 'nil as initial state' do
174
+ exec "INSERT INTO orders(title, workflow_state) VALUES('nil state', NULL)"
175
+ o = Order.find_by_title('nil state')
176
+ assert o.submitted?, 'if workflow_state is nil, the initial state should be assumed'
177
+ assert !o.shipped?
178
+ end
179
+
180
+ test 'initial state immediately set as ActiveRecord attribute for new objects' do
181
+ o = Order.create(:title => 'new object')
182
+ assert_equal 'submitted', o.read_attribute(:workflow_state)
183
+ end
184
+
185
+ test 'question methods for state' do
186
+ o = assert_state 'some order', 'accepted'
187
+ assert o.accepted?
188
+ assert !o.shipped?
189
+ end
190
+
191
+ test 'correct exception for event, that is not allowed in current state' do
192
+ o = assert_state 'some order', 'accepted'
193
+ assert_raise Workflow::NoTransitionAllowed do
194
+ o.accept!
195
+ end
196
+ end
197
+
198
+ test 'multiple events with the same name and different arguments lists from different states'
199
+
200
+ test 'implicit transition callback' do
201
+ args = mock()
202
+ args.expects(:my_tran).once # this is validated at the end
203
+ c = Class.new
204
+ c.class_eval do
205
+ include Workflow
206
+ def my_transition(args)
207
+ args.my_tran
208
+ end
209
+ workflow do
210
+ state :one do
211
+ event :my_transition, :transitions_to => :two
212
+ end
213
+ state :two
214
+ end
215
+ end
216
+ c.new.my_transition!(args)
217
+ end
218
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: workflow
3
+ version: !ruby/object:Gem::Version
4
+ version: "0.1"
5
+ platform: ruby
6
+ authors:
7
+ - Vladimir Dobriakov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-04-25 00:00:00 +02:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description:
17
+ email: vladimir@geekq.net
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files: []
23
+
24
+ files:
25
+ - LICENSE
26
+ - README.rdoc
27
+ - Rakefile
28
+ - lib/workflow.rb
29
+ - test/test_workflow.rb
30
+ has_rdoc: false
31
+ homepage: http://blog.geekQ.net/
32
+ licenses: []
33
+
34
+ post_install_message:
35
+ rdoc_options: []
36
+
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: "0"
44
+ version:
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: "0"
50
+ version:
51
+ requirements: []
52
+
53
+ rubyforge_project:
54
+ rubygems_version: 1.3.1.2403
55
+ signing_key:
56
+ specification_version: 3
57
+ summary: A replacement for acts_as_state_machine.
58
+ test_files: []
59
+