state_machine 0.1.1 → 0.2.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.
@@ -1,93 +1,62 @@
1
- *SVN*
1
+ == master
2
2
 
3
- *0.1.1* (June 22nd, 2008)
3
+ == 0.2.0 / 2008-06-29
4
+
5
+ * Add a non-bang version of events (e.g. park) that will return a boolean value for success
6
+ * Raise an exception if the bang version of events are used (e.g. park!) and no transition is successful
7
+ * Change callbacks to act a little more like ActiveRecord
8
+ * Avoid using string evaluation for dynamic methods
9
+
10
+ == 0.1.1 / 2008-06-22
4
11
 
5
12
  * Remove log files from gems
6
13
 
7
- *0.1.0* (May 5th, 2008)
14
+ == 0.1.0 / 2008-05-05
8
15
 
9
16
  * Completely rewritten from scratch
10
-
11
17
  * Renamed to state_machine
12
-
13
18
  * Removed database dependencies
14
-
15
19
  * Removed models in favor of an attribute-agnostic design
16
-
17
20
  * Use ActiveSupport::Callbacks instead of eval_call
18
-
19
21
  * Remove dry_transaction_rollbacks dependencies
20
-
21
22
  * Added functional tests
22
-
23
23
  * Updated documentation
24
24
 
25
- *0.0.1* (September 26th, 2007)
25
+ == 0.0.1 / 2007-09-26
26
26
 
27
27
  * Add dependency on custom_callbacks
28
-
29
28
  * Move test fixtures out of the test application root directory
30
-
31
29
  * Improve documentation
32
-
33
30
  * Remove the StateExtension module in favor of adding singleton methods to the stateful class
34
-
35
31
  * Convert dos newlines to unix newlines
36
-
37
32
  * Fix error message when a given event can't be found in the database
38
-
39
33
  * Add before_#{action} and #{action} callbacks when an event is performed
40
-
41
34
  * All state and event callbacks can now explicitly return false in order to cancel the action
42
-
43
35
  * Refactor ActiveState callback creation
44
-
45
36
  * Refactor unit tests so that they use mock classes instead of themselves
46
-
47
37
  * Allow force_reload option to be set in the state association
48
-
49
38
  * Don't save the entire model when updating the state_id
50
-
51
39
  * Raise exception if a class tries to define a state more than once
52
-
53
40
  * Add tests for PluginAWeek::Has::States::ActiveState
54
-
55
41
  * Refactor active state/active event creation
56
-
57
42
  * Fix owner_type not being set correctly in active states/events of subclasses
58
-
59
43
  * Allow subclasses to override the initial state
60
-
61
44
  * Fix problem with migrations using default null when column cannot be null
62
-
63
45
  * Moved deadline support into a separate plugin (has_state_deadlines).
64
-
65
46
  * Added many more unit tests.
66
-
67
47
  * Simplified many of the interfaces for maintainability.
68
-
69
48
  * Added support for turning off recording state changes.
70
-
71
49
  * Removed the short_description and long_description columns, in favor of an optional human_name column.
72
-
73
50
  * Fixed not overriding the correct equality methods in the StateTransition class.
74
-
75
51
  * Added to_sym to State and Event.
76
-
77
52
  * State#name and Event#name now return the string version of the name instead of the symbol version.
78
-
79
53
  * 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.
80
-
81
54
  * Updated manual rollbacks to use the new Rails edge api (ActiveRecord::Rollback exception).
82
-
83
55
  * Moved StateExtension class into a separate file in order to help keep the has_state files clean.
84
-
85
56
  * Renamed InvalidState and InvalidEvent exceptions to StateNotFound and EventNotFound in order to follow the ActiveRecord convention (i.e. RecordNotFound).
86
-
87
57
  * Added StateNotActive and EventNotActive exceptions to help differentiate between states which don't exist and states which weren't defined in the class.
88
-
89
58
  * Added support for defining callbacks like so:
90
-
59
+
91
60
  def before_exit_parked
92
61
  end
93
62
 
@@ -95,11 +64,9 @@
95
64
  end
