state_machine 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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