stator 0.2.1 → 0.3.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.ruby-version +1 -1
- data/.travis.yml +26 -0
- data/Gemfile +2 -2
- data/gemfiles/ar42.gemfile +1 -1
- data/gemfiles/ar52.gemfile +10 -0
- data/lib/stator/integration.rb +47 -17
- data/lib/stator/machine.rb +17 -26
- data/lib/stator/model.rb +29 -15
- data/lib/stator/transition.rb +23 -18
- data/lib/stator/version.rb +7 -3
- data/spec/model_spec.rb +186 -65
- data/spec/spec_helper.rb +1 -0
- data/spec/support/models.rb +28 -2
- data/spec/support/schema.rb +10 -0
- metadata +4 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 3d680c518e970462c590c98580c2d53fbde45bfe7a6caeecdcf9cc061986a8a8
|
4
|
+
data.tar.gz: 25ac4b8cd95b28b07f1335d58ce5adb5a2dcf059f0c8e2f4b555de7aa7465387
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 33221bc724169bb503c83c6ae72da5acf009f63ce37e4d366ebf4b0668f8c93735fed301159d7ef344af27793cb55934f9e92e21a6e16841cc0c6105f39ea6ef
|
7
|
+
data.tar.gz: 23036f52c40f2f10818f0b03b530a754fcd01c71f022c7ff0d863c6981085b6b89c2777e3eff48545627b2dc8e73fae73fe865c5a4d8f91f0d898f6393e5ec7a
|
data/.ruby-version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
2.
|
1
|
+
2.5.1
|
data/.travis.yml
CHANGED
@@ -4,12 +4,38 @@ rvm:
|
|
4
4
|
- 2.0.0
|
5
5
|
- 2.1.6
|
6
6
|
- 2.2.3
|
7
|
+
- 2.4.5
|
8
|
+
- 2.5.3
|
7
9
|
|
8
10
|
gemfile:
|
9
11
|
- gemfiles/ar40.gemfile
|
10
12
|
- gemfiles/ar41.gemfile
|
11
13
|
- gemfiles/ar42.gemfile
|
14
|
+
- gemfiles/ar52.gemfile
|
12
15
|
|
13
16
|
matrix:
|
14
17
|
allow_failures:
|
15
18
|
- gemfile: gemfiles/ar42.gemfile
|
19
|
+
- gemfile: gemfiles/ar52.gemfile
|
20
|
+
|
21
|
+
exclude:
|
22
|
+
- rvm: 2.0.0
|
23
|
+
gemfile: gemfiles/ar52.gemfile
|
24
|
+
|
25
|
+
- rvm: 2.1.6
|
26
|
+
gemfile: gemfiles/ar52.gemfile
|
27
|
+
|
28
|
+
- rvm: 2.2.3
|
29
|
+
gemfile: gemfiles/ar52.gemfile
|
30
|
+
|
31
|
+
- rvm: 2.4.5
|
32
|
+
gemfile: gemfiles/ar40.gemfile
|
33
|
+
|
34
|
+
- rvm: 2.4.5
|
35
|
+
gemfile: gemfiles/ar41.gemfile
|
36
|
+
|
37
|
+
- rvm: 2.5.3
|
38
|
+
gemfile: gemfiles/ar40.gemfile
|
39
|
+
|
40
|
+
- rvm: 2.5.3
|
41
|
+
gemfile: gemfiles/ar41.gemfile
|
data/Gemfile
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
source 'https://rubygems.org'
|
2
2
|
|
3
3
|
# Specify your gem's dependencies in stator.gemspec
|
4
|
-
gem 'activerecord', '
|
4
|
+
gem 'activerecord', '5.2.3'
|
5
5
|
|
6
6
|
gemspec
|
7
7
|
|
8
|
+
gem 'activerecord-nulldb-adapter', '~> 0.4.0', require: false
|
8
9
|
gem 'rake'
|
9
|
-
gem 'activerecord-nulldb-adapter', :require => false, :git => 'git@github.com:nulldb/nulldb.git', :ref => 'ffc7dae4697c6b9fb15bed9edca3acb1f00eb5f0'
|
10
10
|
gem 'rspec'
|
data/gemfiles/ar42.gemfile
CHANGED
@@ -0,0 +1,10 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
# Specify your gem's dependencies in stator.gemspec
|
4
|
+
gem 'activerecord', '~> 5.2.0'
|
5
|
+
|
6
|
+
gemspec :path => '../'
|
7
|
+
|
8
|
+
gem 'rake'
|
9
|
+
gem 'activerecord-nulldb-adapter', '~> 0.4.0', :require => false, :git => 'git@github.com:nulldb/nulldb.git'
|
10
|
+
gem 'rspec'
|
data/lib/stator/integration.rb
CHANGED
@@ -1,9 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Stator
|
2
4
|
class Integration
|
3
5
|
|
4
|
-
delegate :states, :
|
5
|
-
delegate :transitions, :
|
6
|
-
delegate :namespace, :
|
6
|
+
delegate :states, to: :@machine
|
7
|
+
delegate :transitions, to: :@machine
|
8
|
+
delegate :namespace, to: :@machine
|
9
|
+
|
10
|
+
attr_reader :skip_validations
|
11
|
+
attr_reader :skip_transition_tracking
|
7
12
|
|
8
13
|
def initialize(machine, record)
|
9
14
|
@machine = machine
|
@@ -11,7 +16,7 @@ module Stator
|
|
11
16
|
end
|
12
17
|
|
13
18
|
def state=(new_value)
|
14
|
-
@record.send("#{@machine.field}=",
|
19
|
+
@record.send("#{@machine.field}=", new_value)
|
15
20
|
end
|
16
21
|
|
17
22
|
def state
|
@@ -26,6 +31,14 @@ module Stator
|
|
26
31
|
end
|
27
32
|
end
|
28
33
|
|
34
|
+
def state_by?(state, time)
|
35
|
+
field_name = "#{state}_#{@machine.field}_at"
|
36
|
+
return false unless @record.respond_to?(field_name)
|
37
|
+
return false if @record.send(field_name).nil?
|
38
|
+
return true if time.nil?
|
39
|
+
@record.send(field_name) <= time
|
40
|
+
end
|
41
|
+
|
29
42
|
def state_changed?(use_previous = false)
|
30
43
|
if use_previous
|
31
44
|
!!@record.previous_changes[@machine.field.to_s]
|
@@ -35,11 +48,11 @@ module Stator
|
|
35
48
|
end
|
36
49
|
|
37
50
|
def validate_transition
|
38
|
-
return unless
|
39
|
-
return if
|
51
|
+
return unless state_changed?
|
52
|
+
return if skip_validations
|
40
53
|
|
41
|
-
was =
|
42
|
-
is =
|
54
|
+
was = state_was
|
55
|
+
is = state
|
43
56
|
|
44
57
|
if @record.new_record?
|
45
58
|
invalid_state! unless @machine.matching_transition(::Stator::Transition::ANY, is)
|
@@ -48,7 +61,7 @@ module Stator
|
|
48
61
|
end
|
49
62
|
end
|
50
63
|
|
51
|
-
#
|
64
|
+
# TODO: i18n
|
52
65
|
def invalid_state!
|
53
66
|
@record.errors.add(@machine.field, "is not a valid state")
|
54
67
|
end
|
@@ -58,10 +71,10 @@ module Stator
|
|
58
71
|
end
|
59
72
|
|
60
73
|
def track_transition
|
61
|
-
return if
|
74
|
+
return if skip_transition_tracking
|
62
75
|
|
63
|
-
|
64
|
-
|
76
|
+
attempt_to_track_state(state)
|
77
|
+
attempt_to_track_state_changed_timestamp
|
65
78
|
|
66
79
|
true
|
67
80
|
end
|
@@ -82,7 +95,6 @@ module Stator
|
|
82
95
|
|
83
96
|
# grab all the states and their timestamps that occur on or after state_at and on or before the time in question
|
84
97
|
later_states = all_states.map do |s|
|
85
|
-
|
86
98
|
next if state == s
|
87
99
|
|
88
100
|
at = @record.send("#{s}_#{@machine.field}_at")
|
@@ -98,8 +110,8 @@ module Stator
|
|
98
110
|
return true if later_states.empty?
|
99
111
|
|
100
112
|
# grab the states that were present at the lowest timestamp
|
101
|
-
later_groups = later_states.group_by{|s| s[:at] }
|
102
|
-
later_group_key = later_groups.keys.
|
113
|
+
later_groups = later_states.group_by { |s| s[:at] }
|
114
|
+
later_group_key = later_groups.keys.min
|
103
115
|
later_states = later_groups[later_group_key]
|
104
116
|
|
105
117
|
# if the lowest timestamp is the same as the state's timestamp, evaluate based on state index
|
@@ -111,13 +123,30 @@ module Stator
|
|
111
123
|
end
|
112
124
|
|
113
125
|
def likely_state_at(t)
|
114
|
-
@machine.states.reverse.detect{|s| in_state_at?(s,t) }
|
126
|
+
@machine.states.reverse.detect { |s| in_state_at?(s, t) }
|
127
|
+
end
|
128
|
+
|
129
|
+
def without_validation
|
130
|
+
was = @skip_validations
|
131
|
+
@skip_validations = true
|
132
|
+
yield @record
|
133
|
+
ensure
|
134
|
+
@skip_validations = was
|
135
|
+
end
|
136
|
+
|
137
|
+
def without_transition_tracking
|
138
|
+
was = @skip_transition_tracking
|
139
|
+
@skip_transition_tracking = true
|
140
|
+
yield @record
|
141
|
+
ensure
|
142
|
+
@skip_transition_tracking = was
|
115
143
|
end
|
116
144
|
|
117
145
|
protected
|
118
146
|
|
119
147
|
def attempt_to_track_state(state_to_track)
|
120
148
|
return unless state_to_track
|
149
|
+
|
121
150
|
_attempt_to_track_change("#{state_to_track}_#{@machine.field}_at")
|
122
151
|
end
|
123
152
|
|
@@ -128,7 +157,8 @@ module Stator
|
|
128
157
|
def _attempt_to_track_change(field_name)
|
129
158
|
return unless @record.respond_to?(field_name)
|
130
159
|
return unless @record.respond_to?("#{field_name}=")
|
131
|
-
return unless @record.send(
|
160
|
+
return unless @record.send(field_name.to_s).nil? || state_changed?
|
161
|
+
return if @record.send("#{field_name}_changed?")
|
132
162
|
|
133
163
|
@record.send("#{field_name}=", (Time.zone || Time).now)
|
134
164
|
end
|
data/lib/stator/machine.rb
CHANGED
@@ -7,16 +7,14 @@ module Stator
|
|
7
7
|
attr_reader :transitions
|
8
8
|
attr_reader :states
|
9
9
|
attr_reader :namespace
|
10
|
-
attr_reader :skip_validations
|
11
|
-
attr_reader :skip_transition_tracking
|
12
|
-
|
13
10
|
|
14
11
|
def initialize(klass, options = {})
|
15
|
-
@class_name
|
16
|
-
@field
|
17
|
-
@namespace
|
12
|
+
@class_name = klass.name
|
13
|
+
@field = options[:field] || :state
|
14
|
+
@namespace = options[:namespace]
|
18
15
|
|
19
|
-
@initial_state
|
16
|
+
@initial_state = options[:initial] && options[:initial].to_s
|
17
|
+
@tracking_enabled = options[:track] || false
|
20
18
|
|
21
19
|
@transitions = []
|
22
20
|
@aliases = []
|
@@ -26,7 +24,6 @@ module Stator
|
|
26
24
|
@states = [@initial_state].compact
|
27
25
|
|
28
26
|
@options = options
|
29
|
-
|
30
27
|
end
|
31
28
|
|
32
29
|
def integration(record)
|
@@ -38,7 +35,6 @@ module Stator
|
|
38
35
|
end
|
39
36
|
|
40
37
|
def transition(name, &block)
|
41
|
-
|
42
38
|
t = ::Stator::Transition.new(@class_name, name, @namespace)
|
43
39
|
t.instance_eval(&block) if block_given?
|
44
40
|
|
@@ -66,8 +62,14 @@ module Stator
|
|
66
62
|
end
|
67
63
|
end
|
68
64
|
|
65
|
+
def tracking_enabled?
|
66
|
+
@tracking_enabled
|
67
|
+
end
|
68
|
+
|
69
69
|
def conditional(*states, &block)
|
70
|
-
|
70
|
+
_namespace = @namespace
|
71
|
+
|
72
|
+
klass.instance_exec(proc { states.map(&:to_s).include?(self._stator(_namespace).integration(self).state) }, &block)
|
71
73
|
end
|
72
74
|
|
73
75
|
def matching_transition(from, to)
|
@@ -86,22 +88,6 @@ module Stator
|
|
86
88
|
@class_name.constantize
|
87
89
|
end
|
88
90
|
|
89
|
-
def without_validation
|
90
|
-
was = @skip_validations
|
91
|
-
@skip_validations = true
|
92
|
-
yield
|
93
|
-
ensure
|
94
|
-
@skip_validations = was
|
95
|
-
end
|
96
|
-
|
97
|
-
def without_transition_tracking
|
98
|
-
was = @skip_transition_tracking
|
99
|
-
@skip_transition_tracking = true
|
100
|
-
yield
|
101
|
-
ensure
|
102
|
-
@skip_transition_tracking = was
|
103
|
-
end
|
104
|
-
|
105
91
|
protected
|
106
92
|
|
107
93
|
def verify_transition_validity(transition)
|
@@ -131,6 +117,11 @@ module Stator
|
|
131
117
|
integration = self._stator(#{@namespace.inspect}).integration(self)
|
132
118
|
integration.state == #{state.to_s.inspect}
|
133
119
|
end
|
120
|
+
|
121
|
+
def #{method_name}_by?(time)
|
122
|
+
integration = self._stator(#{@namespace.inspect}).integration(self)
|
123
|
+
integration.state_by?(#{state.to_s.inspect}, time)
|
124
|
+
end
|
134
125
|
EV
|
135
126
|
end
|
136
127
|
end
|
data/lib/stator/model.rb
CHANGED
@@ -14,7 +14,7 @@ module Stator
|
|
14
14
|
f = options[:field] || :state
|
15
15
|
# rescue nil since the table may not exist yet.
|
16
16
|
initial = self.columns_hash[f.to_s].default rescue nil
|
17
|
-
options = options.
|
17
|
+
options = options.merge(initial: initial) if initial
|
18
18
|
end
|
19
19
|
|
20
20
|
machine = (self._stators[options[:namespace].to_s] ||= ::Stator::Machine.new(self, options))
|
@@ -35,26 +35,29 @@ module Stator
|
|
35
35
|
|
36
36
|
def self.included(base)
|
37
37
|
base.class_eval do
|
38
|
-
before_save :
|
38
|
+
before_save :_stator_maybe_track_transition, prepend: true
|
39
39
|
end
|
40
40
|
end
|
41
41
|
|
42
42
|
def in_state_at?(state, t, namespace = '')
|
43
|
-
|
44
|
-
machine.integration(self).in_state_at?(state, t)
|
43
|
+
_integration(namespace).in_state_at?(state, t)
|
45
44
|
end
|
46
45
|
|
47
46
|
def likely_state_at(t, namespace = '')
|
48
|
-
|
49
|
-
machine.integration(self).likely_state_at(t)
|
47
|
+
_integration(namespace).likely_state_at(t)
|
50
48
|
end
|
51
49
|
|
52
|
-
|
50
|
+
def state_by?(state, t, namespace = '')
|
51
|
+
_integration(namespace).state_by?(state, t)
|
52
|
+
end
|
53
53
|
|
54
|
+
protected
|
54
55
|
|
55
|
-
def
|
56
|
+
def _stator_maybe_track_transition
|
56
57
|
self._stators.each do |namespace, machine|
|
57
|
-
machine.
|
58
|
+
next unless machine.tracking_enabled?
|
59
|
+
|
60
|
+
_integration(namespace).track_transition
|
58
61
|
end
|
59
62
|
|
60
63
|
true
|
@@ -70,23 +73,28 @@ module Stator
|
|
70
73
|
end
|
71
74
|
end
|
72
75
|
|
76
|
+
def initialize_dup(other)
|
77
|
+
@_integrations = {}
|
78
|
+
super
|
79
|
+
end
|
80
|
+
|
73
81
|
def without_state_transition_validations(namespace = '')
|
74
|
-
|
75
|
-
yield
|
82
|
+
_integration(namespace).without_validation do
|
83
|
+
yield self
|
76
84
|
end
|
77
85
|
end
|
78
86
|
|
79
87
|
def without_state_transition_tracking(namespace = '')
|
80
|
-
|
81
|
-
yield
|
88
|
+
_integration(namespace).without_transition_tracking do
|
89
|
+
yield self
|
82
90
|
end
|
83
91
|
end
|
84
92
|
|
85
93
|
protected
|
86
94
|
|
87
95
|
def _stator_validate_transition
|
88
|
-
self._stators.
|
89
|
-
|
96
|
+
self._stators.each_key do |namespace|
|
97
|
+
_integration(namespace).validate_transition
|
90
98
|
end
|
91
99
|
end
|
92
100
|
|
@@ -94,6 +102,12 @@ module Stator
|
|
94
102
|
self.class._stator(namespace)
|
95
103
|
end
|
96
104
|
|
105
|
+
def _integration(namespace = '')
|
106
|
+
@_integrations ||= {}
|
107
|
+
@_integrations[namespace] ||= _stator(namespace).integration(self)
|
108
|
+
@_integrations[namespace]
|
109
|
+
end
|
110
|
+
|
97
111
|
end
|
98
112
|
end
|
99
113
|
end
|
data/lib/stator/transition.rb
CHANGED
@@ -42,7 +42,7 @@ module Stator
|
|
42
42
|
end
|
43
43
|
|
44
44
|
def conditional(options = {}, &block)
|
45
|
-
klass.instance_exec(
|
45
|
+
klass.instance_exec(conditional_block(options), &block)
|
46
46
|
end
|
47
47
|
|
48
48
|
def any
|
@@ -63,25 +63,30 @@ module Stator
|
|
63
63
|
@callbacks[kind] || []
|
64
64
|
end
|
65
65
|
|
66
|
-
def
|
66
|
+
def conditional_block(options = {})
|
67
67
|
options[:use_previous] ||= false
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
)
|
78
|
-
|
68
|
+
|
69
|
+
_namespace = @namespace
|
70
|
+
_froms = @froms
|
71
|
+
_to = @to
|
72
|
+
|
73
|
+
Proc.new do
|
74
|
+
(
|
75
|
+
self._stator(_namespace).integration(self).state_changed?(options[:use_previous])
|
76
|
+
) && (
|
77
|
+
_froms.include?(self._stator(_namespace).integration(self).state_was(options[:use_previous])) ||
|
78
|
+
_froms.include?(::Stator::Transition::ANY)
|
79
|
+
) && (
|
80
|
+
self._stator(_namespace).integration(self).state == _to ||
|
81
|
+
_to == ::Stator::Transition::ANY
|
82
|
+
)
|
83
|
+
end
|
79
84
|
end
|
80
85
|
|
81
86
|
def generate_methods
|
82
87
|
klass.class_eval <<-EV, __FILE__, __LINE__ + 1
|
83
88
|
def #{@full_name}(should_save = true)
|
84
|
-
integration =
|
89
|
+
integration = _integration(#{@namespace.to_s.inspect})
|
85
90
|
|
86
91
|
unless can_#{@full_name}?
|
87
92
|
integration.invalid_transition!(integration.state, #{@to.inspect}) if should_save
|
@@ -93,7 +98,7 @@ module Stator
|
|
93
98
|
end
|
94
99
|
|
95
100
|
def #{@full_name}!
|
96
|
-
integration =
|
101
|
+
integration = _integration(#{@namespace.to_s.inspect})
|
97
102
|
|
98
103
|
unless can_#{@full_name}?
|
99
104
|
integration.invalid_transition!(integration.state, #{@to.inspect})
|
@@ -105,10 +110,10 @@ module Stator
|
|
105
110
|
end
|
106
111
|
|
107
112
|
def can_#{@full_name}?
|
108
|
-
|
109
|
-
return true if
|
113
|
+
integration = _integration(#{@namespace.to_s.inspect})
|
114
|
+
return true if integration.skip_validations
|
110
115
|
|
111
|
-
|
116
|
+
machine = self._stator(#{@namespace.to_s.inspect})
|
112
117
|
transition = machine.transitions.detect{|t| t.full_name.to_s == #{@full_name.inspect}.to_s }
|
113
118
|
transition.can?(integration.state)
|
114
119
|
end
|
data/lib/stator/version.rb
CHANGED
@@ -1,8 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Stator
|
4
|
+
|
2
5
|
MAJOR = 0
|
3
|
-
MINOR =
|
4
|
-
PATCH =
|
6
|
+
MINOR = 3
|
7
|
+
PATCH = 4
|
5
8
|
PRERELEASE = nil
|
6
9
|
|
7
|
-
VERSION = [MAJOR, MINOR, PATCH, PRERELEASE].compact.join(
|
10
|
+
VERSION = [MAJOR, MINOR, PATCH, PRERELEASE].compact.join(".")
|
11
|
+
|
8
12
|
end
|
data/spec/model_spec.rb
CHANGED
@@ -1,101 +1,104 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
3
|
+
require "spec_helper"
|
4
4
|
|
5
|
-
|
5
|
+
describe Stator::Model do
|
6
|
+
it "should set the default state after initialization" do
|
6
7
|
u = User.new
|
7
|
-
u.state.should eql(
|
8
|
+
u.state.should eql("pending")
|
8
9
|
end
|
9
10
|
|
10
|
-
it
|
11
|
+
it "should see the initial setting of the state as a change with the initial state as the previous value" do
|
11
12
|
u = User.new
|
12
|
-
u.state =
|
13
|
-
u.state_was.should eql(
|
13
|
+
u.state = "activated"
|
14
|
+
u.state_was.should eql("pending")
|
14
15
|
end
|
15
16
|
|
16
|
-
it
|
17
|
+
it "should not obstruct normal validations" do
|
17
18
|
u = User.new
|
18
19
|
u.should_not be_valid
|
19
20
|
u.errors[:email].grep(/length/).should_not be_empty
|
20
21
|
end
|
21
22
|
|
22
|
-
it
|
23
|
+
it "should ensure a valid state transition when given a bogus state" do
|
23
24
|
u = User.new
|
24
|
-
u.state =
|
25
|
+
u.state = "anythingelse"
|
25
26
|
|
26
27
|
u.should_not be_valid
|
27
|
-
u.errors[:state].should eql([
|
28
|
+
u.errors[:state].should eql(["is not a valid state"])
|
28
29
|
end
|
29
30
|
|
30
|
-
it
|
31
|
-
u = User.new(:
|
32
|
-
u.state =
|
31
|
+
it "should allow creation at any state" do
|
32
|
+
u = User.new(email: "doug@example.com")
|
33
|
+
u.state = "hyperactivated"
|
33
34
|
|
34
35
|
u.should be_valid
|
35
36
|
end
|
36
37
|
|
37
|
-
it
|
38
|
+
it "should ensure a valid state transition when given an illegal state based on the current state" do
|
38
39
|
u = User.new
|
39
|
-
|
40
|
-
u.
|
40
|
+
|
41
|
+
allow(u).to receive(:new_record?).and_return(false)
|
42
|
+
|
43
|
+
u.state = "hyperactivated"
|
41
44
|
|
42
45
|
u.should_not be_valid
|
43
46
|
u.errors[:state].should_not be_empty
|
44
47
|
end
|
45
48
|
|
46
|
-
it
|
47
|
-
u = User.new(:
|
49
|
+
it "should not allow a transition that is currently in a `to` state" do
|
50
|
+
u = User.new(email: "fred@example.com")
|
48
51
|
u.activate!
|
49
52
|
u.hyperactivate!
|
50
53
|
|
51
|
-
lambda{
|
54
|
+
lambda {
|
52
55
|
u.hyperactivate!
|
53
56
|
}.should raise_error(/cannot transition to \"hyperactivated\" from \"hyperactivated\"/)
|
54
57
|
end
|
55
58
|
|
56
|
-
it
|
59
|
+
it "should run conditional validations" do
|
57
60
|
u = User.new
|
58
|
-
u.state =
|
61
|
+
u.state = "semiactivated"
|
59
62
|
u.should_not be_valid
|
60
63
|
|
61
64
|
u.errors[:state].should be_empty
|
62
65
|
u.errors[:email].grep(/format/).should_not be_empty
|
63
66
|
end
|
64
67
|
|
65
|
-
it
|
66
|
-
u = User.new(:
|
68
|
+
it "should invoke callbacks" do
|
69
|
+
u = User.new(activated: true, email: "doug@example.com", name: "doug")
|
67
70
|
u.activated.should == true
|
68
71
|
|
69
72
|
u.deactivate
|
70
73
|
|
71
74
|
u.activated.should == false
|
72
|
-
u.state.should eql(
|
75
|
+
u.state.should eql("deactivated")
|
73
76
|
u.activated_state_at.should be_nil
|
74
77
|
u.should be_persisted
|
75
78
|
end
|
76
79
|
|
77
|
-
it
|
78
|
-
u = User.new(:
|
79
|
-
lambda{
|
80
|
+
it "should blow up if the record is invalid and a bang method is used" do
|
81
|
+
u = User.new(email: "doug@other.com", name: "doug")
|
82
|
+
lambda {
|
80
83
|
u.activate!
|
81
84
|
}.should raise_error(ActiveRecord::RecordInvalid)
|
82
85
|
end
|
83
86
|
|
84
|
-
it
|
87
|
+
it "should allow for other fields to be used other than state" do
|
85
88
|
a = Animal.new
|
86
89
|
a.should be_valid
|
87
90
|
|
88
91
|
a.birth!
|
89
92
|
end
|
90
93
|
|
91
|
-
it
|
94
|
+
it "should create implicit transitions for state declarations" do
|
92
95
|
a = Animal.new
|
93
96
|
a.should_not be_grown_up
|
94
|
-
a.status =
|
97
|
+
a.status = "grown_up"
|
95
98
|
a.save
|
96
99
|
end
|
97
100
|
|
98
|
-
it
|
101
|
+
it "should allow multiple machines in the same model" do
|
99
102
|
f = Farm.new
|
100
103
|
f.should be_dirty
|
101
104
|
f.should be_house_dirty
|
@@ -110,30 +113,30 @@ describe Stator::Model do
|
|
110
113
|
f.should_not be_house_dirty
|
111
114
|
end
|
112
115
|
|
113
|
-
it
|
116
|
+
it "should allow saving to be skipped" do
|
114
117
|
f = Farm.new
|
115
118
|
f.cleanup(false)
|
116
119
|
|
117
120
|
f.should_not be_persisted
|
118
121
|
end
|
119
122
|
|
120
|
-
it
|
123
|
+
it "should allow no initial state" do
|
121
124
|
f = Factory.new
|
122
125
|
f.state.should be_nil
|
123
126
|
|
124
127
|
f.construct.should eql(true)
|
125
128
|
|
126
|
-
f.state.should eql(
|
129
|
+
f.state.should eql("constructed")
|
127
130
|
end
|
128
131
|
|
129
|
-
it
|
132
|
+
it "should allow any transition if validations are opted out of" do
|
130
133
|
u = User.new
|
131
|
-
u.email =
|
134
|
+
u.email = "doug@example.com"
|
132
135
|
|
133
136
|
u.can_hyperactivate?.should eql(false)
|
134
137
|
u.hyperactivate.should eql(false)
|
135
138
|
|
136
|
-
u.state.should eql(
|
139
|
+
u.state.should eql("pending")
|
137
140
|
|
138
141
|
u.without_state_transition_validations do
|
139
142
|
u.can_hyperactivate?.should eql(true)
|
@@ -141,13 +144,13 @@ describe Stator::Model do
|
|
141
144
|
end
|
142
145
|
end
|
143
146
|
|
144
|
-
it
|
147
|
+
it "should skip tracking timestamps if opted out of" do
|
145
148
|
u = User.new
|
146
|
-
u.email =
|
149
|
+
u.email = "doug@example.com"
|
147
150
|
|
148
151
|
u.without_state_transition_tracking do
|
149
152
|
u.semiactivate!
|
150
|
-
u.state.should eql(
|
153
|
+
u.state.should eql("semiactivated")
|
151
154
|
u.semiactivated_state_at.should be_nil
|
152
155
|
end
|
153
156
|
|
@@ -157,9 +160,44 @@ describe Stator::Model do
|
|
157
160
|
u.activated_state_at.should_not be_nil
|
158
161
|
end
|
159
162
|
|
160
|
-
|
163
|
+
it "should skip tracking timestamps if opted out of with thread safety" do
|
164
|
+
threads = []
|
165
|
+
skip = User.new(email: "skip@example.com")
|
166
|
+
nope = User.new(email: "nope@example.com")
|
167
|
+
|
168
|
+
threads << Thread.new do
|
169
|
+
sleep 0.5
|
170
|
+
nope.semiactivate!
|
171
|
+
end
|
172
|
+
threads << Thread.new do
|
173
|
+
skip.without_state_transition_tracking do
|
174
|
+
sleep 1
|
175
|
+
skip.semiactivate!
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
179
|
+
threads.each(&:join)
|
180
|
+
|
181
|
+
nope.semiactivated_state_at.should_not be_nil
|
182
|
+
skip.semiactivated_state_at.should be_nil
|
183
|
+
end
|
184
|
+
|
185
|
+
it "should not inherit _integration cache on dup" do
|
186
|
+
u = User.new(email: "user@example.com")
|
187
|
+
u.save!
|
188
|
+
|
189
|
+
u_duped = u.dup
|
190
|
+
|
191
|
+
u.semiactivate!
|
192
|
+
|
193
|
+
u_duped_integration = u_duped.send(:_integration)
|
161
194
|
|
162
|
-
|
195
|
+
u_duped_integration.state.should_not eql(u.state)
|
196
|
+
u_duped_integration.instance_values["record"].should eq(u_duped)
|
197
|
+
end
|
198
|
+
|
199
|
+
describe "helper methods" do
|
200
|
+
it "should answer the question of whether the state is currently the one invoked" do
|
163
201
|
a = Animal.new
|
164
202
|
a.should be_unborn
|
165
203
|
a.should_not be_born
|
@@ -170,7 +208,7 @@ describe Stator::Model do
|
|
170
208
|
a.should_not be_unborn
|
171
209
|
end
|
172
210
|
|
173
|
-
it
|
211
|
+
it "should determine if it can validly execute a transition" do
|
174
212
|
a = Animal.new
|
175
213
|
a.can_birth?.should eql(true)
|
176
214
|
|
@@ -178,22 +216,20 @@ describe Stator::Model do
|
|
178
216
|
|
179
217
|
a.can_birth?.should eql(false)
|
180
218
|
end
|
181
|
-
|
182
219
|
end
|
183
220
|
|
184
|
-
describe
|
185
|
-
|
221
|
+
describe "tracker methods" do
|
186
222
|
before do
|
187
|
-
Time.zone =
|
223
|
+
Time.zone = "Eastern Time (US & Canada)"
|
188
224
|
end
|
189
225
|
|
190
|
-
it
|
226
|
+
it "should store the initial state timestamp when the record is created" do
|
191
227
|
a = Animal.new
|
192
228
|
a.save
|
193
229
|
a.unborn_status_at.should be_within(1).of(Time.zone.now)
|
194
230
|
end
|
195
231
|
|
196
|
-
it
|
232
|
+
it "should store when a record changed state for the first time" do
|
197
233
|
a = Animal.new
|
198
234
|
a.unborn_status_at.should be_nil
|
199
235
|
a.born_status_at.should be_nil
|
@@ -202,7 +238,7 @@ describe Stator::Model do
|
|
202
238
|
a.born_status_at.should be_within(1).of(Time.zone.now)
|
203
239
|
end
|
204
240
|
|
205
|
-
it
|
241
|
+
it "should store when a record change states" do
|
206
242
|
a = Animal.new
|
207
243
|
a.status_changed_at.should be_nil
|
208
244
|
|
@@ -216,12 +252,11 @@ describe Stator::Model do
|
|
216
252
|
a.save
|
217
253
|
|
218
254
|
a.status_changed_at.should eql(previous_status_changed_at)
|
219
|
-
|
220
255
|
end
|
221
256
|
|
222
|
-
it
|
257
|
+
it "should prepend the setting of the timestamp so other callbacks can use it" do
|
223
258
|
u = User.new
|
224
|
-
u.email =
|
259
|
+
u.email = "doug@example.com"
|
225
260
|
|
226
261
|
u.tagged_at.should be_nil
|
227
262
|
u.semiactivate!
|
@@ -230,11 +265,93 @@ describe Stator::Model do
|
|
230
265
|
u.tagged_at.should_not be_nil
|
231
266
|
end
|
232
267
|
|
268
|
+
it "should respect the timestamp if explicitly provided" do
|
269
|
+
t = Time.at(Time.now.to_i - 3600)
|
270
|
+
|
271
|
+
u = User.new
|
272
|
+
u.email = "doug@example.com"
|
273
|
+
u.state = "semiactivated"
|
274
|
+
u.semiactivated_state_at = t
|
275
|
+
u.save!
|
276
|
+
|
277
|
+
u.state.should eql("semiactivated")
|
278
|
+
u.semiactivated_state_at.should eql(t)
|
279
|
+
end
|
280
|
+
|
281
|
+
it "should respect the timestamp if explicitly provided via create" do
|
282
|
+
t = Time.at(Time.now.to_i - 3600)
|
283
|
+
|
284
|
+
u = User.create!(
|
285
|
+
email: "doug@example.com",
|
286
|
+
state: "semiactivated",
|
287
|
+
semiactivated_state_at: t
|
288
|
+
)
|
289
|
+
|
290
|
+
u.state.should eql("semiactivated")
|
291
|
+
u.semiactivated_state_at.should eql(t)
|
292
|
+
end
|
293
|
+
|
294
|
+
it "should allow opting into track by namespace" do
|
295
|
+
z = ZooKeeper.new(name: "Doug")
|
296
|
+
z.employment_state.should eql("hired")
|
297
|
+
z.employment_fire!
|
298
|
+
z.fired_employment_state_at.should_not be_nil
|
299
|
+
|
300
|
+
z.employment_hire!
|
301
|
+
z.hired_employment_state_at.should_not be_nil
|
302
|
+
|
303
|
+
z.working_start!
|
304
|
+
z.started_working_state_at.should be_nil
|
305
|
+
z.working_end!
|
306
|
+
z.ended_working_state_at.should be_nil
|
307
|
+
end
|
308
|
+
|
309
|
+
describe "#state_by?" do
|
310
|
+
it "should be true when the transition is earlier" do
|
311
|
+
t = Time.now
|
312
|
+
u = User.create!( email: "doug@example.com", activated_state_at: t)
|
313
|
+
u.state_by?(:activated, Time.at(t.to_i + 1)).should be true
|
314
|
+
u.activated_by?(Time.at(t.to_i + 1)).should be true
|
315
|
+
end
|
316
|
+
|
317
|
+
it "should be true when the transition is at the same time" do
|
318
|
+
t = Time.now
|
319
|
+
u = User.create!( email: "doug@example.com", activated_state_at: t)
|
320
|
+
u.state_by?(:activated, t).should be true
|
321
|
+
u.activated_by?(t).should be true
|
322
|
+
end
|
323
|
+
|
324
|
+
it "should be false when the transition is later" do
|
325
|
+
t = Time.now
|
326
|
+
u = User.create!( email: "doug@example.com", activated_state_at: t)
|
327
|
+
u.state_by?(:activated, Time.at(t.to_i - 1)).should be false
|
328
|
+
u.activated_by?(Time.at(t.to_i - 1)).should be false
|
329
|
+
end
|
330
|
+
|
331
|
+
it "should be false when the transition is nil" do
|
332
|
+
t = Time.now
|
333
|
+
u = User.create!( email: "doug@example.com", activated_state_at: nil)
|
334
|
+
u.state_by?(:activated, t).should be false
|
335
|
+
u.activated_by?(t).should be false
|
336
|
+
end
|
337
|
+
|
338
|
+
it "should be true when the transition is not nil and the time is nil" do
|
339
|
+
u = User.create!( email: "doug@example.com", activated_state_at: Time.now)
|
340
|
+
u.state_by?(:activated, nil).should be true
|
341
|
+
u.activated_by?(nil).should be true
|
342
|
+
end
|
343
|
+
|
344
|
+
it "should be false when both are nil" do
|
345
|
+
u = User.create!(email: "doug@example.com", activated_state_at: nil)
|
346
|
+
u.state_by?(:activated, nil).should be false
|
347
|
+
u.activated_by?(nil).should be false
|
348
|
+
end
|
349
|
+
end
|
233
350
|
end
|
234
351
|
|
235
|
-
describe
|
236
|
-
it
|
237
|
-
u = User.new(:
|
352
|
+
describe "aliasing" do
|
353
|
+
it "should allow aliasing within the dsl" do
|
354
|
+
u = User.new(email: "doug@example.com")
|
238
355
|
u.should respond_to(:active?)
|
239
356
|
u.should respond_to(:inactive?)
|
240
357
|
|
@@ -251,11 +368,11 @@ describe Stator::Model do
|
|
251
368
|
u.should be_active
|
252
369
|
u.should_not be_inactive
|
253
370
|
|
254
|
-
User::ACTIVE_STATES.should eql([
|
255
|
-
User::INACTIVE_STATES.should eql([
|
371
|
+
User::ACTIVE_STATES.should eql(%w[activated hyperactivated])
|
372
|
+
User::INACTIVE_STATES.should eql(%w[pending deactivated semiactivated])
|
256
373
|
|
257
|
-
User.active.to_sql.gsub(
|
258
|
-
User.inactive.to_sql.gsub(
|
374
|
+
User.active.to_sql.gsub(" ", " ").should eq("SELECT users.* FROM users WHERE users.state IN ('activated', 'hyperactivated')")
|
375
|
+
User.inactive.to_sql.gsub(" ", " ").should eq("SELECT users.* FROM users WHERE users.state IN ('pending', 'deactivated', 'semiactivated')")
|
259
376
|
end
|
260
377
|
|
261
378
|
it "should evaluate inverses correctly" do
|
@@ -270,7 +387,7 @@ describe Stator::Model do
|
|
270
387
|
f.should be_house_cleaned
|
271
388
|
end
|
272
389
|
|
273
|
-
it
|
390
|
+
it "should namespace aliases just like everything else" do
|
274
391
|
f = Farm.new
|
275
392
|
f.should respond_to(:house_cleaned?)
|
276
393
|
|
@@ -280,19 +397,23 @@ describe Stator::Model do
|
|
280
397
|
f.should be_house_cleaned
|
281
398
|
end
|
282
399
|
|
283
|
-
it
|
400
|
+
it "should allow for explicit constant and scope names to be provided" do
|
284
401
|
User.should respond_to(:luke_warmers)
|
285
402
|
(!!defined?(User::LUKE_WARMERS)).should eql(true)
|
286
403
|
u = User.new
|
287
404
|
u.should respond_to(:luke_warm?)
|
288
405
|
end
|
289
406
|
|
290
|
-
it
|
407
|
+
it "should not create constants or scopes by default" do
|
291
408
|
u = User.new
|
292
409
|
u.should respond_to(:iced_tea?)
|
293
410
|
(!!defined?(User::ICED_TEA_STATES)).should eql(false)
|
294
411
|
User.should_not respond_to(:iced_tea)
|
295
412
|
end
|
296
|
-
end
|
297
413
|
|
414
|
+
it "should determine the full list of states correctly" do
|
415
|
+
states = User._stator("").states
|
416
|
+
states.should eql(%w[pending activated deactivated semiactivated hyperactivated])
|
417
|
+
end
|
418
|
+
end
|
298
419
|
end
|
data/spec/spec_helper.rb
CHANGED
data/spec/support/models.rb
CHANGED
@@ -3,8 +3,7 @@ class User < ActiveRecord::Base
|
|
3
3
|
|
4
4
|
before_save :set_tagged_at
|
5
5
|
|
6
|
-
|
7
|
-
stator track: true do
|
6
|
+
stator track: true, initial: :pending do
|
8
7
|
|
9
8
|
transition :activate do
|
10
9
|
from :pending, :semiactivated
|
@@ -135,6 +134,33 @@ class Zoo < ActiveRecord::Base
|
|
135
134
|
end
|
136
135
|
end
|
137
136
|
|
137
|
+
class ZooKeeper < ActiveRecord::Base
|
138
|
+
extend Stator::Model
|
139
|
+
|
140
|
+
stator namespace: 'employment', field: 'employment_state', track: true do
|
141
|
+
transition :hire do
|
142
|
+
from nil, :fired
|
143
|
+
to :hired
|
144
|
+
end
|
145
|
+
|
146
|
+
transition :fire do
|
147
|
+
from :hired
|
148
|
+
to :fired
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
stator namespace: 'working', field: 'working_state', track: false do
|
153
|
+
transition :start do
|
154
|
+
from nil, :ended
|
155
|
+
to :started
|
156
|
+
end
|
157
|
+
|
158
|
+
transition :end do
|
159
|
+
from :started
|
160
|
+
to :ended
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
138
164
|
|
139
165
|
class Farm < ActiveRecord::Base
|
140
166
|
extend Stator::Model
|
data/spec/support/schema.rb
CHANGED
@@ -22,6 +22,16 @@ ActiveRecord::Schema.define(:version => 20130628161227) do
|
|
22
22
|
t.datetime "born_status_at"
|
23
23
|
end
|
24
24
|
|
25
|
+
create_table "zoo_keepers", :force => true do |t|
|
26
|
+
t.string "name"
|
27
|
+
t.string "employment_state", :default => 'hired'
|
28
|
+
t.datetime "hired_employment_state_at"
|
29
|
+
t.datetime "fired_employment_state_at"
|
30
|
+
t.string "working_state"
|
31
|
+
t.datetime "started_working_state_at"
|
32
|
+
t.datetime "ended_working_state_at"
|
33
|
+
end
|
34
|
+
|
25
35
|
create_table "zoos", :force => true do |t|
|
26
36
|
t.string "name"
|
27
37
|
t.string "state", :default => 'closed'
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: stator
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.4
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Mike Nelson
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-07-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -44,6 +44,7 @@ files:
|
|
44
44
|
- gemfiles/ar40.gemfile
|
45
45
|
- gemfiles/ar41.gemfile
|
46
46
|
- gemfiles/ar42.gemfile
|
47
|
+
- gemfiles/ar52.gemfile
|
47
48
|
- lib/stator.rb
|
48
49
|
- lib/stator/alias.rb
|
49
50
|
- lib/stator/integration.rb
|
@@ -74,8 +75,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
74
75
|
- !ruby/object:Gem::Version
|
75
76
|
version: '0'
|
76
77
|
requirements: []
|
77
|
-
|
78
|
-
rubygems_version: 2.4.5.1
|
78
|
+
rubygems_version: 3.0.6
|
79
79
|
signing_key:
|
80
80
|
specification_version: 4
|
81
81
|
summary: The simplest of ActiveRecord state machines
|