state_machine 0.1.1 → 0.2.0

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