golem_statemachine 0.9

Sign up to get free protection for your applications and to get access to all the features.
data/init.rb ADDED
@@ -0,0 +1,5 @@
1
+ my_dir = File.expand_path(File.dirname(__FILE__))+'/lib'
2
+ ActiveSupport::Dependencies.load_once_paths.delete my_dir unless RAILS_ENV == 'production'
3
+
4
+ $: << my_dir
5
+ require 'golem'
@@ -0,0 +1 @@
1
+ # Install hook code here
@@ -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