workflow 0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +19 -0
- data/README.rdoc +377 -0
- data/Rakefile +43 -0
- data/lib/workflow.rb +238 -0
- data/test/test_workflow.rb +218 -0
- 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
|
+
|