stator 0.3.3 → 0.9.0.beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/build.yml +28 -0
- data/.gitignore +1 -0
- data/.ruby-version +1 -1
- data/Appraisals +23 -0
- data/Gemfile +4 -3
- data/gemfiles/activerecord_5.1.gemfile +12 -0
- data/gemfiles/activerecord_5.1.gemfile.lock +74 -0
- data/gemfiles/activerecord_5.2.gemfile +12 -0
- data/gemfiles/activerecord_5.2.gemfile.lock +74 -0
- data/gemfiles/activerecord_5.gemfile +12 -0
- data/gemfiles/activerecord_5.gemfile.lock +74 -0
- data/gemfiles/activerecord_6.0.gemfile +12 -0
- data/gemfiles/activerecord_6.0.gemfile.lock +74 -0
- data/gemfiles/activerecord_6.1.gemfile +12 -0
- data/gemfiles/activerecord_6.1.gemfile.lock +73 -0
- data/gemfiles/activerecord_7.0.gemfile +12 -0
- data/gemfiles/activerecord_7.0.gemfile.lock +71 -0
- data/lib/stator/alias.rb +51 -30
- data/lib/stator/integration.rb +46 -45
- data/lib/stator/machine.rb +61 -55
- data/lib/stator/model.rb +79 -70
- data/lib/stator/transition.rb +61 -60
- data/lib/stator/version.rb +4 -6
- data/lib/stator.rb +13 -0
- data/spec/model_spec.rb +227 -186
- data/spec/spec_helper.rb +6 -3
- data/spec/support/models.rb +26 -32
- data/spec/support/schema.rb +42 -42
- data/stator.gemspec +1 -0
- metadata +33 -10
- data/.travis.yml +0 -41
- data/gemfiles/ar40.gemfile +0 -10
- data/gemfiles/ar41.gemfile +0 -10
- data/gemfiles/ar42.gemfile +0 -10
- data/gemfiles/ar52.gemfile +0 -10
data/lib/stator/alias.rb
CHANGED
@@ -1,11 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module Stator
|
2
4
|
class Alias
|
5
|
+
attr_reader :machine, :name, :namespace, :attr_name, :states, :not, :opposite_args, :constant, :scope
|
3
6
|
|
4
7
|
def initialize(machine, name, options = {})
|
5
8
|
@machine = machine
|
6
9
|
@name = name
|
7
|
-
@namespace =
|
8
|
-
@full_name = [@namespace, @name].compact.join('_')
|
10
|
+
@namespace = machine.namespace
|
9
11
|
@states = []
|
10
12
|
@not = false
|
11
13
|
@opposite = nil
|
@@ -13,8 +15,12 @@ module Stator
|
|
13
15
|
@scope = options[:scope]
|
14
16
|
end
|
15
17
|
|
18
|
+
def attr_name
|
19
|
+
@attr_name ||= generate_attr_name
|
20
|
+
end
|
21
|
+
|
16
22
|
def is(*args)
|
17
|
-
@states |= args.map(&:
|
23
|
+
@states |= args.map(&:to_sym)
|
18
24
|
end
|
19
25
|
|
20
26
|
def is_not(*args)
|
@@ -22,60 +28,75 @@ module Stator
|
|
22
28
|
is(*args)
|
23
29
|
end
|
24
30
|
|
31
|
+
alias not? not
|
32
|
+
|
25
33
|
def opposite(*args)
|
26
|
-
|
34
|
+
# set the incoming args for opposite as opposite
|
35
|
+
@opposite_args = args
|
27
36
|
end
|
28
37
|
|
29
38
|
def evaluate
|
30
39
|
generate_methods
|
31
40
|
|
32
|
-
if
|
33
|
-
op = @machine.state_alias(*@opposite)
|
41
|
+
return if opposite_args.blank?
|
34
42
|
|
35
|
-
|
36
|
-
|
37
|
-
|
43
|
+
# this will generate the alias for the opposite
|
44
|
+
op = machine.state_alias(*opposite_args)
|
45
|
+
|
46
|
+
op.is(*states) if not?
|
47
|
+
op.is_not(*states) unless not?
|
38
48
|
end
|
39
49
|
|
40
|
-
|
50
|
+
private
|
51
|
+
|
52
|
+
def inverse_states
|
53
|
+
(machine.states - states).map(&:to_sym)
|
54
|
+
end
|
41
55
|
|
42
56
|
def inferred_constant_name
|
43
|
-
[
|
57
|
+
[attr_name.upcase, machine.field.to_s.pluralize.upcase].join('_')
|
58
|
+
end
|
59
|
+
|
60
|
+
def generate_attr_name
|
61
|
+
if namespace == Stator.default_namespace
|
62
|
+
name
|
63
|
+
else
|
64
|
+
[namespace, name].compact.join('_').to_sym
|
65
|
+
end
|
44
66
|
end
|
45
67
|
|
46
68
|
def generate_methods
|
69
|
+
expected_states = (not? ? inverse_states : states)
|
47
70
|
|
48
|
-
|
71
|
+
if scope
|
72
|
+
name = (scope == true ? attr_name : scope)
|
49
73
|
|
50
|
-
|
51
|
-
|
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
|
-
}
|
74
|
+
machine.klass.class_eval <<-EV, __FILE__, __LINE__ + 1
|
75
|
+
scope :#{name}, -> { where(_stator(#{namespace.inspect}).field => #{expected_states}) }
|
56
76
|
EV
|
57
77
|
end
|
58
78
|
|
59
|
-
if
|
60
|
-
name =
|
61
|
-
|
62
|
-
|
63
|
-
|
79
|
+
if constant
|
80
|
+
name = (constant == true ? inferred_constant_name : constant.to_s.upcase)
|
81
|
+
|
82
|
+
if not?
|
83
|
+
machine.klass.class_eval <<-EV, __FILE__, __LINE__ + 1
|
84
|
+
#{name} = #{inverse_states}.freeze
|
64
85
|
EV
|
65
86
|
else
|
66
|
-
|
67
|
-
#{name} = #{
|
87
|
+
machine.klass.class_eval <<-EV, __FILE__, __LINE__ + 1
|
88
|
+
#{name} = #{states}.freeze
|
68
89
|
EV
|
69
90
|
end
|
70
91
|
end
|
71
92
|
|
72
|
-
|
73
|
-
def #{
|
74
|
-
integration =
|
75
|
-
|
93
|
+
machine.klass.class_eval <<-EV, __FILE__, __LINE__ + 1
|
94
|
+
def #{attr_name}?
|
95
|
+
integration = _stator_integration(:#{namespace})
|
96
|
+
|
97
|
+
#{expected_states}.include?(integration.state.to_sym)
|
76
98
|
end
|
77
99
|
EV
|
78
100
|
end
|
79
|
-
|
80
101
|
end
|
81
102
|
end
|
data/lib/stator/integration.rb
CHANGED
@@ -2,40 +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
|
|
33
|
+
def state_by?(state, time)
|
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?
|
37
|
+
return true if time.nil?
|
38
|
+
|
39
|
+
record.send(field_name) <= time
|
40
|
+
end
|
41
|
+
|
34
42
|
def state_changed?(use_previous = false)
|
35
43
|
if use_previous
|
36
|
-
|
44
|
+
!!record.previous_changes[machine.field.to_s]
|
37
45
|
else
|
38
|
-
|
46
|
+
record.send("#{machine.field}_changed?")
|
39
47
|
end
|
40
48
|
end
|
41
49
|
|
@@ -43,23 +51,20 @@ module Stator
|
|
43
51
|
return unless state_changed?
|
44
52
|
return if skip_validations
|
45
53
|
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
if @record.new_record?
|
50
|
-
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)
|
51
56
|
else
|
52
|
-
invalid_transition!(
|
57
|
+
invalid_transition!(state_was, state) unless machine.matching_transition(state_was, state)
|
53
58
|
end
|
54
59
|
end
|
55
60
|
|
56
61
|
# TODO: i18n
|
57
62
|
def invalid_state!
|
58
|
-
|
63
|
+
record.errors.add(machine.field, 'is not a valid state')
|
59
64
|
end
|
60
65
|
|
61
66
|
def invalid_transition!(was, is)
|
62
|
-
|
67
|
+
record.errors.add(machine.field, "cannot transition to #{is} from #{was}")
|
63
68
|
end
|
64
69
|
|
65
70
|
def track_transition
|
@@ -75,7 +80,7 @@ module Stator
|
|
75
80
|
state = state.to_s
|
76
81
|
t = t.to_time
|
77
82
|
|
78
|
-
state_at =
|
83
|
+
state_at = record.send("#{state}_#{machine.field}_at")
|
79
84
|
|
80
85
|
# if we've never been in the state, the answer is no
|
81
86
|
return false if state_at.nil?
|
@@ -83,20 +88,18 @@ module Stator
|
|
83
88
|
# if we came into this state later in life, the answer is no
|
84
89
|
return false if state_at > t
|
85
90
|
|
86
|
-
all_states =
|
91
|
+
all_states = machine.states.reverse
|
87
92
|
|
88
93
|
# grab all the states and their timestamps that occur on or after state_at and on or before the time in question
|
89
|
-
later_states = all_states.
|
94
|
+
later_states = all_states.filter_map do |s|
|
90
95
|
next if state == s
|
91
96
|
|
92
|
-
at =
|
97
|
+
at = record.send("#{s}_#{machine.field}_at")
|
93
98
|
|
94
|
-
next if at.nil?
|
95
|
-
next if at < state_at
|
96
|
-
next if at > t
|
99
|
+
next if at.nil? || at < state_at || at > t
|
97
100
|
|
98
101
|
{ state: s, at: at }
|
99
|
-
end
|
102
|
+
end
|
100
103
|
|
101
104
|
# if there were no states on or after the state_at, the answer is yes
|
102
105
|
return true if later_states.empty?
|
@@ -107,53 +110,51 @@ module Stator
|
|
107
110
|
later_states = later_groups[later_group_key]
|
108
111
|
|
109
112
|
# if the lowest timestamp is the same as the state's timestamp, evaluate based on state index
|
110
|
-
if later_states[0][:at] == state_at
|
111
|
-
return all_states.index(state) < all_states.index(later_states[0][:state])
|
112
|
-
end
|
113
|
+
return all_states.index(state) < all_states.index(later_states[0][:state]) if later_states[0][:at] == state_at
|
113
114
|
|
114
115
|
false
|
115
116
|
end
|
116
117
|
|
117
118
|
def likely_state_at(t)
|
118
|
-
|
119
|
+
machine.states.reverse.detect { |s| in_state_at?(s, t) }
|
119
120
|
end
|
120
121
|
|
121
122
|
def without_validation
|
122
|
-
was =
|
123
|
+
was = skip_validations
|
123
124
|
@skip_validations = true
|
124
|
-
yield
|
125
|
+
yield record
|
125
126
|
ensure
|
126
127
|
@skip_validations = was
|
127
128
|
end
|
128
129
|
|
129
130
|
def without_transition_tracking
|
130
|
-
was =
|
131
|
+
was = skip_transition_tracking
|
131
132
|
@skip_transition_tracking = true
|
132
|
-
yield
|
133
|
+
yield record
|
133
134
|
ensure
|
134
135
|
@skip_transition_tracking = was
|
135
136
|
end
|
136
137
|
|
137
|
-
|
138
|
+
private
|
138
139
|
|
139
140
|
def attempt_to_track_state(state_to_track)
|
140
141
|
return unless state_to_track
|
141
142
|
|
142
|
-
_attempt_to_track_change("#{state_to_track}_#{
|
143
|
+
_attempt_to_track_change("#{state_to_track}_#{machine.field}_at")
|
143
144
|
end
|
144
145
|
|
145
146
|
def attempt_to_track_state_changed_timestamp
|
146
|
-
_attempt_to_track_change("#{
|
147
|
+
_attempt_to_track_change("#{machine.field}_changed_at")
|
147
148
|
end
|
148
149
|
|
149
150
|
def _attempt_to_track_change(field_name)
|
150
|
-
return unless
|
151
|
-
return unless
|
152
|
-
return unless
|
153
|
-
return if @record.send("#{field_name}_changed?")
|
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?
|
154
154
|
|
155
|
-
|
156
|
-
end
|
155
|
+
return if record.send("#{field_name}_changed?")
|
157
156
|
|
157
|
+
record.send("#{field_name}=", (Time.zone || Time).now)
|
158
|
+
end
|
158
159
|
end
|
159
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,29 +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
|
-
|
121
|
+
def #{attr_name(state)}?
|
122
|
+
_stator_integration(:#{namespace}).state.to_sym == :#{state}
|
123
|
+
end
|
124
|
+
|
125
|
+
def #{attr_name(state)}_state_by?(time)
|
126
|
+
_stator_integration(:#{namespace}).state_by?(:#{state}.to_sym, time)
|
119
127
|
end
|
120
128
|
EV
|
121
129
|
end
|
122
130
|
end
|
123
|
-
|
124
|
-
|
125
131
|
end
|
126
132
|
end
|