96
65
 
97
66
  * Added support for defining callbacks using class methods:
98
-
67
+
99
68
  before_exit_parked :fasten_seatbelt
100
69
 
101
70
  * Added event callbacks after the transition has occurred (e.g. after_park)
102
-
103
71
  * State callbacks no longer receive any of the arguments that were provided in the event action
104
-
105
72
  * Updated license to include our names.
File without changes
@@ -4,21 +4,21 @@
4
4
 
5
5
  == Resources
6
6
 
7
- Wiki
8
-
9
- * http://wiki.pluginaweek.org/State_machine
10
-
11
7
  API
12
8
 
13
9
  * http://api.pluginaweek.org/state_machine
14
10
 
11
+ Bugs
12
+
13
+ * http://pluginaweek.lighthouseapp.com/projects/13288-state_machine
14
+
15
15
  Development
16
16
 
17
- * http://dev.pluginaweek.org/browser/trunk/state_machine
17
+ * http://github.com/pluginaweek/state_machine
18
18
 
19
19
  Source
20
20
 
21
- * http://svn.pluginaweek.org/trunk/state_machine
21
+ * git://github.com/pluginaweek/state_machine.git
22
22
 
23
23
  == Description
24
24
 
@@ -86,7 +86,7 @@ events for your models. It is cross-platform, written in Java.
86
86
  == Testing
87
87
 
88
88
  Before you can run any tests, the following gem must be installed:
