state_machine 0.3.1 → 0.4.0

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.
Files changed (52) hide show
  1. data/CHANGELOG.rdoc +26 -0
  2. data/README.rdoc +254 -46
  3. data/Rakefile +29 -3
  4. data/examples/AutoShop_state.png +0 -0
  5. data/examples/Car_state.jpg +0 -0
  6. data/examples/Vehicle_state.png +0 -0
  7. data/lib/state_machine.rb +161 -116
  8. data/lib/state_machine/assertions.rb +21 -0
  9. data/lib/state_machine/callback.rb +168 -0
  10. data/lib/state_machine/eval_helpers.rb +67 -0
  11. data/lib/state_machine/event.rb +135 -101
  12. data/lib/state_machine/extensions.rb +83 -0
  13. data/lib/state_machine/guard.rb +115 -0
  14. data/lib/state_machine/integrations/active_record.rb +242 -0
  15. data/lib/state_machine/integrations/data_mapper.rb +198 -0
  16. data/lib/state_machine/integrations/data_mapper/observer.rb +153 -0
  17. data/lib/state_machine/integrations/sequel.rb +169 -0
  18. data/lib/state_machine/machine.rb +746 -352
  19. data/lib/state_machine/transition.rb +104 -212
  20. data/test/active_record.log +34865 -0
  21. data/test/classes/switch.rb +11 -0
  22. data/test/data_mapper.log +14015 -0
  23. data/test/functional/state_machine_test.rb +249 -15
  24. data/test/sequel.log +3835 -0
  25. data/test/test_helper.rb +3 -12
  26. data/test/unit/assertions_test.rb +13 -0
  27. data/test/unit/callback_test.rb +189 -0
  28. data/test/unit/eval_helpers_test.rb +92 -0
  29. data/test/unit/event_test.rb +247 -113
  30. data/test/unit/guard_test.rb +420 -0
  31. data/test/unit/integrations/active_record_test.rb +515 -0
  32. data/test/unit/integrations/data_mapper_test.rb +407 -0
  33. data/test/unit/integrations/sequel_test.rb +244 -0
  34. data/test/unit/invalid_transition_test.rb +1 -1
  35. data/test/unit/machine_test.rb +1056 -98
  36. data/test/unit/state_machine_test.rb +14 -113
  37. data/test/unit/transition_test.rb +269 -495
  38. metadata +44 -30
  39. data/test/app_root/app/models/auto_shop.rb +0 -34
  40. data/test/app_root/app/models/car.rb +0 -19
  41. data/test/app_root/app/models/highway.rb +0 -3
  42. data/test/app_root/app/models/motorcycle.rb +0 -3
  43. data/test/app_root/app/models/switch.rb +0 -23
  44. data/test/app_root/app/models/switch_observer.rb +0 -20
  45. data/test/app_root/app/models/toggle_switch.rb +0 -2
  46. data/test/app_root/app/models/vehicle.rb +0 -78
  47. data/test/app_root/config/environment.rb +0 -7
  48. data/test/app_root/db/migrate/001_create_switches.rb +0 -12
  49. data/test/app_root/db/migrate/002_create_auto_shops.rb +0 -13
  50. data/test/app_root/db/migrate/003_create_highways.rb +0 -11
  51. data/test/app_root/db/migrate/004_create_vehicles.rb +0 -16
  52. data/test/factory.rb +0 -77
