stator 0.5.0 → 0.9.0.beta
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/build.yml +8 -2
- data/.gitignore +1 -0
- data/Appraisals +4 -0
- data/Gemfile +1 -0
- data/gemfiles/activerecord_5.1.gemfile +1 -0
- data/gemfiles/activerecord_5.1.gemfile.lock +12 -2
- data/gemfiles/activerecord_5.2.gemfile +1 -0
- data/gemfiles/activerecord_5.2.gemfile.lock +12 -2
- data/gemfiles/activerecord_5.gemfile +12 -0
- data/gemfiles/activerecord_5.gemfile.lock +74 -0
- data/gemfiles/activerecord_6.0.gemfile +1 -0
- data/gemfiles/activerecord_6.0.gemfile.lock +12 -2
- data/gemfiles/activerecord_6.1.gemfile +1 -0
- data/gemfiles/activerecord_6.1.gemfile.lock +12 -2
- data/gemfiles/activerecord_7.0.gemfile +1 -0
- data/gemfiles/activerecord_7.0.gemfile.lock +12 -2
- data/lib/stator/alias.rb +51 -30
- data/lib/stator/integration.rb +42 -49
- data/lib/stator/machine.rb +59 -58
- data/lib/stator/model.rb +78 -73
- data/lib/stator/transition.rb +61 -60
- data/lib/stator/version.rb +3 -5
- data/lib/stator.rb +13 -0
- data/spec/model_spec.rb +203 -239
- data/spec/spec_helper.rb +6 -3
- data/spec/support/models.rb +26 -45
- data/spec/support/schema.rb +42 -42
- data/stator.gemspec +1 -1
- metadata +21 -5
data/lib/stator/integration.rb
CHANGED
@@ -2,48 +2,48 @@
|
|
2
2
|
|
3
3
|
module Stator
|
4
4
|
class Integration
|
5
|
+
delegate :states, to: :machine
|
6
|
+
delegate :transitions, to: :machine
|
7
|
+
delegate :namespace, to: :machine
|
5
8
|
|
6
|
-
|
7
|
-
delegate :transitions, to: :@machine
|
8
|
-
delegate :namespace, to: :@machine
|
9
|
-
|
10
|
-
attr_reader :skip_validations
|
11
|
-
attr_reader :skip_transition_tracking
|
9
|
+
attr_reader :skip_validations, :skip_transition_tracking, :record, :machine
|
12
10
|
|
13
11
|
def initialize(machine, record)
|
14
12
|
@machine = machine
|
15
13
|
@record = record
|
14
|
+
@skip_transition_tracking = false
|
16
15
|
end
|
17
16
|
|
18
17
|
def state=(new_value)
|
19
|
-
|
18
|
+
record.send("#{machine.field}=", new_value)
|
20
19
|
end
|
21
20
|
|
22
21
|
def state
|
23
|
-
|
22
|
+
record.send(machine.field)&.to_sym
|
24
23
|
end
|
25
24
|
|
26
25
|
def state_was(use_previous = false)
|
27
26
|
if use_previous
|
28
|
-
|
27
|
+
record.previous_changes[machine.field].try(:[], 0).to_sym
|
29
28
|
else
|
30
|
-
|
29
|
+
record.send("#{@machine.field}_was")
|
31
30
|
end
|
32
31
|
end
|
33
32
|
|
34
33
|
def state_by?(state, time)
|
35
|
-
field_name = "#{state}_#{
|
36
|
-
return false unless
|
37
|
-
return false if
|
34
|
+
field_name = "#{state}_#{machine.field}_at"
|
35
|
+
return false unless record.respond_to?(field_name)
|
36
|
+
return false if record.send(field_name).nil?
|
38
37
|
return true if time.nil?
|
39
|
-
|
38
|
+
|
39
|
+
record.send(field_name) <= time
|
40
40
|
end
|
41
41
|
|
42
42
|
def state_changed?(use_previous = false)
|
43
43
|
if use_previous
|
44
|
-
|
44
|
+
!!record.previous_changes[machine.field.to_s]
|
45
45
|
else
|
46
|
-
|
46
|
+
record.send("#{machine.field}_changed?")
|
47
47
|
end
|
48
48
|
end
|
49
49
|
|
@@ -51,23 +51,20 @@ module Stator
|
|
51
51
|
return unless state_changed?
|
52
52
|
return if skip_validations
|
53
53
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
if @record.new_record?
|
58
|
-
invalid_state! unless @machine.matching_transition(::Stator::Transition::ANY, is)
|
54
|
+
if record.new_record?
|
55
|
+
invalid_state! unless machine.matching_transition(Stator::ANY, state)
|
59
56
|
else
|
60
|
-
invalid_transition!(
|
57
|
+
invalid_transition!(state_was, state) unless machine.matching_transition(state_was, state)
|
61
58
|
end
|
62
59
|
end
|
63
60
|
|
64
61
|
# TODO: i18n
|
65
62
|
def invalid_state!
|
66
|
-
|
63
|
+
record.errors.add(machine.field, 'is not a valid state')
|
67
64
|
end
|
68
65
|
|
69
66
|
def invalid_transition!(was, is)
|
70
|
-
|
67
|
+
record.errors.add(machine.field, "cannot transition to #{is} from #{was}")
|
71
68
|
end
|
72
69
|
|
73
70
|
def track_transition
|
@@ -83,7 +80,7 @@ module Stator
|
|
83
80
|
state = state.to_s
|
84
81
|
t = t.to_time
|
85
82
|
|
86
|
-
state_at =
|
83
|
+
state_at = record.send("#{state}_#{machine.field}_at")
|
87
84
|
|
88
85
|
# if we've never been in the state, the answer is no
|
89
86
|
return false if state_at.nil?
|
@@ -91,20 +88,18 @@ module Stator
|
|
91
88
|
# if we came into this state later in life, the answer is no
|
92
89
|
return false if state_at > t
|
93
90
|
|
94
|
-
all_states =
|
91
|
+
all_states = machine.states.reverse
|
95
92
|
|
96
93
|
# grab all the states and their timestamps that occur on or after state_at and on or before the time in question
|
97
|
-
later_states = all_states.
|
94
|
+
later_states = all_states.filter_map do |s|
|
98
95
|
next if state == s
|
99
96
|
|
100
|
-
at =
|
97
|
+
at = record.send("#{s}_#{machine.field}_at")
|
101
98
|
|
102
|
-
next if at.nil?
|
103
|
-
next if at < state_at
|
104
|
-
next if at > t
|
99
|
+
next if at.nil? || at < state_at || at > t
|
105
100
|
|
106
101
|
{ state: s, at: at }
|
107
|
-
end
|
102
|
+
end
|
108
103
|
|
109
104
|
# if there were no states on or after the state_at, the answer is yes
|
110
105
|
return true if later_states.empty?
|
@@ -115,53 +110,51 @@ module Stator
|
|
115
110
|
later_states = later_groups[later_group_key]
|
116
111
|
|
117
112
|
# if the lowest timestamp is the same as the state's timestamp, evaluate based on state index
|
118
|
-
if later_states[0][:at] == state_at
|
119
|
-
return all_states.index(state) < all_states.index(later_states[0][:state])
|
120
|
-
end
|
113
|
+
return all_states.index(state) < all_states.index(later_states[0][:state]) if later_states[0][:at] == state_at
|
121
114
|
|
122
115
|
false
|
123
116
|
end
|
124
117
|
|
125
118
|
def likely_state_at(t)
|
126
|
-
|
119
|
+
machine.states.reverse.detect { |s| in_state_at?(s, t) }
|
127
120
|
end
|
128
121
|
|
129
122
|
def without_validation
|
130
|
-
was =
|
123
|
+
was = skip_validations
|
131
124
|
@skip_validations = true
|
132
|
-
yield
|
125
|
+
yield record
|
133
126
|
ensure
|
134
127
|
@skip_validations = was
|
135
128
|
end
|
136
129
|
|
137
130
|
def without_transition_tracking
|
138
|
-
was =
|
131
|
+
was = skip_transition_tracking
|
139
132
|
@skip_transition_tracking = true
|
140
|
-
yield
|
133
|
+
yield record
|
141
134
|
ensure
|
142
135
|
@skip_transition_tracking = was
|
143
136
|
end
|
144
137
|
|
145
|
-
|
138
|
+
private
|
146
139
|
|
147
140
|
def attempt_to_track_state(state_to_track)
|
148
141
|
return unless state_to_track
|
149
142
|
|
150
|
-
_attempt_to_track_change("#{state_to_track}_#{
|
143
|
+
_attempt_to_track_change("#{state_to_track}_#{machine.field}_at")
|
151
144
|
end
|
152
145
|
|
153
146
|
def attempt_to_track_state_changed_timestamp
|
154
|
-
_attempt_to_track_change("#{
|
147
|
+
_attempt_to_track_change("#{machine.field}_changed_at")
|
155
148
|
end
|
156
149
|
|
157
150
|
def _attempt_to_track_change(field_name)
|
158
|
-
return unless
|
159
|
-
return unless
|
160
|
-
return unless
|
161
|
-
return if @record.will_save_change_to_attribute?(field_name)
|
151
|
+
return unless record.respond_to?(field_name)
|
152
|
+
return unless record.respond_to?("#{field_name}=")
|
153
|
+
return unless record.send(field_name.to_s).nil? || state_changed?
|
162
154
|
|
163
|
-
|
164
|
-
end
|
155
|
+
return if record.send("#{field_name}_changed?")
|
165
156
|
|
157
|
+
record.send("#{field_name}=", (Time.zone || Time).now)
|
158
|
+
end
|
166
159
|
end
|
167
160
|
end
|
data/lib/stator/machine.rb
CHANGED
@@ -1,57 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Stator
|
2
4
|
class Machine
|
5
|
+
attr_reader :initial_state, :field, :transitions, :states, :namespace,
|
6
|
+
:class_name, :name, :aliases, :options, :tracking_enabled, :klass
|
7
|
+
|
8
|
+
def self.find_or_create(klass, *kwargs)
|
9
|
+
kwargs = kwargs.first
|
10
|
+
|
11
|
+
klass._stators[kwargs[:namespace]] ||= new(klass, **kwargs)
|
12
|
+
end
|
13
|
+
|
14
|
+
def klass
|
15
|
+
@klass ||= class_name.constantize
|
16
|
+
end
|
3
17
|
|
4
|
-
|
5
|
-
|
6
|
-
attr_reader :transition_names
|
7
|
-
attr_reader :transitions
|
8
|
-
attr_reader :states
|
9
|
-
attr_reader :namespace
|
18
|
+
def initialize(klass, *options)
|
19
|
+
options = options.first
|
10
20
|
|
11
|
-
def initialize(klass, options = {})
|
12
21
|
@class_name = klass.name
|
13
22
|
@field = options[:field] || :state
|
14
|
-
@namespace = options[:namespace]
|
23
|
+
@namespace = (options[:namespace] || Stator.default_namespace).to_sym
|
15
24
|
|
16
|
-
@initial_state = options[:initial]
|
25
|
+
@initial_state = options[:initial]&.to_sym
|
26
|
+
@states = [initial_state].compact
|
17
27
|
@tracking_enabled = options[:track] || false
|
18
28
|
|
19
29
|
@transitions = []
|
20
30
|
@aliases = []
|
21
31
|
|
22
|
-
|
23
|
-
@transition_names = []
|
24
|
-
@states = [@initial_state].compact
|
25
|
-
|
26
|
-
@options = options
|
32
|
+
@options = options
|
27
33
|
end
|
28
34
|
|
29
|
-
|
30
|
-
|
35
|
+
alias tracking_enabled? tracking_enabled
|
36
|
+
|
37
|
+
def evaluate_dsl(&block)
|
38
|
+
instance_eval(&block)
|
39
|
+
evaluate
|
31
40
|
end
|
32
41
|
|
33
|
-
def
|
34
|
-
|
42
|
+
def integration(record)
|
43
|
+
Stator::Integration.new(self, record)
|
35
44
|
end
|
36
45
|
|
37
46
|
def transition(name, &block)
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
verify_transition_validity(t)
|
47
|
+
Stator::Transition.new(class_name, name, namespace).tap do |t|
|
48
|
+
t.instance_eval(&block) if block_given?
|
42
49
|
|
43
|
-
|
44
|
-
@transition_names |= [t.full_name] unless t.full_name.blank?
|
45
|
-
@states |= [t.to_state] unless t.to_state.nil?
|
50
|
+
verify_transition_validity(t)
|
46
51
|
|
47
|
-
t
|
52
|
+
@transitions << t
|
53
|
+
@states |= [t.to_state] unless t.to_state.nil?
|
54
|
+
end
|
48
55
|
end
|
49
56
|
|
50
57
|
def state_alias(name, options = {}, &block)
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
58
|
+
Stator::Alias.new(self, name, options).tap do |a|
|
59
|
+
# puts "ALIAS: #{a.inspect}"
|
60
|
+
a.instance_eval(&block) if block_given?
|
61
|
+
@aliases << a
|
62
|
+
end
|
55
63
|
end
|
56
64
|
|
57
65
|
def state(name, &block)
|
@@ -62,33 +70,31 @@ module Stator
|
|
62
70
|
end
|
63
71
|
end
|
64
72
|
|
65
|
-
def tracking_enabled?
|
66
|
-
@tracking_enabled
|
67
|
-
end
|
68
|
-
|
69
73
|
def conditional(*states, &block)
|
70
|
-
|
74
|
+
state_check = proc { states.include?(current_state) }
|
71
75
|
|
72
|
-
klass.instance_exec(
|
76
|
+
klass.instance_exec(state_check, &block)
|
73
77
|
end
|
74
78
|
|
75
79
|
def matching_transition(from, to)
|
76
|
-
|
77
|
-
transition.valid?(from, to)
|
78
|
-
end
|
80
|
+
transitions.detect { |transition| transition.valid?(from, to) }
|
79
81
|
end
|
80
82
|
|
81
83
|
def evaluate
|
82
|
-
|
83
|
-
|
84
|
+
transitions.each(&:evaluate)
|
85
|
+
aliases.each(&:evaluate)
|
84
86
|
generate_methods
|
85
87
|
end
|
86
88
|
|
87
|
-
|
88
|
-
@class_name.constantize
|
89
|
-
end
|
89
|
+
private
|
90
90
|
|
91
|
-
|
91
|
+
def attr_name(name)
|
92
|
+
if namespace == Stator.default_namespace
|
93
|
+
name.to_sym
|
94
|
+
else
|
95
|
+
[namespace, name].compact.join('_').to_sym
|
96
|
+
end
|
97
|
+
end
|
92
98
|
|
93
99
|
def verify_transition_validity(transition)
|
94
100
|
verify_state_singularity_of_transition(transition)
|
@@ -98,34 +104,29 @@ module Stator
|
|
98
104
|
def verify_state_singularity_of_transition(transition)
|
99
105
|
transition.from_states.each do |from|
|
100
106
|
if matching_transition(from, transition.to_state)
|
101
|
-
raise "[Stator] another transition already exists which moves #{
|
107
|
+
raise "[Stator] another transition already exists which moves #{class_name} from #{from} to #{transition}"
|
102
108
|
end
|
103
109
|
end
|
104
110
|
end
|
105
111
|
|
106
112
|
def verify_name_singularity_of_transition(transition)
|
107
|
-
if
|
108
|
-
raise "[Stator] another transition already exists with the name of #{transition.name
|
113
|
+
if transitions.detect { |other| transition.name && transition.name == other.name }
|
114
|
+
raise "[Stator] another transition already exists with the name of #{transition.name} in the #{class_name} class"
|
109
115
|
end
|
110
116
|
end
|
111
117
|
|
112
118
|
def generate_methods
|
113
|
-
|
114
|
-
method_name = [@namespace, state].compact.join('_')
|
119
|
+
states.each do |state|
|
115
120
|
klass.class_eval <<-EV, __FILE__, __LINE__ + 1
|
116
|
-
def #{
|
117
|
-
|
118
|
-
integration.state == #{state.to_s.inspect}
|
121
|
+
def #{attr_name(state)}?
|
122
|
+
_stator_integration(:#{namespace}).state.to_sym == :#{state}
|
119
123
|
end
|
120
124
|
|
121
|
-
def #{
|
122
|
-
|
123
|
-
integration.state_by?(#{state.to_s.inspect}, time)
|
125
|
+
def #{attr_name(state)}_state_by?(time)
|
126
|
+
_stator_integration(:#{namespace}).state_by?(:#{state}.to_sym, time)
|
124
127
|
end
|
125
128
|
EV
|
126
129
|
end
|
127
130
|
end
|
128
|
-
|
129
|
-
|
130
131
|
end
|
131
132
|
end
|
data/lib/stator/model.rb
CHANGED
@@ -1,113 +1,118 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Stator
|
2
4
|
module Model
|
5
|
+
extend ActiveSupport::Concern
|
3
6
|
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
+
included do
|
8
|
+
class_attribute :_stators
|
9
|
+
attr_accessor :_stator_integrations
|
7
10
|
|
8
|
-
|
9
|
-
include TrackerMethods if options[:track] == true
|
11
|
+
validate :_stator_validate_transition
|
10
12
|
|
11
13
|
self._stators ||= {}
|
12
14
|
|
13
|
-
|
14
|
-
f = options[:field] || :state
|
15
|
-
# rescue nil since the table may not exist yet.
|
16
|
-
initial = self.columns_hash[f.to_s].default rescue nil
|
17
|
-
options = options.merge(initial: initial) if initial
|
18
|
-
end
|
19
|
-
|
20
|
-
machine = (self._stators[options[:namespace].to_s] ||= ::Stator::Machine.new(self, options))
|
21
|
-
|
22
|
-
if block_given?
|
23
|
-
machine.instance_eval(&block)
|
24
|
-
machine.evaluate
|
25
|
-
end
|
26
|
-
|
27
|
-
machine
|
15
|
+
before_save :_stator_maybe_track_transition, prepend: true
|
28
16
|
end
|
29
17
|
|
30
|
-
|
31
|
-
|
32
|
-
|
18
|
+
class_methods do
|
19
|
+
def stator(namespace: nil, field: :state, initial: nil, track: true, &block)
|
20
|
+
unless abstract_class?
|
21
|
+
# Discover the default value (usually initial) from the table...
|
22
|
+
# but rescue nil since the table may not exist yet.
|
23
|
+
initial = _determine_initial_stator_state(field)
|
24
|
+
end
|
33
25
|
|
34
|
-
|
26
|
+
opts = { namespace: _stator_namespace(namespace), field: field.to_sym, initial: initial, track: track }
|
35
27
|
|
36
|
-
|
37
|
-
|
38
|
-
before_save :_stator_maybe_track_transition, prepend: true
|
28
|
+
Stator::Machine.find_or_create(self, **opts).tap do |machine|
|
29
|
+
machine.evaluate_dsl(&block) if block_given?
|
39
30
|
end
|
40
31
|
end
|
41
32
|
|
42
|
-
def
|
43
|
-
|
33
|
+
def _stator(namespace)
|
34
|
+
self._stators[_stator_namespace(namespace)]
|
44
35
|
end
|
45
36
|
|
46
|
-
def
|
47
|
-
|
48
|
-
end
|
37
|
+
def _stator_namespace(namespace = nil)
|
38
|
+
namespace = nil if namespace.blank?
|
49
39
|
|
50
|
-
|
51
|
-
_integration(namespace).state_by?(state, t)
|
40
|
+
(namespace || Stator.default_namespace).to_sym
|
52
41
|
end
|
53
42
|
|
54
|
-
|
43
|
+
def _determine_initial_stator_state(field)
|
44
|
+
columns_hash[field.to_s].default.to_sym
|
45
|
+
rescue StandardError
|
46
|
+
nil
|
47
|
+
end
|
48
|
+
end
|
55
49
|
|
56
|
-
|
57
|
-
|
58
|
-
|
50
|
+
def initialize_dup(other)
|
51
|
+
@_stator_integrations = {}
|
52
|
+
super
|
53
|
+
end
|
59
54
|
|
60
|
-
|
61
|
-
|
55
|
+
def without_state_transition_validations(namespace = '')
|
56
|
+
_stator_integration(namespace).without_validation do
|
57
|
+
yield self
|
58
|
+
end
|
59
|
+
end
|
62
60
|
|
63
|
-
|
61
|
+
def without_state_transition_tracking(namespace = '')
|
62
|
+
_stator_integration(namespace).without_transition_tracking do
|
63
|
+
yield self
|
64
64
|
end
|
65
|
+
end
|
65
66
|
|
67
|
+
def current_state
|
68
|
+
_stator_integration.state&.to_sym
|
66
69
|
end
|
67
70
|
|
68
|
-
|
71
|
+
def in_state_at?(state, t, namespace = '')
|
72
|
+
_stator_integration(namespace).in_state_at?(state, t)
|
73
|
+
end
|
69
74
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
end
|
74
|
-
end
|
75
|
+
def likely_state_at(t, namespace = '')
|
76
|
+
_stator_integration(namespace).likely_state_at(t)
|
77
|
+
end
|
75
78
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
end
|
79
|
+
def state_by?(state, t, namespace = '')
|
80
|
+
_stator_integration(namespace).state_by?(state, t)
|
81
|
+
end
|
80
82
|
|
81
|
-
|
82
|
-
_integration(namespace).without_validation do
|
83
|
-
yield self
|
84
|
-
end
|
85
|
-
end
|
83
|
+
private
|
86
84
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
end
|
85
|
+
# core methods
|
86
|
+
def _stator(namespace = nil)
|
87
|
+
self.class._stator(namespace)
|
88
|
+
end
|
92
89
|
|
93
|
-
|
90
|
+
def _stator_namespace(namespace = nil)
|
91
|
+
self.class._stator_namespace(namespace)
|
92
|
+
end
|
94
93
|
|
95
|
-
|
96
|
-
|
97
|
-
_integration(namespace).validate_transition
|
98
|
-
end
|
99
|
-
end
|
94
|
+
def _stator_integration(namespace = nil)
|
95
|
+
ns = _stator_namespace(namespace)
|
100
96
|
|
101
|
-
|
102
|
-
|
97
|
+
self._stator_integrations ||= {}
|
98
|
+
self._stator_integrations[ns] ||= self.class._stator(ns).integration(self)
|
99
|
+
end
|
100
|
+
|
101
|
+
# validation/transitional
|
102
|
+
def _stator_validate_transition
|
103
|
+
self._stators.each_key do |namespace|
|
104
|
+
_stator_integration(namespace).validate_transition
|
103
105
|
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def _stator_maybe_track_transition
|
109
|
+
self._stators.each do |namespace, machine|
|
110
|
+
next unless machine.tracking_enabled?
|
104
111
|
|
105
|
-
|
106
|
-
@_integrations ||= {}
|
107
|
-
@_integrations[namespace] ||= _stator(namespace).integration(self)
|
108
|
-
@_integrations[namespace]
|
112
|
+
_stator_integration(namespace).track_transition
|
109
113
|
end
|
110
114
|
|
115
|
+
true
|
111
116
|
end
|
112
117
|
end
|
113
118
|
end
|