stator 0.0.13

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,103 @@
1
+ module Stator
2
+ class Transition
3
+
4
+ ANY = '__any__'
5
+
6
+ attr_reader :name
7
+ attr_reader :full_name
8
+
9
+ def initialize(class_name, name, namespace = nil)
10
+ @class_name = class_name
11
+ @name = name
12
+ @namespace = namespace
13
+ @full_name = [@namespace, @name].compact.join('_') if @name
14
+ @froms = []
15
+ @to = nil
16
+ @callbacks = {}
17
+ end
18
+
19
+ def from(*froms)
20
+ @froms |= froms.map{|f| f.try(:to_s) } # nils are ok
21
+ end
22
+
23
+ def to(to)
24
+ @to = to.to_s
25
+ end
26
+
27
+ def to_state
28
+ @to
29
+ end
30
+
31
+ def from_states
32
+ @froms
33
+ end
34
+
35
+ def can?(current_state)
36
+ @froms.include?(current_state) || @froms.include?(ANY) || current_state == ANY
37
+ end
38
+
39
+ def valid?(from, to)
40
+ can?(from) &&
41
+ (@to == to || @to == ANY || to == ANY)
42
+ end
43
+
44
+ def conditional(options = {}, &block)
45
+ klass.instance_exec(conditional_string(options), &block)
46
+ end
47
+
48
+ def any
49
+ ANY
50
+ end
51
+
52
+ def evaluate
53
+ generate_methods unless @full_name.blank?
54
+ end
55
+
56
+ protected
57
+
58
+ def klass
59
+ @class_name.constantize
60
+ end
61
+
62
+ def callbacks(kind)
63
+ @callbacks[kind] || []
64
+ end
65
+
66
+ def conditional_string(options = {})
67
+ options[:use_previous] ||= false
68
+ %Q{
69
+ (
70
+ #{@froms.inspect}.include?(self._stator(#{@namespace.inspect}).integration(self).state_was(#{options[:use_previous].inspect})) ||
71
+ #{@froms.inspect}.include?(::Stator::Transition::ANY)
72
+ ) && (
73
+ self._stator(#{@namespace.inspect}).integration(self).state == #{@to.inspect} ||
74
+ #{@to.inspect} == ::Stator::Transition::ANY
75
+ )
76
+ }
77
+ end
78
+
79
+ def generate_methods
80
+ klass.class_eval <<-EV, __FILE__, __LINE__ + 1
81
+ def #{@full_name}(should_save = true)
82
+ integration = self._stator(#{@namespace.inspect}).integration(self)
83
+ integration.state = #{@to.inspect}
84
+ self.save if should_save
85
+ end
86
+
87
+ def #{@full_name}!
88
+ integration = self._stator(#{@namespace.inspect}).integration(self)
89
+ integration.state = #{@to.inspect}
90
+ self.save!
91
+ end
92
+
93
+ def can_#{@full_name}?
94
+ machine = self._stator(#{@namespace.inspect})
95
+ integration = machine.integration(self)
96
+ transition = machine.transitions.detect{|t| t.full_name.to_s == #{@full_name.inspect}.to_s }
97
+ transition.can?(integration.state)
98
+ end
99
+ EV
100
+ end
101
+
102
+ end
103
+ end
@@ -0,0 +1,8 @@
1
+ module Stator
2
+ MAJOR = 0
3
+ MINOR = 0
4
+ PATCH = 13
5
+ PRERELEASE = nil
6
+
7
+ VERSION = [MAJOR, MINOR, PATCH, PRERELEASE].compact.join('.')
8
+ end
data/lib/stator.rb ADDED
@@ -0,0 +1,11 @@
1
+ require "stator/version"
2
+
3
+ module Stator
4
+
5
+ autoload :Alias, 'stator/alias'
6
+ autoload :Integration, 'stator/integration'
7
+ autoload :Machine, 'stator/machine'
8
+ autoload :Model, 'stator/model'
9
+ autoload :Transition, 'stator/transition'
10
+
11
+ end
@@ -0,0 +1,214 @@
1
+ require 'spec_helper'
2
+
3
+ describe Stator::Model do
4
+
5
+ it 'should set the default state after initialization' do
6
+ u = User.new
7
+ u.state.should eql('pending')
8
+ end
9
+
10
+ it 'should see the initial setting of the state as a change with the initial state as the previous value' do
11
+ u = User.new
12
+ u.state = 'activated'
13
+ u.state_was.should eql('pending')
14
+ end
15
+
16
+ it 'should not obstruct normal validations' do
17
+ u = User.new
18
+ u.should_not be_valid
19
+ u.errors[:email].grep(/length/).should_not be_empty
20
+ end
21
+
22
+ it 'should ensure a valid state transition when given a bogus state' do
23
+ u = User.new
24
+ u.state = 'anythingelse'
25
+
26
+ u.should_not be_valid
27
+ u.errors[:state].should eql(['is not a valid state'])
28
+ end
29
+
30
+ it 'should allow creation at any state' do
31
+ u = User.new(:email => 'doug@example.com')
32
+ u.state = 'hyperactivated'
33
+
34
+ u.should be_valid
35
+ end
36
+
37
+ it 'should ensure a valid state transition when given an illegal state based on the current state' do
38
+ u = User.new
39
+ u.stub(:new_record?).and_return(false)
40
+ u.state = 'hyperactivated'
41
+
42
+ u.should_not be_valid
43
+ u.errors[:state].should_not be_empty
44
+
45
+ end
46
+
47
+ it 'should run conditional validations' do
48
+ u = User.new
49
+ u.state = 'semiactivated'
50
+ u.should_not be_valid
51
+
52
+ u.errors[:state].should be_empty
53
+ u.errors[:email].grep(/format/).should_not be_empty
54
+ end
55
+
56
+ it 'should invoke callbacks' do
57
+ u = User.new(:activated => true, :email => 'doug@example.com', :name => 'doug')
58
+ u.activated.should == true
59
+
60
+ u.deactivate
61
+
62
+ u.activated.should == false
63
+ u.state.should eql('deactivated')
64
+ u.activated_state_at.should be_nil
65
+ u.should be_persisted
66
+ end
67
+
68
+ it 'should blow up if the record is invalid and a bang method is used' do
69
+ u = User.new(:email => 'doug@other.com', :name => 'doug')
70
+ lambda{
71
+ u.activate!
72
+ }.should raise_error(ActiveRecord::RecordInvalid)
73
+ end
74
+
75
+ it 'should allow for other fields to be used other than state' do
76
+ a = Animal.new
77
+ a.should be_valid
78
+
79
+ a.birth!
80
+ end
81
+
82
+ it 'should create implicit transitions for state declarations' do
83
+ a = Animal.new
84
+ a.should_not be_grown_up
85
+ a.status = 'grown_up'
86
+ a.save
87
+ end
88
+
89
+ it 'should allow multiple machines in the same model' do
90
+ f = Farm.new
91
+ f.should be_dirty
92
+ f.should be_house_dirty
93
+
94
+ f.cleanup
95
+
96
+ f.should_not be_dirty
97
+ f.should be_house_dirty
98
+
99
+ f.house_cleanup
100
+
101
+ f.should_not be_house_dirty
102
+ end
103
+
104
+ it 'should allow saving to be skipped' do
105
+ f = Farm.new
106
+ f.cleanup(false)
107
+
108
+ f.should_not be_persisted
109
+ end
110
+
111
+ it 'should allow no initial state' do
112
+ f = Factory.new
113
+ f.state.should be_nil
114
+
115
+ f.construct.should be_true
116
+
117
+ f.state.should eql('constructed')
118
+ end
119
+
120
+ describe 'helper methods' do
121
+
122
+ it 'should answer the question of whether the state is currently the one invoked' do
123
+ a = Animal.new
124
+ a.should be_unborn
125
+ a.should_not be_born
126
+
127
+ a.birth
128
+
129
+ a.should be_born
130
+ a.should_not be_unborn
131
+ end
132
+
133
+ it 'should determine if it can validly execute a transition' do
134
+ a = Animal.new
135
+ a.can_birth?.should be_true
136
+
137
+ a.birth
138
+
139
+ a.can_birth?.should be_false
140
+ end
141
+
142
+ end
143
+
144
+ describe 'tracker methods' do
145
+
146
+ before do
147
+ Time.zone = 'Eastern Time (US & Canada)'
148
+ end
149
+
150
+ it 'should store when a record changed state for the first time' do
151
+ a = Animal.new
152
+ a.unborn_status_at.should be_nil
153
+ a.born_status_at.should be_nil
154
+ a.birth
155
+ a.unborn_status_at.should be_within(1).of(Time.zone.now)
156
+ a.born_status_at.should be_within(1).of(Time.zone.now)
157
+ end
158
+
159
+ end
160
+
161
+ describe 'aliasing' do
162
+ it 'should allow aliasing within the dsl' do
163
+ u = User.new(:email => 'doug@example.com')
164
+ u.should respond_to(:active?)
165
+ u.should respond_to(:inactive?)
166
+
167
+ u.should_not be_active
168
+
169
+ u.inactive?
170
+ u.should be_inactive
171
+
172
+ u.activate!
173
+ u.should be_active
174
+ u.should_not be_inactive
175
+
176
+ u.hyperactivate!
177
+ u.should be_active
178
+ u.should_not be_inactive
179
+
180
+ User::ACTIVE_STATES.should eql(['activated', 'hyperactivated'])
181
+ User::INACTIVE_STATES.should eql(['pending', 'deactivated', 'semiactivated'])
182
+
183
+ u2 = User.create(:email => 'phil@example.com')
184
+
185
+ User.active.to_sql.gsub(' ', ' ').should eq("SELECT users.* FROM users WHERE users.state IN ('activated', 'hyperactivated')")
186
+ User.inactive.to_sql.gsub(' ', ' ').should eq("SELECT users.* FROM users WHERE users.state IN ('pending', 'deactivated', 'semiactivated')")
187
+ end
188
+
189
+ it 'should namespace aliases just like everything else' do
190
+ f = Farm.new
191
+ f.should respond_to(:house_cleaned?)
192
+
193
+ f.should_not be_house_cleaned
194
+ f.house_cleanup!
195
+
196
+ f.should be_house_cleaned
197
+ end
198
+
199
+ it 'should allow for explicit constant and scope names to be provided' do
200
+ User.should respond_to(:luke_warmers)
201
+ defined?(User::LUKE_WARMERS).should be_true
202
+ u = User.new
203
+ u.should respond_to(:luke_warm?)
204
+ end
205
+
206
+ it 'should not create constants or scopes by default' do
207
+ u = User.new
208
+ u.should respond_to(:iced_tea?)
209
+ defined?(User::ICED_TEA_STATES).should be_false
210
+ User.should_not respond_to(:iced_tea)
211
+ end
212
+ end
213
+
214
+ end
@@ -0,0 +1,34 @@
1
+ # This file was generated by the `rspec --init` command. Conventionally, all
2
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
3
+ # Require this file using `require "spec_helper"` to ensure that it is only
4
+ # loaded once.
5
+ #
6
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
7
+
8
+ require 'active_record'
9
+ require 'nulldb/core'
10
+ require 'active_support/core_ext'
11
+ require 'stator'
12
+
13
+ RSpec.configure do |config|
14
+ config.treat_symbols_as_metadata_keys_with_true_values = true
15
+ config.run_all_when_everything_filtered = true
16
+ config.filter_run :focus
17
+
18
+ NullDB.configure do |c|
19
+ c.project_root = File.dirname(__FILE__)
20
+ end
21
+
22
+ ActiveRecord::Base.establish_connection(
23
+ :adapter => :nulldb,
24
+ :schema => 'support/schema.rb'
25
+ )
26
+
27
+ require 'support/models'
28
+
29
+ # Run specs in random order to surface order dependencies. If you find an
30
+ # order dependency and want to debug it, you can fix the order by providing
31
+ # the seed, which is printed after each run.
32
+ # --seed 1234
33
+ config.order = 'random'
34
+ end
@@ -0,0 +1,177 @@
1
+ class User < ActiveRecord::Base
2
+ extend Stator::Model
3
+
4
+
5
+ # initial state = pending
6
+ stator do
7
+
8
+ transition :activate do
9
+ from :pending, :semiactivated
10
+ to :activated
11
+ end
12
+
13
+ transition :deactivate do
14
+ from any
15
+ to :deactivated
16
+
17
+ conditional do |condition|
18
+ before_save :set_deactivated, :if => condition
19
+ end
20
+ end
21
+
22
+ transition :semiactivate do
23
+ from :pending
24
+ to :semiactivated
25
+
26
+ conditional do |condition|
27
+ validate :check_email_validity, :if => condition
28
+ end
29
+ end
30
+
31
+ transition :hyperactivate do
32
+ from :activated
33
+ to :hyperactivated
34
+ end
35
+
36
+ conditional :semiactivated, :activated do |condition|
37
+ validate :check_email_presence, :if => condition
38
+ end
39
+
40
+ state_alias :active, :constant => true, :scope => true do
41
+ is :activated, :hyperactivated
42
+ opposite :inactive, :constant => true, :scope => true
43
+ end
44
+
45
+ state_alias :luke_warm, :constant => :luke_warmers, :scope => :luke_warmers do
46
+ is :semiactivated
47
+ opposite :iced_tea
48
+ end
49
+
50
+ end
51
+
52
+ validate :email_is_right_length
53
+
54
+ protected
55
+
56
+ def check_email_presence
57
+ unless self.email.present?
58
+ self.errors.add(:email, 'needs to be present')
59
+ return false
60
+ end
61
+
62
+ true
63
+ end
64
+
65
+ def check_email_validity
66
+ unless self.email.to_s =~ /example\.com$/
67
+ self.errors.add(:email, 'format needs to be example.com')
68
+ return false
69
+ end
70
+
71
+ true
72
+ end
73
+
74
+ def email_is_right_length
75
+ unless self.email.to_s.length == 'four@example.com'.length
76
+ self.errors.add(:email, 'needs to be the right length')
77
+ return false
78
+ end
79
+
80
+ true
81
+ end
82
+
83
+ def set_deactivated
84
+ self.activated = false
85
+ true
86
+ end
87
+
88
+ end
89
+
90
+ class Animal < ActiveRecord::Base
91
+ extend Stator::Model
92
+
93
+ # initial state = unborn
94
+ stator :field => :status, :helpers => true, :track => true do
95
+
96
+ transition :birth do
97
+ from :unborn
98
+ to :born
99
+ end
100
+
101
+ state :grown_up
102
+
103
+ end
104
+ end
105
+
106
+ class Zoo < ActiveRecord::Base
107
+ extend Stator::Model
108
+
109
+ # initial state = closed
110
+ stator do
111
+
112
+ transition :open do
113
+ from :closed
114
+ to :opened
115
+ end
116
+
117
+ transition :close do
118
+ from :opened
119
+ to :closed
120
+ end
121
+
122
+ conditional :opened do |c|
123
+ validate :validate_lights_are_on, :if => c
124
+ end
125
+ end
126
+
127
+ protected
128
+
129
+ def validate_lights_are_on
130
+ true
131
+ end
132
+ end
133
+
134
+
135
+ class Farm < ActiveRecord::Base
136
+ extend Stator::Model
137
+
138
+ # initial state = dirty
139
+ stator do
140
+ transition :cleanup do
141
+ from :dirty
142
+ to :clean
143
+ end
144
+ end
145
+
146
+
147
+ # initial state = dirty
148
+ stator :field => 'house_state', :namespace => 'house' do
149
+ transition :cleanup do
150
+ from :dirty
151
+ to :clean
152
+ end
153
+
154
+ state_alias :cleaned do
155
+ is_not :dirty
156
+ end
157
+ end
158
+
159
+ end
160
+
161
+ class Factory < ActiveRecord::Base
162
+ extend Stator::Model
163
+
164
+ # initial state = nil
165
+ stator do
166
+ transition :construct do
167
+ from nil
168
+ to :constructed
169
+ end
170
+
171
+ transition :destruct do
172
+ from :constructed
173
+ to :on_the_ground
174
+ end
175
+ end
176
+
177
+ end
@@ -0,0 +1,43 @@
1
+ ActiveRecord::Schema.define(:version => 20130628161227) do
2
+
3
+ create_table "users", :force => true do |t|
4
+ t.string "name"
5
+ t.string "email"
6
+ t.string "state", :default => 'pending'
7
+ t.boolean "activated", :default => true
8
+ t.datetime "created_at", :null => false
9
+ t.datetime "updated_at", :null => false
10
+ t.datetime "semiactivated_state_at"
11
+ t.datetime "activated_state_at"
12
+ end
13
+
14
+ create_table "animals", :force => true do |t|
15
+ t.string "name"
16
+ t.string "status", :default => 'unborn'
17
+ t.datetime "created_at", :null => false
18
+ t.datetime "updated_at", :null => false
19
+ t.datetime "unborn_status_at"
20
+ t.datetime "born_status_at"
21
+ end
22
+
23
+ create_table "zoos", :force => true do |t|
24
+ t.string "name"
25
+ t.string "state", :default => 'closed'
26
+ t.datetime "created_at", :null => false
27
+ t.datetime "updated_at", :null => false
28
+ end
29
+
30
+ create_table "farms", :force => true do |t|
31
+ t.string "name"
32
+ t.string "state", :default => 'dirty'
33
+ t.string "house_state", :default => 'dirty'
34
+ t.datetime "created_at", :null => false
35
+ t.datetime "updated_at", :null => false
36
+ end
37
+
38
+ create_table "factories", :force => true do |t|
39
+ t.string "name"
40
+ t.string "state"
41
+ end
42
+
43
+ end
data/stator.gemspec ADDED
@@ -0,0 +1,21 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'stator/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "stator"
8
+ gem.version = Stator::VERSION
9
+ gem.authors = ["Mike Nelson"]
10
+ gem.email = ["mike@mikeonrails.com"]
11
+ gem.description = %q{The simplest of ActiveRecord state machines. Intended to be lightweight and minimalistic.}
12
+ gem.summary = %q{The simplest of ActiveRecord state machines}
13
+ gem.homepage = "http://www.mikeonrails.com"
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
+ gem.require_paths = ["lib"]
19
+
20
+ gem.add_dependency 'activerecord'
21
+ end
metadata ADDED
@@ -0,0 +1,91 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stator
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.13
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Mike Nelson
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2014-03-22 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ description: The simplest of ActiveRecord state machines. Intended to be lightweight
31
+ and minimalistic.
32
+ email:
33
+ - mike@mikeonrails.com
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - .gitignore
39
+ - .rspec
40
+ - .ruby-gemset
41
+ - .ruby-version
42
+ - .travis.yml
43
+ - Gemfile
44
+ - LICENSE.txt
45
+ - README.md
46
+ - Rakefile
47
+ - gemfiles/ar30.gemfile
48
+ - gemfiles/ar31.gemfile
49
+ - gemfiles/ar32.gemfile
50
+ - gemfiles/ar40.gemfile
51
+ - lib/stator.rb
52
+ - lib/stator/alias.rb
53
+ - lib/stator/integration.rb
54
+ - lib/stator/machine.rb
55
+ - lib/stator/model.rb
56
+ - lib/stator/transition.rb
57
+ - lib/stator/version.rb
58
+ - spec/model_spec.rb
59
+ - spec/spec_helper.rb
60
+ - spec/support/models.rb
61
+ - spec/support/schema.rb
62
+ - stator.gemspec
63
+ homepage: http://www.mikeonrails.com
64
+ licenses: []
65
+ post_install_message:
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ none: false
71
+ requirements:
72
+ - - ! '>='
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ none: false
77
+ requirements:
78
+ - - ! '>='
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubyforge_project:
83
+ rubygems_version: 1.8.25
84
+ signing_key:
85
+ specification_version: 3
86
+ summary: The simplest of ActiveRecord state machines
87
+ test_files:
88
+ - spec/model_spec.rb
89
+ - spec/spec_helper.rb
90
+ - spec/support/models.rb
91
+ - spec/support/schema.rb