@@ -0,0 +1,242 @@
1
+ module StateMachine
2
+ module Integrations #:nodoc:
3
+ # Adds support for integrating state machines with ActiveRecord models.
4
+ #
5
+ # == Examples
6
+ #
7
+ # Below is an example of a simple state machine defined within an
8
+ # ActiveRecord model:
9
+ #
10
+ # class Vehicle < ActiveRecord::Base
11
+ # state_machine :initial => 'parked' do
12
+ # event :ignite do
13
+ # transition :to => 'idling', :from => 'parked'
14
+ # end
15
+ # end
16
+ # end
17
+ #
18
+ # The examples in the sections below will use the above class as a
19
+ # reference.
20
+ #
21
+ # == Actions
22
+ #
23
+ # By default, the action that will be invoked when a state is transitioned
24
+ # is the +save+ action. This will cause the record to save the changes
25
+ # made to the state machine's attribute. *Note* that if any other changes
26
+ # were made to the record prior to transition, then those changes will
27
+ # be saved as well.
28
+ #
29
+ # For example,
30
+ #
31
+ # vehicle = Vehicle.create # => #<Vehicle id: 1, name: nil, state: nil>
32
+ # vehicle.name = 'Ford Explorer'
33
+ # vehicle.ignite # => true
34
+ # vehicle.reload # => #<Vehicle id: 1, name: "Ford Explorer", state: "idling">
35
+ #
36
+ # == Transactions
37
+ #
38
+ # In order to ensure that any changes made during transition callbacks
39
+ # are rolled back during a failed attempt, every transition is wrapped
40
+ # within a transaction.
41
+ #
42
+ # For example,
43
+ #
44
+ # class Message < ActiveRecord::Base
45
+ # end
46
+ #
47
+ # Vehicle.state_machine do
48
+ # before_transition do |vehicle, transition|
49
+ # Message.create(:content => transition.inspect)
50
+ # false
51
+ # end
52
+ # end
53
+ #
54
+ # vehicle = Vehicle.create # => #<Vehicle id: 1, name: nil, state: nil>
55
+ # vehicle.ignite # => false
56
+ # Message.count # => 0
57
+ #
58
+ # *Note* that only before callbacks that halt the callback chain and
59
+ # failed attempts to save the record will result in the transaction being
60
+ # rolled back. If an after callback halts the chain, the previous result
61
+ # still applies and the transaction is *not* rolled back.
62
+ #
63
+ # == Scopes
64
+ #
65
+ # To assist in filtering models with specific states, a series of named
66
+ # scopes are defined on the model for finding records with or without a
67
+ # particular set of states.
68
+ #
69
+ # These named scopes are the functional equivalent of the following
70
+ # definitions:
71
+ #
72
+ # class Vehicle < ActiveRecord::Base
73
+ # named_scope :with_states, lambda {|*values| {:conditions => {:state => values.flatten}}}
74
+ # # with_states also aliased to with_state
75
+ #
76
+ # named_scope :without_states, lambda {|*values| {:conditions => ['state NOT IN (?)', values.flatten]}}
77
+ # # without_states also aliased to without_state
78
+ # end
79
+ #
80
+ # Because of the way named scopes work in ActiveRecord, they can be
81
+ # chained like so:
82
+ #
83
+ # Vehicle.with_state('parked').all(:order => 'id DESC')
84
+ #
85
+ # == Callbacks
86
+ #
87
+ # All before/after transition callbacks defined for ActiveRecord models
88
+ # behave in the same way that other ActiveRecord callbacks behave. The
89
+ # object involved in the transition is passed in as an argument.
90
+ #
91
+ # For example,
92
+ #
93
+ # class Vehicle < ActiveRecord::Base
94
+ # state_machine :initial => 'parked' do
95
+ # before_transition :to => 'idling' do |vehicle|
96
+ # vehicle.put_on_seatbelt
97
+ # end
98
+ #
99
+ # before_transition do |vehicle, transition|
100
+ # # log message
101
+ # end
102
+ #
103
+ # event :ignite do
104
+ # transition :to => 'idling', :from => 'parked'
105
+ # end
106
+ # end
107
+ #
108
+ # def put_on_seatbelt
109
+ # ...
110
+ # end
111
+ # end
112
+ #
113
+ # Note, also, that the transition can be accessed by simply defining
114
+ # additional arguments in the callback block.
115
+ #
116
+ # == Observers
117
+ #
118
+ # In addition to support for ActiveRecord-like hooks, there is additional
119
+ # support for ActiveRecord observers. Because of the way ActiveRecord
120
+ # observers are designed, there is less flexibility around the specific
121
+ # transitions that can be hooked in. As a result, observers can only
122
+ # hook into before/after callbacks for events and generic transitions
123
+ # like so:
124
+ #
125
+ # class VehicleObserver < ActiveRecord::Observer
126
+ # def before_save(vehicle)
127
+ # # log message
128
+ # end
129
+ #
130
+ # # Callback for :ignite event *before* the transition is performed
131
+ # def before_ignite(vehicle, transition)
132
+ # # log message
133
+ # end
134
+ #
135
+ # # Callback for :ignite event *after* the transition has been performed
136
+ # def after_ignite(vehicle, transition)
137
+ # # put on seatbelt
138
+ # end
139
+ #
140
+ # # Generic transition callback *before* the transition is performed
141
+ # def after_transition(vehicle, transition)
142
+ # Audit.log(vehicle, transition)
143
+ # end
144
+ # end
145
+ #
146
+ # More flexible transition callbacks can be defined directly within the
147
+ # model as described in StateMachine::Machine#before_transition
148
+ # and StateMachine::Machine#after_transition.
149
+ #
150
+ # To define a single observer for multiple state machines:
151
+ #
152
+ # class StateMachineObserver < ActiveRecord::Observer
153
+ # observe Vehicle, Switch, Project
154
+ #
155
+ # def after_transition(record, transition)
156
+ # Audit.log(record, transition)
157
+ # end
158
+ # end
159
+ module ActiveRecord
160
+ # Should this integration be used for state machines in the given class?
161
+ # Classes that inherit from ActiveRecord::Base will automatically use
162
+ # the ActiveRecord integration.
163
+ def self.matches?(klass)
164
+ defined?(::ActiveRecord::Base) && klass <= ::ActiveRecord::Base
165
+ end
166
+
167
+ # Runs a new database transaction, rolling back any changes by raising
168
+ # an ActiveRecord::Rollback exception if the yielded block fails
169
+ # (i.e. returns false).
170
+ def within_transaction(object)
171
+ object.class.transaction {raise ::ActiveRecord::Rollback unless yield}
172
+ end
173
+
174
+ protected
175
+ # Adds the default callbacks for notifying ActiveRecord observers
176
+ # before/after a transition has been performed.
177
+ def after_initialize
178
+ # Observer callbacks never halt the chain; result is ignored
179
+ callbacks[:before] << Callback.new {|object, transition| notify(:before, object, transition)}
180
+ callbacks[:after] << Callback.new {|object, transition, result| notify(:after, object, transition)}
181
+ end
182
+
183
+ # Sets the default action for all ActiveRecord state machines to +save+
184
+ def default_action
185
+ :save
186
+ end
187
+
188
+ # Forces all attribute methods to be generated for the model so that
189
+ # the reader/writer methods for the attribute are available
190
+ def define_attribute_accessor
191
+ owner_class.define_attribute_methods if owner_class.table_exists?
192
+ super
193
+ end
194
+
195
+ # Defines a scope for finding records *with* a particular value or
196
+ # values for the attribute
197
+ def define_with_scope(name)
198
+ attribute = self.attribute
199
+ owner_class.named_scope name.to_sym, lambda {|*values| {:conditions => {attribute => values.flatten}}}
200
+ end
201
+
202
+ # Defines a scope for finding records *without* a particular value or
203
+ # values for the attribute
204
+ def define_without_scope(name)
205
+ attribute = self.attribute
206
+ owner_class.named_scope name.to_sym, lambda {|*values| {:conditions => ["#{attribute} NOT IN (?)", values.flatten]}}
207
+ end
208
+
209
+ # Creates a new callback in the callback chain, always inserting it
210
+ # before the default Observer callbacks that were created after
211
+ # initialization.
212
+ def add_callback(type, options, &block)
213
+ options[:terminator] = @terminator ||= lambda {|result| result == false}
214
+ @callbacks[type].insert(-2, callback = Callback.new(options, &block))
215
+ add_states(callback.known_states)
216
+
217
+ callback
218
+ end
219
+
220
+ private
221
+ # Notifies observers on the given object that a callback occurred
222
+ # involving the given transition. This will attempt to call the
223
+ # following methods on observers:
224
+ # * #{type}_#{event}
225
+ # * #{type}_transition
226
+ #
227
+ # This will always return true regardless of the results of the
228
+ # callbacks.
229
+ def notify(type, object, transition)
230
+ ["#{type}_#{transition.event}", "#{type}_transition"].each do |method|
231
+ object.class.class_eval do
232
+ @observer_peers.dup.each do |observer|
233
+ observer.send(method, object, transition) if observer.respond_to?(method)
234
+ end if defined?(@observer_peers)
235
+ end
236
+ end
237
+
238
+ true
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,198 @@
1
+ module StateMachine
2
+ module Integrations #:nodoc:
3
+ # Adds support for integrating state machines with DataMapper resources.
4
+ #
5
+ # == Examples
6
+ #
7
+ # Below is an example of a simple state machine defined within a
8
+ # DataMapper resource:
9
+ #
10
+ # class Vehicle
11
+ # include DataMapper::Resource
12
+ #
13
+ # property :id, Serial
14
+ # property :name, String
15
+ # property :state, String
16
+ #
17
+ # state_machine :initial => 'parked' do
18
+ # event :ignite do
19
+ # transition :to => 'idling', :from => 'parked'
20
+ # end
21
+ # end
22
+ # end
23
+ #
24
+ # The examples in the sections below will use the above class as a
25
+ # reference.
26
+ #
27
+ # == Actions
28
+ #
29
+ # By default, the action that will be invoked when a state is transitioned
30
+ # is the +save+ action. This will cause the resource to save the changes
31
+ # made to the state machine's attribute. *Note* that if any other changes
32
+ # were made to the resource prior to transition, then those changes will
33
+ # be saved as well.
34
+ #
35
+ # For example,
36
+ #
37
+ # vehicle = Vehicle.create # => #<Vehicle id=1 name=nil state=nil>
38
+ # vehicle.name = 'Ford Explorer'
39
+ # vehicle.ignite # => true
40
+ # vehicle.reload # => #<Vehicle id=1 name="Ford Explorer" state="idling">
41
+ #
42
+ # == Transactions
43
+ #
44
+ # In order to ensure that any changes made during transition callbacks
45
+ # are rolled back during a failed attempt, every transition is wrapped
46
+ # within a transaction.
47
+ #
48
+ # For example,
49
+ #
50
+ # class Message
51
+ # include DataMapper::Resource
52
+ #
53
+ # property :id, Serial
54
+ # property :content, String
55
+ # end
56
+ #
57
+ # Vehicle.state_machine do
58
+ # before_transition do |transition|
59
+ # Message.create(:content => transition.inspect)
60
+ # throw :halt
61
+ # end
62
+ # end
63
+ #
64
+ # vehicle = Vehicle.create # => #<Vehicle id=1 name=nil state=nil>
65
+ # vehicle.ignite # => false
66
+ # Message.all.count # => 0
67
+ #
68
+ # *Note* that only before callbacks that halt the callback chain and
69
+ # failed attempts to save the record will result in the transaction being
70
+ # rolled back. If an after callback halts the chain, the previous result
71
+ # still applies and the transaction is *not* rolled back.
72
+ #
73
+ # == Scopes
74
+ #
75
+ # To assist in filtering models with specific states, a series of class
76
+ # methods are defined on the model for finding records with or without a
77
+ # particular set of states.
78
+ #
79
+ # These named scopes are the functional equivalent of the following
80
+ # definitions:
81
+ #
82
+ # class Vehicle
83
+ # include DataMapper::Resource
84
+ #
85
+ # property :id, Serial
86
+ # property :state, String
87
+ #
88
+ # class << self
89
+ # def with_states(*values)
90
+ # all(:state => values.flatten)
91
+ # end
92
+ # alias_method :with_state, :with_states
93
+ #
94
+ # def without_states(*values)
95
+ # all(:state.not => values.flatten)
96
+ # end
97
+ # alias_method :without_state, :without_states
98
+ # end
99
+ # end
100
+ #
101
+ # Because of the way scopes work in DataMapper, they can be chained like
102
+ # so:
103
+ #
104
+ # Vehicle.with_state('parked').all(:order => [:id.desc])
105
+ #
106
+ # == Callbacks / Observers
107
+ #
108
+ # All before/after transition callbacks defined for DataMapper resources
109
+ # behave in the same way that other DataMapper hooks behave. Rather than
110
+ # passing in the record as an argument to the callback, the callback is
111
+ # instead bound to the object and evaluated within its context.
112
+ #
113
+ # For example,
114
+ #
115
+ # class Vehicle
116
+ # include DataMapper::Resource
117
+ #
118
+ # property :id, Serial
119
+ # property :state, String
120
+ #
121
+ # state_machine :initial => 'parked' do
122
+ # before_transition :to => 'idling' do
123
+ # put_on_seatbelt
124
+ # end
125
+ #
126
+ # before_transition do |transition|
127
+ # # log message
128
+ # end
129
+ #
130
+ # event :ignite do
131
+ # transition :to => 'idling', :from => 'parked'
132
+ # end
133
+ # end
134
+ #
135
+ # def put_on_seatbelt
136
+ # ...
137
+ # end
138
+ # end
139
+ #
140
+ # Note, also, that the transition can be accessed by simply defining
141
+ # additional arguments in the callback block.
142
+ #
143
+ # In addition to support for DataMapper-like hooks, there is additional
144
+ # support for DataMapper observers. See StateMachine::Integrations::DataMapper::Observer
145
+ # for more information.
146
+ module DataMapper
147
+ # Should this integration be used for state machines in the given class?
148
+ # Classes that include DataMapper::Resource will automatically use the
149
+ # DataMapper integration.
150
+ def self.matches?(klass)
151
+ defined?(::DataMapper::Resource) && klass <= ::DataMapper::Resource
152
+ end
153
+
154
+ # Loads additional files specific to DataMapper
155
+ def self.extended(base) #:nodoc:
156
+ require 'state_machine/integrations/data_mapper/observer'
157
+ end
158
+
159
+ # Runs a new database transaction, rolling back any changes if the
160
+ # yielded block fails (i.e. returns false).
161
+ def within_transaction(object)
162
+ object.class.transaction {|t| t.rollback unless yield}
163
+ end
164
+
165
+ protected
166
+ # Sets the default action for all DataMapper state machines to +save+
167
+ def default_action
168
+ :save
169
+ end
170
+
171
+ # Defines a scope for finding records *with* a particular value or
172
+ # values for the attribute
173
+ def define_with_scope(name)
174
+ attribute = self.attribute
175
+ (class << owner_class; self; end).class_eval do
176
+ define_method(name) {|*values| all(attribute => values.flatten)}
177
+ end
178
+ end
179
+
180
+ # Defines a scope for finding records *without* a particular value or
181
+ # values for the attribute
182
+ def define_without_scope(name)
183
+ attribute = self.attribute
184
+ (class << owner_class; self; end).class_eval do
185
+ define_method(name) {|*values| all(attribute.to_sym.not => values.flatten)}
186
+ end
187
+ end
188
+
189
+ # Creates a new callback in the callback chain, always ensuring that
190
+ # it's configured to bind to the object as this is the convention for
191
+ # DataMapper/Extlib callbacks
192
+ def add_callback(type, options, &block)
193
+ options[:bind_to_object] = true
194
+ super
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,153 @@
1
+ module StateMachine
2
+ module Integrations #:nodoc:
3
+ module DataMapper
4
+ # Adds support for creating before/after transition callbacks within a
5
+ # DataMapper observer. These callbacks behave very similarly to
6
+ # before/after hooks during save/update/destroy/etc., but with the
7
+ # following modifications:
8
+ # * Each callback can define a set of transition conditions (i.e. guards)
9
+ # that must be met in order for the callback to get invoked.
10
+ # * An additional transition parameter is available that provides
11
+ # contextual information about the event (see StateMachine::Transition
12
+ # for more information)
13
+ #
14
+ # To define a single observer for multiple state machines:
15
+ #
16
+ # class StateMachineObserver
17
+ # include DataMapper::Observer
18
+ #
19
+ # observe Vehicle, Switch, Project
20
+ #
21
+ # after_transition do |transition, saved|
22
+ # Audit.log(self, transition) if saved
23
+ # end
24
+ # end
25
+ module Observer
26
+ # Creates a callback that will be invoked *before* a transition is
27
+ # performed, so long as the given configuration options match the
28
+ # transition. Each part of the transition (event, to state, from state)
29
+ # must match in order for the callback to get invoked.
30
+ #
31
+ # See StateMachine::Machine#before_transition for more
32
+ # information about the various configuration options available.
33
+ #
34
+ # == Examples
35
+ #
36
+ # class Vehicle
37
+ # include DataMapper::Resource
38
+ #
39
+ # property :id, Serial
40
+ # property :state, :String
41
+ #
42
+ # state_machine :initial => 'parked' do
43
+ # event :ignite do
44
+ # transition :to => 'idling', :from => 'parked'
45
+ # end
46
+ # end
47
+ # end
48
+ #
49
+ # class VehicleObserver
50
+ # include DataMapper::Observer
51
+ #
52
+ # observe Vehicle
53
+ #
54
+ # before :save do
55
+ # # log message
56
+ # end
57
+ #
58
+ # before_transition :to => 'idling', :from => 'parked', :on => 'ignite' do
59
+ # # put on seatbelt
60
+ # end
61
+ #
62
+ # before_transition do |transition|
63
+ # # log message
64
+ # end
65
+ # end
66
+ #
67
+ # *Note* that in each of the above +before_transition+ callbacks, the
68
+ # callback is executed within the context of the object (i.e. the
69
+ # Vehicle instance being transition). This means that +self+ refers
70
+ # to the vehicle record within each callback block.
71
+ def before_transition(*args, &block)
72
+ add_transition_callback(:before, *args, &block)
73
+ end
74
+
75
+ # Creates a callback that will be invoked *after* a transition is
76
+ # performed, so long as the given configuration options match the
77
+ # transition. Each part of the transition (event, to state, from state)
78
+ # must match in order for the callback to get invoked.
79
+ #
80
+ # See StateMachine::Machine#after_transition for more
81
+ # information about the various configuration options available.
82
+ #
83
+ # == Examples
84
+ #
85
+ # class Vehicle
86
+ # include DataMapper::Resource
87
+ #
88
+ # property :id, Serial
89
+ # property :state, :String
90
+ #
91
+ # state_machine :initial => 'parked' do
92
+ # event :ignite do
93
+ # transition :to => 'idling', :from => 'parked'
94
+ # end
95
+ # end
96
+ # end
97
+ #
98
+ # class VehicleObserver
99
+ # include DataMapper::Observer
100
+ #
101
+ # observe Vehicle
102
+ #
103
+ # after :save do |saved|
104
+ # # log message
105
+ # end
106
+ #
107
+ # after_transition :to => 'idling', :from => 'parked', :on => 'ignite' do
108
+ # # put on seatbelt
109
+ # end
110
+ #
111
+ # after_transition do |transition, saved|
112
+ # if saved
113
+ # # log message
114
+ # end
115
+ # end
116
+ # end
117
+ #
118
+ # *Note* that in each of the above +before_transition+ callbacks, the
119
+ # callback is executed within the context of the object (i.e. the
120
+ # Vehicle instance being transition). This means that +self+ refers
121
+ # to the vehicle record within each callback block.
122
+ def after_transition(*args, &block)
123
+ add_transition_callback(:after, *args, &block)
124
+ end
125
+
126
+ private
127
+ # Adds the transition callback to a specific machine or all of the
128
+ # state machines for each observed class.
129
+ def add_transition_callback(type, *args, &block)
130
+ if args.first && !args.first.is_a?(Hash)
131
+ # Specific attribute is being targeted
132
+ attribute = args.first.to_s
133
+ transition_args = args[1..-1]
134
+ else
135
+ # Target all state machines
136
+ attribute = nil
137
+ transition_args = args
138
+ end
139
+
140
+ # Add the transition callback to each class being observed
141
+ observing.each do |klass|
142
+ state_machines = attribute ? [klass.state_machines[attribute]] : klass.state_machines.values
143
+ state_machines.each {|machine| machine.send("#{type}_transition", *transition_args, &block)}
144
+ end if observing
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+
151
+ DataMapper::Observer::ClassMethods.class_eval do
152
+ include StateMachine::Integrations::DataMapper::Observer
153
+ end