golem_statemachine 0.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,51 @@
1
+ require 'golem/model/condition'
2
+ require 'golem/model/callback'
3
+
4
+ module Golem
5
+ module DSL
6
+ class TransitionDef
7
+ def initialize(machine, event, from_state, options = {}, &block)
8
+ @machine = machine
9
+ @event = event
10
+ @from = from_state
11
+
12
+ if options[:to].blank? || options[:to] == :self
13
+ @to = options[:to] = @state
14
+ else
15
+ @to = @machine.get_or_define_state(options[:to])
16
+ end
17
+
18
+ callbacks = {}
19
+ callbacks[:on_transition] = options[:action] if options[:action]
20
+
21
+ @transition = Golem::Model::Transition.new(@from, @to || @from, options[:guards], callbacks)
22
+
23
+ instance_eval(&block) if block
24
+
25
+ @from.transitions_on_event[@event] ||= []
26
+ @from.transitions_on_event[@event] << @transition
27
+ end
28
+
29
+ def guard(callback_or_options = {}, guard_options = {}, &block)
30
+ if callback_or_options.kind_of? Hash
31
+ callback = block
32
+ guard_options = callback_or_options
33
+ else
34
+ callback = callback_or_options
35
+ end
36
+
37
+ @transition.guards << Golem::Model::Condition.new(callback, guard_options)
38
+ end
39
+
40
+ def action(callback = nil, &block)
41
+ #if @transition.callbacks[:on_transition]
42
+ # puts "WARNING: Overriding event action for #{@transition.to_s.inspect}."
43
+ #end
44
+
45
+ callback = block unless callback
46
+
47
+ @transition.callbacks[:on_transition] = Golem::Model::Callback.new(callback)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,32 @@
1
+ module Golem
2
+ module Model
3
+ class Callback
4
+ attr_accessor :callback
5
+
6
+ def initialize(callback, options = {})
7
+ @callback = callback
8
+ end
9
+
10
+ def call(obj, *args)
11
+ case @callback
12
+ when Proc
13
+ if @callback.arity.abs > 1
14
+ @callback.call(obj, *args)
15
+ else
16
+ @callback.call(obj)
17
+ end
18
+ else
19
+ if obj.method(@callback).arity.abs > 0
20
+ obj.send(@callback, *args)
21
+ else
22
+ obj.send(@callback)
23
+ end
24
+ end
25
+ end
26
+
27
+ def to_s
28
+ "#{@callback.inspect}"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ module Golem
2
+ module Model
3
+ class Condition < Golem::Model::Callback
4
+ attr_accessor :failure_message
5
+
6
+ def initialize(callback, options = {})
7
+ @callback = callback
8
+ @failure_message = options[:failure_message]
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ module Golem
2
+ module Model
3
+ class Event
4
+ attr_reader :name
5
+ attr_reader :callbacks
6
+
7
+ def initialize(name)
8
+ @name = name
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,26 @@
1
+ require 'golem/util/element_collection'
2
+
3
+ module Golem
4
+ module Model
5
+ class State
6
+ attr_reader :name
7
+ attr_reader :callbacks
8
+ attr_reader :transitions_on_event
9
+
10
+ def initialize(name)
11
+ name = name.to_sym unless name.is_a?(Symbol)
12
+ @name = name
13
+ @transitions_on_event = Golem::Util::ElementCollection.new
14
+ @callbacks = {}
15
+ end
16
+
17
+ def to_s
18
+ name.to_s
19
+ end
20
+
21
+ def to_sym
22
+ name.to_sym
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,224 @@
1
+ require 'golem/model/event'
2
+ require 'golem/model/state'
3
+ require 'golem/model/transition'
4
+ require 'golem/util/element_collection'
5
+
6
+ module Golem
7
+
8
+ module Model
9
+
10
+ class StateMachine
11
+ attr_accessor :name
12
+ attr_accessor :state_attribute
13
+ attr_reader :states
14
+ attr_reader :events
15
+ attr_accessor :transition_errors
16
+
17
+ # Callback executed on every successful transition.
18
+ attr_accessor :on_all_transitions
19
+
20
+ # Callback executed on every successful event.
21
+ attr_accessor :on_all_events
22
+
23
+ def initialize(name)
24
+ @name = name
25
+ @states = Golem::Util::ElementCollection.new(Golem::Model::State)
26
+ @events = Golem::Util::ElementCollection.new(Golem::Model::Event)
27
+ @transition_errors = []
28
+ @throw_exceptions = false
29
+ end
30
+
31
+ def initial_state
32
+ @initial_state
33
+ end
34
+
35
+ def initial_state=(state)
36
+ # for the sake of readability in debugging, we store initial state by name rather than by reference to a State object
37
+ @initial_state = state.name
38
+ end
39
+
40
+ def all_states
41
+ @states
42
+ end
43
+
44
+ def all_events
45
+ @events
46
+ end
47
+
48
+ def get_current_state_of(obj)
49
+ obj.send(state_attribute)
50
+ end
51
+
52
+ def set_current_state_of(obj, state)
53
+ obj.send("#{state_attribute}=".to_sym, state)
54
+ end
55
+
56
+ def init(obj, *args)
57
+ # set the initial state
58
+ set_current_state_of(obj, initial_state)
59
+
60
+ # call the on_entry callback for the initial state (if defined)
61
+ init_state = states[get_current_state_of(obj)]
62
+ init_state.callbacks[:on_enter].call(obj, *args) if init_state && init_state.callbacks[:on_enter]
63
+ end
64
+
65
+ def fire_event_with_exceptions(obj, event, *args)
66
+ @throw_exceptions = true
67
+ fire_event(obj, event, *args)
68
+ end
69
+
70
+ def fire_event_without_exceptions(obj, event, *args)
71
+ @throw_exceptions = false
72
+ fire_event(obj, event, *args)
73
+ end
74
+
75
+ def fire_event(obj, event, *args)
76
+ @transition_errors = []
77
+ transition = determine_transition_on_event(obj, event, *args)
78
+
79
+ on_all_events.call(obj, event, args) if on_all_events
80
+
81
+ if transition
82
+ before_state = states[get_current_state_of(obj)]
83
+ before_state.callbacks[:on_exit].call(obj, *args) if before_state.callbacks[:on_exit]
84
+
85
+ set_current_state_of(obj, transition.to.name)
86
+ transition.callbacks[:on_transition].call(obj, *args) if transition.callbacks[:on_transition]
87
+ on_all_transitions.call(obj, event, transition, *args) if on_all_transitions
88
+
89
+ after_state = states[get_current_state_of(obj)]
90
+ after_state.callbacks[:on_enter].call(obj, *args) if after_state.callbacks[:on_enter]
91
+
92
+ save_result = true
93
+ if @throw_exceptions
94
+ save_result = obj.save! if obj.respond_to?(:save!)
95
+ else
96
+ save_result = obj.save if obj.respond_to?(:save)
97
+ end
98
+ return save_result
99
+ else
100
+ return false
101
+ end
102
+ end
103
+
104
+ def determine_transition_on_event(obj, event, *args)
105
+ event = @events[event] unless event.is_a?(Golem::Model::Event)
106
+
107
+ from_state = states[get_current_state_of(obj)]
108
+ possible_transitions = from_state.transitions_on_event[event.name]
109
+
110
+ selected_transition = determine_transition(possible_transitions, obj, *args)
111
+
112
+ if selected_transition.nil?
113
+ if @cannot_transition_because.blank?
114
+ msg = "#{event.name.to_s.inspect} is not a valid action for #{obj} because no outgoing transitions are available when #{name.blank? ? "the state" : "#{name} "} is #{from_state}."
115
+ msg << "\n\tPossible transitions are: \n\t\t#{possible_transitions.collect.collect{|t| t.to_s}.join("\n\t\t")}" unless possible_transitions.blank?
116
+ elsif @cannot_transition_because.length == 1
117
+ msg = "#{event.name.to_s.inspect} is not a valid action for #{obj} because #{@cannot_transition_because.first}."
118
+ else
119
+ msg = "#{event.name.to_s.inspect} is not a valid action for #{obj} because #{@cannot_transition_because.uniq.join(" and ")}"
120
+ end
121
+
122
+ if @throw_exceptions
123
+ raise Golem::ImpossibleEvent.new(msg, event, obj, @cannot_transition_because)
124
+ else
125
+ obj.transition_errors << msg
126
+ end
127
+ end
128
+
129
+ return selected_transition
130
+ end
131
+
132
+ def get_or_define_state(state)
133
+ if states[state]
134
+ return states[state]
135
+ else
136
+ case state
137
+ when Golem::Model::State
138
+ return states[state] = state
139
+ when String, Symbol
140
+ return states[state] = Golem::Model::State.new(state)
141
+ else
142
+ raise ArgumentError, "State must be a Golem::Model::State, String, or Symbol but is a #{state.class}"
143
+ end
144
+ end
145
+ end
146
+
147
+ def get_or_define_event(event)
148
+ if events[event]
149
+ return events[event]
150
+ else
151
+ case event
152
+ when Golem::Model::Event
153
+ return events[event] = event
154
+ when String, Symbol
155
+ return events[event] = Golem::Model::Event.new(event)
156
+ else
157
+ raise ArgumentError, "Event must be a Golem::Model::Event, String, or Symbol but is a #{event.class}"
158
+ end
159
+ end
160
+ end
161
+
162
+ # def get_or_create_state(name, options = {})
163
+ # @states[name] || define_state(name, options)
164
+ # end
165
+ #
166
+ # def define_state(name, options = {})
167
+ #
168
+ # s.set_callback(:on_enter, options[:enter]) if options[:enter]
169
+ # s.set_callback(:on_exit, options[:exit]) if options[:exit]
170
+ # # TODO: implement :do callback as an "Activity" (Thread/subrocess?)
171
+ # states[name] = s
172
+ # return s
173
+ # end
174
+ #
175
+ # def get_or_define_event(name, options = {})
176
+ # @events[name] || define_event(name, options)
177
+ # end
178
+ #
179
+ # def define_event(name, options = {}, &block)
180
+ # e = Event.new(name)
181
+ # e.instance_eval(&block) if block
182
+ #
183
+ # model.class_eval do
184
+ # define_method("#{name}!") do |*args|
185
+ # model.transaction do
186
+ # e.fire(args)
187
+ # end
188
+ # end
189
+ # end
190
+ #
191
+ # @events << e
192
+ # return e
193
+ # end
194
+
195
+ private
196
+
197
+ def determine_transition(possible_transitions, obj, *args)
198
+ return nil if possible_transitions.blank?
199
+
200
+ @cannot_transition_because = []
201
+
202
+ possible_transitions.each do |transition|
203
+ guard_failed = false
204
+ unless transition.guards.empty?
205
+ transition.guards.each do |guard| # all guards must evaluate to true
206
+ unless guard.call(obj, *args)
207
+ @cannot_transition_because << (guard.failure_message || "#{guard} is false")
208
+ guard_failed = true
209
+ break
210
+ end
211
+ end
212
+ end
213
+
214
+ next if guard_failed
215
+
216
+ return transition
217
+ end
218
+
219
+ return nil
220
+ end
221
+
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,37 @@
1
+ # To change this template, choose Tools | Templates
2
+ # and open the template in the editor.
3
+
4
+ module Golem
5
+ module Model
6
+ class Transition
7
+ attr_reader :from
8
+ attr_reader :to
9
+ attr_accessor :guards
10
+ attr_accessor :callbacks
11
+
12
+ def initialize(from, to, guards = [], callbacks = {})
13
+ @from = from
14
+ @to = to
15
+
16
+ raise ArgumentError, "'guards' must be an Enumerable collection of Golem::Model::Conditions but is #{guards.inspect}" unless
17
+ guards.blank? || (guards.kind_of?(Enumerable) && guards.all?{|g| g.kind_of?(Golem::Model::Condition)})
18
+
19
+ @guards = guards
20
+
21
+ raise ArgumentError, "'callbacks' must be a Hash of Golem::Model::Callbacks but is #{callbacks.inspect}" unless
22
+ callbacks.blank? || (callbacks.kind_of?(Hash) && callbacks.all?{|k, c| c.kind_of?(Golem::Model::Callback)})
23
+
24
+ # only the :on_transition callback is currently implemented, but using a Hash of callbacks here leaves open
25
+ # the possibility of other callbacks (e.g. :on_start, :on_finish, etc.)
26
+ @callbacks = callbacks
27
+ end
28
+
29
+ def to_s
30
+ s ="Transition from #{from} to #{to}"
31
+ s << " [#{guards.collect{|g| g.to_s}.join(" and ")}]" unless guards.empty?
32
+ s << " / #{callbacks.collect{|k,v| v.to_s}.join(",")}" unless callbacks.blank? || callbacks.values.all?{|v| v.blank?}
33
+ return s
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,33 @@
1
+ module Golem
2
+ module Util
3
+ class ElementCollection
4
+ include Enumerable
5
+
6
+ def initialize(restricted_to_type = nil)
7
+ @collection = {}
8
+ @restricted_to_type = restricted_to_type
9
+ end
10
+
11
+ def [](key)
12
+ return nil if key.nil?
13
+ key = key.name if key.respond_to?(:name)
14
+ @collection[key.to_sym]
15
+ end
16
+
17
+ def []=(key, value)
18
+ key = key.name if key.respond_to?(:name)
19
+ raise ArgumentError, "Value must be a #{@restricted_to_type.name.inspect} but is a #{value.class.name.inspect}!" if
20
+ @restricted_to_type && !value.kind_of?(@restricted_to_type)
21
+ @collection[key.to_sym] = value
22
+ end
23
+
24
+ def each
25
+ @collection.values.each{|v| yield v}
26
+ end
27
+
28
+ def values
29
+ @collection.values
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :golem_statemachine do
3
+ # # Task goes here
4
+ # end
@@ -0,0 +1,189 @@
1
+ require 'test_helper'
2
+
3
+ require 'rubygems'
4
+
5
+
6
+ begin
7
+ require 'sqlite3'
8
+ rescue
9
+ gem 'sqlite3-ruby'
10
+ require 'sqlite3'
11
+ end
12
+
13
+ require 'activerecord'
14
+
15
+ require 'ruby-debug'
16
+
17
+ class ActiveRecordTest < Test::Unit::TestCase
18
+
19
+ def setup
20
+ eval %{
21
+ class Foo < ActiveRecord::Base
22
+ include Golem
23
+ end
24
+ }
25
+
26
+ File.delete('test.db') if File.exists?('test.db')
27
+
28
+ ActiveRecord::Base.establish_connection(
29
+ :adapter => 'sqlite3',
30
+ :database => 'test.db'
31
+ )
32
+
33
+ tmp = $stdout
34
+ $stdout = StringIO.new
35
+ ActiveRecord::Schema.define do
36
+ create_table :foos do |t|
37
+ t.column :state, :string, :null => true
38
+ t.column :alpha_state, :string, :null => true
39
+ t.column :status, :string, :null => true
40
+ end
41
+ end
42
+ $stdout = tmp
43
+ end
44
+
45
+ def teardown
46
+ self.class.send(:remove_const, :Foo)
47
+ end
48
+
49
+ def test_restore_state
50
+ foo = Foo.create(
51
+ :state => 'b',
52
+ :alpha_state => 'c',
53
+ :status => 'd'
54
+ )
55
+
56
+ Foo.instance_eval do
57
+ define_statemachine do
58
+ initial_state :a
59
+ state :a
60
+ state :b
61
+ state :c
62
+ state :d
63
+ end
64
+
65
+ define_statemachine(:alpha) do
66
+ initial_state :a
67
+ state :a
68
+ state :b
69
+ state :c
70
+ state :d
71
+ end
72
+
73
+ define_statemachine(:beta) do
74
+ state_attribute(:status)
75
+ initial_state :a
76
+ state :a
77
+ state :b
78
+ state :c
79
+ state :d
80
+ end
81
+ end
82
+
83
+ foo = Foo.find(foo.id)
84
+
85
+ assert_equal :b, foo.state
86
+ assert_equal :c, foo.alpha_state
87
+ assert_equal :d, foo.status
88
+
89
+ # check that initial state works too
90
+ foo = Foo.create
91
+
92
+ foo = Foo.find(foo.id)
93
+
94
+ assert_equal :a, foo.state
95
+ assert_equal :a, foo.alpha_state
96
+ assert_equal :a, foo.status
97
+ end
98
+
99
+ def test_save_state
100
+ Foo.instance_eval do
101
+ define_statemachine do
102
+ initial_state :a
103
+ state :a do
104
+ on :go, :to => :b
105
+ end
106
+ state :b
107
+ state :c
108
+ state :d
109
+ end
110
+
111
+ define_statemachine(:alpha) do
112
+ initial_state :a
113
+ state :a do
114
+ on :go, :to => :c
115
+ end
116
+ state :b
117
+ state :c
118
+ state :d
119
+ end
120
+
121
+ define_statemachine(:beta) do
122
+ state_attribute(:status)
123
+ initial_state :a
124
+ state :a do
125
+ on :go, :to => :d
126
+ end
127
+ state :b
128
+ state :c
129
+ state :d
130
+ end
131
+ end
132
+
133
+ foo = Foo.create
134
+
135
+ assert_equal :a, foo.state
136
+ assert_equal :a, foo.alpha_state
137
+ assert_equal :a, foo.status
138
+
139
+ foo.go
140
+
141
+ assert_equal :b, foo.state
142
+ assert_equal :c, foo.alpha_state
143
+ assert_equal :d, foo.status
144
+
145
+ foo = Foo.find(foo.id)
146
+
147
+ assert_equal :b, foo.state
148
+ assert_equal :c, foo.alpha_state
149
+ assert_equal :d, foo.status
150
+ end
151
+
152
+
153
+ def test_fire_entry_action_on_initial_state
154
+ Foo.instance_eval do
155
+ define_statemachine do
156
+ initial_state :a
157
+ state :a do
158
+ enter do |r|
159
+ throw :it_worked!
160
+ end
161
+ on :go, :to => :b
162
+ end
163
+ state :b
164
+ end
165
+
166
+ define_statemachine(:alpha) do
167
+ initial_state :a
168
+ state :a do
169
+ on :go, :to => :c
170
+ end
171
+ state :b
172
+ state :c
173
+ state :d
174
+ end
175
+ end
176
+
177
+ assert_throws :it_worked! do
178
+ foo = Foo.create
179
+ end
180
+ end
181
+
182
+
183
+ def test_transaction_around_fire_event
184
+ #TODO: check that transaction around fire_event is respected (i.e. changes not persisted when an execption is
185
+ # raised inside an event firing)
186
+ end
187
+
188
+ end
189
+