state_machine 0.1.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.
@@ -0,0 +1,141 @@
1
+ require 'state_machine/event'
2
+
3
+ module PluginAWeek #:nodoc:
4
+ module StateMachine
5
+ # Represents a state machine for a particular attribute
6
+ #
7
+ # == State callbacks
8
+ #
9
+ # These callbacks are invoked in the following order:
10
+ # 1. before_exit (old state)
11
+ # 2. before_enter (new state)
12
+ # 3. after_exit (old state)
13
+ # 4. after_enter (new state)
14
+ class Machine
15
+ # The events that trigger transitions
16
+ attr_reader :events
17
+
18
+ # The attribute for which the state machine is being defined
19
+ attr_accessor :attribute
20
+
21
+ # The initial state that the machine will be in
22
+ attr_reader :initial_state
23
+
24
+ # The class that the attribute belongs to
25
+ attr_reader :owner_class
26
+
27
+ # Creates a new state machine for the given attribute
28
+ #
29
+ # Configuration options:
30
+ # * +initial+ - The initial value to set the attribute to
31
+ #
32
+ # == Scopes
33
+ #
34
+ # This will automatically created a named scope called with_#{attribute}
35
+ # that will find all records that have the attribute set to a given value.
36
+ # For example,
37
+ #
38
+ # Switch.with_state('on') # => Finds all switches where the state is on
39
+ # Switch.with_states('on', 'off') # => Finds all switches where the state is either on or off
40
+ def initialize(owner_class, attribute, options = {})
41
+ options.assert_valid_keys(:initial)
42
+
43
+ @owner_class = owner_class
44
+ @attribute = attribute.to_s
45
+ @initial_state = options[:initial]
46
+ @events = {}
47
+
48
+ add_named_scopes
49
+ end
50
+
51
+ # Gets the initial state of the machine for the given record. The record
52
+ # is only used if a dynamic initial state is being used
53
+ def initial_state(record)
54
+ @initial_state.is_a?(Proc) ? @initial_state.call(record) : @initial_state
55
+ end
56
+
57
+ # Gets the initial state without processing it against a particular record
58
+ def initial_state_without_processing
59
+ @initial_state
60
+ end
61
+
62
+ # Defines an event of the system. This can take an optional hash that
63
+ # defines callbacks which will be invoked when the object enters/exits
64
+ # the event.
65
+ #
66
+ # Configuration options:
67
+ # * +before+ - Invoked before the event has been executed
68
+ # * +after+ - Invoked after the event has been executed
69
+ #
70
+ # == Callback order
71
+ #
72
+ # These callbacks are invoked in the following order:
73
+ # 1. before
74
+ # 2. after
75
+ #
76
+ # == Instance methods
77
+ #
78
+ # The following instance methods are generated when a new event is defined
79
+ # (the "park" event is used as an example):
80
+ # * <tt>park!(*args)</tt> - Fires the "park" event, transitioning from the current state to the next valid state. This takes an optional +args+ list which is passed to the event callbacks.
81
+ #
82
+ # == Defining transitions
83
+ #
84
+ # +event+ requires a block which allows you to define the possible
85
+ # transitions that can happen as a result of that event. For example,
86
+ #
87
+ # event :park do
88
+ # transition :to => 'parked', :from => 'idle'
89
+ # end
90
+ #
91
+ # event :first_gear do
92
+ # transition :to => 'first_gear', :from => 'parked', :if => :seatbelt_on?
93
+ # end
94
+ #
95
+ # See PluginAWeek::StateMachine::Event#transition for more information on
96
+ # the possible options that can be passed in.
97
+ #
98
+ # == Example
99
+ #
100
+ # class Car < ActiveRecord::Base
101
+ # state_machine(:state, :initial => 'parked') do
102
+ # event :park, :after => :release_seatbelt do
103
+ # transition :to => 'parked', :from => %w(first_gear reverse)
104
+ # end
105
+ # ...
106
+ # end
107
+ # end
108
+ def event(name, options = {}, &block)
109
+ name = name.to_s
110
+ event = events[name] = Event.new(self, name, options)
111
+ event.instance_eval(&block)
112
+ event
113
+ end
114
+
115
+ # Define state callbacks
116
+ %w(before_exit before_enter after_exit after_enter).each do |callback|
117
+ module_eval <<-end_eval
118
+ def #{callback}(state, callback)
119
+ callback_name = "#{callback}_\#{attribute}_\#{state}"
120
+ owner_class.define_callbacks(callback_name)
121
+ owner_class.send(callback_name, callback)
122
+ end
123
+ end_eval
124
+ end
125
+
126
+ private
127
+ def add_named_scopes
128
+ unless owner_class.respond_to?("with_#{attribute}")
129
+ # How do you alias named scopes? (doesn't work completely with a simple alias/alias_method)
130
+ %W(with_#{attribute} with_#{attribute.pluralize}).each do |scope_name|
131
+ owner_class.class_eval <<-end_eos
132
+ named_scope :#{scope_name}, Proc.new {|*values| {
133
+ :conditions => {:#{attribute} => values.flatten}
134
+ }}
135
+ end_eos
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,75 @@
1
+ module PluginAWeek #:nodoc:
2
+ module StateMachine
3
+ # A transition indicates a state change and is described by a condition
4
+ # that would need to be fulfilled to enable the transition. Transitions
5
+ # consist of:
6
+ # * The starting state
7
+ # * The ending state
8
+ # * A guard to check if the transition is allowed
9
+ class Transition
10
+ # The state from which the transition is being made
11
+ attr_reader :from_state
12
+
13
+ # The state to which the transition is being made
14
+ attr_reader :to_state
15
+
16
+ # The event that caused the transition
17
+ attr_reader :event
18
+
19
+ delegate :machine,
20
+ :to => :event
21
+
22
+ def initialize(event, from_state, to_state) #:nodoc:
23
+ @event = event
24
+ @from_state = from_state
25
+ @to_state = to_state
26
+ @loopback = from_state == to_state
27
+ end
28
+
29
+ # Whether or not this is a loopback transition (i.e. from and to state are the same)
30
+ def loopback?(state = from_state)
31
+ state == to_state
32
+ end
33
+
34
+ # Determines whether or not this transition can be performed on the given
35
+ # states
36
+ def can_perform_on?(record)
37
+ !from_state || from_state == record.send(machine.attribute)
38
+ end
39
+
40
+ # Runs the actual transition and any actions associated with entering
41
+ # and exiting the states
42
+ def perform(record, *args)
43
+ state = record.send(machine.attribute)
44
+
45
+ invoke_before_callbacks(state, record) != false &&
46
+ update_state(state, record) &&
47
+ invoke_after_callbacks(state, record) != false
48
+ end
49
+
50
+ private
51
+ def update_state(from_state, record)
52
+ loopback?(from_state) ? true : record.update_attribute(machine.attribute, to_state)
53
+ end
54
+
55
+ def invoke_before_callbacks(from_state, record)
56
+ # Start leaving the last state and start entering the next state
57
+ loopback?(from_state) || invoke_callbacks(:before_exit, from_state, record) && invoke_callbacks(:before_enter, to_state, record)
58
+ end
59
+
60
+ def invoke_after_callbacks(from_state, record)
61
+ # Start leaving the last state and start entering the next state
62
+ loopback?(from_state) || invoke_callbacks(:after_exit, from_state, record) && invoke_callbacks(:after_enter, to_state, record)
63
+ end
64
+
65
+ def invoke_callbacks(type, state, record)
66
+ kind = "#{type}_#{machine.attribute}_#{state}"
67
+ if record.class.respond_to?("#{kind}_callback_chain")
68
+ record.run_callbacks(kind) {|result, record| result == false}
69
+ else
70
+ true
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,34 @@
1
+ class AutoShop < ActiveRecord::Base
2
+ state_machine :state, :initial => 'available' do
3
+ after_exit 'available', :increment_customers
4
+ after_exit 'busy', :decrement_customers
5
+
6
+ event :tow_vehicle do
7
+ transition :to => 'busy', :from => 'available'
8
+ end
9
+
10
+ event :fix_vehicle do
11
+ transition :to => 'available', :from => 'busy'
12
+ end
13
+ end
14
+
15
+ # Is the Auto Shop available for new customers?
16
+ def available?
17
+ state == 'available'
18
+ end
19
+
20
+ # Is the Auto Shop currently not taking new customers?
21
+ def busy?
22
+ state == 'busy'
23
+ end
24
+
25
+ # Increments the number of customers in service
26
+ def increment_customers
27
+ update_attribute(:num_customers, num_customers + 1)
28
+ end
29
+
30
+ # Decrements the number of customers in service
31
+ def decrement_customers
32
+ update_attribute(:num_customers, num_customers - 1)
33
+ end
34
+ end
@@ -0,0 +1,19 @@
1
+ class Car < Vehicle
2
+ state_machine :state do
3
+ event :reverse do
4
+ transition :to => 'backing_up', :from => %w(parked idling first_gear)
5
+ end
6
+
7
+ event :park do
8
+ transition :to => 'parked', :from => 'backing_up'
9
+ end
10
+
11
+ event :idle do
12
+ transition :to => 'idling', :from => 'backing_up'
13
+ end
14
+
15
+ event :shift_up do
16
+ transition :to => 'first_gear', :from => 'backing_up'
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,3 @@
1
+ class Highway < ActiveRecord::Base
2
+ has_many :vehicles
3
+ end
@@ -0,0 +1,3 @@
1
+ class Motorcycle < Vehicle
2
+ state_machine :state, :initial => 'idling'
3
+ end
@@ -0,0 +1,12 @@
1
+ class Switch < ActiveRecord::Base
2
+ # Tracks the callbacks that were invoked
3
+ attr_reader :callbacks
4
+
5
+ # Dynamic sets the initial state
6
+ attr_accessor :initial_state
7
+
8
+ def initialize(attributes = nil)
9
+ @callbacks = []
10
+ super
11
+ end
12
+ end
@@ -0,0 +1,2 @@
1
+ class ToggleSwitch < Switch
2
+ end
@@ -0,0 +1,71 @@
1
+ class Vehicle < ActiveRecord::Base
2
+ belongs_to :auto_shop
3
+ belongs_to :highway
4
+
5
+ attr_accessor :force_idle
6
+
7
+ # Defines the state machine for the state of the vehicle
8
+ state_machine :state, :initial => Proc.new {|vehicle| vehicle.force_idle ? 'idling' : 'parked'} do
9
+ before_exit 'parked', :put_on_seatbelt
10
+ after_enter 'parked', Proc.new {|vehicle| vehicle.update_attribute(:seatbelt_on, false)}
11
+ before_enter 'stalled', :increase_insurance_premium
12
+
13
+ event :park do
14
+ transition :to => 'parked', :from => %w(idling first_gear)
15
+ end
16
+
17
+ event :ignite do
18
+ transition :to => 'stalled', :from => 'stalled'
19
+ transition :to => 'idling', :from => 'parked'
20
+ end
21
+
22
+ event :idle do
23
+ transition :to => 'idling', :from => 'first_gear'
24
+ end
25
+
26
+ event :shift_up do
27
+ transition :to => 'first_gear', :from => 'idling'
28
+ transition :to => 'second_gear', :from => 'first_gear'
29
+ transition :to => 'third_gear', :from => 'second_gear'
30
+ end
31
+
32
+ event :shift_down do
33
+ transition :to => 'second_gear', :from => 'third_gear'
34
+ transition :to => 'first_gear', :from => 'second_gear'
35
+ end
36
+
37
+ event :crash, :after => :tow! do
38
+ transition :to => 'stalled', :from => %w(first_gear second_gear third_gear), :if => Proc.new {|vehicle| vehicle.auto_shop.available?}
39
+ end
40
+
41
+ event :repair, :after => :fix! do
42
+ transition :to => 'parked', :from => 'stalled', :if => :auto_shop_busy?
43
+ end
44
+ end
45
+
46
+ # Tows the vehicle to the auto shop
47
+ def tow!
48
+ auto_shop.tow_vehicle!
49
+ end
50
+
51
+ # Fixes the vehicle; it will no longer be in the auto shop
52
+ def fix!
53
+ auto_shop.fix_vehicle!
54
+ end
55
+
56
+ private
57
+ # Safety first! Puts on our seatbelt
58
+ def put_on_seatbelt
59
+ self.seatbelt_on = true
60
+ end
61
+
62
+ # We crashed! Increase the insurance premium on the vehicle
63
+ def increase_insurance_premium
64
+ update_attribute(:insurance_premium, self.insurance_premium + 100)
65
+ end
66
+
67
+ # Is the auto shop currently servicing another customer?
68
+ def auto_shop_busy?
69
+ auto_shop.busy?
70
+ end
71
+ end
@@ -0,0 +1,12 @@
1
+ class CreateSwitches < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :switches do |t|
4
+ t.string :state, :null => false
5
+ t.string :kind
6
+ end
7
+ end
8
+
9
+ def self.down
10
+ drop_table :switches
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ class CreateAutoShops < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :auto_shops do |t|
4
+ t.string :name, :null => false
5
+ t.integer :num_customers, :null => false
6
+ t.string :state, :null => false
7
+ end
8
+ end
9
+
10
+ def self.down
11
+ drop_table :auto_shops
12
+ end
13
+ end
@@ -0,0 +1,11 @@
1
+ class CreateHighways < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :highways do |t|
4
+ t.string :name, :null => false
5
+ end
6
+ end
7
+
8
+ def self.down
9
+ drop_table :highways
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ class CreateVehicles < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :vehicles do |t|
4
+ t.references :highway, :null => false
5
+ t.references :auto_shop, :null => false
6
+ t.boolean :seatbelt_on, :null => false
7
+ t.integer :insurance_premium, :null => false
8
+ t.string :state, :null => false
9
+ t.string :type
10
+ end
11
+ end
12
+
13
+ def self.down
14
+ drop_table :vehicles
15
+ end
16
+ end
data/test/factory.rb ADDED
@@ -0,0 +1,61 @@
1
+ module Factory
2
+ # Build actions for the class
3
+ def self.build(klass, &block)
4
+ name = klass.to_s.underscore
5
+ define_method("#{name}_attributes", block)
6
+
7
+ module_eval <<-end_eval
8
+ def valid_#{name}_attributes(attributes = {})
9
+ #{name}_attributes(attributes)
10
+ attributes
11
+ end
12
+
13
+ def new_#{name}(attributes = {})
14
+ #{klass}.new(valid_#{name}_attributes(attributes))
15
+ end
16
+
17
+ def create_#{name}(*args)
18
+ record = new_#{name}(*args)
19
+ record.save!
20
+ record.reload
21
+ record
22
+ end
23
+ end_eval
24
+ end
25
+
26
+ build AutoShop do |attributes|
27
+ attributes.reverse_merge!(
28
+ :name => "Joe's Auto Body",
29
+ :num_customers => 0
30
+ )
31
+ end
32
+
33
+ build Car do |attributes|
34
+ attributes[:highway] = create_highway unless attributes.include?(:highway)
35
+ attributes[:auto_shop] = create_auto_shop unless attributes.include?(:auto_shop)
36
+ attributes.reverse_merge!(
37
+ :seatbelt_on => false,
38
+ :insurance_premium => 50
39
+ )
40
+ end
41
+
42
+ build Highway do |attributes|
43
+ attributes.reverse_merge!(
44
+ :name => 'Route 66'
45
+ )
46
+ end
47
+
48
+ build Motorcycle do |attributes|
49
+ valid_car_attributes(attributes)
50
+ end
51
+
52
+ build Switch do |attributes|
53
+ attributes.reverse_merge!(
54
+ :state => 'off'
55
+ )
56
+ end
57
+
58
+ build Vehicle do |attributes|
59
+ valid_car_attributes(attributes)
60
+ end
61
+ end