89
- * plugin_test_helper[http://wiki.pluginaweek.org/Plugin_test_helper]
89
+ * plugin_test_helper[http://github.com/pluginaweek/plugin_test_helper]
90
90
 
91
91
  To run against a specific version of Rails:
92
92
 
data/Rakefile CHANGED
@@ -3,46 +3,54 @@ require 'rake/rdoctask'
3
3
  require 'rake/gempackagetask'
4
4
  require 'rake/contrib/sshpublisher'
5
5
 
6
- PKG_NAME = 'state_machine'
7
- PKG_VERSION = '0.1.1'
8
- PKG_FILE_NAME = "#{PKG_NAME}-#{PKG_VERSION}"
9
- RUBY_FORGE_PROJECT = 'pluginaweek'
6
+ spec = Gem::Specification.new do |s|
7
+ s.name = 'state_machine'
8
+ s.version = '0.2.0'
9
+ s.platform = Gem::Platform::RUBY
10
+ s.summary = 'Adds support for creating state machines for attributes within a model'
11
+
12
+ s.files = FileList['{lib,test}/**/*'].to_a - FileList['test/app_root/log/*'].to_a + %w(CHANGELOG.rdoc init.rb LICENSE Rakefile README.rdoc)
13
+ s.require_path = 'lib'
14
+ s.has_rdoc = true
15
+ s.test_files = Dir['test/**/*_test.rb']
16
+
17
+ s.author = 'Aaron Pfeifer'
18
+ s.email = 'aaron@pluginaweek.org'
19
+ s.homepage = 'http://www.pluginaweek.org'
20
+ s.rubyforge_project = 'pluginaweek'
21
+ end
10
22
 
11
- desc 'Default: run unit tests.'
23
+ desc 'Default: run all tests.'
12
24
  task :default => :test
13
25
 
14
- desc 'Test the state_machine plugin.'
26
+ desc "Test the #{spec.name} plugin."
15
27
  Rake::TestTask.new(:test) do |t|
16
28
  t.libs << 'lib'
17
- t.pattern = 'test/**/*_test.rb'
29
+ t.test_files = spec.test_files
18
30
  t.verbose = true
19
31
  end
20
32
 
21
- desc 'Generate documentation for the state_machine plugin.'
33
+ begin
34
+ require 'rcov/rcovtask'
35
+ namespace :test do
36
+ desc "Test the #{spec.name} plugin with Rcov."
37
+ Rcov::RcovTask.new(:rcov) do |t|
38
+ t.libs << 'lib'
39
+ t.test_files = spec.test_files
40
+ t.rcov_opts << '--exclude="^(?!lib/)"'
41
+ t.verbose = true
42
+ end
43
+ end
44
+ rescue LoadError
45
+ end
46
+
47
+ desc "Generate documentation for the #{spec.name} plugin."
22
48
  Rake::RDocTask.new(:rdoc) do |rdoc|
23
49
  rdoc.rdoc_dir = 'rdoc'
24
- rdoc.title = 'StateMachine'
50
+ rdoc.title = spec.name
25
51
  rdoc.template = '../rdoc_template.rb'
26
52
  rdoc.options << '--line-numbers' << '--inline-source'
27
- rdoc.rdoc_files.include('README')
28
- rdoc.rdoc_files.include('lib/**/*.rb')
29
- end
30
-
31
- spec = Gem::Specification.new do |s|
32
- s.name = PKG_NAME
33
- s.version = PKG_VERSION
34
- s.platform = Gem::Platform::RUBY
35
- s.summary = 'Adds support for creating state machines for attributes within a model'
36
-
37
- s.files = FileList['{lib,test}/**/*'].to_a - FileList['test/app_root/log/*'].to_a + %w(CHANGELOG init.rb MIT-LICENSE Rakefile README)
38
- s.require_path = 'lib'
39
- s.autorequire = 'state_machine'
40
- s.has_rdoc = true
41
- s.test_files = Dir['test/**/*_test.rb']
42
-
43
- s.author = 'Aaron Pfeifer'
44
- s.email = 'aaron@pluginaweek.org'
45
- s.homepage = 'http://www.pluginaweek.org'
53
+ rdoc.rdoc_files.include('README.rdoc', 'CHANGELOG.rdoc', 'LICENSE', 'lib/**/*.rb')
46
54
  end
47
55
 
48
56
  Rake::GemPackageTask.new(spec) do |p|
@@ -51,14 +59,14 @@ Rake::GemPackageTask.new(spec) do |p|
51
59
  p.need_zip = true
52
60
  end
53
61
 
54
- desc 'Publish the beta gem'
62
+ desc 'Publish the beta gem.'
55
63
  task :pgem => [:package] do
56
- Rake::SshFilePublisher.new('aaron@pluginaweek.org', '/home/aaron/gems.pluginaweek.org/public/gems', 'pkg', "#{PKG_FILE_NAME}.gem").upload
64
+ Rake::SshFilePublisher.new('aaron@pluginaweek.org', '/home/aaron/gems.pluginaweek.org/public/gems', 'pkg', "#{spec.name}-#{spec.version}.gem").upload
57
65
  end
58
66
 
59
- desc 'Publish the API documentation'
67
+ desc 'Publish the API documentation.'
60
68
  task :pdoc => [:rdoc] do
61
- Rake::SshDirPublisher.new('aaron@pluginaweek.org', "/home/aaron/api.pluginaweek.org/public/#{PKG_NAME}", 'rdoc').upload
69
+ Rake::SshDirPublisher.new('aaron@pluginaweek.org', "/home/aaron/api.pluginaweek.org/public/#{spec.name}", 'rdoc').upload
62
70
  end
63
71
 
64
72
  desc 'Publish the API docs and gem'
@@ -71,10 +79,10 @@ task :release => [:gem, :package] do
71
79
  ruby_forge = RubyForge.new.configure
72
80
  ruby_forge.login
73
81
 
74
- %w( gem tgz zip ).each do |ext|
75
- file = "pkg/#{PKG_FILE_NAME}.#{ext}"
82
+ %w(gem tgz zip).each do |ext|
83
+ file = "pkg/#{spec.name}-#{spec.version}.#{ext}"
76
84
  puts "Releasing #{File.basename(file)}..."
77
85
 
78
- ruby_forge.add_release(RUBY_FORGE_PROJECT, PKG_NAME, PKG_VERSION, file)
86
+ ruby_forge.add_release(spec.rubyforge_project, spec.name, spec.version, file)
79
87
  end
80
88
  end
data/lib/state_machine.rb CHANGED
@@ -67,6 +67,8 @@ module PluginAWeek #:nodoc:
67
67
 
68
68
  attribute_keys = (attributes || {}).keys.map!(&:to_s)
69
69
 
70
+ # Set the initial value of each state machine as long as the value wasn't
71
+ # included in the attribute hash passed in
70
72
  self.class.state_machines.each do |attribute, machine|
71
73
  unless attribute_keys.include?(attribute)
72
74
  send("#{attribute}=", machine.initial_state(self))
@@ -80,7 +82,7 @@ module PluginAWeek #:nodoc:
80
82
  def run_initial_state_machine_actions
81
83
  self.class.state_machines.each do |attribute, machine|
82
84
  callback = "after_enter_#{attribute}_#{self[attribute]}"
83
- run_callbacks(callback) if self.class.respond_to?(callback)
85
+ run_callbacks(callback) if self[attribute] && self.class.respond_to?(callback)
84
86
  end
85
87
  end
86
88
  end
@@ -22,7 +22,7 @@ module PluginAWeek #:nodoc:
22
22
  @name = name
23
23
  @options = options.stringify_keys
24
24
 
25
- add_transition_action
25
+ add_transition_actions
26
26
  add_transition_callbacks
27
27
  add_event_callbacks
28
28
  end
@@ -46,49 +46,66 @@ module PluginAWeek #:nodoc:
46
46
  options.assert_valid_keys(:to, :from, :if, :unless)
47
47
  raise ArgumentError, ':to state must be specified' unless options.include?(:to)
48
48
 
49
+ # Get the states involved in the transition
49
50
  to_state = options.delete(:to)
50
51
  from_states = Array(options.delete(:from))
52
+
51
53
  from_states.collect do |from_state|
52
- # Create the actual transition that will update records when run
54
+ # Create the actual transition that will update records when performed
53
55
  transition = Transition.new(self, from_state, to_state)
54
56
 
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
57
+ # Add the callback to the model. If the callback fails, then the next
58
+ # available callback for the event will run until one is successful.
59
+ callback = Proc.new {|record, *args| try_transition(transition, false, record, *args)}
66
60
  owner_class.send("transition_on_#{name}", callback, options)
67
61
 
62
+ # Add the callback! to the model similar to above
63
+ callback = Proc.new {|record, *args| try_transition(transition, true, record, *args)}
64
+ owner_class.send("transition_bang_on_#{name}", callback, options)
65
+
68
66
  transition
69
67
  end
70
68
  end
71
69
 
72
- # Attempts to transition to one of the next possible states for the given record
70
+ # Attempts to perform one of the event's transitions for the given record
71
+ def fire(record, *args)
72
+ record.class.transaction {invoke_transition_callbacks(record, false, *args) || raise(ActiveRecord::Rollback)} || false
73
+ end
74
+
75
+ # Attempts to perform one of the event's transitions for the given record.
76
+ # If the transition cannot be made, then a PluginAWeek::StateMachine::InvalidTransition
77
+ # error will be raised.
73
78
  def fire!(record, *args)
74
- success = false
75
- record.class.transaction {success = invoke_transition_callbacks(record, *args) == true || raise(ActiveRecord::Rollback)}
76
- success
79
+ record.class.transaction {invoke_transition_callbacks(record, true, *args) || raise(ActiveRecord::Rollback)} || raise(PluginAWeek::StateMachine::InvalidTransition)
77
80
  end
78
81
 
79
82
  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)
83
+ # Add the various instance methods that can transition the record using
84
+ # the current event
85
+ def add_transition_actions
86
+ name = self.name
87
+ owner_class = self.owner_class
88
+ machine = self.machine
89
+
90
+ owner_class.class_eval do
91
+ # Fires the event, returning true/false
92
+ define_method(name) do |*args|
93
+ owner_class.state_machines[machine.attribute].events[name].fire(self, *args)
85
94
  end
86
- end_eval
95
+
96
+ # Fires the event, raising an exception if it fails
97
+ define_method("#{name}!") do |*args|
98
+ owner_class.state_machines[machine.attribute].events[name].fire!(self, *args)
99
+ end
100
+ end
87
101
  end
88
102
 
89
103
  # Defines callbacks for invoking transitions when this event is performed
90
104
  def add_transition_callbacks
91
- owner_class.define_callbacks("transition_on_#{name}")
105
+ %W(transition transition_bang).each do |callback_name|
106
+ callback_name = "#{callback_name}_on_#{name}"
107
+ owner_class.define_callbacks(callback_name)
108
+ end
92
109
  end
93
110
 
94
111
  # Adds the before/after callbacks for when the event is performed
@@ -102,7 +119,25 @@ module PluginAWeek #:nodoc:
102
119
  end
103
120
  end
104
121
 
105
- # Invokes a particulary type of callbacks for the event
122
+ # Attempts to perform the given transition. If it can't be performed based
123
+ # on the state of the given record, then the transition will be skipped
124
+ # and the next available one will be tried.
125
+ #
126
+ # If +bang+ is specified, then perform! will be called on the transition.
127
+ # Otherwise, the default +perform+ will be invoked.
128
+ def try_transition(transition, bang, record, *args)
129
+ if transition.can_perform_on?(record)
130
+ return false if invoke_event_callbacks(:before, record, *args) == false
131
+ result = bang ? transition.perform!(record, *args) : transition.perform(record, *args)
132
+ invoke_event_callbacks(:after, record, *args)
133
+ result
134
+ else
135
+ # Indicate that the transition cannot be performed
136
+ :skip
137
+ end
138
+ end
139
+
140
+ # Invokes a particulary type of callback for the event
106
141
  def invoke_event_callbacks(type, record, *args)
107
142
  args = [record] + args
108
143
 
@@ -113,14 +148,19 @@ module PluginAWeek #:nodoc:
113
148
  end
114
149
 
115
150
  # Invokes the callbacks for each transition in order to find one that
116
- # completes successfully
117
- def invoke_transition_callbacks(record, *args)
151
+ # completes successfully.
152
+ #
153
+ # +bang+ indicates whether perform or perform! will be invoked on the
154
+ # transitions in the callback chain
155
+ def invoke_transition_callbacks(record, bang, *args)
118
156
  args = [record] + args
157
+ callback_chain = "transition#{'_bang' if bang}_on_#{name}_callback_chain"
119
158
 
120
- record.class.send("transition_on_#{name}_callback_chain").each do |callback|
159
+ result = record.class.send(callback_chain).each do |callback|
121
160
  result = callback.call(*args)
122
- break result if result == true
161
+ break result if [true, false].include?(result)
123
162
  end
163
+ result == true
124
164
  end
125
165
  end
126
166
  end
@@ -77,7 +77,8 @@ module PluginAWeek #:nodoc:
77
77
  #
78
78
  # The following instance methods are generated when a new event is defined
79
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.
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
+ # * <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. If the transition cannot happen (for validation, database, etc. reasons), then an error will be raised
81
82
  #
82
83
  # == Defining transitions
83
84
  #
@@ -113,26 +114,25 @@ module PluginAWeek #:nodoc:
113
114
  end
114
115
 
115
116
  # 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
117
+ %w(before_exit before_enter after_exit after_enter).each do |callback_type|
118
+ define_method(callback_type) {|state, callback| add_callback(callback_type, state, callback)}
124
119
  end
125
120
 
126
121
  private
122
+ # Adds the given callback to the callback chain during a state transition
123
+ def add_callback(type, state, callback)
124
+ callback_name = "#{type}_#{attribute}_#{state}"
125
+ owner_class.define_callbacks(callback_name)
126
+ owner_class.send(callback_name, callback)
127
+ end
128
+
129
+ # Add named scopes for finding records with a particular value or values
130
+ # for the attribute
127
131
  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
132
+ [attribute, attribute.pluralize].each do |name|
133
+ unless owner_class.respond_to?("with_#{name}")
134
+ name = "with_#{name}"
135
+ owner_class.named_scope name, Proc.new {|*values| {:conditions => {attribute => values.flatten}}}
136
136
  end
137
137
  end
138
138
  end