pluginaweek-state_machine 0.7.6

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 +273 -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 +429 -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 +251 -0
  33. data/lib/state_machine/event_collection.rb +113 -0
  34. data/lib/state_machine/extensions.rb +158 -0
  35. data/lib/state_machine/guard.rb +219 -0
  36. data/lib/state_machine/integrations.rb +68 -0
  37. data/lib/state_machine/integrations/active_record.rb +444 -0
  38. data/lib/state_machine/integrations/active_record/locale.rb +10 -0
  39. data/lib/state_machine/integrations/active_record/observer.rb +41 -0
  40. data/lib/state_machine/integrations/data_mapper.rb +325 -0
  41. data/lib/state_machine/integrations/data_mapper/observer.rb +139 -0
  42. data/lib/state_machine/integrations/sequel.rb +292 -0
  43. data/lib/state_machine/machine.rb +1431 -0
  44. data/lib/state_machine/machine_collection.rb +146 -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 +367 -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 +129 -0
  60. data/test/unit/event_collection_test.rb +293 -0
  61. data/test/unit/event_test.rb +605 -0
  62. data/test/unit/guard_test.rb +862 -0
  63. data/test/unit/integrations/active_record_test.rb +1001 -0
  64. data/test/unit/integrations/data_mapper_test.rb +694 -0
  65. data/test/unit/integrations/sequel_test.rb +486 -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 +710 -0
  70. data/test/unit/machine_test.rb +1910 -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 +1113 -0
  78. metadata +161 -0
