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