golem_statemachine 0.9
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +3 -0
- data/MIT-LICENSE +20 -0
- data/README.rdoc +472 -0
- data/Rakefile +23 -0
- data/examples/document.rb +44 -0
- data/examples/monster.rb +100 -0
- data/examples/seminar.rb +138 -0
- data/examples/seminar_enrollment.rb +141 -0
- data/init.rb +5 -0
- data/install.rb +1 -0
- data/lib/golem.rb +207 -0
- data/lib/golem/dsl/decision_def.rb +38 -0
- data/lib/golem/dsl/event_def.rb +89 -0
- data/lib/golem/dsl/state_def.rb +34 -0
- data/lib/golem/dsl/state_machine_def.rb +73 -0
- data/lib/golem/dsl/transition_def.rb +51 -0
- data/lib/golem/model/callback.rb +32 -0
- data/lib/golem/model/condition.rb +12 -0
- data/lib/golem/model/event.rb +12 -0
- data/lib/golem/model/state.rb +26 -0
- data/lib/golem/model/state_machine.rb +224 -0
- data/lib/golem/model/transition.rb +37 -0
- data/lib/golem/util/element_collection.rb +33 -0
- data/tasks/golem_statemachine_tasks.rake +4 -0
- data/test/active_record_test.rb +189 -0
- data/test/dsl_test.rb +429 -0
- data/test/monster_test.rb +110 -0
- data/test/problematic_test.rb +95 -0
- data/test/seminar_test.rb +106 -0
- data/test/statemachine_assertions.rb +79 -0
- data/test/test_helper.rb +5 -0
- data/uninstall.rb +1 -0
- metadata +119 -0
data/init.rb
ADDED
data/install.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
# Install hook code here
|
data/lib/golem.rb
ADDED
@@ -0,0 +1,207 @@
|
|
1
|
+
require 'activesupport'
|
2
|
+
|
3
|
+
require 'golem/dsl/state_machine_def'
|
4
|
+
|
5
|
+
module Golem
|
6
|
+
def self.included(mod)
|
7
|
+
mod.extend Golem::ClassMethods
|
8
|
+
|
9
|
+
# Override the initialize method in the object we're imbuing with statemachine
|
10
|
+
# functionality so that we can do statemachine initialization when the object
|
11
|
+
# is instantiated.
|
12
|
+
mod.class_eval do
|
13
|
+
alias_method :_initialize, :initialize
|
14
|
+
def initialize(*args)
|
15
|
+
# call the original initialize
|
16
|
+
_initialize(*args)
|
17
|
+
|
18
|
+
if respond_to?(:statemachines)
|
19
|
+
self.statemachines.each{|name, sm| sm.init(self, *args)}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
module ClassMethods
|
26
|
+
def define_statemachine(statemachine_name = nil, options = {}, &block)
|
27
|
+
default_statemachine_name = :statemachine
|
28
|
+
|
29
|
+
class_inheritable_hash(:statemachines) unless respond_to?(:statemachines)
|
30
|
+
self.statemachines ||= {}
|
31
|
+
|
32
|
+
if statemachines.has_key?(statemachine_name || default_statemachine_name)
|
33
|
+
if statemachine_name == default_statemachine_name
|
34
|
+
raise ArgumentError, "If you are declaring more than one statemachine within the same class, you must give each statemachine a unique name (i.e. define_statemachine(name) do ... end)."
|
35
|
+
else
|
36
|
+
raise ArgumentError, "Cannot declare a statemachine under #{(statemachine_name || default_statemachine_name).inspect} because this statemachine name is already taken."
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
statemachine_def = Golem::DSL::StateMachineDef.new(self, statemachine_name, &block)
|
41
|
+
|
42
|
+
statemachine = statemachine_def.machine
|
43
|
+
|
44
|
+
raise Golem::DefinitionSyntaxError, "No initial_state defined for statemachine #{statemachine}!" if statemachine.initial_state.blank?
|
45
|
+
|
46
|
+
self.statemachines[statemachine_name || default_statemachine_name] = statemachine
|
47
|
+
class_eval do
|
48
|
+
define_method(statemachine_name || default_statemachine_name) do
|
49
|
+
statemachines[statemachine_name || default_statemachine_name]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
state_attribute = statemachine_def.state_attribute
|
54
|
+
|
55
|
+
if state_attribute.blank?
|
56
|
+
if statemachine_name.nil?
|
57
|
+
state_attribute = :state
|
58
|
+
else
|
59
|
+
state_attribute = "#{statemachine_name}_state".to_sym
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
statemachine.state_attribute = state_attribute
|
64
|
+
|
65
|
+
# state reader
|
66
|
+
define_method("#{state_attribute}".to_sym) do
|
67
|
+
case
|
68
|
+
when state_attribute.respond_to?(:call)
|
69
|
+
state = state_attribute.call(self)
|
70
|
+
when Object.const_defined?('ActiveRecord') && self.kind_of?(ActiveRecord::Base)
|
71
|
+
state = self[state_attribute.to_s] && self[state_attribute.to_s].to_sym
|
72
|
+
else
|
73
|
+
state = self.instance_variable_get("@#{state_attribute}")
|
74
|
+
end
|
75
|
+
|
76
|
+
state ||= statemachine.initial_state
|
77
|
+
state = state.to_sym if state.is_a?(String)
|
78
|
+
|
79
|
+
raise InvalidStateError, "#{self} is in an unrecognized state (#{state.inspect})" unless statemachine.states[state]
|
80
|
+
|
81
|
+
state = statemachine.states[state].name
|
82
|
+
|
83
|
+
return state
|
84
|
+
end
|
85
|
+
|
86
|
+
# state writer
|
87
|
+
define_method("#{state_attribute}=".to_sym) do |new_state|
|
88
|
+
new_state = new_state.name if new_state.respond_to?(:name)
|
89
|
+
new_state = new_state.to_sym
|
90
|
+
raise ArgumentError, "#{new_state.inspect} is not a valid state for #{statemachine}!" unless statemachine.states[new_state]
|
91
|
+
|
92
|
+
case
|
93
|
+
when state_attribute.respond_to?(:call)
|
94
|
+
state_attribute.call(self, new_state)
|
95
|
+
when Object.const_defined?('ActiveRecord') && self.kind_of?(ActiveRecord::Base)
|
96
|
+
self[state_attribute.to_s] = new_state.to_s # store as String rather than Symbol to prevent serialization weirdness
|
97
|
+
else
|
98
|
+
self.instance_variable_set("@#{state_attribute}", new_state)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
validate :check_for_transition_errors if respond_to? :validate
|
103
|
+
|
104
|
+
define_method(:transition_errors) do
|
105
|
+
@transition_errors ||= []
|
106
|
+
end
|
107
|
+
|
108
|
+
define_method(:check_for_transition_errors) do
|
109
|
+
if transition_errors && !transition_errors.empty?
|
110
|
+
transition_errors.each do |err|
|
111
|
+
errors.add_to_base(err)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
|
117
|
+
statemachine.events.each do |event|
|
118
|
+
self.class_eval do
|
119
|
+
|
120
|
+
# For every event defined in each statemachine we define a regular
|
121
|
+
# (non-exception-raising) method and bang! (exception-raising).
|
122
|
+
# This allows for triggering the event by calling the appropriate
|
123
|
+
# event-named method on the object.
|
124
|
+
[event.name,"#{event.name}!"].each do |meth|
|
125
|
+
define_method(meth) do |*args|
|
126
|
+
fire_proc = lambda do
|
127
|
+
impossible = {}
|
128
|
+
results = self.statemachines.collect do |name, sm|
|
129
|
+
begin
|
130
|
+
if meth =~ /!$/
|
131
|
+
sm.fire_event_with_exceptions(self, event, *args)
|
132
|
+
else
|
133
|
+
sm.fire_event_without_exceptions(self, event, *args)
|
134
|
+
end
|
135
|
+
rescue Golem::ImpossibleEvent => e
|
136
|
+
impossible[sm] = e
|
137
|
+
end
|
138
|
+
end
|
139
|
+
if impossible.size == self.statemachines.size
|
140
|
+
# all statemachines raised Golem::ImpossibleEvent
|
141
|
+
message = impossible.values.collect{|e| e.message}.uniq.join("\n")
|
142
|
+
events = impossible.values.collect{|e| e.event}.uniq
|
143
|
+
events = events[0] if events.size <= 1
|
144
|
+
objects = impossible.values.collect{|e| e.object}.uniq
|
145
|
+
objects = objects[0] if objects.size <= 1
|
146
|
+
reasons = impossible.values.collect{|e| e.reasons}.flatten.uniq
|
147
|
+
reasons = reasons[0] if reasons.size <= 1
|
148
|
+
raise Golem::ImpossibleEvent.new(message, events, objects, reasons)
|
149
|
+
end
|
150
|
+
results.all?{|result| result == true}
|
151
|
+
end
|
152
|
+
|
153
|
+
if self.class.respond_to?(:transaction)
|
154
|
+
# Wrap event call inside a transaction, if supported (i.e. for ActiveRecord)
|
155
|
+
self.class.transaction(&fire_proc)
|
156
|
+
else
|
157
|
+
fire_proc.call
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
define_method("determine_#{"#{statemachine_name}_" if statemachine_name}state_after_#{event.name}") do |*args|
|
163
|
+
transition = nil
|
164
|
+
if self.class.respond_to?(:transaction)
|
165
|
+
self.class.transaction do
|
166
|
+
# TODO: maybe better to use fire_event + transaction rollback to simulate event firing?
|
167
|
+
transition = statemachine.determine_transition_on_event(self, event, *args)
|
168
|
+
end
|
169
|
+
else
|
170
|
+
transition = statemachine.determine_transition_on_event(self, event, *args)
|
171
|
+
end
|
172
|
+
|
173
|
+
transition ? transition.to.name : nil
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
180
|
+
class ImpossibleEvent < StandardError
|
181
|
+
attr_reader :event, :object, :reasons
|
182
|
+
def initialize(message, event = nil, object = nil, reasons = nil)
|
183
|
+
@event = event
|
184
|
+
@object = object
|
185
|
+
@reasons = reasons
|
186
|
+
super(message)
|
187
|
+
end
|
188
|
+
|
189
|
+
def human_explanation
|
190
|
+
event = [@event] unless @event.is_a?(Array)
|
191
|
+
object = [@object] unless @object.is_a?(Array)
|
192
|
+
"'#{event.collect{|ev|ev.name.to_s.humanize.upcase}.join("/")}' for #{object.collect{|ob|ob.to_s}.join("/")} failed"
|
193
|
+
end
|
194
|
+
|
195
|
+
def human_reasons
|
196
|
+
reasons = [@reasons] unless @reasons.is_a?(Array)
|
197
|
+
reasons
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
class DefinitionSyntaxError < StandardError
|
202
|
+
end
|
203
|
+
|
204
|
+
class InvalidStateError < StandardError
|
205
|
+
end
|
206
|
+
|
207
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'golem/model/state'
|
2
|
+
require 'golem/model/transition'
|
3
|
+
|
4
|
+
module Golem
|
5
|
+
module DSL
|
6
|
+
class DecisionDef
|
7
|
+
def initialize(machine, state, event)
|
8
|
+
@machine = machine
|
9
|
+
@state = state
|
10
|
+
@event = event
|
11
|
+
end
|
12
|
+
|
13
|
+
def transition(options, &block)
|
14
|
+
if options[:to]
|
15
|
+
to = @machine.states[options[:to]] ||= Golem::Model::State.new(options[:to])
|
16
|
+
else
|
17
|
+
# self-transition
|
18
|
+
to = @state
|
19
|
+
end
|
20
|
+
|
21
|
+
if options[:guard] || options[:if]
|
22
|
+
options[:guard] = Golem::Model::Callback.new(options[:guard] || options[:if]) # :guard and :if mean the same thing
|
23
|
+
end
|
24
|
+
|
25
|
+
if block || options[:action]
|
26
|
+
options[:action] = Golem::Model::Callback.new(options[:action] || block)
|
27
|
+
end
|
28
|
+
|
29
|
+
@state.transitions_on_event[@event.name] ||= []
|
30
|
+
@state.transitions_on_event[@event.name] << Golem::Model::Transition.new(@state, to, options)
|
31
|
+
end
|
32
|
+
|
33
|
+
def method_missing?
|
34
|
+
raise SyntaxError, "Only 'transition' declarations can be placed in a state's decision block."
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
require 'golem/dsl/transition_def'
|
2
|
+
|
3
|
+
module Golem
|
4
|
+
module DSL
|
5
|
+
class EventDef
|
6
|
+
def initialize(machine, on_state, event_name, options, &block)
|
7
|
+
@machine = machine
|
8
|
+
@state = on_state
|
9
|
+
@event = @machine.get_or_define_event(event_name)
|
10
|
+
|
11
|
+
if options[:to]
|
12
|
+
@to = @machine.get_or_define_state(options[:to])
|
13
|
+
end
|
14
|
+
|
15
|
+
@guards = []
|
16
|
+
|
17
|
+
guard = options[:if] || options[:guard]
|
18
|
+
if guard
|
19
|
+
if guard.kind_of?(Golem::Model::Condition)
|
20
|
+
@guards << guard
|
21
|
+
else
|
22
|
+
@guards << Golem::Model::Condition.new(guard, options[:guard_options] || {})
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
action = options[:action] || options[:on_transition]
|
27
|
+
@action = Golem::Model::Callback.new(action) if action
|
28
|
+
|
29
|
+
|
30
|
+
instance_eval(&block) if block_given?
|
31
|
+
|
32
|
+
if @state.transitions_on_event[@event].blank?
|
33
|
+
transition :to => (@to || @state), :guards => @guards, :action => @action
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def transition(options = {}, &block)
|
38
|
+
if options[:to] == :self
|
39
|
+
to = @state
|
40
|
+
elsif options[:to]
|
41
|
+
to = @machine.get_or_define_state(options[:to])
|
42
|
+
else
|
43
|
+
to = @to
|
44
|
+
end
|
45
|
+
|
46
|
+
options[:to] = to
|
47
|
+
|
48
|
+
guard = options[:if] || options[:guard]
|
49
|
+
if guard
|
50
|
+
if guard.kind_of?(Golem::Model::Condition)
|
51
|
+
guards = @guards + [guard]
|
52
|
+
else
|
53
|
+
guards = @guards + [Golem::Model::Condition.new(guard)]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
options[:guards] = guards || @guards.dup
|
58
|
+
|
59
|
+
action = options[:action] || options[:on_transition]
|
60
|
+
if action
|
61
|
+
options[:action] = Golem::Model::Callback.new(action) unless action.kind_of?(Golem::Model::Callback)
|
62
|
+
else
|
63
|
+
options[:action] = @action
|
64
|
+
end
|
65
|
+
|
66
|
+
TransitionDef.new(@machine, @event, @state, options.dup, &block)
|
67
|
+
end
|
68
|
+
|
69
|
+
def guard(callback_or_options = {}, guard_options = {}, &block)
|
70
|
+
# FIXME: are guard_options ever actually used?
|
71
|
+
if callback_or_options.kind_of? Hash
|
72
|
+
callback = block
|
73
|
+
guard_options = callback_or_options
|
74
|
+
else
|
75
|
+
callback = callback_or_options
|
76
|
+
end
|
77
|
+
|
78
|
+
@guards << Golem::Model::Condition.new(callback, guard_options)
|
79
|
+
end
|
80
|
+
|
81
|
+
def action(callback = nil, &block)
|
82
|
+
callback = block unless callback
|
83
|
+
|
84
|
+
@action = Golem::Model::Callback.new(callback)
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'golem/model/state'
|
2
|
+
require 'golem/model/event'
|
3
|
+
require 'golem/model/transition'
|
4
|
+
require 'golem/model/callback'
|
5
|
+
require 'golem/model/condition'
|
6
|
+
|
7
|
+
require 'golem/dsl/event_def'
|
8
|
+
|
9
|
+
module Golem
|
10
|
+
module DSL
|
11
|
+
class StateDef
|
12
|
+
def initialize(machine, state_name, options = {}, &block)
|
13
|
+
@machine = machine
|
14
|
+
@state = @machine.get_or_define_state(state_name)
|
15
|
+
@options = options
|
16
|
+
instance_eval(&block) if block
|
17
|
+
end
|
18
|
+
|
19
|
+
def on(event_name, options = {}, &block)
|
20
|
+
Golem::DSL::EventDef.new(@machine, @state, event_name, options, &block)
|
21
|
+
end
|
22
|
+
|
23
|
+
def enter(callback = nil, &block)
|
24
|
+
raise Golem::DefinitionSyntaxError, "Provide either a callback method or a block, not both." if callback && block
|
25
|
+
@state.callbacks[:on_enter] = Golem::Model::Callback.new(block || callback)
|
26
|
+
end
|
27
|
+
|
28
|
+
def exit(callback = nil, &block)
|
29
|
+
raise Golem::DefinitionSyntaxError, "Provide either a callback method or a block, not both." if callback && block
|
30
|
+
@state.callbacks[:on_exit] = Golem::Model::Callback.new(block || callback)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'golem/model/state_machine'
|
2
|
+
require 'golem/model/state'
|
3
|
+
require 'golem/model/callback'
|
4
|
+
|
5
|
+
require 'golem/dsl/state_def'
|
6
|
+
|
7
|
+
module Golem
|
8
|
+
module DSL
|
9
|
+
class StateMachineDef
|
10
|
+
attr_reader :machine_name
|
11
|
+
|
12
|
+
def initialize(klass, machine_name = nil, &block)
|
13
|
+
@klass = klass # this is the Class that we to endow with FSM behaviour
|
14
|
+
@machine = Golem::Model::StateMachine.new(machine_name)
|
15
|
+
instance_eval(&block) if block
|
16
|
+
end
|
17
|
+
|
18
|
+
def machine
|
19
|
+
@machine
|
20
|
+
end
|
21
|
+
|
22
|
+
def state(state_name, options = {}, &block)
|
23
|
+
Golem::DSL::StateDef.new(@machine, state_name, options, &block)
|
24
|
+
end
|
25
|
+
|
26
|
+
def all_states
|
27
|
+
@machine.all_states.collect{|state| StateDef.new(@machine, state.name)}
|
28
|
+
end
|
29
|
+
|
30
|
+
def initial_state(state)
|
31
|
+
@machine.initial_state = @machine.get_or_define_state(state)
|
32
|
+
end
|
33
|
+
|
34
|
+
# Sets or returns the state_attribute name.
|
35
|
+
def state_attribute(attribute = nil)
|
36
|
+
if attribute.nil?
|
37
|
+
@state_attribute
|
38
|
+
else
|
39
|
+
@state_attribute = attribute
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Sets the state_attribute name.
|
44
|
+
def state_attribute=(attribute)
|
45
|
+
@state_attribute = attribute
|
46
|
+
end
|
47
|
+
|
48
|
+
def on_all_transitions(callback = nil, &block)
|
49
|
+
raise Golem::DefinitionSyntaxError, "A callback or block must be given for on_all_transitions" unless
|
50
|
+
(callback || block)
|
51
|
+
raise Golem::DefinitionSyntaxError, "Either a callback or block, not both, must be given for on_all_transitions" if
|
52
|
+
(callback && block)
|
53
|
+
callback ||= block
|
54
|
+
unless callback.kind_of?(Golem::Model::Callback)
|
55
|
+
callback = Golem::Model::Callback.new(callback)
|
56
|
+
end
|
57
|
+
@machine.on_all_transitions = callback
|
58
|
+
end
|
59
|
+
|
60
|
+
def on_all_events(callback = nil, &block)
|
61
|
+
raise Golem::DefinitionSyntaxError, "A callback or block must be given for on_all_events" unless
|
62
|
+
(callback || block)
|
63
|
+
raise Golem::DefinitionSyntaxError, "Either a callback or block, not both, must be given for on_all_events" if
|
64
|
+
(callback && block)
|
65
|
+
callback ||= block
|
66
|
+
unless callback.kind_of?(Golem::Model::Callback)
|
67
|
+
callback = Golem::Model::Callback.new(callback)
|
68
|
+
end
|
69
|
+
@machine.on_all_events = callback
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|