@@ -0,0 +1,68 @@
1
+ # Load each available integration
2
+ Dir["#{File.dirname(__FILE__)}/integrations/*.rb"].sort.each do |path|
3
+ require "state_machine/integrations/#{File.basename(path)}"
4
+ end
5
+
6
+ module StateMachine
7
+ # Integrations allow state machines to take advantage of features within the
8
+ # context of a particular library. This is currently most useful with
9
+ # database libraries. For example, the various database integrations allow
10
+ # state machines to hook into features like:
11
+ # * Saving
12
+ # * Transactions
13
+ # * Observers
14
+ # * Scopes
15
+ # * Callbacks
16
+ # * Validation errors
17
+ #
18
+ # This type of integration allows the user to work with state machines in a
19
+ # fashion similar to other object models in their application.
20
+ #
21
+ # The integration interface is loosely defined by various unimplemented
22
+ # methods in the StateMachine::Machine class. See that class or the various
23
+ # built-in integrations for more information about how to define additional
24
+ # integrations.
25
+ module Integrations
26
+ # Attempts to find an integration that matches the given class. This will
27
+ # look through all of the built-in integrations under the StateMachine::Integrations
28
+ # namespace and find one that successfully matches the class.
29
+ #
30
+ # == Examples
31
+ #
32
+ # class Vehicle
33
+ # end
34
+ #
35
+ # class ARVehicle < ActiveRecord::Base
36
+ # end
37
+ #
38
+ # class DMVehicle
39
+ # include DataMapper::Resource
40
+ # end
41
+ #
42
+ # class SequelVehicle < Sequel::Model
43
+ # end
44
+ #
45
+ # StateMachine::Integrations.match(Vehicle) # => nil
46
+ # StateMachine::Integrations.match(ARVehicle) # => StateMachine::Integrations::ActiveRecord
47
+ # StateMachine::Integrations.match(DMVehicle) # => StateMachine::Integrations::DataMapper
48
+ # StateMachine::Integrations.match(SequelVehicle) # => StateMachine::Integrations::Sequel
49
+ def self.match(klass)
50
+ if integration = constants.find {|name| const_get(name).matches?(klass)}
51
+ find(integration)
52
+ end
53
+ end
54
+
55
+ # Finds an integration with the given name. If the integration cannot be
56
+ # found, then a NameError exception will be raised.
57
+ #
58
+ # == Examples
59
+ #
60
+ # StateMachine::Integrations.find(:active_record) # => StateMachine::Integrations::ActiveRecord
61
+ # StateMachine::Integrations.find(:data_mapper) # => StateMachine::Integrations::DataMapper
62
+ # StateMachine::Integrations.find(:sequel) # => StateMachine::Integrations::Sequel
63
+ # StateMachine::Integrations.find(:invalid) # => NameError: wrong constant name Invalid
64
+ def self.find(name)
65
+ const_get(name.to_s.gsub(/(?:^|_)(.)/) {$1.upcase})
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,444 @@
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 :parked => :idling
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: "parked">
32
+ # vehicle.name = 'Ford Explorer'
33
+ # vehicle.ignite # => true
34
+ # vehicle.reload # => #<Vehicle id: 1, name: "Ford Explorer", state: "idling">
35
+ #
36
+ # == Events
37
+ #
38
+ # As described in StateMachine::InstanceMethods#state_machine, event
39
+ # attributes are created for every machine that allow transitions to be
40
+ # performed automatically when the object's action (in this case, :save)
41
+ # is called.
42
+ #
43
+ # In ActiveRecord, these automated events are run in the following order:
44
+ # * before validation - Run before callbacks and persist new states, then validate
45
+ # * before save - If validation was skipped, run before callbacks and persist new states, then save
46
+ # * after save - Run after callbacks
47
+ #
48
+ # For example,
49
+ #
50
+ # vehicle = Vehicle.create # => #<Vehicle id: 1, name: nil, state: "parked">
51
+ # vehicle.state_event # => nil
52
+ # vehicle.state_event = 'invalid'
53
+ # vehicle.valid? # => false
54
+ # vehicle.errors.full_messages # => ["State event is invalid"]
55
+ #
56
+ # vehicle.state_event = 'ignite'
57
+ # vehicle.valid? # => true
58
+ # vehicle.save # => true
59
+ # vehicle.state # => "idling"
60
+ # vehicle.state_event # => nil
61
+ #
62
+ # Note that this can also be done on a mass-assignment basis:
63
+ #
64
+ # vehicle = Vehicle.create(:state_event => 'ignite') # => #<Vehicle id: 1, name: nil, state: "idling">
65
+ # vehicle.state # => "idling"
66
+ #
67
+ # === Security implications
68
+ #
69
+ # Beware that public event attributes mean that events can be fired
70
+ # whenever mass-assignment is being used. If you want to prevent malicious
71
+ # users from tampering with events through URLs / forms, the attribute
72
+ # should be protected like so:
73
+ #
74
+ # class Vehicle < ActiveRecord::Base
75
+ # attr_protected :state_event
76
+ # # attr_accessible ... # Alternative technique
77
+ #
78
+ # state_machine do
79
+ # ...
80
+ # end
81
+ # end
82
+ #
83
+ # If you want to only have *some* events be able to fire via mass-assignment,
84
+ # you can build two state machines (one public and one protected) like so:
85
+ #
86
+ # class Vehicle < ActiveRecord::Base
87
+ # alias_attribute :public_state # Allow both machines to share the same state
88
+ # attr_protected :state_event # Prevent access to events in the first machine
89
+ #
90
+ # state_machine do
91
+ # # Define private events here
92
+ # end
93
+ #
94
+ # state_machine :public_state do
95
+ # # Define public events here
96
+ # end
97
+ # end
98
+ #
99
+ # == Transactions
100
+ #
101
+ # In order to ensure that any changes made during transition callbacks
102
+ # are rolled back during a failed attempt, every transition is wrapped
103
+ # within a transaction.
104
+ #
105
+ # For example,
106
+ #
107
+ # class Message < ActiveRecord::Base
108
+ # end
109
+ #
110
+ # Vehicle.state_machine do
111
+ # before_transition do |vehicle, transition|
112
+ # Message.create(:content => transition.inspect)
113
+ # false
114
+ # end
115
+ # end
116
+ #
117
+ # vehicle = Vehicle.create # => #<Vehicle id: 1, name: nil, state: "parked">
118
+ # vehicle.ignite # => false
119
+ # Message.count # => 0
120
+ #
121
+ # *Note* that only before callbacks that halt the callback chain and
122
+ # failed attempts to save the record will result in the transaction being
123
+ # rolled back. If an after callback halts the chain, the previous result
124
+ # still applies and the transaction is *not* rolled back.
125
+ #
126
+ # To turn off transactions:
127
+ #
128
+ # class Vehicle < ActiveRecord::Base
129
+ # state_machine :initial => :parked, :use_transactions => false do
130
+ # ...
131
+ # end
132
+ # end
133
+ #
134
+ # == Validation errors
135
+ #
136
+ # If an event fails to successfully fire because there are no matching
137
+ # transitions for the current record, a validation error is added to the
138
+ # record's state attribute to help in determining why it failed and for
139
+ # reporting via the UI.
140
+ #
141
+ # For example,
142
+ #
143
+ # vehicle = Vehicle.create(:state => 'idling') # => #<Vehicle id: 1, name: nil, state: "idling">
144
+ # vehicle.ignite # => false
145
+ # vehicle.errors.full_messages # => ["State cannot transition via \"ignite\""]
146
+ #
147
+ # If an event fails to fire because of a validation error on the record and
148
+ # *not* because a matching transition was not available, no error messages
149
+ # will be added to the state attribute.
150
+ #
151
+ # == Scopes
152
+ #
153
+ # To assist in filtering models with specific states, a series of named
154
+ # scopes are defined on the model for finding records with or without a
155
+ # particular set of states.
156
+ #
157
+ # These named scopes are essentially the functional equivalent of the
158
+ # following definitions:
159
+ #
160
+ # class Vehicle < ActiveRecord::Base
161
+ # named_scope :with_states, lambda {|*states| {:conditions => {:state => states}}}
162
+ # # with_states also aliased to with_state
163
+ #
164
+ # named_scope :without_states, lambda {|*states| {:conditions => ['state NOT IN (?)', states]}}
165
+ # # without_states also aliased to without_state
166
+ # end
167
+ #
168
+ # *Note*, however, that the states are converted to their stored values
169
+ # before being passed into the query.
170
+ #
171
+ # Because of the way named scopes work in ActiveRecord, they can be
172
+ # chained like so:
173
+ #
174
+ # Vehicle.with_state(:parked).all(:order => 'id DESC')
175
+ #
176
+ # == Callbacks
177
+ #
178
+ # All before/after transition callbacks defined for ActiveRecord models
179
+ # behave in the same way that other ActiveRecord callbacks behave. The
180
+ # object involved in the transition is passed in as an argument.
181
+ #
182
+ # For example,
183
+ #
184
+ # class Vehicle < ActiveRecord::Base
185
+ # state_machine :initial => :parked do
186
+ # before_transition any => :idling do |vehicle|
187
+ # vehicle.put_on_seatbelt
188
+ # end
189
+ #
190
+ # before_transition do |vehicle, transition|
191
+ # # log message
192
+ # end
193
+ #
194
+ # event :ignite do
195
+ # transition :parked => :idling
196
+ # end
197
+ # end
198
+ #
199
+ # def put_on_seatbelt
200
+ # ...
201
+ # end
202
+ # end
203
+ #
204
+ # Note, also, that the transition can be accessed by simply defining
205
+ # additional arguments in the callback block.
206
+ #
207
+ # == Observers
208
+ #
209
+ # In addition to support for ActiveRecord-like hooks, there is additional
210
+ # support for ActiveRecord observers. Because of the way ActiveRecord
211
+ # observers are designed, there is less flexibility around the specific
212
+ # transitions that can be hooked in. However, a large number of hooks
213
+ # *are* supported. For example, if a transition for a record's +state+
214
+ # attribute changes the state from +parked+ to +idling+ via the +ignite+
215
+ # event, the following observer methods are supported:
216
+ # * before/after_ignite_from_parked_to_idling
217
+ # * before/after_ignite_from_parked
218
+ # * before/after_ignite_to_idling
219
+ # * before/after_ignite
220
+ # * before/after_transition_state_from_parked_to_idling
221
+ # * before/after_transition_state_from_parked
222
+ # * before/after_transition_state_to_idling
223
+ # * before/after_transition_state
224
+ # * before/after_transition
225
+ #
226
+ # The following class shows an example of some of these hooks:
227
+ #
228
+ # class VehicleObserver < ActiveRecord::Observer
229
+ # def before_save(vehicle)
230
+ # # log message
231
+ # end
232
+ #
233
+ # # Callback for :ignite event *before* the transition is performed
234
+ # def before_ignite(vehicle, transition)
235
+ # # log message
236
+ # end
237
+ #
238
+ # # Callback for :ignite event *after* the transition has been performed
239
+ # def after_ignite(vehicle, transition)
240
+ # # put on seatbelt
241
+ # end
242
+ #
243
+ # # Generic transition callback *before* the transition is performed
244
+ # def after_transition(vehicle, transition)
245
+ # Audit.log(vehicle, transition)
246
+ # end
247
+ # end
248
+ #
249
+ # More flexible transition callbacks can be defined directly within the
250
+ # model as described in StateMachine::Machine#before_transition
251
+ # and StateMachine::Machine#after_transition.
252
+ #
253
+ # To define a single observer for multiple state machines:
254
+ #
255
+ # class StateMachineObserver < ActiveRecord::Observer
256
+ # observe Vehicle, Switch, Project
257
+ #
258
+ # def after_transition(record, transition)
259
+ # Audit.log(record, transition)
260
+ # end
261
+ # end
262
+ module ActiveRecord
263
+ # The default options to use for state machines using this integration
264
+ class << self; attr_reader :defaults; end
265
+ @defaults = {:action => :save}
266
+
267
+ # Should this integration be used for state machines in the given class?
268
+ # Classes that inherit from ActiveRecord::Base will automatically use
269
+ # the ActiveRecord integration.
270
+ def self.matches?(klass)
271
+ defined?(::ActiveRecord::Base) && klass <= ::ActiveRecord::Base
272
+ end
273
+
274
+ # Loads additional files specific to ActiveRecord
275
+ def self.extended(base) #:nodoc:
276
+ require 'state_machine/integrations/active_record/observer'
277
+
278
+ if Object.const_defined?(:I18n)
279
+ locale = "#{File.dirname(__FILE__)}/active_record/locale.rb"
280
+ I18n.load_path << locale unless I18n.load_path.include?(locale)
281
+ end
282
+ end
283
+
284
+ # Adds a validation error to the given object
285
+ def invalidate(object, attribute, message, values = [])
286
+ attribute = self.attribute(attribute)
287
+
288
+ if Object.const_defined?(:I18n)
289
+ options = values.inject({}) {|options, (key, value)| options[key] = value; options}
290
+ object.errors.add(attribute, message, options.merge(
291
+ :default => @messages[message]
292
+ ))
293
+ else
294
+ object.errors.add(attribute, generate_message(message, values))
295
+ end
296
+ end
297
+
298
+ # Resets any errors previously added when invalidating the given object
299
+ def reset(object)
300
+ object.errors.clear
301
+ end
302
+
303
+ protected
304
+ # Adds the default callbacks for notifying ActiveRecord observers
305
+ # before/after a transition has been performed.
306
+ def after_initialize
307
+ callbacks[:before] << Callback.new {|object, transition| notify(:before, object, transition)}
308
+ callbacks[:after] << Callback.new {|object, transition| notify(:after, object, transition)}
309
+ end
310
+
311
+ # Skips defining reader/writer methods since this is done automatically
312
+ def define_state_accessor
313
+ owner_class.validates_each(attribute) do |record, attr, value|
314
+ machine = record.class.state_machine(attr)
315
+ machine.invalidate(record, attr, :invalid) unless machine.states.match(record)
316
+ end
317
+ end
318
+
319
+ # Adds support for defining the attribute predicate, while providing
320
+ # compatibility with the default predicate which determines whether
321
+ # *anything* is set for the attribute's value
322
+ def define_state_predicate
323
+ name = self.name
324
+ attribute = self.attribute
325
+
326
+ # Still use class_eval here instance of define_instance_method since
327
+ # we need to be able to call +super+
328
+ @instance_helper_module.class_eval do
329
+ define_method("#{name}?") do |*args|
330
+ args.empty? ? super(*args) : self.class.state_machine(attribute).states.matches?(self, *args)
331
+ end
332
+ end
333
+ end
334
+
335
+ # Adds hooks into validation for automatically firing events
336
+ def define_action_helpers
337
+ if action == :save
338
+ if super(:create_or_update)
339
+ @instance_helper_module.class_eval do
340
+ define_method(:valid?) do |*args|
341
+ self.class.state_machines.fire_event_attributes(self, :save, false) { super(*args) }
342
+ end
343
+ end
344
+ end
345
+ else
346
+ super
347
+ end
348
+ end
349
+
350
+ # Creates a scope for finding records *with* a particular state or
351
+ # states for the attribute
352
+ def create_with_scope(name)
353
+ attribute = self.attribute
354
+ define_scope(name, lambda {|values| {:conditions => {attribute => values}}})
355
+ end
356
+
357
+ # Creates a scope for finding records *without* a particular state or
358
+ # states for the attribute
359
+ def create_without_scope(name)
360
+ attribute = self.attribute
361
+ define_scope(name, lambda {|values| {:conditions => ["#{attribute} NOT IN (?)", values]}})
362
+ end
363
+
364
+ # Runs a new database transaction, rolling back any changes by raising
365
+ # an ActiveRecord::Rollback exception if the yielded block fails
366
+ # (i.e. returns false).
367
+ def transaction(object)
368
+ object.class.transaction {raise ::ActiveRecord::Rollback unless yield}
369
+ end
370
+
371
+ # Creates a new callback in the callback chain, always inserting it
372
+ # before the default Observer callbacks that were created after
373
+ # initialization.
374
+ def add_callback(type, options, &block)
375
+ options[:terminator] = @terminator ||= lambda {|result| result == false}
376
+ @callbacks[type].insert(-2, callback = Callback.new(options, &block))
377
+ add_states(callback.known_states)
378
+
379
+ callback
380
+ end
381
+
382
+ private
383
+ # Defines a new named scope with the given name. Since ActiveRecord
384
+ # does not allow direct access to the model being used within the
385
+ # evaluation of a dynamic named scope, the scope must be generated
386
+ # manually. It's necessary to have access to the model so that the
387
+ # state names can be translated to their associated values and so that
388
+ # inheritance is respected properly.
389
+ def define_scope(name, scope)
390
+ name = name.to_sym
391
+ attribute = self.attribute
392
+
393
+ # Created the scope and then override it with state translation
394
+ owner_class.named_scope(name)
395
+ owner_class.scopes[name] = lambda do |klass, *states|
396
+ machine_states = klass.state_machine(attribute).states
397
+ values = states.flatten.map {|state| machine_states.fetch(state).value}
398
+
399
+ ::ActiveRecord::NamedScope::Scope.new(klass, scope.call(values))
400
+ end
401
+
402
+ false
403
+ end
404
+
405
+ # Notifies observers on the given object that a callback occurred
406
+ # involving the given transition. This will attempt to call the
407
+ # following methods on observers:
408
+ # * #{type}_#{qualified_event}_from_#{from}_to_#{to}
409
+ # * #{type}_#{qualified_event}_from_#{from}
410
+ # * #{type}_#{qualified_event}_to_#{to}
411
+ # * #{type}_#{qualified_event}
412
+ # * #{type}_transition_#{attribute}_from_#{from}_to_#{to}
413
+ # * #{type}_transition_#{attribute}_from_#{from}
414
+ # * #{type}_transition_#{attribute}_to_#{to}
415
+ # * #{type}_transition_#{attribute}
416
+ # * #{type}_transition
417
+ #
418
+ # This will always return true regardless of the results of the
419
+ # callbacks.
420
+ def notify(type, object, transition)
421
+ name = self.name
422
+ event = transition.qualified_event
423
+ from = transition.from_name
424
+ to = transition.to_name
425
+
426
+ # Machine-specific updates
427
+ ["#{type}_#{event}", "#{type}_transition_#{name}"].each do |event_segment|
428
+ ["_from_#{from}", nil].each do |from_segment|
429
+ ["_to_#{to}", nil].each do |to_segment|
430
+ object.class.changed
431
+ object.class.notify_observers([event_segment, from_segment, to_segment].join, object, transition)
432
+ end
433
+ end
434
+ end
435
+
436
+ # Generic updates
437
+ object.class.changed
438
+ object.class.notify_observers("#{type}_transition", object, transition)
439
+
440
+ true
441
+ end
442
+ end
443
+ end
444
+ end