simple_state_machine 0.5.1 → 0.5.2
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/README.rdoc
CHANGED
@@ -5,28 +5,55 @@ A simple DSL to decorate existing methods with state transition guards.
|
|
5
5
|
Instead of using a DSL to define events, SimpleStateMachine decorates methods
|
6
6
|
to help you encapsulate state and guard state transitions.
|
7
7
|
|
8
|
-
|
8
|
+
It supports exception rescuing, google chart visualization and mountable state_machines.
|
9
9
|
|
10
|
-
|
10
|
+
== Basic example
|
11
|
+
|
12
|
+
class LampSwitch
|
13
|
+
|
14
|
+
extend SimpleStateMachine
|
15
|
+
|
16
|
+
def initialize
|
17
|
+
self.state = 'off'
|
18
|
+
end
|
19
|
+
|
20
|
+
def push_switch
|
21
|
+
puts "pushed switch"
|
22
|
+
end
|
23
|
+
event :push_switch, :off => :on,
|
24
|
+
:on => :off
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
lamp = LampSwitch.new
|
29
|
+
lamp.state # => 'off'
|
30
|
+
lamp.off? # => true
|
31
|
+
lamp.push_switch # => 'pushed switch'
|
32
|
+
lamp.state # => 'on'
|
33
|
+
lamp.on? # => true
|
34
|
+
lamp.push_switch # => 'pushed switch'
|
35
|
+
lamp.off? # => true
|
36
|
+
|
37
|
+
|
38
|
+
== Basic usage
|
39
|
+
|
40
|
+
Define your event as a method, arguments are allowed:
|
11
41
|
|
12
42
|
def activate_account(activation_code)
|
13
43
|
# call other methods, no need to add these in callbacks
|
14
|
-
|
15
|
-
log.debug "Try to activate account with #{activation_code}"
|
44
|
+
..
|
16
45
|
end
|
17
46
|
|
18
47
|
Now mark the method as an event and specify how the state should transition
|
19
|
-
when the method is called.
|
20
|
-
set the state to :active if the initial state is :pending.
|
48
|
+
when the method is called. If we want the state to change from :pending to :active we write:
|
21
49
|
|
22
50
|
event :activate_account, :pending => :active
|
23
51
|
|
52
|
+
That's it!
|
53
|
+
You can now call activate_account and the state will automatically change.
|
54
|
+
If the state change is not allowed, a SimpleStateMachine::IllegalStateTransitionError is raised.
|
24
55
|
|
25
|
-
|
26
|
-
You can now call the method and the state will automatically change.
|
27
|
-
If the state change is not allowed, a SimpleStateMachine::Error is raised.
|
28
|
-
|
29
|
-
=== Example usage with ActiveRecord / ActiveModel
|
56
|
+
=== Using ActiveRecord / ActiveModel validations
|
30
57
|
When using ActiveRecord / ActiveModel you can add an error to the errors object.
|
31
58
|
This will prevent the state from being changed.
|
32
59
|
|
@@ -36,7 +63,9 @@ This will prevent the state from being changed.
|
|
36
63
|
end
|
37
64
|
end
|
38
65
|
|
39
|
-
|
66
|
+
activate_account!("INVALID_CODE") # => ActiveRecord::RecordInvalid, "Validation failed: Activation code is invalid"
|
67
|
+
|
68
|
+
=== Catching exceptions
|
40
69
|
You can rescue exceptions and specify the failure state
|
41
70
|
|
42
71
|
def download_data
|
@@ -45,40 +74,13 @@ You can rescue exceptions and specify the failure state
|
|
45
74
|
event :download_data, :pending => :downloaded,
|
46
75
|
Service::ConnectionError => :download_failed
|
47
76
|
|
48
|
-
|
49
|
-
|
50
|
-
To add a state machine:
|
51
|
-
- extend SimpleStateMachine
|
52
|
-
- set the initial state
|
53
|
-
- turn methods into events
|
54
|
-
|
55
|
-
class LampWithHotelSwitch
|
56
|
-
|
57
|
-
extend SimpleStateMachine
|
58
|
-
|
59
|
-
def initialize
|
60
|
-
self.state = 'off'
|
61
|
-
end
|
62
|
-
|
63
|
-
def push_switch_1
|
64
|
-
puts "pushed switch 1 #{state}"
|
65
|
-
end
|
66
|
-
event :push_switch_1, :off => :on,
|
67
|
-
:on => :off
|
77
|
+
download_data! # catches Service::ConnectionError
|
78
|
+
state # => "download_failed"
|
68
79
|
|
69
|
-
|
70
|
-
|
71
|
-
event :push_switch_2, :off => :on,
|
72
|
-
:on => :off
|
80
|
+
=== Catching all from states
|
81
|
+
If an event should transition from all states you can use :all
|
73
82
|
|
74
|
-
|
75
|
-
|
76
|
-
lamp = LampWithHotelSwitch.new
|
77
|
-
lamp.state # => 'off'
|
78
|
-
lamp.push_switch_1
|
79
|
-
lamp.state # => 'on'
|
80
|
-
lamp.push_siwtch_2
|
81
|
-
lamp.state # => 'off'
|
83
|
+
event :suspend, :all => :suspended
|
82
84
|
|
83
85
|
== ActiveRecord Example
|
84
86
|
|
@@ -92,17 +94,17 @@ To add a state machine with ActiveRecord persistence:
|
|
92
94
|
extend SimpleStateMachine::ActiveRecord
|
93
95
|
|
94
96
|
def after_initialize
|
95
|
-
self.ssm_state ||= '
|
97
|
+
self.ssm_state ||= 'pending'
|
96
98
|
# if you get an ActiveRecord::MissingAttributeError
|
97
99
|
# you'll probably need to do (http://bit.ly/35q23b):
|
98
|
-
# write_attribute(:ssm_state, "
|
100
|
+
# write_attribute(:ssm_state, "pending") unless read_attribute(:ssm_state)
|
99
101
|
end
|
100
102
|
|
101
103
|
def invite
|
102
104
|
self.activation_code = Digest::SHA1.hexdigest("salt #{Time.now.to_f}")
|
103
105
|
#send_activation_email
|
104
106
|
end
|
105
|
-
event :invite, :
|
107
|
+
event :invite, :pending => :invited
|
106
108
|
|
107
109
|
def confirm_invitation activation_code
|
108
110
|
if self.activation_code != activation_code
|
@@ -110,26 +112,49 @@ To add a state machine with ActiveRecord persistence:
|
|
110
112
|
end
|
111
113
|
end
|
112
114
|
event :confirm_invitation, :invited => :active
|
113
|
-
|
114
|
-
# :all can be used to catch all from states
|
115
|
-
event :suspend, :all => :suspended
|
116
115
|
end
|
117
116
|
|
118
117
|
This generates the following event methods
|
119
118
|
- invite!
|
120
119
|
- confirm_invitation!
|
121
|
-
|
120
|
+
|
122
121
|
And the following methods to query the state:
|
122
|
+
- pending?
|
123
123
|
- invited?
|
124
124
|
- active?
|
125
|
-
- suspended?
|
126
125
|
|
127
126
|
If you want to be more verbose you can also use:
|
128
127
|
- invite_and_save (behave like ActiveRecord save)
|
129
128
|
- invite_and_save! (is equal to invite! and behaves like save!)
|
130
129
|
|
130
|
+
== Mountable Example
|
131
|
+
|
132
|
+
You can define your state machine in a seperate class:
|
133
|
+
|
134
|
+
class MyStateMachine < SimpleStateMachine::StateMachineDefinition
|
135
|
+
def initialize(subject)
|
136
|
+
self.lazy_decorator = lambda { SimpleStateMachine::Decorator.new(subject) }
|
137
|
+
add_transition(:invite, :new, :invited)
|
138
|
+
add_transition(:confirm_invitation, :invited, :active)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
class User < ActiveRecord::Base
|
143
|
+
|
144
|
+
extend SimpleStateMachine::Mountable
|
145
|
+
self.state_machine_definition = MyStateMachine.new self
|
146
|
+
|
147
|
+
def after_initialize
|
148
|
+
self.ssm_state ||= 'new'
|
149
|
+
end
|
150
|
+
|
151
|
+
end
|
152
|
+
|
153
|
+
|
154
|
+
== Generating google chart visualizations
|
155
|
+
|
156
|
+
If your using rails you get rake tasks for generating a graphiz google chart of the state machine.
|
131
157
|
|
132
|
-
This code was just released, we do not claim it to be stable.
|
133
158
|
|
134
159
|
== Note on Patches/Pull Requests
|
135
160
|
|
@@ -17,7 +17,7 @@ module SimpleStateMachine::ActiveRecord
|
|
17
17
|
unless @subject.method_defined?(event_name_and_save)
|
18
18
|
@subject.send(:define_method, event_name_and_save) do |*args|
|
19
19
|
old_state = self.send(self.class.state_machine_definition.state_method)
|
20
|
-
send event_name, *args
|
20
|
+
send "with_managed_state_#{event_name}", *args
|
21
21
|
if !self.errors.entries.empty?
|
22
22
|
self.send("#{self.class.state_machine_definition.state_method}=", old_state)
|
23
23
|
return false
|
@@ -35,7 +35,7 @@ module SimpleStateMachine::ActiveRecord
|
|
35
35
|
unless @subject.method_defined?(event_name_and_save_bang)
|
36
36
|
@subject.send(:define_method, event_name_and_save_bang) do |*args|
|
37
37
|
old_state = self.send(self.class.state_machine_definition.state_method)
|
38
|
-
send event_name, *args
|
38
|
+
send "with_managed_state_#{event_name}", *args
|
39
39
|
if !self.errors.entries.empty?
|
40
40
|
self.send("#{self.class.state_machine_definition.state_method}=", old_state)
|
41
41
|
raise ActiveRecord::RecordInvalid.new(self)
|
@@ -47,18 +47,26 @@ module SimpleStateMachine::ActiveRecord
|
|
47
47
|
raise #re raise
|
48
48
|
end
|
49
49
|
end
|
50
|
-
@subject.send :alias_method, "#{transition.event_name}!", event_name_and_save_bang
|
50
|
+
@subject.send :alias_method, "#{transition.event_name}!", event_name_and_save_bang
|
51
51
|
end
|
52
52
|
end
|
53
|
-
|
54
|
-
private
|
55
53
|
|
56
|
-
|
54
|
+
protected
|
57
55
|
|
58
|
-
|
56
|
+
def alias_event_methods event_name
|
57
|
+
@subject.send(:define_method, "cannot_call_#{event_name}") do |*args|
|
58
|
+
raise "You cannot call #{event_name}. Use #{event_name}! instead"
|
59
|
+
end
|
60
|
+
@subject.send :alias_method, "without_managed_state_#{event_name}", event_name
|
61
|
+
@subject.send :alias_method, event_name, "cannot_call_#{event_name}"
|
62
|
+
end
|
63
|
+
|
64
|
+
def define_state_setter_method; end
|
65
|
+
|
66
|
+
def define_state_getter_method; end
|
59
67
|
|
60
68
|
end
|
61
|
-
|
69
|
+
|
62
70
|
def state_machine_definition
|
63
71
|
unless @state_machine_definition
|
64
72
|
@state_machine_definition = SimpleStateMachine::StateMachineDefinition.new
|
@@ -1,7 +1,7 @@
|
|
1
1
|
module SimpleStateMachine
|
2
2
|
|
3
3
|
require 'cgi'
|
4
|
-
|
4
|
+
|
5
5
|
class IllegalStateTransitionError < ::RuntimeError
|
6
6
|
end
|
7
7
|
|
@@ -26,10 +26,10 @@ module SimpleStateMachine
|
|
26
26
|
include Mountable
|
27
27
|
|
28
28
|
##
|
29
|
-
# Adds state machine methods to the extended class
|
29
|
+
# Adds state machine methods to the extended class
|
30
30
|
module Extendable
|
31
31
|
|
32
|
-
# mark the method as an event and specify how the state should transition
|
32
|
+
# mark the method as an event and specify how the state should transition
|
33
33
|
def event event_name, state_transitions
|
34
34
|
state_transitions.each do |froms, to|
|
35
35
|
[froms].flatten.each do |from|
|
@@ -53,7 +53,7 @@ module SimpleStateMachine
|
|
53
53
|
end
|
54
54
|
end
|
55
55
|
include Inheritable
|
56
|
-
|
56
|
+
|
57
57
|
##
|
58
58
|
# Defines state machine transitions
|
59
59
|
class StateMachineDefinition
|
@@ -67,18 +67,18 @@ module SimpleStateMachine
|
|
67
67
|
def transitions
|
68
68
|
@transitions ||= []
|
69
69
|
end
|
70
|
-
|
70
|
+
|
71
71
|
def add_transition event_name, from, to
|
72
72
|
transition = Transition.new(event_name, from, to)
|
73
73
|
transitions << transition
|
74
74
|
decorator.decorate(transition)
|
75
75
|
end
|
76
|
-
|
76
|
+
|
77
77
|
def state_method
|
78
78
|
@state_method ||= :state
|
79
|
-
end
|
79
|
+
end
|
80
80
|
|
81
|
-
# Human readable format: old_state.event! => new_state
|
81
|
+
# Human readable format: old_state.event! => new_state
|
82
82
|
def to_s
|
83
83
|
transitions.map(&:to_s).join("\n")
|
84
84
|
end
|
@@ -94,7 +94,7 @@ module SimpleStateMachine
|
|
94
94
|
"http://chart.googleapis.com/chart?cht=gv&chl=digraph{#{::CGI.escape to_graphiz_dot}}"
|
95
95
|
end
|
96
96
|
end
|
97
|
-
|
97
|
+
|
98
98
|
##
|
99
99
|
# Defines the state machine used by the instance
|
100
100
|
class StateMachine
|
@@ -145,9 +145,9 @@ module SimpleStateMachine
|
|
145
145
|
illegal_event_callback event_name
|
146
146
|
end
|
147
147
|
end
|
148
|
-
|
148
|
+
|
149
149
|
private
|
150
|
-
|
150
|
+
|
151
151
|
def state_machine_definition
|
152
152
|
@subject.class.state_machine_definition
|
153
153
|
end
|
@@ -164,12 +164,12 @@ module SimpleStateMachine
|
|
164
164
|
def illegal_event_callback event_name
|
165
165
|
raise IllegalStateTransitionError.new("You cannot '#{event_name}' when state is '#{@subject.send(state_method)}'")
|
166
166
|
end
|
167
|
-
|
167
|
+
|
168
168
|
end
|
169
169
|
|
170
170
|
##
|
171
171
|
# Defines transitions for events
|
172
|
-
class Transition
|
172
|
+
class Transition
|
173
173
|
attr_reader :event_name, :from, :to
|
174
174
|
def initialize(event_name, from, to)
|
175
175
|
@event_name = event_name.to_s
|
@@ -194,7 +194,7 @@ module SimpleStateMachine
|
|
194
194
|
def to_graphiz_dot
|
195
195
|
%("#{from}"->"#{to}"[label=#{event_name}])
|
196
196
|
end
|
197
|
-
|
197
|
+
|
198
198
|
|
199
199
|
private
|
200
200
|
|
@@ -256,8 +256,7 @@ module SimpleStateMachine
|
|
256
256
|
send("without_managed_state_#{event_name}", *args)
|
257
257
|
end
|
258
258
|
end
|
259
|
-
|
260
|
-
@subject.send :alias_method, event_name, "with_managed_state_#{event_name}"
|
259
|
+
alias_event_methods event_name
|
261
260
|
end
|
262
261
|
end
|
263
262
|
|
@@ -274,7 +273,7 @@ module SimpleStateMachine
|
|
274
273
|
@subject.send(:attr_reader, state_method)
|
275
274
|
end
|
276
275
|
end
|
277
|
-
|
276
|
+
|
278
277
|
def any_method_defined?(method)
|
279
278
|
@subject.method_defined?(method) ||
|
280
279
|
@subject.protected_method_defined?(method) ||
|
@@ -283,6 +282,11 @@ module SimpleStateMachine
|
|
283
282
|
|
284
283
|
protected
|
285
284
|
|
285
|
+
def alias_event_methods event_name
|
286
|
+
@subject.send :alias_method, "without_managed_state_#{event_name}", event_name
|
287
|
+
@subject.send :alias_method, event_name, "with_managed_state_#{event_name}"
|
288
|
+
end
|
289
|
+
|
286
290
|
def state_method
|
287
291
|
@subject.state_machine_definition.state_method
|
288
292
|
end
|
data/spec/active_record_spec.rb
CHANGED
@@ -12,7 +12,7 @@ ActiveRecord::Base.establish_connection(:adapter => "sqlite3",
|
|
12
12
|
:database => ":memory:")
|
13
13
|
|
14
14
|
def setup_db
|
15
|
-
ActiveRecord::Schema.define(:version => 1) do
|
15
|
+
ActiveRecord::Schema.define(:version => 1) do
|
16
16
|
create_table :users do |t|
|
17
17
|
t.column :id, :integer
|
18
18
|
t.column :name, :string
|
@@ -38,7 +38,7 @@ class Ticket < ActiveRecord::Base
|
|
38
38
|
extend SimpleStateMachine::ActiveRecord
|
39
39
|
|
40
40
|
state_machine_definition.state_method = :ssm_state
|
41
|
-
|
41
|
+
|
42
42
|
def after_initialize
|
43
43
|
self.ssm_state ||= 'open'
|
44
44
|
end
|
@@ -47,21 +47,21 @@ class Ticket < ActiveRecord::Base
|
|
47
47
|
end
|
48
48
|
|
49
49
|
describe ActiveRecord do
|
50
|
-
|
50
|
+
|
51
51
|
before do
|
52
52
|
setup_db
|
53
53
|
end
|
54
|
-
|
54
|
+
|
55
55
|
after do
|
56
56
|
teardown_db
|
57
57
|
end
|
58
|
-
|
58
|
+
|
59
59
|
it "has a default state" do
|
60
60
|
User.new.should be_new
|
61
61
|
end
|
62
|
-
|
62
|
+
|
63
63
|
# TODO needs nesting/grouping, seems to have some duplication
|
64
|
-
|
64
|
+
|
65
65
|
describe "event_and_save" do
|
66
66
|
it "persists transitions" do
|
67
67
|
user = User.create!(:name => 'name')
|
@@ -88,9 +88,9 @@ describe ActiveRecord do
|
|
88
88
|
|
89
89
|
it "raises an error if an invalid state_transition is called" do
|
90
90
|
user = User.create!(:name => 'name')
|
91
|
-
expect {
|
92
|
-
user.confirm_invitation_and_save 'abc'
|
93
|
-
}.to raise_error(SimpleStateMachine::IllegalStateTransitionError,
|
91
|
+
expect {
|
92
|
+
user.confirm_invitation_and_save 'abc'
|
93
|
+
}.to raise_error(SimpleStateMachine::IllegalStateTransitionError,
|
94
94
|
"You cannot 'confirm_invitation' when state is 'new'")
|
95
95
|
end
|
96
96
|
|
@@ -133,9 +133,9 @@ describe ActiveRecord do
|
|
133
133
|
|
134
134
|
it "raises an error if an invalid state_transition is called" do
|
135
135
|
user = User.create!(:name => 'name')
|
136
|
-
expect {
|
137
|
-
user.confirm_invitation_and_save! 'abc'
|
138
|
-
}.to raise_error(SimpleStateMachine::IllegalStateTransitionError,
|
136
|
+
expect {
|
137
|
+
user.confirm_invitation_and_save! 'abc'
|
138
|
+
}.to raise_error(SimpleStateMachine::IllegalStateTransitionError,
|
139
139
|
"You cannot 'confirm_invitation' when state is 'new'")
|
140
140
|
end
|
141
141
|
|
@@ -143,9 +143,9 @@ describe ActiveRecord do
|
|
143
143
|
user = User.new
|
144
144
|
user.should be_new
|
145
145
|
user.should_not be_valid
|
146
|
-
expect {
|
147
|
-
user.invite_and_save!
|
148
|
-
}.to raise_error(ActiveRecord::RecordInvalid,
|
146
|
+
expect {
|
147
|
+
user.invite_and_save!
|
148
|
+
}.to raise_error(ActiveRecord::RecordInvalid,
|
149
149
|
"Validation failed: Name can't be blank")
|
150
150
|
user.should be_new
|
151
151
|
end
|
@@ -154,9 +154,9 @@ describe ActiveRecord do
|
|
154
154
|
user = User.create!(:name => 'name')
|
155
155
|
user.invite_and_save!
|
156
156
|
user.should be_invited
|
157
|
-
expect {
|
158
|
-
user.confirm_invitation_and_save!('x')
|
159
|
-
}.to raise_error(ActiveRecord::RecordInvalid,
|
157
|
+
expect {
|
158
|
+
user.confirm_invitation_and_save!('x')
|
159
|
+
}.to raise_error(ActiveRecord::RecordInvalid,
|
160
160
|
"Validation failed: Activation code is invalid")
|
161
161
|
user.should be_invited
|
162
162
|
end
|
@@ -165,19 +165,9 @@ describe ActiveRecord do
|
|
165
165
|
|
166
166
|
describe "event" do
|
167
167
|
|
168
|
-
it "
|
168
|
+
it "raises an error" do
|
169
169
|
user = User.create!(:name => 'name')
|
170
|
-
user.invite.
|
171
|
-
User.find(user.id).should_not be_invited
|
172
|
-
User.find(user.id).activation_code.should be_nil
|
173
|
-
end
|
174
|
-
|
175
|
-
it "returns false and keeps state if record is invalid" do
|
176
|
-
user = User.new
|
177
|
-
user.should be_new
|
178
|
-
user.should_not be_valid
|
179
|
-
user.invite.should == false
|
180
|
-
user.should be_new
|
170
|
+
expect { user.invite }.to raise_error(RuntimeError, "You cannot call invite. Use invite! instead")
|
181
171
|
end
|
182
172
|
|
183
173
|
end
|
@@ -202,11 +192,11 @@ describe ActiveRecord do
|
|
202
192
|
end
|
203
193
|
|
204
194
|
describe 'custom state method' do
|
205
|
-
|
195
|
+
|
206
196
|
it "persists transitions" do
|
207
197
|
ticket = Ticket.create!
|
208
198
|
ticket.should be_open
|
209
|
-
ticket.close
|
199
|
+
ticket.close!.should == true
|
210
200
|
ticket.should be_closed
|
211
201
|
end
|
212
202
|
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: simple_state_machine
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
hash:
|
4
|
+
hash: 15
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
8
|
- 5
|
9
|
-
-
|
10
|
-
version: 0.5.
|
9
|
+
- 2
|
10
|
+
version: 0.5.2
|
11
11
|
platform: ruby
|
12
12
|
authors:
|
13
13
|
- Marek de Heus
|