mattsnyder-stately 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,11 @@
1
+ .bundle/
2
+ log/*.log
3
+ pkg/
4
+ rdoc/
5
+ .DS_Store
6
+ .yardoc/
7
+ doc/
8
+ test/dummy/db/*.sqlite3
9
+ test/dummy/log/*.log
10
+ test/dummy/tmp/
11
+ test/dummy/.sass-cache
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format progress
2
+ --color
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ stately
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ ruby-1.8.7-p358
data/.travis.yml ADDED
@@ -0,0 +1,10 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.8.7
4
+ - 1.9.3
5
+ - 2.0.0
6
+ branches:
7
+ only:
8
+ - master
9
+ script:
10
+ - bundle exec rake spec
data/.yardopts ADDED
@@ -0,0 +1,4 @@
1
+ lib/**/*.rb
2
+ -
3
+ README.md
4
+ MIT-LICENSE
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source 'http://rubygems.org'
2
+
3
+ gem 'rake'
4
+
5
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,34 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ stately (0.2.0)
5
+
6
+ GEM
7
+ remote: http://rubygems.org/
8
+ specs:
9
+ diff-lcs (1.1.3)
10
+ json (1.8.0)
11
+ rake (10.0.4)
12
+ rdoc (4.0.1)
13
+ json (~> 1.4)
14
+ redcarpet (2.2.2)
15
+ rspec (2.11.0)
16
+ rspec-core (~> 2.11.0)
17
+ rspec-expectations (~> 2.11.0)
18
+ rspec-mocks (~> 2.11.0)
19
+ rspec-core (2.11.1)
20
+ rspec-expectations (2.11.3)
21
+ diff-lcs (~> 1.1.3)
22
+ rspec-mocks (2.11.3)
23
+ yard (0.8.3)
24
+
25
+ PLATFORMS
26
+ ruby
27
+
28
+ DEPENDENCIES
29
+ rake
30
+ rdoc
31
+ redcarpet (~> 2.2.2)
32
+ rspec (~> 2.0)
33
+ stately!
34
+ yard (~> 0.8.3)
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2012 Ryan Twomey
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.md ADDED
@@ -0,0 +1,178 @@
1
+ # Stately
2
+
3
+ [![Build Status](https://api.travis-ci.org/rtwomey/stately.png)](https://travis-ci.org/rtwomey/stately)
4
+
5
+ A minimal, elegant state machine for your ruby objects.
6
+
7
+ ![A stately fellow.](https://dl.dropbox.com/u/2754528/exquisite_cat.jpg "A stately fellow.")
8
+
9
+ ## Making a stately start
10
+
11
+ Stately is a state machine for ruby objects, with an elegant, easy-to-read DSL. Here's an example showing off what Stately can do:
12
+
13
+ ```ruby
14
+ class Order
15
+ stately :start => :processing do
16
+ state :completed do
17
+ prevent_from :refunded
18
+
19
+ before_transition :from => :processing, :do => :calculate_total
20
+ after_transition :do => :email_receipt
21
+
22
+ validate :validates_credit_card
23
+ end
24
+
25
+ state :invalid do
26
+ prevent_from :completed, :refunded
27
+ end
28
+
29
+ state :refunded do
30
+ allow_from :completed
31
+
32
+ after_transition :do => :email_receipt
33
+ end
34
+ end
35
+ end
36
+ ```
37
+
38
+ Stately tries hard not to surprise you. When you transition to a new state, you're responsible for taking whatever actions that means using `before_transition` and `after_transition`. Stately also has no dependencies on things like DataMapper or ActiveModel, so it will never surprise you with an implicit `save` after transitioning states.
39
+
40
+ ## When to use stately
41
+
42
+ Often, you'll find yourself writing an object that can have multiple states. Tracking these states can usually be done either:
43
+
44
+ * By hand (i.e. adding a string column in the db and storing the current state there).
45
+ * Via a state machine of some kind. [state_machine](https://github.com/pluginaweek/state_machine) is a popular one that I've used quite a bit, which has a lot of advanced features (most of which I've never used).
46
+
47
+ Stately exists in a middle space between the two options. The goal of stately is to make the most common case, where you just need to track state and react appropriately when switching those states, easy.
48
+
49
+ ## Design goals
50
+
51
+ * Minimalist. Stately tries to solve the most common use case: tracking the current state and handling transitions between states.
52
+
53
+ * No magic. In other words, if you're using, say, ActiveRecord, stately won't hook in to activerecord callbacks. This requires you to be more explicit and perhaps more verbose, but I think it helps with readability and reduces surprises. See the Examples section below for what this looks like when in an ActiveRecord environment.
54
+
55
+ * Syntax that is as self-documenting as possible. Someone not familiar with Stately should be able to understand what happens when an object's state is changed just by reading the DSL.
56
+
57
+ ## Getting started
58
+
59
+ Either install locally:
60
+
61
+ ```shell
62
+ gem install stately
63
+ ```
64
+
65
+ or add it to your Gemfile:
66
+
67
+ ```ruby
68
+ gem stately
69
+ ```
70
+
71
+ Be sure to run `bundle install` afterwards.
72
+
73
+ The first step is to add the following to your object:
74
+
75
+ ```ruby
76
+ stately :start => :initial_state, :attr => :my_state_attr do
77
+ # ...
78
+ end
79
+ ```
80
+
81
+ This sets up Stately to look for an attribute named `my_state_attr`, and initially set it to `initial_state`. If you omit `:attr => :my_state_attr`, Stately will automatically look for an attribute named `state`.
82
+
83
+ ## Defining a state
84
+
85
+ States make up the core of Stately and define two things: the name of the state (i.e. "completed"), and a verb as the name of the method to call to begin a transition into that state (i.e. "complete"). Stately has support for some common state/verb combinations, but you can always use your own:
86
+
87
+ ```ruby
88
+ class Order
89
+ stately :start => :processing do
90
+ state :my_state, :action => transition_to_my_state
91
+ end
92
+ end
93
+
94
+ order = Order.new
95
+ order.transition_to_my_state
96
+ ```
97
+
98
+ ## Transitions
99
+
100
+ A "transition" is the process of moving from one state to another. You can define legal transitions using `allow_from` and `prevent_from`:
101
+
102
+ ```ruby
103
+ state :completed do
104
+ allow_from :processing
105
+ prevent_from :refunded
106
+ end
107
+ ```
108
+
109
+ In the above example, if you try to transition to `completed` (by calling `complete` on the object) from `refunded`, you'll see a `Stately::InvalidTransition` is raised. By default, all transitions are allowed.
110
+
111
+ ## Validations
112
+
113
+ While transitioning from one state to another, you can define validations to be run. If any validation returns `false`, the transition is halted.
114
+
115
+ ```ruby
116
+ state :completed do
117
+ validate :validates_amount
118
+ validate :validates_credit_card
119
+ end
120
+ ```
121
+
122
+ Each validation is also called in order, so first `validates_amount` will be called, and if it doesn't return `false`, then `validates_credit_card` will be called and checked.
123
+
124
+ ## Callbacks
125
+
126
+ Callbacks can be defined to run either before or after a transition occurs. A `before_transition` is run after validations are checked, but before the `state_attr` has been written to with the new state. An `after_transition` is called after the `state_attr` has been written to.
127
+
128
+ If you're using Stately with some kind of persistence layer, sych as activerecord, you'll probably want an `after_transition` that calls `save` or the equivalent.
129
+
130
+ ```ruby
131
+ class Order
132
+ stately :start => :processing do
133
+ # ...
134
+
135
+ state :completed do
136
+ before_transition :from => :processing, :do => :before_completed
137
+ before_transition :from => :invalid, :do => :cleanup_invalid
138
+ after_transition :do => :after_completed
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ def after_completed
145
+ save
146
+ end
147
+ end
148
+ ```
149
+
150
+ A callback can include an optional `from` state name, which is only called when transitioning from the named state. Omitting it means the callback is always called.
151
+
152
+ Additionally, each callback is executed in the order in which it's defined.
153
+
154
+ ## Example: using Stately with ActiveRecord
155
+
156
+ Let's say you are modeling a Bicycle object for your rental shop and you're using ActiveRecord. A Bicycle has two states: `available` and `rented`. Using stately, you could define this as the following:
157
+
158
+ ```ruby
159
+ class Bicycle < ActiveRecord::Base
160
+ stately :start => :available do
161
+ state :rented, :action => :rent do
162
+ after_transition :do => :save
163
+ end
164
+ end
165
+ end
166
+ ```
167
+
168
+ When Bicycle is first instantiated, its `state` column is set to the string `available`. If you want to rent the Bicycle, you'd call `bicycle.rent`, which would update the `state` column to be the string `rented` and then call the ActiveRecord method `save`.
169
+
170
+ As you can see, Stately is slightly more verbose than other state machine gems, but with the upside of being more self-documenting. Additionally, it doesn't hook into ActiveRecord's callback chains, and instead requires you to explicitely call `save`.
171
+
172
+ ## Requirements
173
+
174
+ Stately requires Ruby 1.8.7 or newer. If you'd like to contribute to Stately, you'll need Rspec 2.0+.
175
+
176
+ ## License
177
+
178
+ Stately is Copyright © 2013 Ryan Twomey. It is free software, and may be redistributed under the terms specified in the MIT-LICENSE file.
data/Rakefile ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env rake
2
+
3
+ require 'bundler'
4
+ require 'rspec/core/rake_task'
5
+
6
+ Bundler::GemHelper.install_tasks
7
+
8
+ begin
9
+ require 'rdoc/task'
10
+ rescue LoadError
11
+ require 'rdoc/rdoc'
12
+ require 'rake/rdoctask'
13
+ RDoc::Task = Rake::RDocTask
14
+ end
15
+
16
+ RDoc::Task.new(:rdoc) do |rdoc|
17
+ rdoc.rdoc_dir = 'rdoc'
18
+ rdoc.title = 'Stately'
19
+ rdoc.options << '--line-numbers'
20
+ rdoc.rdoc_files.include('lib/**/*.rb')
21
+ end
22
+
23
+ namespace :spec do
24
+ desc 'Run unit specs'
25
+ RSpec::Core::RakeTask.new('unit') do |t|
26
+ t.pattern = 'spec/unit/**/*_spec.rb'
27
+ end
28
+
29
+ desc 'Run functional specs'
30
+ RSpec::Core::RakeTask.new('functional') do |t|
31
+ t.pattern = 'spec/functional/**/*_spec.rb'
32
+ end
33
+ end
34
+
35
+ desc 'Run unit and functional specs'
36
+ task :spec => ['spec:unit', 'spec:functional']
37
+
38
+ task :default => :spec
@@ -0,0 +1,5 @@
1
+ # Includes Stately on Ruby's Object.
2
+
3
+ Object.class_eval do
4
+ include Stately::Core
5
+ end
@@ -0,0 +1,26 @@
1
+ module Stately
2
+ # A Stately::Machine is a container for Stately::States.
3
+ class Machine
4
+ attr_reader :start, :state_attr, :states
5
+
6
+ # Sets up a new instance of Stately::Machine
7
+ def initialize(attr_name, start)
8
+ @state_attr = attr_name
9
+ @start = start
10
+ @states = [State.new(@start)]
11
+ end
12
+
13
+ # Define a new Stately::State and add it to this Stately::Machine.
14
+ #
15
+ # @param [String] name The name of the state. This is also stored in the instance object's
16
+ # state attribute.
17
+ # @param [Hash] opts Optionally, a method name can be defined as this state's action, if it
18
+ # can't be inferred from the name.
19
+ def state(name, opts={}, &block)
20
+ @states.delete_if { |s| s.name == name }
21
+
22
+ action = opts ? opts[:action] : nil
23
+ @states << State.new(name, action, &block)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,96 @@
1
+ module Stately
2
+ # A Stately::State object contains the configuration and other information about a defined
3
+ # state.
4
+ #
5
+ # It's made up of a name (which is saved to the parent instance's state attribute), the
6
+ # name of an action (which is a method called to transition into this state), and a DSL to
7
+ # define allowed transitions, callbacks, and validations.
8
+
9
+ class State
10
+ attr_reader :action, :name
11
+ attr_reader :allow_from_states, :prevent_from_states
12
+ attr_reader :after_transitions, :before_transitions, :validations
13
+
14
+ # Sets up and returns a new Stately::State object.
15
+ #
16
+ # @param [String] name The name of the state
17
+ # @param [String] action The method name that's called to transition to this state. Some method
18
+ # names can be inferred based on the state's name.
19
+ def initialize(name, action=nil, &block)
20
+ @action = (action || guess_action_for(name)).to_s
21
+ @name = name
22
+
23
+ @allow_from_states = []
24
+ @prevent_from_states = []
25
+
26
+ @before_transitions = []
27
+ @after_transitions = []
28
+ @validations = []
29
+
30
+ if block_given?
31
+ configuration = StateConfigurator.new(&block)
32
+
33
+ @allow_from_states = configuration.allow_from_states || []
34
+ @prevent_from_states = configuration.prevent_from_states || []
35
+
36
+ @after_transitions = configuration.after_transitions || []
37
+ @before_transitions = configuration.before_transitions || []
38
+ @validations = configuration.validations || []
39
+ end
40
+ end
41
+
42
+ # @return [String] The state name as a string
43
+ def to_s
44
+ @name.to_s
45
+ end
46
+
47
+ # @return [Symbol] The state name as a string
48
+ def to_sym
49
+ @name.to_sym
50
+ end
51
+
52
+ private
53
+
54
+ ACTIONS = { :completed => :complete, :converting => :convert, :invalid => :invalidate,
55
+ :preparing => :prepare, :processing => :process, :refunded => :refund, :reticulating => :reticulate,
56
+ :saving => :save, :searching => :search, :started => :start, :stopped => :stop }
57
+
58
+ def guess_action_for(name)
59
+ ACTIONS[name.to_sym]
60
+ end
61
+
62
+ class StateConfigurator
63
+ attr_reader :after_transitions, :before_transitions, :validations
64
+ attr_reader :allow_from_states, :prevent_from_states
65
+
66
+ def initialize(&block)
67
+ instance_eval(&block)
68
+ end
69
+
70
+ def allow_from(*states)
71
+ @allow_from_states ||= []
72
+ @allow_from_states |= states.map(&:to_sym)
73
+ end
74
+
75
+ def before_transition(options={})
76
+ @before_transitions ||= []
77
+ @before_transitions << options
78
+ end
79
+
80
+ def after_transition(options={})
81
+ @after_transitions ||= []
82
+ @after_transitions << options
83
+ end
84
+
85
+ def prevent_from(*states)
86
+ @prevent_from_states ||= []
87
+ @prevent_from_states |= states.map(&:to_sym)
88
+ end
89
+
90
+ def validate(options={})
91
+ @validations ||= []
92
+ @validations << options
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,3 @@
1
+ module Stately
2
+ VERSION = '0.2.1'
3
+ end
data/lib/stately.rb ADDED
@@ -0,0 +1,196 @@
1
+ require 'stately/machine'
2
+ require 'stately/state'
3
+
4
+ module Stately
5
+ # An InvalidTransition is an error that is raised when attempting to transition from a state
6
+ # that's not allowable, based on the Stately::State DSL definitions allow_from and prevent_from.
7
+ class InvalidTransition < StandardError
8
+ end
9
+
10
+ module Core
11
+ # Define a new Stately state machine.
12
+ #
13
+ # As an example, let's say you have an Order object and you'd like an elegant state machine for
14
+ # it. Here's one way you might set it up:
15
+ #
16
+ # Class Order do
17
+ # stately start: :processing do
18
+ # state :completed do
19
+ # prevent_from :refunded
20
+ #
21
+ # before_transition from: :processing, do: :calculate_total
22
+ # after_transition do: :email_receipt
23
+ #
24
+ # validate :validates_credit_card
25
+ # end
26
+ #
27
+ # state :invalid do
28
+ # prevent_from :completed, :refunded
29
+ # end
30
+ #
31
+ # state :refunded do
32
+ # allow_from :completed
33
+ #
34
+ # after_transition do: :email_receipt
35
+ # end
36
+ # end
37
+ # end
38
+ #
39
+ # This example is doing quite a few things, paraphrased as:
40
+ #
41
+ # * It sets up a new state machine using the default state attribute on Order to store the
42
+ # current state. It also indicates the initial state should be :processing.
43
+ # * It defines three states: :completed, :refunded, and :invalid
44
+ # * Order can transition to the completed state from all but the refunded state. Similar
45
+ # definitions are setup for the other two states.
46
+ # * Callbacks are setup using before_transition and after_transition
47
+ # * Validations are added. If a validation fails, it prevents the transition.
48
+ #
49
+ # Stately tries hard not to surprise you. In a typical Stately implementation, you'll always have
50
+ # an after_transition, primarily to call save (or whatever the equivalent is to store the
51
+ # instance's current state).
52
+ def stately(*opts, &block)
53
+ options = opts.last.is_a?(Hash) ? opts.last : {}
54
+ options[:attr] ||= :state
55
+
56
+ self.stately_machine = Stately::Machine.new(options[:attr], options[:start])
57
+ self.stately_machine.instance_eval(&block) if block_given?
58
+
59
+ include Stately::InstanceMethods
60
+ end
61
+
62
+ # Get the current Stately::Machine object
63
+ def stately_machine
64
+ @@stately_machine
65
+ end
66
+
67
+ # Set the current Stately::Machine object
68
+ def stately_machine=(obj)
69
+ @@stately_machine = obj
70
+ end
71
+ end
72
+
73
+ module InstanceMethods
74
+ # Sets up an object with Stately. The DSL is parsed and the Stately::Machine is initialized.
75
+ #
76
+ # When an object is first initialized, Stately automatically sets the state attribute to the
77
+ # start state.
78
+ #
79
+ # Additionally, a method is defined for each of the state's actions. These methods are used to
80
+ # transition between states. If you have a state named 'completed', Stately will infer the
81
+ # action to be 'complete' and define a method named 'complete'. You can then call 'complete' on
82
+ # the object to transition into the completed state.
83
+
84
+ def InstanceMethods.included(klass)
85
+ klass.class_eval do
86
+ alias_method :init_instance, :initialize
87
+ def initialize(*args)
88
+ init_instance(*args)
89
+ initialize_stately
90
+ end
91
+
92
+ stately_machine.states.each do |state|
93
+ define_method(state.action) do
94
+ transition_to(state)
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ # @return [Array<String>] a list of state names.
101
+ def states
102
+ stately_machine.states.map(&:name)
103
+ end
104
+
105
+ private
106
+
107
+ def allowed_state_transition?(to_state)
108
+ if current_state == to_state.to_s
109
+ raise InvalidTransition,
110
+ "Prevented transition from #{current_state} to #{state.to_s}."
111
+ end
112
+
113
+ allowed_from_states(to_state).include?(current_state.to_sym)
114
+ end
115
+
116
+ def allowed_from_states(state)
117
+ if state.allow_from_states.empty?
118
+ stately_machine.states.map(&:to_sym) - state.prevent_from_states
119
+ else
120
+ state.allow_from_states
121
+ end
122
+ end
123
+
124
+ def current_state
125
+ (self.send(stately_machine.state_attr) || stately_machine.start).to_s
126
+ end
127
+
128
+ def eligible_callback?(callback)
129
+ if (callback.has_key?(:from) && callback[:from].to_s == current_state) ||
130
+ (!callback.has_key?(:from))
131
+ true
132
+ else
133
+ false
134
+ end
135
+ end
136
+
137
+ def initialize_stately
138
+ set_initial_state
139
+ end
140
+
141
+ def run_before_transition_callbacks(state)
142
+ state.before_transitions.each do |callback|
143
+ if eligible_callback?(callback)
144
+ self.send callback[:do]
145
+ end
146
+ end
147
+ end
148
+
149
+ def run_after_transition_callbacks(state)
150
+ state.after_transitions.each do |callback|
151
+ self.send callback[:do]
152
+ end
153
+ end
154
+
155
+ def state_named(state_name)
156
+ stately_machine.states.find { |s| s.to_s == state_name.to_s }
157
+ end
158
+
159
+ def transition_to(state_name)
160
+ state = state_named(state_name)
161
+
162
+ if valid_transition_to?(state)
163
+ run_before_transition_callbacks(state)
164
+ write_attribute(stately_machine.state_attr, state.to_s)
165
+ run_after_transition_callbacks(state)
166
+ end
167
+ end
168
+
169
+ def set_initial_state
170
+ write_attribute(stately_machine.state_attr, stately_machine.start.to_s)
171
+ end
172
+
173
+ def write_attribute(attr, val)
174
+ send("#{attr}=", val)
175
+ end
176
+
177
+ def valid_transition_to?(state)
178
+ if allowed_state_transition?(state)
179
+ if state.validations.nil? || state.validations.empty?
180
+ true
181
+ else
182
+ results = state.validations.collect do |validation|
183
+ self.send validation
184
+ end
185
+
186
+ results.detect { |r| r == false }.nil?
187
+ end
188
+ else
189
+ raise InvalidTransition,
190
+ "Prevented transition from #{current_state} to #{state.to_s}."
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ require 'stately/core_ext'
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :stately do
3
+ # # Task goes here
4
+ # end