state_machine 0.3.1 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
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
Binary file
Binary file
Binary file
data/lib/state_machine.rb CHANGED
@@ -1,123 +1,168 @@
1
1
  require 'state_machine/machine'
2
2
 
3
- module PluginAWeek #:nodoc:
4
- # A state machine is a model of behavior composed of states, events, and
5
- # transitions. This helper adds support for defining this type of
6
- # functionality within ActiveRecord models.
7
- module StateMachine
8
- module MacroMethods
9
- # Creates a state machine for the given attribute. The default attribute
10
- # is "state".
11
- #
12
- # Configuration options:
13
- # * +initial+ - The initial value of the attribute. This can either be the actual value or a Proc for dynamic initial states.
14
- #
15
- # This also requires a block which will be used to actually configure the
16
- # events and transitions for the state machine. *Note* that this block
17
- # will be executed within the context of the state machine. As a result,
18
- # you will not be able to access any class methods on the model unless you
19
- # refer to them directly (i.e. specifying the class name).
20
- #
21
- # For examples on the types of configured state machines and blocks, see
22
- # the section below.
23
- #
24
- # == Examples
25
- #
26
- # With the default attribute and no initial state:
27
- #
28
- # class Switch < ActiveRecord::Base
29
- # state_machine do
30
- # event :park do
31
- # ...
32
- # end
33
- # end
34
- # end
35
- #
36
- # The above example will define a state machine for the attribute "state"
37
- # on the model. Every switch will start with no initial state.
38
- #
39
- # With a custom attribute:
40
- #
41
- # class Switch < ActiveRecord::Base
42
- # state_machine :status do
43
- # ...
44
- # end
45
- # end
46
- #
47
- # With a static initial state:
48
- #
49
- # class Switch < ActiveRecord::Base
50
- # state_machine :status, :initial => 'off' do
51
- # ...
52
- # end
53
- # end
54
- #
55
- # With a dynamic initial state:
56
- #
57
- # class Switch < ActiveRecord::Base
58
- # state_machine :status, :initial => lambda {|switch| (8..22).include?(Time.now.hour) ? 'on' : 'off'} do
59
- # ...
60
- # end
61
- # end
62
- #
63
- # == Events and Transitions
64
- #
65
- # For more information about how to configure an event and its associated
66
- # transitions, see PluginAWeek::StateMachine::Machine#event
67
- #
68
- # == Defining callbacks
69
- #
70
- # Within the +state_machine+ block, you can also define callbacks for
71
- # particular states. For more information about defining these callbacks,
72
- # see PluginAWeek::StateMachine::Machine#before_transition and
73
- # PluginAWeek::StateMachine::Machine#after_transition.
74
- def state_machine(*args, &block)
75
- unless included_modules.include?(PluginAWeek::StateMachine::InstanceMethods)
76
- write_inheritable_attribute :state_machines, {}
77
- class_inheritable_reader :state_machines
78
-
79
- include PluginAWeek::StateMachine::InstanceMethods
80
- end
81
-
82
- options = args.extract_options!
83
- attribute = args.any? ? args.first.to_s : 'state'
84
-
85
- # Creates the state machine for this class. If a superclass has already
86
- # defined the machine, then a copy of it will be used with its context
87
- # changed to this class. If no machine has been defined before for the
88
- # attribute, a new one will be created.
89
- original = state_machines[attribute]
90
- machine = state_machines[attribute] = original ? original.within_context(self, options) : PluginAWeek::StateMachine::Machine.new(self, attribute, options)
91
- machine.instance_eval(&block) if block
92
-
93
- machine
94
- end
95
- end
96
-
97
- module InstanceMethods
98
- def self.included(base) #:nodoc:
99
- base.class_eval do
100
- alias_method_chain :initialize, :state_machine
101
- end
102
- end
103
-
104
- # Defines the initial values for state machine attributes
105
- def initialize_with_state_machine(attributes = nil)
106
- initialize_without_state_machine(attributes)
107
-
108
- # Set the initial value of each state machine as long as the value wasn't
109
- # included in the initial attributes
110
- attributes = remove_attributes_protected_from_mass_assignment((attributes || {}).stringify_keys)
111
- self.class.state_machines.each do |attribute, machine|
112
- send("#{attribute}=", machine.initial_state(self)) unless attributes.include?(attribute)
113
- end
114
-
115
- yield self if block_given?
116
- end
3
+ # A state machine is a model of behavior composed of states, events, and
4
+ # transitions. This helper adds support for defining this type of
5
+ # functionality on any Ruby class.
6
+ module StateMachine
7
+ module MacroMethods
8
+ # Creates a new state machine for the given attribute. The default
9
+ # attribute, if not specified, is "state".
10
+ #
11
+ # Configuration options:
12
+ # * +initial+ - The initial value to set the attribute to. This can be a static value or a dynamic proc which will be evaluated at runtime. Default is nil.
13
+ # * +action+ - The action to invoke when an object transitions. Default is nil unless otherwise specified by the configured integration.
14
+ # * +plural+ - The pluralized name of the attribute. By default, this will attempt to call +pluralize+ on the attribute, otherwise an "s" is appended.
15
+ # * +integration+ - The name of the integration to use for adding library-specific behavior to the machine. Built-in integrations include :data_mapper and :active_record. By default, this is determined automatically.
16
+ #
17
+ # This also requires a block which will be used to actually configure the
18
+ # events and transitions for the state machine. *Note* that this block
19
+ # will be executed within the context of the state machine. As a result,
20
+ # you will not be able to access any class methods unless you refer to
21
+ # them directly (i.e. specifying the class name).
22
+ #
23
+ # For examples on the types of configured state machines and blocks, see
24
+ # the section below.
25
+ #
26
+ # == Examples
27
+ #
28
+ # With the default attribute and no configuration:
29
+ #
30
+ # class Vehicle
31
+ # state_machine do
32
+ # event :park do
33
+ # ...
34
+ # end
35
+ # end
36
+ # end
37
+ #
38
+ # The above example will define a state machine for the attribute "state"
39
+ # on the class. Every vehicle will start without an initial state.
40
+ #
41
+ # With a custom attribute:
42
+ #
43
+ # class Vehicle
44
+ # state_machine :status do
45
+ # ...
46
+ # end
47
+ # end
48
+ #
49
+ # With a static initial state:
50
+ #
51
+ # class Vehicle
52
+ # state_machine :status, :initial => 'Vehicle' do
53
+ # ...
54
+ # end
55
+ # end
56
+ #
57
+ # With a dynamic initial state:
58
+ #
59
+ # class Switch
60
+ # state_machine :status, :initial => lambda {|switch| (8..22).include?(Time.now.hour) ? 'on' : 'off'} do
61
+ # ...
62
+ # end
63
+ # end
64
+ #
65
+ # == Attribute accessor
66
+ #
67
+ # The attribute for each machine stores the value for the current state
68
+ # of the machine. In order to access this value and modify it during
69
+ # transitions, a reader/writer must be available. The following methods
70
+ # will be automatically generated if they are not already defined
71
+ # (assuming the attribute is called "state"):
72
+ # * <tt>state</tt> - Gets the current value for the attribute
73
+ # * <tt>state=(value)</tt> - Sets the current value for the attribute
74
+ # * <tt>state?(value)</tt> - Checks the given value against the current value. If the value is not a known state, then an ArgumentError is raised.
75
+ #
76
+ # For example, the following machine definition will not generate any
77
+ # accessor methods since the class has already defined an attribute
78
+ # accessor:
79
+ #
80
+ # class Vehicle
81
+ # attr_accessor :state
82
+ #
83
+ # state_machine do
84
+ # ...
85
+ # end
86
+ # end
87
+ #
88
+ # On the other hand, the following state machine will define both a
89
+ # reader and writer method, which is functionally equivalent to the
90
+ # example above:
91
+ #
92
+ # class Vehicle
93
+ # state_machine do
94
+ # ...
95
+ # end
96
+ # end
97
+ #
98
+ # == States
99
+ #
100
+ # All of the valid states for the machine are automatically tracked based
101
+ # on the events, transitions, and callbacks defined for the machine. If
102
+ # there are additional states that are never referenced, these should be
103
+ # explicitly added using the StateMachine::Machine#other_states
104
+ # helper.
105
+ #
106
+ # For each state tracked, a predicate method for that state is generated
107
+ # on the class. For example,
108
+ #
109
+ # class Vehicle
110
+ # state_machine :initial => 'parked' do
111
+ # event :ignite do
112
+ # transition :to => 'idling'
113
+ # end
114
+ # end
115
+ # end
116
+ #
117
+ # ...will generate the following instance methods (assuming they're not
118
+ # already defined in the class):
119
+ # * <tt>parked?</tt>
120
+ # * <tt>idling?</tt>
121
+ #
122
+ # Each predicate method will return true if it matches the object's
123
+ # current state. Otherwise, it will return false.
124
+ #
125
+ # == Events and Transitions
126
+ #
127
+ # For more information about how to configure an event and its associated
128
+ # transitions, see StateMachine::Machine#event.
129
+ #
130
+ # == Defining callbacks
131
+ #
132
+ # Within the +state_machine+ block, you can also define callbacks for
133
+ # particular states. For more information about defining these callbacks,
134
+ # see StateMachine::Machine#before_transition and
135
+ # StateMachine::Machine#after_transition.
136
+ #
137
+ # == Scopes
138
+ #
139
+ # For integrations that support it, a group of default scope filters will
140
+ # be automatically created for assisting in finding objects that have the
141
+ # attribute set to a given value.
142
+ #
143
+ # For example,
144
+ #
145
+ # Vehicle.with_state('parked') # => Finds all vehicles where the state is parked
146
+ # Vehicle.with_states('parked', 'idling') # => Finds all vehicles where the state is either parked or idling
147
+ #
148
+ # Vehicle.without_state('parked') # => Finds all vehicles where the state is *not* parked
149
+ # Vehicle.without_states('parked', 'idling') # => Finds all vehicles where the state is *not* parked or idling
150
+ #
151
+ # *Note* that if class methods already exist with those names (i.e.
152
+ # "with_state", "with_states", "without_state", or "without_states"), then
153
+ # a scope will not be defined for that name.
154
+ #
155
+ # See StateMachine::Machine for more information about using
156
+ # integrations and the individual integration docs for information about
157
+ # the actual scopes that are generated.
158
+ def state_machine(*args, &block)
159
+ machine = StateMachine::Machine.find_or_create(self, *args)
160
+ machine.instance_eval(&block) if block
161
+ machine
117
162
  end
