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.
data/CHANGELOG ADDED
@@ -0,0 +1,101 @@
1
+ *SVN*
2
+
3
+ *0.1.0* (May 5th, 2008)
4
+
5
+ * Completely rewritten from scratch
6
+
7
+ * Renamed to state_machine
8
+
9
+ * Removed database dependencies
10
+
11
+ * Removed models in favor of an attribute-agnostic design
12
+
13
+ * Use ActiveSupport::Callbacks instead of eval_call
14
+
15
+ * Remove dry_transaction_rollbacks dependencies
16
+
17
+ * Added functional tests
18
+
19
+ * Updated documentation
20
+
21
+ *0.0.1* (September 26th, 2007)
22
+
23
+ * Add dependency on custom_callbacks
24
+
25
+ * Move test fixtures out of the test application root directory
26
+
27
+ * Improve documentation
28
+
29
+ * Remove the StateExtension module in favor of adding singleton methods to the stateful class
30
+
31
+ * Convert dos newlines to unix newlines
32
+
33
+ * Fix error message when a given event can't be found in the database
34
+
35
+ * Add before_#{action} and #{action} callbacks when an event is performed
36
+
37
+ * All state and event callbacks can now explicitly return false in order to cancel the action
38
+
39
+ * Refactor ActiveState callback creation
40
+
41
+ * Refactor unit tests so that they use mock classes instead of themselves
42
+
43
+ * Allow force_reload option to be set in the state association
44
+
45
+ * Don't save the entire model when updating the state_id
46
+
47
+ * Raise exception if a class tries to define a state more than once
48
+
49
+ * Add tests for PluginAWeek::Has::States::ActiveState
50
+
51
+ * Refactor active state/active event creation
52
+
53
+ * Fix owner_type not being set correctly in active states/events of subclasses
54
+
55
+ * Allow subclasses to override the initial state
56
+
57
+ * Fix problem with migrations using default null when column cannot be null
58
+
59
+ * Moved deadline support into a separate plugin (has_state_deadlines).
60
+
61
+ * Added many more unit tests.
62
+
63
+ * Simplified many of the interfaces for maintainability.
64
+
65
+ * Added support for turning off recording state changes.
66
+
67
+ * Removed the short_description and long_description columns, in favor of an optional human_name column.
68
+
69
+ * Fixed not overriding the correct equality methods in the StateTransition class.
70
+
71
+ * Added to_sym to State and Event.
72
+
73
+ * State#name and Event#name now return the string version of the name instead of the symbol version.
74
+
75
+ * Added State#human_name and Event#human_name to automatically figure out what the human name is if it isn't specified in the table.
76
+
77
+ * Updated manual rollbacks to use the new Rails edge api (ActiveRecord::Rollback exception).
78
+
79
+ * Moved StateExtension class into a separate file in order to help keep the has_state files clean.
80
+
81
+ * Renamed InvalidState and InvalidEvent exceptions to StateNotFound and EventNotFound in order to follow the ActiveRecord convention (i.e. RecordNotFound).
82
+
83
+ * Added StateNotActive and EventNotActive exceptions to help differentiate between states which don't exist and states which weren't defined in the class.
84
+
85
+ * Added support for defining callbacks like so:
86
+
87
+ def before_exit_parked
88
+ end
89
+
90
+ def after_enter_idling
91
+ end
92
+
93
+ * Added support for defining callbacks using class methods:
94
+
95
+ before_exit_parked :fasten_seatbelt
96
+
97
+ * Added event callbacks after the transition has occurred (e.g. after_park)
98
+
99
+ * State callbacks no longer receive any of the arguments that were provided in the event action
100
+
101
+ * Updated license to include our names.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2006 Scott Barron, 2006-2008 Aaron Pfefier
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,97 @@
1
+ == state_machine
2
+
3
+ +state_machine+ support for creating state machines for attributes within a model.
4
+
5
+ == Resources
6
+
7
+ Wiki
8
+
9
+ * http://wiki.pluginaweek.org/State_machine
10
+
11
+ API
12
+
13
+ * http://api.pluginaweek.org/state_machine
14
+
15
+ Development
16
+
17
+ * http://dev.pluginaweek.org/browser/trunk/state_machine
18
+
19
+ Source
20
+
21
+ * http://svn.pluginaweek.org/trunk/state_machine
22
+
23
+ == Description
24
+
25
+ State machines make it dead-simple to manage the behavior of a model. Too often,
26
+ the status of a record is kept by creating multiple boolean columns in the table
27
+ and deciding how to behave based on the values in those columns. This can become
28
+ cumbersome and difficult to maintain when the complexity of your models starts to
29
+ increase.
30
+
31
+ +state_machine+ simplifies this design by introducing the various parts of a state
32
+ machine, including states, events, and transitions. However, its api is designed
33
+ to be similar to ActiveRecord in terms of validations and callbacks, making it
34
+ so simple you don't even need to know what a state machine is :)
35
+
36
+ == Usage
37
+
38
+ === Example
39
+
40
+ class Vehicle < ActiveRecord::Base
41
+ state_machine :state, :initial => 'idling' do
42
+ before_exit 'parked', :put_on_seatbelt
43
+ after_enter 'parked', Proc.new {|vehicle| vehicle.update_attribute(:seatbelt_on, false)}
44
+
45
+ event :park do
46
+ transition :to => 'parked', :from => %w(idling first_gear)
47
+ end
48
+
49
+ event :ignite do
50
+ transition :to => 'stalled', :from => 'stalled'
51
+ transition :to => 'idling', :from => 'parked'
52
+ end
53
+
54
+ event :idle do
55
+ transition :to => 'idling', :from => 'first_gear'
56
+ end
57
+
58
+ event :shift_up do
59
+ transition :to => 'first_gear', :from => 'idling'
60
+ transition :to => 'second_gear', :from => 'first_gear'
61
+ transition :to => 'third_gear', :from => 'second_gear'
62
+ end
63
+
64
+ event :shift_down do
65
+ transition :to => 'second_gear', :from => 'third_gear'
66
+ transition :to => 'first_gear', :from => 'second_gear'
67
+ end
68
+
69
+ event :crash, :after => :tow! do
70
+ transition :to => 'stalled', :from => %w(first_gear second_gear third_gear), :unless => :auto_shop_busy?
71
+ end
72
+
73
+ event :repair, :after => :fix! do
74
+ transition :to => 'parked', :from => 'stalled', :if => :auto_shop_busy?
75
+ end
76
+ end
77
+ end
78
+
79
+ == Tools
80
+
81
+ Jean Bovet - {Visual Automata Simulator}[http://www.cs.usfca.edu/~jbovet/vas.html].
82
+ This is a great tool for "simulating, visualizing and transforming finite state
83
+ automata and Turing Machines". This tool can help in the creation of states and
84
+ events for your models. It is cross-platform, written in Java.
85
+
86
+ == Dependencies
87
+
88
+ None.
89
+
90
+ == Testing
91
+
92
+ Before you can run any tests, the following gem must be installed:
93
+ * plugin_test_helper[http://wiki.pluginaweek.org/Plugin_test_helper]
94
+
95
+ == References
96
+
97
+ * Scott Barron - acts_as_state_machine[http://elitists.textdriven.com/svn/plugins/acts_as_state_machine]
data/Rakefile ADDED
@@ -0,0 +1,79 @@
1
+ require 'rake/testtask'
2
+ require 'rake/rdoctask'
3
+ require 'rake/gempackagetask'
4
+ require 'rake/contrib/sshpublisher'
5
+
6
+ PKG_NAME = 'state_machine'
7
+ PKG_VERSION = '0.1.0'
8
+ PKG_FILE_NAME = "#{PKG_NAME}-#{PKG_VERSION}"
9
+ RUBY_FORGE_PROJECT = 'pluginaweek'
10
+
11
+ desc 'Default: run unit tests.'
12
+ task :default => :test
13
+
14
+ desc 'Test the state_machine plugin.'
15
+ Rake::TestTask.new(:test) do |t|
16
+ t.libs << 'lib'
17
+ t.pattern = 'test/**/*_test.rb'
18
+ t.verbose = true
19
+ end
20
+
21
+ desc 'Generate documentation for the state_machine plugin.'
22
+ Rake::RDocTask.new(:rdoc) do |rdoc|
23
+ rdoc.rdoc_dir = 'rdoc'
24
+ rdoc.title = 'StateMachine'
25
+ rdoc.options << '--line-numbers' << '--inline-source'
26
+ rdoc.rdoc_files.include('README')
27
+ rdoc.rdoc_files.include('lib/**/*.rb')
28
+ end
29
+
30
+ spec = Gem::Specification.new do |s|
31
+ s.name = PKG_NAME
32
+ s.version = PKG_VERSION
33
+ s.platform = Gem::Platform::RUBY
34
+ s.summary = 'Adds support for creating state machines for attributes within a model'
35
+
36
+ s.files = FileList['{lib,test}/**/*'].to_a + %w(CHANGELOG init.rb MIT-LICENSE Rakefile README)
37
+ s.require_path = 'lib'
38
+ s.autorequire = 'state_machine'
39
+ s.has_rdoc = true
40
+ s.test_files = Dir['test/**/*_test.rb']
41
+
42
+ s.author = 'Aaron Pfeifer'
43
+ s.email = 'aaron@pluginaweek.org'
44
+ s.homepage = 'http://www.pluginaweek.org'
45
+ end
46
+
47
+ Rake::GemPackageTask.new(spec) do |p|
48
+ p.gem_spec = spec
49
+ p.need_tar = true
50
+ p.need_zip = true
51
+ end
52
+
53
+ desc 'Publish the beta gem'
54
+ task :pgem => [:package] do
55
+ Rake::SshFilePublisher.new('aaron@pluginaweek.org', '/home/aaron/gems.pluginaweek.org/public/gems', 'pkg', "#{PKG_FILE_NAME}.gem").upload
56
+ end
57
+
58
+ desc 'Publish the API documentation'
59
+ task :pdoc => [:rdoc] do
60
+ Rake::SshDirPublisher.new('aaron@pluginaweek.org', "/home/aaron/api.pluginaweek.org/public/#{PKG_NAME}", 'rdoc').upload
61
+ end
62
+
63
+ desc 'Publish the API docs and gem'
64
+ task :publish => [:pdoc, :release]
65
+
66
+ desc 'Publish the release files to RubyForge.'
67
+ task :release => [:gem, :package] do
68
+ require 'rubyforge'
69
+
70
+ ruby_forge = RubyForge.new
71
+ ruby_forge.login
72
+
73
+ %w( gem tgz zip ).each do |ext|
74
+ file = "pkg/#{PKG_FILE_NAME}.#{ext}"
75
+ puts "Releasing #{File.basename(file)}..."
76
+
77
+ ruby_forge.add_release(RUBY_FORGE_PROJECT, PKG_NAME, PKG_VERSION, file)
78
+ end
79
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'state_machine'
@@ -0,0 +1,92 @@
1
+ require 'state_machine/machine'
2
+
3
+ module PluginAWeek #:nodoc:
4
+ # A state machine is a model of behavior composed of states, transitions,
5
+ # and events. This helper adds support for defining this type of
6
+ # functionality within your ActiveRecord models.
7
+ module StateMachine
8
+ def self.included(base) #:nodoc:
9
+ base.class_eval do
10
+ extend PluginAWeek::StateMachine::MacroMethods
11
+ end
12
+ end
13
+
14
+ module MacroMethods
15
+ # Creates a state machine for the given attribute.
16
+ #
17
+ # Configuration options:
18
+ # * +initial+ - The initial value of the attribute. This can either be the actual value or a Proc for dynamic initial states.
19
+ #
20
+ # == Example
21
+ #
22
+ # With a static state:
23
+ #
24
+ # class Switch < ActiveRecord::Base
25
+ # state_machine :state, :initial => 'off' do
26
+ # ...
27
+ # end
28
+ # end
29
+ #
30
+ # With a dynamic state:
31
+ #
32
+ # class Switch < ActiveRecord::Base
33
+ # state_machine :state, :initial => Proc.new {|switch| (8..22).include?(Time.now.hour) ? 'on' : 'off'} do
34
+ # ...
35
+ # end
36
+ # end
37
+ def state_machine(attribute, options = {}, &block)
38
+ unless included_modules.include?(PluginAWeek::StateMachine::InstanceMethods)
39
+ write_inheritable_attribute :state_machines, {}
40
+ class_inheritable_reader :state_machines
41
+
42
+ after_create :run_initial_state_machine_actions
43
+
44
+ include PluginAWeek::StateMachine::InstanceMethods
45
+ end
46
+
47
+ # This will create a new machine for subclasses as well so that the owner_class and
48
+ # initial state can be overridden
49
+ attribute = attribute.to_s
50
+ options[:initial] = state_machines[attribute].initial_state_without_processing if !options.include?(:initial) && state_machines[attribute]
51
+ machine = state_machines[attribute] = PluginAWeek::StateMachine::Machine.new(self, attribute, options)
52
+ machine.instance_eval(&block) if block
53
+ machine
54
+ end
55
+ end
56
+
57
+ module InstanceMethods
58
+ def self.included(base) #:nodoc:
59
+ base.class_eval do
60
+ alias_method_chain :initialize, :state_machine
61
+ end
62
+ end
63
+
64
+ # Defines the initial values for state machine attributes
65
+ def initialize_with_state_machine(attributes = nil)
66
+ initialize_without_state_machine(attributes)
67
+
68
+ attribute_keys = (attributes || {}).keys.map!(&:to_s)
69
+
70
+ self.class.state_machines.each do |attribute, machine|
71
+ unless attribute_keys.include?(attribute)
72
+ send("#{attribute}=", machine.initial_state(self))
73
+ end
74
+ end
75
+
76
+ yield self if block_given?
77
+ end
78
+
79
+ # Records the transition for the record going into its initial state
80
+ def run_initial_state_machine_actions
81
+ self.class.state_machines.each do |attribute, machine|
82
+ callback = "after_enter_#{attribute}_#{self[attribute]}"
83
+ run_callbacks(callback) if self.class.respond_to?(callback)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ ActiveRecord::Base.class_eval do
91
+ include PluginAWeek::StateMachine
92
+ end
@@ -0,0 +1,127 @@
1
+ require 'state_machine/transition'
2
+
3
+ module PluginAWeek #:nodoc:
4
+ module StateMachine
5
+ # An event defines an action that transitions an attribute from one state to
6
+ # another
7
+ class Event
8
+ # The state machine for which this event is defined
9
+ attr_reader :machine
10
+
11
+ # The name of the action that fires the event
12
+ attr_reader :name
13
+
14
+ delegate :owner_class,
15
+ :to => :machine
16
+
17
+ # Creates a new event with the given name
18
+ def initialize(machine, name, options = {})
19
+ options.assert_valid_keys(:before, :after)
20
+
21
+ @machine = machine
22
+ @name = name
23
+ @options = options.stringify_keys
24
+
25
+ add_transition_action
26
+ add_transition_callbacks
27
+ add_event_callbacks
28
+ end
29
+
30
+ # Creates a new transition to the specified state.
31
+ #
32
+ # Configuration options:
33
+ # * +to+ - The state that being transitioned to
34
+ # * +from+ - A state or array of states that can be transitioned from
35
+ # * +if+ - Specifies a method, proc or string to call to determine if the validation should occur (e.g. :if => :moving?, or :if => Proc.new {|car| car.speed > 60}). The method, proc or string should return or evaluate to a true or false value.
36
+ # * +unless+ - Specifies a method, proc or string to call to determine if the transition should not occur (e.g. :unless => :stopped?, or :unless => Proc.new {|car| car.speed <= 60}). The method, proc or string should return or evaluate to a true or false value.
37
+ #
38
+ # == Examples
39
+ #
40
+ # transition :to => 'parked', :from => 'first_gear'
41
+ # transition :to => 'parked', :from => %w(first_gear reverse)
42
+ # transition :to => 'parked', :from => 'first_gear', :if => :moving?
43
+ # transition :to => 'parked', :from => 'first_gear', :unless => :stopped?
44
+ def transition(options = {})
45
+ options.symbolize_keys!
46
+ options.assert_valid_keys(:to, :from, :if, :unless)
47
+ raise ArgumentError, ':to state must be specified' unless options.include?(:to)
48
+
49
+ to_state = options.delete(:to)
50
+ from_states = Array(options.delete(:from))
51
+ from_states.collect do |from_state|
52
+ # Create the actual transition that will update records when run
53
+ transition = Transition.new(self, from_state, to_state)
54
+
55
+ # The callback that will be invoked when the event is run. If the callback
56
+ # fails, then the next available callback for the event will run until
57
+ # one is successful.
58
+ callback = Proc.new do |record, *args|
59
+ transition.can_perform_on?(record) &&
60
+ invoke_event_callbacks(:before, record, *args) != false &&
61
+ transition.perform(record, *args) &&
62
+ invoke_event_callbacks(:after, record, *args) != false
63
+ end
64
+
65
+ # Add the callback to the model
66
+ owner_class.send("transition_on_#{name}", callback, options)
67
+
68
+ transition
69
+ end
70
+ end
71
+
72
+ # Attempts to transition to one of the next possible states for the given record
73
+ def fire!(record, *args)
74
+ success = false
75
+ record.class.transaction {success = invoke_transition_callbacks(record, *args) == true || raise(ActiveRecord::Rollback)}
76
+ success
77
+ end
78
+
79
+ private
80
+ # Add action for transitioning the record
81
+ def add_transition_action
82
+ owner_class.class_eval <<-end_eval
83
+ def #{name}!(*args)
84
+ #{owner_class}.state_machines['#{machine.attribute}'].events['#{name}'].fire!(self, *args)
85
+ end
86
+ end_eval
87
+ end
88
+
89
+ # Defines callbacks for invoking transitions when this event is performed
90
+ def add_transition_callbacks
91
+ owner_class.define_callbacks("transition_on_#{name}")
92
+ end
93
+
94
+ # Adds the before/after callbacks for when the event is performed
95
+ def add_event_callbacks
96
+ %w(before after).each do |type|
97
+ callback_name = "#{type}_#{name}"
98
+ owner_class.define_callbacks(callback_name)
99
+
100
+ # Add each defined callback
101
+ Array(@options[type]).each {|callback| owner_class.send(callback_name, callback)}
102
+ end
103
+ end
104
+
105
+ # Invokes a particulary type of callbacks for the event
106
+ def invoke_event_callbacks(type, record, *args)
107
+ args = [record] + args
108
+
109
+ record.class.send("#{type}_#{name}_callback_chain").each do |callback|
110
+ result = callback.call(*args)
111
+ break result if result == false
112
+ end
113
+ end
114
+
115
+ # Invokes the callbacks for each transition in order to find one that
116
+ # completes successfully
117
+ def invoke_transition_callbacks(record, *args)
118
+ args = [record] + args
119
+
120
+ record.class.send("transition_on_#{name}_callback_chain").each do |callback|
121
+ result = callback.call(*args)
122
+ break result if result == true
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end