golem_statemachine 0.9

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/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