joelind-state_machine 0.8.1

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 (78) hide show
  1. data/CHANGELOG.rdoc +297 -0
  2. data/LICENSE +20 -0
  3. data/README.rdoc +466 -0
  4. data/Rakefile +98 -0
  5. data/examples/AutoShop_state.png +0 -0
  6. data/examples/Car_state.png +0 -0
  7. data/examples/TrafficLight_state.png +0 -0
  8. data/examples/Vehicle_state.png +0 -0
  9. data/examples/auto_shop.rb +11 -0
  10. data/examples/car.rb +19 -0
  11. data/examples/merb-rest/controller.rb +51 -0
  12. data/examples/merb-rest/model.rb +28 -0
  13. data/examples/merb-rest/view_edit.html.erb +24 -0
  14. data/examples/merb-rest/view_index.html.erb +23 -0
  15. data/examples/merb-rest/view_new.html.erb +13 -0
  16. data/examples/merb-rest/view_show.html.erb +17 -0
  17. data/examples/rails-rest/controller.rb +43 -0
  18. data/examples/rails-rest/migration.rb +11 -0
  19. data/examples/rails-rest/model.rb +23 -0
  20. data/examples/rails-rest/view_edit.html.erb +25 -0
  21. data/examples/rails-rest/view_index.html.erb +23 -0
  22. data/examples/rails-rest/view_new.html.erb +14 -0
  23. data/examples/rails-rest/view_show.html.erb +17 -0
  24. data/examples/traffic_light.rb +7 -0
  25. data/examples/vehicle.rb +31 -0
  26. data/init.rb +1 -0
  27. data/lib/state_machine.rb +388 -0
  28. data/lib/state_machine/assertions.rb +36 -0
  29. data/lib/state_machine/callback.rb +189 -0
  30. data/lib/state_machine/condition_proxy.rb +94 -0
  31. data/lib/state_machine/eval_helpers.rb +67 -0
  32. data/lib/state_machine/event.rb +252 -0
  33. data/lib/state_machine/event_collection.rb +122 -0
  34. data/lib/state_machine/extensions.rb +149 -0
  35. data/lib/state_machine/guard.rb +230 -0
  36. data/lib/state_machine/integrations.rb +68 -0
  37. data/lib/state_machine/integrations/active_record.rb +492 -0
  38. data/lib/state_machine/integrations/active_record/locale.rb +11 -0
  39. data/lib/state_machine/integrations/active_record/observer.rb +41 -0
  40. data/lib/state_machine/integrations/data_mapper.rb +351 -0
  41. data/lib/state_machine/integrations/data_mapper/observer.rb +139 -0
  42. data/lib/state_machine/integrations/sequel.rb +322 -0
  43. data/lib/state_machine/machine.rb +1467 -0
  44. data/lib/state_machine/machine_collection.rb +155 -0
  45. data/lib/state_machine/matcher.rb +123 -0
  46. data/lib/state_machine/matcher_helpers.rb +54 -0
  47. data/lib/state_machine/node_collection.rb +152 -0
  48. data/lib/state_machine/state.rb +249 -0
  49. data/lib/state_machine/state_collection.rb +112 -0
  50. data/lib/state_machine/transition.rb +394 -0
  51. data/tasks/state_machine.rake +1 -0
  52. data/tasks/state_machine.rb +30 -0
  53. data/test/classes/switch.rb +11 -0
  54. data/test/functional/state_machine_test.rb +941 -0
  55. data/test/test_helper.rb +4 -0
  56. data/test/unit/assertions_test.rb +40 -0
  57. data/test/unit/callback_test.rb +455 -0
  58. data/test/unit/condition_proxy_test.rb +328 -0
  59. data/test/unit/eval_helpers_test.rb +120 -0
  60. data/test/unit/event_collection_test.rb +326 -0
  61. data/test/unit/event_test.rb +743 -0
  62. data/test/unit/guard_test.rb +908 -0
  63. data/test/unit/integrations/active_record_test.rb +1374 -0
  64. data/test/unit/integrations/data_mapper_test.rb +962 -0
  65. data/test/unit/integrations/sequel_test.rb +859 -0
  66. data/test/unit/integrations_test.rb +42 -0
  67. data/test/unit/invalid_event_test.rb +7 -0
  68. data/test/unit/invalid_transition_test.rb +7 -0
  69. data/test/unit/machine_collection_test.rb +938 -0
  70. data/test/unit/machine_test.rb +2004 -0
  71. data/test/unit/matcher_helpers_test.rb +37 -0
  72. data/test/unit/matcher_test.rb +155 -0
  73. data/test/unit/node_collection_test.rb +207 -0
  74. data/test/unit/state_collection_test.rb +280 -0
  75. data/test/unit/state_machine_test.rb +31 -0
  76. data/test/unit/state_test.rb +795 -0
  77. data/test/unit/transition_test.rb +1212 -0
  78. metadata +163 -0
