multiflow 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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