multiflow 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/multiflow.rb ADDED
@@ -0,0 +1,121 @@
1
+ require 'active_support'
2
+ require 'active_support/inflector'
3
+
4
+ module Multiflow
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ Multiflow::Persistence.load!(self)
9
+ end
10
+
11
+ def self.persistence
12
+ @@persistence ||= nil
13
+ end
14
+
15
+ def self.persistence=(persistence)
16
+ @@persistence = persistence
17
+ end
18
+
19
+ module ClassMethods
20
+ def machines
21
+ @machines ||= []
22
+ end
23
+
24
+ def stateflow(&block)
25
+ @machines ||= []
26
+
27
+ machine = Multiflow::Machine.new(&block)
28
+ @machines << machine
29
+
30
+ reader = :"machine_#{machine.state_column}"
31
+ state = :"current_#{machine.state_column}"
32
+ mach_ivar = :"@#{reader}"
33
+ state_ivar = :"@#{state}"
34
+
35
+ if respond_to?(reader)
36
+ raise ArgumentError, "Machine for #{machine.state_column} is already defined"
37
+ end
38
+
39
+ define_method(reader) do
40
+ self.class.send reader
41
+ end
42
+
43
+ define_singleton_method(reader) do
44
+ instance_variable_get(mach_ivar)
45
+ end
46
+
47
+ instance_variable_set(mach_ivar, machine)
48
+
49
+ define_method(state) do
50
+ state = instance_variable_get(state_ivar)
51
+
52
+ if state.nil?
53
+ loaded = load_from_persistence(machine)
54
+ if loaded.nil?
55
+ state = machine.initial_state
56
+ else
57
+ state = machine.states[loaded.to_sym]
58
+ end
59
+
60
+ instance_variable_set(state_ivar, state)
61
+ end
62
+
63
+ state
64
+ end
65
+
66
+ define_method("set_#{state}") do |state, options = {}|
67
+ save_to_persistence(machine, state.name.to_s, options)
68
+ instance_variable_set(state_ivar, state)
69
+ end
70
+
71
+ machine.states.values.each do |state|
72
+ state_name = state.name
73
+
74
+ define_method(:"#{state_name}?") do
75
+ state_name == send(:"current_#{machine.state_column}").name
76
+ end
77
+
78
+ if machine.create_scopes?
79
+ add_scope(machine, state)
80
+ end
81
+ end
82
+
83
+ machine.events.keys.each do |key|
84
+ define_method(key.to_sym) do
85
+ fire_event(machine, key, :save => false)
86
+ end
87
+
88
+ define_method(:"#{key}!") do
89
+ fire_event(machine, key, :save => true)
90
+ end
91
+ end
92
+ end
93
+ end
94
+
95
+ def machines
96
+ self.class.machines
97
+ end
98
+
99
+ private
100
+
101
+ def fire_event(machine, event_name, options = {})
102
+ event = machine.events[event_name.to_sym]
103
+ raise Multiflow::NoEventFound.new("No event matches #{event_name}") if event.nil?
104
+
105
+ current_state = send :"current_#{machine.state_column}"
106
+
107
+ state = event.fire(machine, current_state, self, options)
108
+
109
+ unless state.nil?
110
+ send :"set_current_#{machine.state_column}", state, options
111
+ end
112
+ end
113
+ end
114
+
115
+ require 'multiflow/machine'
116
+ require 'multiflow/state'
117
+ require 'multiflow/event'
118
+ require 'multiflow/transition'
119
+ require 'multiflow/persistence'
120
+ require 'multiflow/exception'
121
+ require 'multiflow/railtie' if defined?(Rails)
data/multiflow.gemspec ADDED
@@ -0,0 +1,24 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "multiflow"
5
+ s.version = "1.0.0"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 1.2")
8
+ s.authors = ["Ryan Oberholzer", "Sergey Gridasov"]
9
+ s.date = "2013-03-06"
10
+ s.description = "State machine that allows dynamic transitions for business workflows"
11
+ s.email = ["ryan@platform45.com", "grindars@gmail.com"]
12
+ s.files = `git ls-files`.split($/)
13
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
14
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
15
+ s.require_paths = ["lib"]
16
+ s.homepage = "https://github.com/grindars/multiflow"
17
+ s.rubygems_version = "2.0.0"
18
+ s.summary = "State machine that allows dynamic transitions for business workflows"
19
+
20
+ s.add_runtime_dependency("activesupport", ">= 0")
21
+ s.add_development_dependency("rspec", ">= 2.0.0")
22
+ s.add_development_dependency("activerecord", ">= 0")
23
+ s.add_development_dependency("sqlite3-ruby", ">= 0")
24
+ end
data/spec/mongoid.yml ADDED
@@ -0,0 +1,6 @@
1
+ test:
2
+ sessions:
3
+ default:
4
+ database: stateflow_test
5
+ hosts:
6
+ - localhost:27017
@@ -0,0 +1,209 @@
1
+ require 'spec_helper'
2
+ require 'active_record'
3
+
4
+ Multiflow.persistence = :active_record
5
+
6
+ # change this if sqlite is unavailable
7
+ dbconfig = {
8
+ :adapter => 'sqlite3',
9
+ :database => ':memory:'
10
+ }
11
+
12
+ ActiveRecord::Base.establish_connection(dbconfig)
13
+ ActiveRecord::Migration.verbose = false
14
+
15
+ class TestMigration < ActiveRecord::Migration
16
+ def self.up
17
+ create_table :active_record_robots, :force => true do |t|
18
+ t.column :state, :string
19
+ t.column :name, :string
20
+ end
21
+
22
+ create_table :active_record_no_scope_robots, :force => true do |t|
23
+ t.column :state, :string
24
+ t.column :name, :string
25
+ end
26
+ end
27
+
28
+ def self.down
29
+ drop_table :active_record_robots
30
+ drop_table :active_record_no_scope_robots
31
+ end
32
+ end
33
+
34
+ class ActiveRecordRobot < ActiveRecord::Base
35
+ include Multiflow
36
+
37
+ stateflow do
38
+ initial :red
39
+
40
+ state :red, :green
41
+
42
+ event :change do
43
+ transitions :from => :red, :to => :green
44
+ end
45
+ end
46
+ end
47
+
48
+ class ActiveRecordNoScopeRobot < ActiveRecord::Base
49
+ include Multiflow
50
+
51
+ stateflow do
52
+ create_scopes false
53
+ initial :red
54
+
55
+ state :red, :green
56
+
57
+ event :change do
58
+ transitions :from => :red, :to => :green
59
+ end
60
+ end
61
+ end
62
+
63
+ class ActiveRecordStateColumnSetRobot < ActiveRecord::Base
64
+ include Multiflow
65
+
66
+ stateflow do
67
+ state_column :status
68
+ initial :red
69
+
70
+ state :red, :green
71
+
72
+ event :change do
73
+ transitions :from => :red, :to => :green
74
+ end
75
+ end
76
+ end
77
+
78
+ describe Multiflow::Persistence::ActiveRecord do
79
+ before(:all) { TestMigration.up }
80
+ after(:all) { TestMigration.down }
81
+ after { ActiveRecordRobot.delete_all }
82
+
83
+ let(:robot) { ActiveRecordRobot.new }
84
+
85
+ describe "includes" do
86
+ it "should include current_state" do
87
+ robot.respond_to?(:current_state).should be_true
88
+ end
89
+
90
+ it "should include current_state=" do
91
+ robot.respond_to?(:set_current_state).should be_true
92
+ end
93
+
94
+ it "should include save_to_persistence" do
95
+ robot.respond_to?(:save_to_persistence).should be_true
96
+ end
97
+
98
+ it "should include load_from_persistence" do
99
+ robot.respond_to?(:load_from_persistence).should be_true
100
+ end
101
+ end
102
+
103
+ describe "bang method" do
104
+ before do
105
+ @robot = robot
106
+ @robot.state = "red"
107
+ end
108
+
109
+ it "should call the set_current_state with save being true" do
110
+ @robot.should_receive(:set_current_state).with(@robot.machine_state.states[:green], {:save=>true})
111
+ @robot.change!
112
+ end
113
+
114
+ it "should call the setter method for the state column" do
115
+ @robot.should_receive(:state=).with("green")
116
+ @robot.change!
117
+ end
118
+
119
+ it "should call save after setting the state column" do
120
+ @robot.should_receive(:save!)
121
+ @robot.change!
122
+ end
123
+
124
+ it "should save the record" do
125
+ @robot.new_record?.should be_true
126
+ @robot.change!
127
+ @robot.new_record?.should be_false
128
+ @robot.reload.state.should == "green"
129
+ end
130
+ end
131
+
132
+ describe "non bang method" do
133
+ before do
134
+ @robot = robot
135
+ @robot.state = "red"
136
+ end
137
+
138
+ it "should call the set_current_state with save being false" do
139
+ @robot.should_receive(:set_current_state).with(@robot.machine_state.states[:green], {:save=>false})
140
+ @robot.change
141
+ end
142
+
143
+ it "should call the setter method for the state column" do
144
+ @robot.should_receive(:state=).with("green")
145
+ @robot.change
146
+ end
147
+
148
+ it "should call save after setting the state column" do
149
+ @robot.should_not_receive(:save)
150
+ @robot.change
151
+ end
152
+
153
+ it "should not save the record" do
154
+ @robot.new_record?.should be_true
155
+ @robot.change
156
+ @robot.new_record?.should be_true
157
+ @robot.state.should == "green"
158
+ end
159
+ end
160
+
161
+ it "Make sure stateflow saves the initial state if no state is set" do
162
+ @robot3 = robot
163
+
164
+ @robot3.save
165
+ @robot3.reload
166
+
167
+ @robot3.state.should == "red"
168
+ end
169
+
170
+ describe "load from persistence" do
171
+ before do
172
+ @robot = robot
173
+ @robot.state = "green"
174
+ @robot.name = "Bottie"
175
+ @robot.save
176
+ end
177
+
178
+ it "should call the load_from_persistence method" do
179
+ @robot.reload
180
+ @robot.should_receive(:load_from_persistence)
181
+
182
+ @robot.current_state
183
+ end
184
+ end
185
+
186
+ describe "scopes" do
187
+ it "should be added for each state" do
188
+ ActiveRecordRobot.should respond_to(:red)
189
+ ActiveRecordRobot.should respond_to(:green)
190
+ end
191
+ it "should be added for each state when the state column is not 'state'" do
192
+ ActiveRecordStateColumnSetRobot.should respond_to(:red)
193
+ ActiveRecordStateColumnSetRobot.should respond_to(:green)
194
+ end
195
+
196
+ it "should not be added for each state" do
197
+ ActiveRecordNoScopeRobot.should_not respond_to(:red)
198
+ ActiveRecordNoScopeRobot.should_not respond_to(:green)
199
+ end
200
+
201
+ it "should behave like Mongoid scopes" do
202
+ 2.times { ActiveRecordRobot.create(:state => "red") }
203
+ 3.times { ActiveRecordRobot.create(:state => "green") }
204
+ ActiveRecordRobot.red.count.should == 2
205
+ ActiveRecordRobot.green.count.should == 3
206
+ end
207
+ end
208
+ end
209
+
@@ -0,0 +1,4 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+
4
+ require 'multiflow'
@@ -0,0 +1,347 @@
1
+ require 'spec_helper'
2
+
3
+ Multiflow.persistence = :none
4
+
5
+ class Robot
6
+ include Multiflow
7
+
8
+ stateflow do
9
+ initial :green
10
+
11
+ state :green, :yellow, :red
12
+
13
+ event :change_color do
14
+ transitions :from => :green, :to => :yellow
15
+ transitions :from => :yellow, :to => :red
16
+ transitions :from => :red, :to => :green
17
+ end
18
+ end
19
+ end
20
+
21
+ class Car
22
+ include Multiflow
23
+
24
+ stateflow do
25
+ initial :parked
26
+
27
+ state :parked do
28
+ enter do
29
+ "Entering parked"
30
+ end
31
+
32
+ exit do
33
+ "Exiting parked"
34
+ end
35
+ end
36
+
37
+ state :driving do
38
+ enter do
39
+ "Entering parked"
40
+ end
41
+ end
42
+
43
+ event :drive do
44
+ transitions :from => :parked, :to => :driving
45
+ end
46
+
47
+ event :park do
48
+ transitions :from => :driving, :to => :parked
49
+ end
50
+ end
51
+ end
52
+
53
+ class Bob
54
+ include Multiflow
55
+
56
+ stateflow do
57
+ state :yellow, :red, :purple
58
+
59
+ event :change_hair_color do
60
+ transitions :from => :purple, :to => :yellow
61
+ transitions :from => :yellow, :to => :red
62
+ transitions :from => :red, :to => :purple
63
+ end
64
+ end
65
+ end
66
+
67
+ class Dater
68
+ include Multiflow
69
+
70
+ stateflow do
71
+ state :single, :dating, :married
72
+
73
+ event :take_out do
74
+ transitions :from => :single, :to => :dating
75
+ end
76
+
77
+ event :gift do
78
+ transitions :from => :dating, :to => [:single, :married], :decide => :girls_mood?
79
+ end
80
+
81
+ event :blank_decision do
82
+ transitions :from => :single, :to => [:single, :married], :decide => :girls_mood?
83
+ end
84
+
85
+ event :fail do
86
+ transitions :from => :dating, :to => [:single, :married]
87
+ end
88
+ end
89
+
90
+ def girls_mood?
91
+ end
92
+ end
93
+
94
+ class Priority
95
+ include Multiflow
96
+
97
+ stateflow do
98
+ initial :medium
99
+ state :low, :medium, :high
100
+
101
+ event :low do
102
+ transitions :from => any, :to => :low
103
+ end
104
+
105
+ event :medium do
106
+ transitions :from => any, :to => :medium
107
+ end
108
+
109
+ event :high do
110
+ transitions :from => any, :to => :high
111
+ end
112
+ end
113
+ end
114
+
115
+ class Stater
116
+ include Multiflow
117
+
118
+ stateflow do
119
+ initial :bill
120
+
121
+ state :bob, :bill
122
+
123
+ event :lolcats do
124
+ transitions :from => :bill, :to => :bob
125
+ end
126
+ end
127
+ end
128
+
129
+ describe Multiflow do
130
+ describe "class methods" do
131
+ it "should respond to stateflow block to setup the intial stateflow" do
132
+ Robot.should respond_to(:stateflow)
133
+ end
134
+
135
+ it "should respond to the machine attr accessor" do
136
+ Robot.should respond_to(:machine_state)
137
+ end
138
+
139
+ it "should return all active persistence layers" do
140
+ Set[*Multiflow::Persistence.active.should] == Set[:active_record, :mongo_mapper, :mongoid, :none]
141
+ end
142
+ end
143
+
144
+ describe "instance methods" do
145
+ before(:each) do
146
+ @r = Robot.new
147
+ end
148
+
149
+ it "should respond to current state" do
150
+ @r.should respond_to(:current_state)
151
+ end
152
+
153
+ it "should respond to the current state setter" do
154
+ @r.should respond_to(:set_current_state)
155
+ end
156
+
157
+ it "should respond to the current machine" do
158
+ @r.should respond_to(:machine_state)
159
+ end
160
+
161
+ it "should respond to load from persistence" do
162
+ @r.should respond_to(:load_from_persistence)
163
+ end
164
+
165
+ it "should respond to save to persistence" do
166
+ @r.should respond_to(:save_to_persistence)
167
+ end
168
+ end
169
+
170
+ describe "initial state" do
171
+ it "should set the initial state" do
172
+ robot = Robot.new
173
+ robot.current_state.name.should == :green
174
+ end
175
+
176
+ it "should return true for green?" do
177
+ robot = Robot.new
178
+ robot.green?.should be_true
179
+ end
180
+
181
+ it "should return false for yellow?" do
182
+ robot = Robot.new
183
+ robot.yellow?.should be_false
184
+ end
185
+
186
+ it "should return false for red?" do
187
+ robot = Robot.new
188
+ robot.red?.should be_false
189
+ end
190
+
191
+ it "should set the initial state to the first state set" do
192
+ bob = Bob.new
193
+ bob.current_state.name.should == :yellow
194
+ bob.yellow?.should be_true
195
+ end
196
+ end
197
+
198
+ it "robot class should contain red, yellow and green states" do
199
+ robot = Robot.new
200
+ robot.machine_state.states.keys.should include(:red, :yellow, :green)
201
+ end
202
+
203
+ describe "firing events" do
204
+ let(:robot) { Robot.new }
205
+ let(:event) { :change_color }
206
+ subject { robot }
207
+
208
+ it "should raise an exception if the event does not exist" do
209
+ lambda { robot.send(:fire_event, robot.machine_state, :fake) }.should raise_error(Multiflow::NoEventFound)
210
+ end
211
+
212
+ shared_examples_for "an entity supporting state changes" do
213
+ context "when firing" do
214
+ after(:each) { subject.send(event_method) }
215
+ it "should call the fire method on event" do
216
+ subject.machine_state.events[event].should_receive(:fire)
217
+ end
218
+
219
+ it "should call the fire_event method" do
220
+ subject.should_receive(:fire_event).with(subject.machine_state, event, {:save=>persisted})
221
+ end
222
+ end
223
+
224
+ it "should update the current state" do
225
+ subject.current_state.name.should == :green
226
+ subject.send(event_method)
227
+ subject.current_state.name.should == :yellow
228
+ end
229
+ end
230
+
231
+ describe "bang event" do
232
+ let(:event_method) { :change_color! }
233
+ let(:persisted) { true }
234
+ it_should_behave_like "an entity supporting state changes"
235
+ end
236
+
237
+ describe "non-bang event" do
238
+ let(:event_method) { :change_color }
239
+ let(:persisted) { false }
240
+ it_should_behave_like "an entity supporting state changes"
241
+ end
242
+
243
+ describe "before filters" do
244
+ before(:each) do
245
+ @car = Car.new
246
+ end
247
+
248
+ it "should call the exit state before filter on the exiting old state" do
249
+ @car.machine_state.states[:parked].should_receive(:execute_action).with(:exit, @car)
250
+ @car.drive!
251
+ end
252
+
253
+ it "should call the enter state before filter on the entering new state" do
254
+ @car.machine_state.states[:driving].should_receive(:execute_action).with(:enter, @car)
255
+ @car.drive!
256
+ end
257
+ end
258
+ end
259
+
260
+ describe "persistence" do
261
+ it "should attempt to persist the new state and the name should be a string" do
262
+ robot = Robot.new
263
+ robot.should_receive(:save_to_persistence).with(robot.machine_state, "yellow", {:save=>true})
264
+ robot.change_color!
265
+ end
266
+
267
+ it "should attempt to read the initial state from the persistence" do
268
+ robot = Robot.new
269
+
270
+ def robot.load_from_persistence(machine)
271
+ :red
272
+ end
273
+
274
+ robot.current_state.name.should == :red
275
+ end
276
+ end
277
+
278
+ describe "dynamic to transitions" do
279
+ it "should raise an error without any decide argument" do
280
+ date = Dater.new
281
+
282
+ def date.load_from_persistence(machine)
283
+ :dating
284
+ end
285
+
286
+ lambda { date.fail! }.should raise_error(Multiflow::IncorrectTransition)
287
+ end
288
+
289
+ it "should raise an error if the decision method does not return a valid state" do
290
+ date = Dater.new
291
+
292
+ def date.girls_mood?
293
+ :lol
294
+ end
295
+
296
+ lambda { date.blank_decision! }.should raise_error(Multiflow::NoStateFound, "Decision did not return a state that was set in the 'to' argument")
297
+ end
298
+
299
+ it "should raise an error if the decision method returns blank/nil" do
300
+ date = Dater.new
301
+
302
+ def date.girls_mood?
303
+ end
304
+
305
+ lambda { date.blank_decision! }.should raise_error(Multiflow::NoStateFound, "Decision did not return a state that was set in the 'to' argument")
306
+ end
307
+
308
+ it "should calculate the decide block or method and transition to the correct state" do
309
+ date = Dater.new
310
+
311
+ def date.load_from_persistence(machine)
312
+ :dating
313
+ end
314
+
315
+ date.current_state.name.should == :dating
316
+
317
+ def date.girls_mood?
318
+ :single
319
+ end
320
+
321
+ date.gift!
322
+
323
+ date.current_state.name.should == :single
324
+ end
325
+ end
326
+
327
+ describe "transitions from any" do
328
+ it "should properly change state" do
329
+ priority = Priority.new
330
+ priority.low!
331
+ priority.should be_low
332
+ priority.medium!
333
+ priority.should be_medium
334
+ priority.high!
335
+ priority.should be_high
336
+ end
337
+ end
338
+
339
+ describe 'sub-classes of Multiflow\'d classes' do
340
+ subject { Class.new(Stater) }
341
+
342
+ it 'shouldn\'t raise an exception' do
343
+ expect { subject.new.lolcats! }.not_to raise_exception
344
+ end
345
+ end
346
+
347
+ end