@@ -0,0 +1,11 @@
1
+ {:en => {
2
+ :activerecord => {
3
+ :errors => {
4
+ :messages => {
5
+ :invalid => StateMachine::Machine.default_messages[:invalid],
6
+ :invalid_event => StateMachine::Machine.default_messages[:invalid_event] % ['{{state}}'],
7
+ :invalid_transition => StateMachine::Machine.default_messages[:invalid_transition] % ['{{event}}']
8
+ }
9
+ }
10
+ }
11
+ }}
@@ -0,0 +1,41 @@
1
+ module StateMachine
2
+ module Integrations #:nodoc:
3
+ module ActiveRecord
4
+ # Adds support for invoking callbacks on ActiveRecord observers with more
5
+ # than one argument (e.g. the record *and* the state transition). By
6
+ # default, ActiveRecord only supports passing the record into the
7
+ # callbacks.
8
+ #
9
+ # For example:
10
+ #
11
+ # class VehicleObserver < ActiveRecord::Observer
12
+ # # The default behavior: only pass in the record
13
+ # def after_save(vehicle)
14
+ # end
15
+ #
16
+ # # Custom behavior: allow the transition to be passed in as well
17
+ # def after_transition(vehicle, transition)
18
+ # Audit.log(vehicle, transition)
19
+ # end
20
+ # end
21
+ module Observer
22
+ def self.included(base) #:nodoc:
23
+ base.class_eval do
24
+ alias_method :update_without_multiple_args, :update
25
+ alias_method :update, :update_with_multiple_args
26
+ end
27
+ end
28
+
29
+ # Allows additional arguments other than the object to be passed to the
30
+ # observed methods
31
+ def update_with_multiple_args(observed_method, object, *args) #:nodoc:
32
+ send(observed_method, object, *args) if respond_to?(observed_method)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ ActiveRecord::Observer.class_eval do
40
+ include StateMachine::Integrations::ActiveRecord::Observer
41
+ end
@@ -0,0 +1,351 @@
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 :parked => :idling
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="parked">
38
+ # vehicle.name = 'Ford Explorer'
39
+ # vehicle.ignite # => true
40
+ # vehicle.reload # => #<Vehicle id=1 name="Ford Explorer" state="idling">
41
+ #
42
+ # == Events
43
+ #
44
+ # As described in StateMachine::InstanceMethods#state_machine, event
45
+ # attributes are created for every machine that allow transitions to be
46
+ # performed automatically when the object's action (in this case, :save)
47
+ # is called.
48
+ #
49
+ # In DataMapper, these automated events are run in the following order:
50
+ # * before validation - If validation feature loaded, run before callbacks and persist new states, then validate
51
+ # * before save - If validation feature was skipped/not loaded, run before callbacks and persist new states, then save
52
+ # * after save - Run after callbacks
53
+ #
54
+ # For example,
55
+ #
56
+ # vehicle = Vehicle.create # => #<Vehicle id=1 name=nil state="parked">
57
+ # vehicle.state_event # => nil
58
+ # vehicle.state_event = 'invalid'
59
+ # vehicle.valid? # => false
60
+ # vehicle.errors # => #<DataMapper::Validate::ValidationErrors:0xb7a48b54 @errors={"state_event"=>["is invalid"]}>
61
+ #
62
+ # vehicle.state_event = 'ignite'
63
+ # vehicle.valid? # => true
64
+ # vehicle.save # => true
65
+ # vehicle.state # => "idling"
66
+ # vehicle.state_event # => nil
67
+ #
68
+ # Note that this can also be done on a mass-assignment basis:
69
+ #
70
+ # vehicle = Vehicle.create(:state_event => 'ignite') # => #<Vehicle id=1 name=nil state="idling">
71
+ # vehicle.state # => "idling"
72
+ #
73
+ # === Security implications
74
+ #
75
+ # Beware that public event attributes mean that events can be fired
76
+ # whenever mass-assignment is being used. If you want to prevent malicious
77
+ # users from tampering with events through URLs / forms, the attribute
78
+ # should be protected like so:
79
+ #
80
+ # class Vehicle
81
+ # include DataMapper::Resource
82
+ # ...
83
+ #
84
+ # state_machine do
85
+ # ...
86
+ # end
87
+ # protected :state_event
88
+ # end
89
+ #
90
+ # If you want to only have *some* events be able to fire via mass-assignment,
91
+ # you can build two state machines (one public and one protected) like so:
92
+ #
93
+ # class Vehicle
94
+ # include DataMapper::Resource
95
+ # ...
96
+ #
97
+ # state_machine do
98
+ # # Define private events here
99
+ # end
100
+ # protected :state_event= # Prevent access to events in the first machine
101
+ #
102
+ # # Allow both machines to share the same state
103
+ # state_machine :public_state, :attribute => :state do
104
+ # # Define public events here
105
+ # end
106
+ # end
107
+ #
108
+ # == Transactions
109
+ #
110
+ # By default, the use of transactions during an event transition is
111
+ # turned off to be consistent with DataMapper. This means that if
112
+ # changes are made to the database during a before callback, but the
113
+ # transition fails to complete, those changes will *not* be rolled back.
114
+ #
115
+ # For example,
116
+ #
117
+ # class Message
118
+ # include DataMapper::Resource
119
+ #
120
+ # property :id, Serial
121
+ # property :content, String
122
+ # end
123
+ #
124
+ # Vehicle.state_machine do
125
+ # before_transition do |transition|
126
+ # Message.create(:content => transition.inspect)
127
+ # throw :halt
128
+ # end
129
+ # end
130
+ #
131
+ # vehicle = Vehicle.create # => #<Vehicle id=1 name=nil state="parked">
132
+ # vehicle.ignite # => false
133
+ # Message.all.count # => 1
134
+ #
135
+ # To turn on transactions:
136
+ #
137
+ # class Vehicle < ActiveRecord::Base
138
+ # state_machine :initial => :parked, :use_transactions => true do
139
+ # ...
140
+ # end
141
+ # end
142
+ #
143
+ # == Validation errors
144
+ #
145
+ # If an event fails to successfully fire because there are no matching
146
+ # transitions for the current record, a validation error is added to the
147
+ # record's state attribute to help in determining why it failed and for
148
+ # reporting via the UI.
149
+ #
150
+ # For example,
151
+ #
152
+ # vehicle = Vehicle.create(:state => 'idling') # => #<Vehicle id=1 name=nil state="idling">
153
+ # vehicle.ignite # => false
154
+ # vehicle.errors.full_messages # => ["cannot transition via \"ignite\""]
155
+ #
156
+ # If an event fails to fire because of a validation error on the record and
157
+ # *not* because a matching transition was not available, no error messages
158
+ # will be added to the state attribute.
159
+ #
160
+ # == Scopes
161
+ #
162
+ # To assist in filtering models with specific states, a series of class
163
+ # methods are defined on the model for finding records with or without a
164
+ # particular set of states.
165
+ #
166
+ # These named scopes are the functional equivalent of the following
167
+ # definitions:
168
+ #
169
+ # class Vehicle
170
+ # include DataMapper::Resource
171
+ #
172
+ # property :id, Serial
173
+ # property :state, String
174
+ #
175
+ # class << self
176
+ # def with_states(*states)
177
+ # all(:state => states.flatten)
178
+ # end
179
+ # alias_method :with_state, :with_states
180
+ #
181
+ # def without_states(*states)
182
+ # all(:state.not => states.flatten)
183
+ # end
184
+ # alias_method :without_state, :without_states
185
+ # end
186
+ # end
187
+ #
188
+ # *Note*, however, that the states are converted to their stored values
189
+ # before being passed into the query.
190
+ #
191
+ # Because of the way scopes work in DataMapper, they can be chained like
192
+ # so:
193
+ #
194
+ # Vehicle.with_state(:parked).all(:order => [:id.desc])
195
+ #
196
+ # == Callbacks / Observers
197
+ #
198
+ # All before/after transition callbacks defined for DataMapper resources
199
+ # behave in the same way that other DataMapper hooks behave. Rather than
200
+ # passing in the record as an argument to the callback, the callback is
201
+ # instead bound to the object and evaluated within its context.
202
+ #
203
+ # For example,
204
+ #
205
+ # class Vehicle
206
+ # include DataMapper::Resource
207
+ #
208
+ # property :id, Serial
209
+ # property :state, String
210
+ #
211
+ # state_machine :initial => :parked do
212
+ # before_transition any => :idling do
213
+ # put_on_seatbelt
214
+ # end
215
+ #
216
+ # before_transition do |transition|
217
+ # # log message
218
+ # end
219
+ #
220
+ # event :ignite do
221
+ # transition :parked => :idling
222
+ # end
223
+ # end
224
+ #
225
+ # def put_on_seatbelt
226
+ # ...
227
+ # end
228
+ # end
229
+ #
230
+ # Note, also, that the transition can be accessed by simply defining
231
+ # additional arguments in the callback block.
232
+ #
233
+ # In addition to support for DataMapper-like hooks, there is additional
234
+ # support for DataMapper observers. See StateMachine::Integrations::DataMapper::Observer
235
+ # for more information.
236
+ module DataMapper
237
+ # The default options to use for state machines using this integration
238
+ class << self; attr_reader :defaults; end
239
+ @defaults = {:action => :save, :use_transactions => false}
240
+
241
+ # Should this integration be used for state machines in the given class?
242
+ # Classes that include DataMapper::Resource will automatically use the
243
+ # DataMapper integration.
244
+ def self.matches?(klass)
245
+ defined?(::DataMapper::Resource) && klass <= ::DataMapper::Resource
246
+ end
247
+
248
+ # Loads additional files specific to DataMapper
249
+ def self.extended(base) #:nodoc:
250
+ require 'dm-core/version' unless ::DataMapper.const_defined?('VERSION')
251
+ require 'state_machine/integrations/data_mapper/observer' if ::DataMapper.const_defined?('Observer')
252
+ end
253
+
254
+ # Forces the change in state to be recognized regardless of whether the
255
+ # state value actually changed
256
+ def write(object, attribute, value)
257
+ result = super
258
+ if attribute == :state && owner_class.properties.detect {|property| property.name == self.attribute}
259
+ if ::DataMapper::VERSION =~ /^(0\.\d\.)/ # Match anything < 0.10
260
+ object.original_values[self.attribute] = "#{value}-ignored"
261
+ else
262
+ object.original_attributes[owner_class.properties[self.attribute]] = "#{value}-ignored"
263
+ end
264
+ end
265
+ result
266
+ end
267
+
268
+ # Adds a validation error to the given object
269
+ def invalidate(object, attribute, message, values = [])
270
+ object.errors.add(self.attribute(attribute), generate_message(message, values)) if supports_validations?
271
+ end
272
+
273
+ # Resets any errors previously added when invalidating the given object
274
+ def reset(object)
275
+ object.errors.clear if supports_validations?
276
+ end
277
+
278
+ protected
279
+ # Is validation support currently loaded?
280
+ def supports_validations?
281
+ @supports_validations ||= ::DataMapper.const_defined?('Validate')
282
+ end
283
+
284
+ # Defines an initialization hook into the owner class for setting the
285
+ # initial state of the machine *before* any attributes are set on the
286
+ # object
287
+ def define_state_initializer
288
+ @instance_helper_module.class_eval <<-end_eval, __FILE__, __LINE__
289
+ def initialize(attributes = {}, *args)
290
+ ignore = attributes ? attributes.keys : []
291
+ initialize_state_machines(:dynamic => false, :ignore => ignore)
292
+ super
293
+ initialize_state_machines(:dynamic => true, :ignore => ignore)
294
+ end
295
+ end_eval
296
+ end
297
+
298
+ # Skips defining reader/writer methods since this is done automatically
299
+ def define_state_accessor
300
+ owner_class.property(attribute, String) unless owner_class.properties.detect {|property| property.name == attribute}
301
+
302
+ if supports_validations?
303
+ name = self.name
304
+ owner_class.validates_with_block(attribute) do
305
+ machine = self.class.state_machine(name)
306
+ machine.states.match(self) ? true : [false, machine.generate_message(:invalid)]
307
+ end
308
+ end
309
+ end
310
+
311
+ # Adds hooks into validation for automatically firing events
312
+ def define_action_helpers
313
+ if super && action == :save && supports_validations?
314
+ @instance_helper_module.class_eval do
315
+ define_method(:valid?) do |*args|
316
+ self.class.state_machines.fire_event_attributes(self, :save, false) { super(*args) }
317
+ end
318
+ end
319
+ end
320
+ end
321
+
322
+ # Creates a scope for finding records *with* a particular state or
323
+ # states for the attribute
324
+ def create_with_scope(name)
325
+ attribute = self.attribute
326
+ lambda {|resource, values| resource.all(attribute => values)}
327
+ end
328
+
329
+ # Creates a scope for finding records *without* a particular state or
330
+ # states for the attribute
331
+ def create_without_scope(name)
332
+ attribute = self.attribute
333
+ lambda {|resource, values| resource.all(attribute.to_sym.not => values)}
334
+ end
335
+
336
+ # Runs a new database transaction, rolling back any changes if the
337
+ # yielded block fails (i.e. returns false).
338
+ def transaction(object)
339
+ object.class.transaction {|t| t.rollback unless yield}
340
+ end
341
+
342
+ # Creates a new callback in the callback chain, always ensuring that
343
+ # it's configured to bind to the object as this is the convention for
344
+ # DataMapper/Extlib callbacks
345
+ def add_callback(type, options, &block)
346
+ options[:bind_to_object] = true
347
+ super
348
+ end
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,139 @@
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 similar 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|
22
+ # Audit.log(self, transition)
23
+ # end
24
+ # end
25
+ #
26
+ # == Requirements
27
+ #
28
+ # To use this feature of the DataMapper integration, the dm-observer library
29
+ # must be available. This can be installed either directly or indirectly
30
+ # through dm-more. When loading DataMapper, be sure to load the dm-observer
31
+ # library as well like so:
32
+ #
33
+ # require 'rubygems'
34
+ # require 'dm-core'
35
+ # require 'dm-observer'
36
+ #
37
+ # If dm-observer is not available, then this feature will be skipped.
38
+ module Observer
39
+ include MatcherHelpers
40
+
41
+ # Creates a callback that will be invoked *before* a transition is
42
+ # performed, so long as the given configuration options match the
43
+ # transition. Each part of the transition (event, to state, from state)
44
+ # must match in order for the callback to get invoked.
45
+ #
46
+ # See StateMachine::Machine#before_transition for more
47
+ # information about the various configuration options available.
48
+ #
49
+ # == Examples
50
+ #
51
+ # class Vehicle
52
+ # include DataMapper::Resource
53
+ #
54
+ # property :id, Serial
55
+ # property :state, :String
56
+ #
57
+ # state_machine :initial => :parked do
58
+ # event :ignite do
59
+ # transition :parked => :idling
60
+ # end
61
+ # end
62
+ # end
63
+ #
64
+ # class VehicleObserver
65
+ # include DataMapper::Observer
66
+ #
67
+ # observe Vehicle
68
+ #
69
+ # before :save do
70
+ # # log message
71
+ # end
72
+ #
73
+ # # Target all state machines
74
+ # before_transition :parked => :idling, :on => :ignite do
75
+ # # put on seatbelt
76
+ # end
77
+ #
78
+ # # Target a specific state machine
79
+ # before_transition :state, any => :idling do
80
+ # # put on seatbelt
81
+ # end
82
+ #
83
+ # # Target all state machines without requirements
84
+ # before_transition do |transition|
85
+ # # log message
86
+ # end
87
+ # end
88
+ #
89
+ # *Note* that in each of the above +before_transition+ callbacks, the
90
+ # callback is executed within the context of the object (i.e. the
91
+ # Vehicle instance being transition). This means that +self+ refers
92
+ # to the vehicle record within each callback block.
93
+ def before_transition(*args, &block)
94
+ add_transition_callback(:before, *args, &block)
95
+ end
96
+
97
+ # Creates a callback that will be invoked *after* a transition is
98
+ # performed so long as the given configuration options match the
99
+ # transition.
100
+ #
101
+ # See +before_transition+ for a description of the possible configurations
102
+ # for defining callbacks.
103
+ def after_transition(*args, &block)
104
+ add_transition_callback(:after, *args, &block)
105
+ end
106
+
107
+ private
108
+ # Adds the transition callback to a specific machine or all of the
109
+ # state machines for each observed class.
110
+ def add_transition_callback(type, *args, &block)
111
+ if args.any? && !args.first.is_a?(Hash)
112
+ # Specific machine(s) being targeted
113
+ names = args
114
+ args = args.last.is_a?(Hash) ? [args.pop] : []
115
+ else
116
+ # Target all state machines
117
+ names = nil
118
+ end
119
+
120
+ # Add the transition callback to each class being observed
121
+ observing.each do |klass|
122
+ state_machines =
123
+ if names
124
+ names.map {|name| klass.state_machines.fetch(name)}
125
+ else
126
+ klass.state_machines.values
127
+ end
128
+
129
+ state_machines.each {|machine| machine.send("#{type}_transition", *args, &block)}
130
+ end if observing
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ DataMapper::Observer::ClassMethods.class_eval do
138
+ include StateMachine::Integrations::DataMapper::Observer
139
+ end