118
163
  end
119
164
  end
120
165
 
121
- ActiveRecord::Base.class_eval do
122
- extend PluginAWeek::StateMachine::MacroMethods
166
+ Class.class_eval do
167
+ include StateMachine::MacroMethods
123
168
  end
@@ -0,0 +1,21 @@
1
+ module StateMachine
2
+ # Provides a set of helper methods for making assertions about content of
3
+ # various objects
4
+ module Assertions
5
+ # Validates that all keys in the given hash *only* includes the specified
6
+ # valid keys. If any invalid keys are found, an ArgumentError will be
7
+ # raised.
8
+ #
9
+ # == Examples
10
+ #
11
+ # options = {:name => 'John Smith', :age => 30}
12
+ #
13
+ # assert_valid_keys(options, :name) # => ArgumentError: Invalid key(s): age
14
+ # assert_valid_keys(options, 'name', 'age') # => ArgumentError: Invalid key(s): age, name
15
+ # assert_valid_keys(options, :name, :age) # => nil
16
+ def assert_valid_keys(hash, *valid_keys)
17
+ invalid_keys = hash.keys - valid_keys
18
+ raise ArgumentError, "Invalid key(s): #{invalid_keys.join(", ")}" unless invalid_keys.empty?
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,168 @@
1
+ require 'state_machine/guard'
2
+ require 'state_machine/eval_helpers'
3
+
4
+ module StateMachine
5
+ # Callbacks represent hooks into objects that allow you to trigger logic
6
+ # before or after a specific transition occurs.
7
+ class Callback
8
+ include EvalHelpers
9
+
10
+ class << self
11
+ # Whether to automatically bind the callback to the object being
12
+ # transitioned. This only applies to callbacks that are defined as
13
+ # lambda blocks (or Procs). Some libraries, such as Extlib, handle
14
+ # callbacks by executing them bound to the object involved, while other
15
+ # libraries, such as ActiveSupport, pass the object as an argument to
16
+ # the callback. This can be configured on an application-wide basis by
17
+ # setting this configuration to +true+ or +false+. The default value
18
+ # is +false+.
19
+ #
20
+ # *Note* that the DataMapper and Sequel integrations automatically
21
+ # configure this value on a per-callback basis, so it does not have to
22
+ # be enabled application-wide.
23
+ #
24
+ # == Examples
25
+ #
26
+ # When not bound to the object:
27
+ #
28
+ # class Vehicle
29
+ # state_machine do
30
+ # before_transition do |vehicle|
31
+ # vehicle.set_alarm
32
+ # end
33
+ # end
34
+ #
35
+ # def set_alarm
36
+ # ...
37
+ # end
38
+ # end
39
+ #
40
+ # When bound to the object application-wide:
41
+ #
42
+ # StateMachine::Callback.bind_to_object = true
43
+ #
44
+ # class Vehicle
45
+ # state_machine do
46
+ # before_transition do
47
+ # self.set_alarm
48
+ # end
49
+ # end
50
+ #
51
+ # def set_alarm
52
+ # ...
53
+ # end
54
+ # end
55
+ attr_accessor :bind_to_object
56
+ end
57
+
58
+ # An optional block for determining whether to cancel the callback chain
59
+ # based on the return value of the callback. By default, the callback
60
+ # chain never cancels based on the return value (i.e. there is no implicit
61
+ # terminator). Certain integrations, such as ActiveRecord, change this
62
+ # default value.
63
+ #
64
+ # == Examples
65
+ #
66
+ # Canceling the callback chain without a terminator:
67
+ #
68
+ # class Vehicle
69
+ # state_machine do
70
+ # before_transition do |vehicle|
71
+ # throw :halt
72
+ # end
73
+ # end
74
+ # end
75
+ #
76
+ # Canceling the callback chain with a terminator value of +false+:
77
+ #
78
+ # class Vehicle
79
+ # state_machine do
80
+ # before_transition do |vehicle|
81
+ # false
82
+ # end
83
+ # end
84
+ # end
85
+ attr_reader :terminator
86
+
87
+ # The guard that determines whether or not this callback can be invoked
88
+ # based on the context of the transition. The event, from state, and
89
+ # to state must all match in order for the guard to pass.
90
+ #
91
+ # See StateMachine::Guard for more information.
92
+ attr_reader :guard
93
+
94
+ # Creates a new callback that can get called based on the configured
95
+ # options.
96
+ #
97
+ # In addition to the possible configuration options for guards, the
98
+ # following options can be configured:
99
+ # * +bind_to_object+ - Whether to bind the callback to the object involved. If set to false, the object will be passed as a parameter instead. Default is integration-specific or set to the application default.
100
+ # * +terminator+ - A block/proc that determines what callback results should cause the callback chain to halt (if not using the default <tt>throw :halt</tt> technique).
101
+ #
102
+ # More information about how those options affect the behavior of the
103
+ # callback can be found in their attr_accessor definitions.
104
+ def initialize(options = {}, &block) #:nodoc:
105
+ if options.is_a?(Hash)
106
+ @method = options.delete(:do) || block
107
+ else
108
+ # Only the callback was configured
109
+ @method = options
110
+ options = {}
111
+ end
112
+
113
+ # The actual method to invoke must be defined
114
+ raise ArgumentError, ':do callback must be specified' unless @method
115
+
116
+ # Proxy the method so that it's bound to the object
117
+ @method = bound_method(@method) if @method.is_a?(Proc) && (!options.include?(:bind_to_object) && self.class.bind_to_object || options.delete(:bind_to_object))
118
+ @terminator = options.delete(:terminator)
119
+
120
+ @guard = Guard.new(options)
121
+ end
122
+
123
+ # Gets a list of the states known to this callback by looking at the
124
+ # guard's requirements
125
+ def known_states
126
+ guard.known_states
127
+ end
128
+
129
+ # Runs the callback as long as the transition context matches the guard
130
+ # requirements configured for this callback.
131
+ def call(object, context = {}, *args)
132
+ # Only evaluate the method if the guard passes
133
+ if @guard.matches?(object, context)
134
+ result = evaluate_method(object, @method, *args)
135
+
136
+ # If a terminator has been configured and it matches the result from
137
+ # the evaluated method, then the callback chain should be halted
138
+ if @terminator && @terminator.call(result)
139
+ throw :halt
140
+ else
141
+ result
142
+ end
143
+ end
144
+ end
145
+
146
+ private
147
+ # Generates an method that can be bound to the object being transitioned
148
+ # when the callback is invoked
149
+ def bound_method(block)
150
+ # Generate a thread-safe unbound method that can be used on any object
151
+ unbound_method = Object.class_eval do
152
+ time = Time.now
153
+ method_name = "__bind_#{time.to_i}_#{time.usec}"
154
+ define_method(method_name, &block)
155
+ method = instance_method(method_name)
156
+ remove_method(method_name)
157
+ method
158
+ end
159
+ arity = unbound_method.arity
160
+
161
+ # Proxy calls to the method so that the method can be bound *and*
162
+ # the arguments are adjusted
163
+ lambda do |object, *args|
164
+ unbound_method.bind(object).call(*(arity == 0 ? [] : args))
165
+ end
166
+ end
167
+ end
168
+ end