stator 0.0.13

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format documentation
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ stator
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 1.9.3-p194
data/.travis.yml ADDED
@@ -0,0 +1,17 @@
1
+ language: ruby
2
+
3
+ rvm:
4
+ - 1.8.7
5
+ - 1.9.3
6
+ - 2.0.0
7
+
8
+ gemfile:
9
+ - gemfiles/ar30.gemfile
10
+ - gemfiles/ar31.gemfile
11
+ - gemfiles/ar32.gemfile
12
+ - gemfiles/ar40.gemfile
13
+
14
+ matrix:
15
+ exclude:
16
+ - rvm: 1.8.7
17
+ gemfile: gemfiles/ar40.gemfile
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in stator.gemspec
4
+ gem 'activerecord', '4.0.0'
5
+
6
+ gemspec
7
+
8
+ gem 'rake'
9
+ gem 'activerecord-nulldb-adapter', :require => false, :git => 'git@github.com:nulldb/nulldb.git', :ref => 'ffc7dae4697c6b9fb15bed9edca3acb1f00eb5f0'
10
+ gem 'rspec'
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Mike Nelson
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # Stator
2
+
3
+ Stator is a minimalist's state machine. It's a simple dsl that uses existing ActiveRecord functionality to accomplish common state machine functionality. This is not a full-featured computer-science driven gem, it's a gem that covers the 98% of use cases that I've run into.
4
+
5
+ ```ruby
6
+ gem 'stator', github: 'mnelson/stator', tag: 'v0.0.1'
7
+ ```
8
+
9
+ ## Usage
10
+
11
+ If you've used the state_machine it's a pretty similar dsl. You define your state machine, transitions, states, and your callbacks (if any). One difference is that stator assumes you've set your db column to the initial state.
12
+
13
+ ```ruby
14
+ class User < ActiveRecord::Base
15
+ extend Stator::Model
16
+
17
+ stator do
18
+
19
+ transition :semiactivate do
20
+ from :unactivated
21
+ to :semiactivated
22
+ end
23
+
24
+ transition :activate do
25
+ from :unactivated, :semiactivated
26
+ to :activated
27
+ end
28
+
29
+ transition :deactivate do
30
+ from any
31
+ to :deactivate
32
+ end
33
+
34
+ end
35
+ end
36
+ ```
37
+
38
+ Then you use like this:
39
+
40
+ ```ruby
41
+ u = User.new
42
+ u.state
43
+ # => 'unactivated'
44
+ u.persisted?
45
+ # => false
46
+ u.semiactivate
47
+ # => true
48
+ u.state
49
+ # => 'semiactivated'
50
+ u.persisted?
51
+ # => true
52
+ ```
53
+
54
+ ## Advanced Usage
55
+
56
+ The intention of stator was to avoid hijacking ActiveRecord or reinvent the wheel. You can conditionally validate, invoke callbacks, etc. via a conditional block - no magic:
57
+
58
+ ```ruby
59
+ class User < ActiveRecord::Base
60
+ extend Stator::Model
61
+
62
+ stator field: :status, track: true do
63
+
64
+ transition :activate do
65
+ from :unactivated
66
+ to :activated
67
+
68
+ # conditions is a string condition which will ensure the state
69
+ # was one of the `from` states and is one of the `to` states.
70
+ conditional do |conditions|
71
+ validate :validate_user_ip_not_blacklisted, if: conditions
72
+ end
73
+
74
+ end
75
+
76
+ # conditions is a string condition which will ensure the state
77
+ # is one of the ones provided.
78
+ conditional :unactivated do |conditions|
79
+ validates :email, presence: true, unless: conditions
80
+ end
81
+
82
+ end
83
+ end
84
+ ```
85
+
86
+ Within a transition, the `conditional` block accepts a `use_previous` option which tells the state checks to use the record's previous_changes rather than the current changes. This is especially useful for after_commit scenarios where the record's changes hash is cleared before the execution begins.
87
+
88
+ ```ruby
89
+ transition :activate do
90
+ from :unactivated
91
+ to :activated
92
+
93
+ conditional(use_previous: true) do |conditions|
94
+ after_commit :send_things, if: conditions
95
+ end
96
+ ```
97
+
98
+ The instance has some convenience methods which are generated by the state machine:
99
+
100
+ ```ruby
101
+ u = User.new
102
+ u.activated?
103
+ # => false
104
+ u.can_activate?
105
+ # => true
106
+ ```
107
+
108
+ Note that asking if a transition can take place via `can_[transition_name]?` does not invoke validations. It simply determines whether the record is in a state which the transition can take place from.
109
+
110
+
111
+ The `track: true` option enables timekeeping of the state transition. It will try to set a field in the format of "state_field_at" before saving the record. For example, in the previous state machine the following would occur:
112
+
113
+ ```ruby
114
+ u = User.new
115
+ u.activate
116
+
117
+ u.activated_status_at
118
+ # => (now)
119
+ ```
120
+
121
+ You can have multiple state machines for your model:
122
+
123
+ ```ruby
124
+
125
+ class User < ActiveRecord::Base
126
+ extend Stator::Model
127
+
128
+ # initial state = asleep
129
+ stator do
130
+ # wake up
131
+ end
132
+
133
+ # initial state = incomplete
134
+ stator namespace: 'homework', field: 'homework_state' do
135
+ # get it done
136
+ end
137
+ end
138
+ ```
139
+
140
+
141
+ If you need to access the state machine directly, you can do so via the class:
142
+
143
+ ```ruby
144
+ User._stator(namespace)
145
+ ```
146
+
147
+ #### Aliasing
148
+
149
+ It's a really common case to have a set of states evaluated as a single concept. For example, many apps have a concept of "active" users. You generally see something like this:
150
+
151
+ ```ruby
152
+ class User < ActiveRecord::Base
153
+ ACTIVE_STATES = %w(semiactivated activated)
154
+
155
+ scope :active, -> { where(state: ACTIVE_STATES) }
156
+
157
+ def active?
158
+ self.state.in?(ACTIVE_STATES)
159
+ end
160
+ end
161
+ ```
162
+
163
+ To this point, we're doing ok. But how about defining inactive as well? At this point things start getting a little dirtier since a change to ACTIVE_STATES should impact INACTIVE_STATES. For this reason, stator allows you to define state aliases:
164
+
165
+ ```ruby
166
+ class User < ActiveRecord::Base
167
+ extend Stator::Model
168
+
169
+ stator do
170
+ # forgoing state definitions...
171
+
172
+ state_alias :active do
173
+ is :semiactivated, :activated
174
+ opposite :inactive
175
+ end
176
+ end
177
+ end
178
+ ```
179
+
180
+ The provided example will define an `active?` and `inactive?` method. If you want to create the constant and/or the scope, just pass them as options to the state_alias method:
181
+
182
+ ```ruby
183
+ # will generate a User::ACTIVE_STATES constant, User.active scope, and User#active? instance method
184
+ state_alias :active, scope: true, constant: true do
185
+ # ...
186
+ end
187
+ ```
188
+
189
+ Passing `true` for the scope or constant will result in default naming conventions. You can pass your own names if you'd rather:
190
+
191
+ ```ruby
192
+ # will generate a User::THE_ACTIVE_STATES constant, User.the_active_ones scope, and User#active? instance method
193
+ state_alias :active, scope: :the_active_ones, constant: :the_active_states do
194
+ # ...
195
+ end
196
+ ```
197
+
198
+ The `opposite` method also accepts the scope and constant options, but does not yield to a block since the state definitions are inheritenly tied to the ones described in the parent state_alias block.
199
+
200
+ #### TODO
201
+
202
+ * Allow for multiple variations of a transition (shift_down style - :third_gear => :second_gear, :second_gear => :first_gear)
203
+ * Create adapters for different backends (not just ActiveRecord)
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env rake
2
+ require 'rake'
3
+ require "bundler/gem_tasks"
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec) do |spec|
7
+ spec.pattern = 'spec/*_spec.rb'
8
+ end
9
+ task :default => :spec
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in stator.gemspec
4
+ gem 'activerecord', '3.0.20'
5
+
6
+ gemspec :path => '../'
7
+
8
+ gem 'rake'
9
+ gem 'activerecord-nulldb-adapter', :require => false
10
+ gem 'rspec'
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in stator.gemspec
4
+ gem 'activerecord', '3.1.12'
5
+
6
+ gemspec :path => '../'
7
+
8
+ gem 'rake'
9
+ gem 'activerecord-nulldb-adapter', :require => false
10
+ gem 'rspec'
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in stator.gemspec
4
+ gem 'activerecord', '3.2.13'
5
+
6
+ gemspec :path => '../'
7
+
8
+ gem 'rake'
9
+ gem 'activerecord-nulldb-adapter', :require => false
10
+ gem 'rspec'
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in stator.gemspec
4
+ gem 'activerecord', '4.0.0'
5
+
6
+ gemspec :path => '../'
7
+
8
+ gem 'rake'
9
+ gem 'activerecord-nulldb-adapter', :require => false, :github => 'nulldb/nulldb', :ref => 'ffc7dae4697c6b9fb15bed9edca3acb1f00eb5f0'
10
+ gem 'rspec'
@@ -0,0 +1,81 @@
1
+ module Stator
2
+ class Alias
3
+
4
+ def initialize(machine, name, options = {})
5
+ @machine = machine
6
+ @name = name
7
+ @namespace = @machine.namespace
8
+ @full_name = [@namespace, @name].compact.join('_')
9
+ @states = []
10
+ @not = false
11
+ @opposite = nil
12
+ @constant = options[:constant]
13
+ @scope = options[:scope]
14
+ end
15
+
16
+ def is(*args)
17
+ @states |= args.map(&:to_s)
18
+ end
19
+
20
+ def is_not(*args)
21
+ @not = true
22
+ is(*args)
23
+ end
24
+
25
+ def opposite(*args)
26
+ @opposite = args
27
+ end
28
+
29
+ def evaluate
30
+ generate_methods
31
+
32
+ if @opposite
33
+ op = @machine.state_alias(*@opposite)
34
+
35
+ op.is(*@states) if @not
36
+ op.is_not(*@states) if !@not
37
+ end
38
+ end
39
+
40
+ protected
41
+
42
+ def inferred_constant_name
43
+ [@full_name.upcase, @machine.field.to_s.pluralize.upcase].join('_')
44
+ end
45
+
46
+ def generate_methods
47
+
48
+ not_states = (@machine.states - @states)
49
+
50
+ if @scope
51
+ name = @scope == true ? @full_name : @scope
52
+ @machine.klass.class_eval <<-EV, __FILE__, __LINE__ + 1
53
+ scope #{name.inspect}, lambda {
54
+ where(_stator(#{@namespace.inspect}).field => #{(@not ? not_states : @states).inspect})
55
+ }
56
+ EV
57
+ end
58
+
59
+ if @constant
60
+ name = @constant == true ? inferred_constant_name : @constant.to_s.upcase
61
+ if @not
62
+ @machine.klass.class_eval <<-EV, __FILE__, __LINE__ + 1
63
+ #{name} = #{not_states.inspect}.freeze
64
+ EV
65
+ else
66
+ @machine.klass.class_eval <<-EV, __FILE__, __LINE__ + 1
67
+ #{name} = #{@states.inspect}.freeze
68
+ EV
69
+ end
70
+ end
71
+
72
+ @machine.klass.class_eval <<-EV, __FILE__, __LINE__ + 1
73
+ def #{@full_name}?
74
+ integration = _stator(#{@namespace.inspect}).integration(self)
75
+ #{(@not ? not_states : @states).inspect}.include?(integration.state)
76
+ end
77
+ EV
78
+ end
79
+
80
+ end
81
+ end
@@ -0,0 +1,82 @@
1
+ module Stator
2
+ class Integration
3
+
4
+ delegate :states, :to => :@machine
5
+ delegate :transitions, :to => :@machine
6
+ delegate :namespace, :to => :@machine
7
+
8
+ def initialize(machine, record)
9
+ @machine = machine
10
+ @record = record
11
+ end
12
+
13
+
14
+
15
+
16
+ def state=(new_value)
17
+ @record.send("#{@machine.field}=", new_value)
18
+ end
19
+
20
+ def state
21
+ @record.send(@machine.field)
22
+ end
23
+
24
+
25
+ def state_was(use_previous = false)
26
+ if use_previous
27
+ @record.previous_changes[@machine.field.to_s].try(:[], 0)
28
+ else
29
+ @record.send("#{@machine.field}_was")
30
+ end
31
+ end
32
+
33
+
34
+ def state_changed?
35
+ @record.send("#{@machine.field}_changed?")
36
+ end
37
+
38
+
39
+
40
+ def validate_transition
41
+ return unless self.state_changed?
42
+
43
+ was = self.state_was
44
+ is = self.state
45
+
46
+ if @record.new_record?
47
+ unless @machine.matching_transition(::Stator::Transition::ANY, is)
48
+ @record.errors.add(@machine.field, "is not a valid state")
49
+ end
50
+ else
51
+ unless @machine.matching_transition(was, is)
52
+ @record.errors.add(@machine.field, "cannot transition to #{is.inspect} from #{was.inspect}")
53
+ end
54
+ end
55
+ end
56
+
57
+ def track_transition
58
+ self.attempt_to_track_state(self.state_was)
59
+ self.attempt_to_track_state(self.state)
60
+
61
+ true
62
+ end
63
+
64
+
65
+ protected
66
+
67
+ def attempt_to_track_state(state_to_track)
68
+ return unless state_to_track
69
+
70
+ field_name = "#{state_to_track}_#{@machine.field}_at"
71
+
72
+ return unless @record.respond_to?(field_name)
73
+ return unless @record.respond_to?("#{field_name}=")
74
+
75
+ unless @record.send(field_name)
76
+ @record.send("#{field_name}=", (Time.zone || Time).now)
77
+ end
78
+ end
79
+
80
+
81
+ end
82
+ end
@@ -0,0 +1,123 @@
1
+ module Stator
2
+ class Machine
3
+
4
+ attr_reader :initial_state
5
+ attr_reader :field
6
+ attr_reader :transition_names
7
+ attr_reader :transitions
8
+ attr_reader :states
9
+ attr_reader :namespace
10
+
11
+
12
+ def initialize(klass, options = {})
13
+ @class_name = klass.name
14
+ @field = options[:field] || :state
15
+ @namespace = options[:namespace] || nil
16
+
17
+ # rescue nil since the table may not exist yet.
18
+ @initial_state = klass.columns_hash[@field.to_s].default rescue nil
19
+
20
+ @transitions = []
21
+ @aliases = []
22
+
23
+ # pushed out into their own variables for performance reasons (AR integration can use method missing - see the HelperMethods module)
24
+ @transition_names = []
25
+ @states = [@initial_state].compact
26
+
27
+ @options = options
28
+
29
+ end
30
+
31
+ def integration(record)
32
+ ::Stator::Integration.new(self, record)
33
+ end
34
+
35
+ def get_transition(name)
36
+ @transitions.detect{|t| t.name.to_s == name.to_s}
37
+ end
38
+
39
+ def transition(name, &block)
40
+
41
+ t = ::Stator::Transition.new(@class_name, name, @namespace)
42
+ t.instance_eval(&block) if block_given?
43
+
44
+ verify_transition_validity(t)
45
+
46
+ @transitions << t
47
+ @transition_names |= [t.full_name] unless t.full_name.blank?
48
+ @states |= [t.to_state] unless t.to_state.nil?
49
+
50
+ t
51
+ end
52
+
53
+ def state_alias(name, options = {}, &block)
54
+ a = ::Stator::Alias.new(self, name, options)
55
+ a.instance_eval(&block) if block_given?
56
+ @aliases << a
57
+ a
58
+ end
59
+
60
+ def state(name, &block)
61
+ transition(nil) do
62
+ from any
63
+ to name
64
+ instance_eval(&block) if block_given?
65
+ end
66
+ end
67
+
68
+ def conditional(*states, &block)
69
+ klass.instance_exec("#{states.map(&:to_s).inspect}.include?(self._stator(#{@namespace.inspect}).integration(self).state)", &block)
70
+ end
71
+
72
+ def matching_transition(from, to)
73
+ @transitions.detect do |transition|
74
+ transition.valid?(from, to)
75
+ end
76
+ end
77
+
78
+ def evaluate
79
+ @transitions.each(&:evaluate)
80
+ @aliases.each(&:evaluate)
81
+ generate_methods
82
+ end
83
+
84
+ def klass
85
+ @class_name.constantize
86
+ end
87
+
88
+ protected
89
+
90
+ def verify_transition_validity(transition)
91
+ verify_state_singularity_of_transition(transition)
92
+ verify_name_singularity_of_transition(transition)
93
+ end
94
+
95
+ def verify_state_singularity_of_transition(transition)
96
+ transition.from_states.each do |from|
97
+ if other = matching_transition(from, transition.to_state)
98
+ raise "[Stator] another transition already exists which moves #{@class_name} from #{from.inspect} to #{transition.to_state.inspect}"
99
+ end
100
+ end
101
+ end
102
+
103
+ def verify_name_singularity_of_transition(transition)
104
+ if other = @transitions.detect{|other| transition.name && transition.name == other.name }
105
+ raise "[Stator] another transition already exists with the name of #{transition.name.inspect} in the #{@class_name} class"
106
+ end
107
+ end
108
+
109
+ def generate_methods
110
+ self.states.each do |state|
111
+ method_name = [@namespace, state].compact.join('_')
112
+ klass.class_eval <<-EV, __FILE__, __LINE__ + 1
113
+ def #{method_name}?
114
+ integration = self._stator(#{@namespace.inspect}).integration(self)
115
+ integration.state == #{state.to_s.inspect}
116
+ end
117
+ EV
118
+ end
119
+ end
120
+
121
+
122
+ end
123
+ end
@@ -0,0 +1,71 @@
1
+ module Stator
2
+ module Model
3
+
4
+ def stator(options = {}, &block)
5
+
6
+ class_attribute :_stators unless respond_to?(:_stators)
7
+
8
+ include InstanceMethods unless self.included_modules.include?(InstanceMethods)
9
+ include TrackerMethods if options[:track] == true
10
+
11
+ self._stators ||= {}
12
+ machine = (self._stators[options[:namespace].to_s] ||= ::Stator::Machine.new(self, options))
13
+
14
+ if block_given?
15
+ machine.instance_eval(&block)
16
+ machine.evaluate
17
+ end
18
+
19
+ machine
20
+ end
21
+
22
+ def _stator(namespace)
23
+ self._stators[namespace.to_s]
24
+ end
25
+
26
+ module TrackerMethods
27
+
28
+ def self.included(base)
29
+ base.class_eval do
30
+ before_save :_stator_track_transition
31
+ end
32
+ end
33
+
34
+
35
+ protected
36
+
37
+
38
+ def _stator_track_transition
39
+
40
+ self._stators.each do |namespace, machine|
41
+ machine.integration(self).track_transition
42
+ end
43
+
44
+ true
45
+ end
46
+
47
+ end
48
+
49
+ module InstanceMethods
50
+
51
+ def self.included(base)
52
+ base.class_eval do
53
+ validate :_stator_validate_transition
54
+ end
55
+ end
56
+
57
+ protected
58
+
59
+ def _stator_validate_transition
60
+ self._stators.each do |namespace, machine|
61
+ machine.integration(self).validate_transition
62
+ end
63
+ end
64
+
65
+ def _stator(namespace)
66
+ self.class._stator(namespace)
67
+ end
68
+
69
+ end
70
+ end
71
+ end