can_has_state 0.7.0 → 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/MIT-LICENSE +1 -1
- data/README.md +51 -36
- data/lib/can_has_state/definition.rb +47 -57
- data/lib/can_has_state/machine.rb +75 -46
- data/lib/can_has_state/trigger.rb +62 -0
- data/lib/can_has_state/version.rb +1 -1
- data/lib/can_has_state.rb +2 -2
- data/test/can_has_state_test.rb +274 -31
- metadata +9 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 516a5676e62907d7868c5a0812c760e292b87c8c785cb246c5eb3bd2db2d7a41
|
4
|
+
data.tar.gz: 1e4b230a4a4c933b1db2884b2f7490439c9fb2f4f18b1ee686286decc2640903
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cb9ef6c65385ef382a01d9f73c40da6217136f9939fea20eb6aae0183ce57f492e119302fc560f9273b2f6c2933cde3fa2c50daf3e0f13d038cf19dbfa377d5b
|
7
|
+
data.tar.gz: f1aee70c43b00b6ae153c63583db1d1bf4bd9adb072fa4b5adf33eecd1b4f3c1668aa1aba54438b296158ad858475a1d52333ce95f0e2e9959d1d1fb8b7d499b
|
data/MIT-LICENSE
CHANGED
data/README.md
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
# CanHasState
|
1
|
+
# CanHasState
|
2
2
|
|
3
3
|
|
4
4
|
`can_has_state` is a simplified state machine gem. It relies on ActiveModel and
|
@@ -10,21 +10,21 @@ Key features:
|
|
10
10
|
* Simplified DSL syntax
|
11
11
|
* Few added methods avoids clobbering up model's namespace
|
12
12
|
* Compatible with ActionPack-style attribute value changes via
|
13
|
-
|
13
|
+
`state_column: 'new_state'`
|
14
14
|
* Use any ActiveModel-compatible persistence layer (including your own)
|
15
15
|
|
16
16
|
|
17
17
|
|
18
|
-
## Installation
|
18
|
+
## Installation
|
19
19
|
|
20
20
|
Add it to your `Gemfile`:
|
21
21
|
|
22
22
|
gem 'can_has_state'
|
23
23
|
|
24
24
|
|
25
|
-
## DSL
|
25
|
+
## DSL
|
26
26
|
|
27
|
-
### ActiveRecord
|
27
|
+
### ActiveRecord
|
28
28
|
|
29
29
|
class Account < ActiveRecord::Base
|
30
30
|
|
@@ -43,20 +43,20 @@ Add it to your `Gemfile`:
|
|
43
43
|
# under triggers.
|
44
44
|
#
|
45
45
|
state :active, :initial,
|
46
|
-
:
|
47
|
-
:
|
48
|
-
:
|
49
|
-
:
|
46
|
+
from: :inactive,
|
47
|
+
on_enter: :update_plan_details,
|
48
|
+
on_enter_deferred: :start_billing,
|
49
|
+
on_exit_deferred: lambda{|r| r.stop_billing }
|
50
50
|
|
51
51
|
# :from restricts which states can switch to this one. Multiple "from"
|
52
|
-
# states are allowed, as shown under state :deleted
|
52
|
+
# states are allowed, as shown below under `state :deleted`.
|
53
53
|
#
|
54
54
|
# If :from is not present, this state may be entered from any other
|
55
55
|
# state. To prevent ever moving to a given state (only useful if that
|
56
|
-
# state is also the initial state), use :
|
56
|
+
# state is also the initial state), use `from: []`.
|
57
57
|
#
|
58
58
|
state :inactive,
|
59
|
-
:
|
59
|
+
from: [:active]
|
60
60
|
|
61
61
|
# :timestamp automatically sets the current date/time when this state
|
62
62
|
# is entered. Both *_at and *_on (datetime and date) columns are
|
@@ -65,21 +65,22 @@ Add it to your `Gemfile`:
|
|
65
65
|
# :require adds additional restrictions before this state can be
|
66
66
|
# entered. Like :on_enter/:on_exit, it can be a method name or a
|
67
67
|
# lambda/Proc. Multiple methods may be provided. Each method must
|
68
|
-
# return a ruby
|
68
|
+
# return a ruby truthy value (anything except nil or false) for the
|
69
69
|
# condition to be satisfied. If any :require is not true, then the
|
70
70
|
# state transition is blocked.
|
71
71
|
#
|
72
72
|
# :message allows the validation error message to be customized. It is
|
73
73
|
# used when conditions for either :from or :require fail. The default
|
74
74
|
# message is used in the example below. %{from} and %{to} parameters
|
75
|
-
# are optional, and will be the from and to
|
75
|
+
# are optional, and will be the old (from) and new (to) values of the
|
76
|
+
# state field.
|
76
77
|
#
|
77
78
|
state :deleted,
|
78
|
-
:
|
79
|
-
:
|
80
|
-
:
|
81
|
-
:
|
82
|
-
:
|
79
|
+
from: [:active, :inactive],
|
80
|
+
timestamp: :deleted_at,
|
81
|
+
on_enter_deferred: [:delete_record, :delete_payment_info],
|
82
|
+
require: proc{ !active_services? },
|
83
|
+
message: "has invalid transition from %{from} to %{to}"
|
83
84
|
|
84
85
|
# Custom triggers are called for certain "from" => "to" state
|
85
86
|
# combinations. They are especially useful for DRYing up triggers
|
@@ -95,8 +96,8 @@ Add it to your `Gemfile`:
|
|
95
96
|
# Multiple triggers can be specified on either side of the transition
|
96
97
|
# or for the trigger actions:
|
97
98
|
#
|
98
|
-
on [:active, :inactive] => :deleted, :
|
99
|
-
on :active => [:inactive, :deleted], :
|
99
|
+
on [:active, :inactive] => :deleted, trigger: [:do_one, :do_two]
|
100
|
+
on :active => [:inactive, :deleted], trigger: ->(r){ r.act }
|
100
101
|
|
101
102
|
# If :deferred is included, and it's true, then this trigger will
|
102
103
|
# happen post-save, instead of pre-validation. Default pre-validation
|
@@ -117,15 +118,15 @@ Add it to your `Gemfile`:
|
|
117
118
|
# from = state_was # (specifically, <state_column>_was)
|
118
119
|
# to = state
|
119
120
|
#
|
120
|
-
on :* => :*, :
|
121
|
-
on :* => :deleted, :
|
121
|
+
on :* => :*, trigger: :log_state_change, deferred: true
|
122
|
+
on :* => :deleted, trigger: :same_as_on_enter
|
122
123
|
|
123
124
|
end
|
124
125
|
|
125
126
|
end
|
126
127
|
|
127
128
|
|
128
|
-
### Just ActiveModel
|
129
|
+
### Just ActiveModel
|
129
130
|
|
130
131
|
class Account
|
131
132
|
include CanHasState::DirtyHelper
|
@@ -137,12 +138,12 @@ Add it to your `Gemfile`:
|
|
137
138
|
|
138
139
|
state_machine :account_state do
|
139
140
|
state :active, :initial,
|
140
|
-
:
|
141
|
+
from: :inactive
|
141
142
|
state :inactive,
|
142
|
-
:
|
143
|
+
from: :active
|
143
144
|
state :deleted,
|
144
|
-
:
|
145
|
-
:
|
145
|
+
from: [:active, :inactive],
|
146
|
+
timestamp: :deleted_at
|
146
147
|
end
|
147
148
|
|
148
149
|
end
|
@@ -159,7 +160,7 @@ non-ActiveRecord persistence engine provides #after_save, then deferred triggers
|
|
159
160
|
will be enabled.
|
160
161
|
|
161
162
|
|
162
|
-
## Managing states
|
163
|
+
## Managing states
|
163
164
|
|
164
165
|
States are set directly via the relevant state column--no added methods.
|
165
166
|
|
@@ -186,12 +187,12 @@ collisions.
|
|
186
187
|
# column is :state
|
187
188
|
state_machine :state do
|
188
189
|
state :active,
|
189
|
-
:
|
190
|
-
:
|
190
|
+
from: :inactive,
|
191
|
+
require: lambda{|r| r.payment_status=='current'}
|
191
192
|
state :inactive,
|
192
|
-
:
|
193
|
+
from: :active
|
193
194
|
state :deleted,
|
194
|
-
:
|
195
|
+
from: [:active, :inactive]
|
195
196
|
end
|
196
197
|
|
197
198
|
# column is :payment_status
|
@@ -236,7 +237,7 @@ will be called.
|
|
236
237
|
|
237
238
|
|
238
239
|
|
239
|
-
##
|
240
|
+
## Using triggers on initial states
|
240
241
|
|
241
242
|
`can_has_state` relies on the `ActiveModel::Dirty` module to detect when a state
|
242
243
|
attribute has changed. In general, this shouldn't matter much to you as long as
|
@@ -251,6 +252,20 @@ because the initial state value changed from nil to the initial state.
|
|
251
252
|
|
252
253
|
|
253
254
|
|
254
|
-
##
|
255
|
+
## Modifying state attributes in `before_validation`
|
255
256
|
|
256
|
-
|
257
|
+
`can_has_state`'s validation callbacks run very early, almost always before any
|
258
|
+
Model-specific validation. This ensures that validations and normal callbacks
|
259
|
+
see the model's attributes after any triggers have been run.
|
260
|
+
|
261
|
+
This can cause problems when modifying the value of a state attribute as part of
|
262
|
+
a callback. The most common way this shows up is receiving state validation
|
263
|
+
errors (typically due to a `:require`), even when a `before_validation` callback
|
264
|
+
seems to be executed.
|
265
|
+
|
266
|
+
Often, the best solution is to review the modification of state attributes
|
267
|
+
during callbacks, as the original problem can hint at code design issue. If the
|
268
|
+
callbacks are definitely warranted, try moving your validation to the start of
|
269
|
+
the callback chain so it runs before `can_has_state`:
|
270
|
+
|
271
|
+
before_validation :some_method, prepend: true
|
@@ -1,10 +1,11 @@
|
|
1
1
|
module CanHasState
|
2
2
|
class Definition
|
3
3
|
|
4
|
-
|
4
|
+
attr_accessor :parent_context
|
5
|
+
attr_reader :column, :states, :triggers, :initial_state
|
5
6
|
|
6
|
-
def initialize(column_name,
|
7
|
-
@parent_context =
|
7
|
+
def initialize(column_name, model_class, &block)
|
8
|
+
@parent_context = model_class
|
8
9
|
@column = column_name.to_sym
|
9
10
|
@states = {}
|
10
11
|
@triggers = []
|
@@ -12,6 +13,16 @@ module CanHasState
|
|
12
13
|
@initial_state ||= @states.keys.first
|
13
14
|
end
|
14
15
|
|
16
|
+
def initialize_dup(orig)
|
17
|
+
@states = @states.deep_dup
|
18
|
+
@triggers = @triggers.map do |t|
|
19
|
+
t = t.dup
|
20
|
+
t.state_machine = self
|
21
|
+
t
|
22
|
+
end
|
23
|
+
super
|
24
|
+
end
|
25
|
+
|
15
26
|
|
16
27
|
def extend_machine(&block)
|
17
28
|
instance_eval(&block)
|
@@ -26,111 +37,90 @@ module CanHasState
|
|
26
37
|
@initial_state = state_name
|
27
38
|
end
|
28
39
|
|
29
|
-
|
30
|
-
guards = []
|
40
|
+
requirements = []
|
31
41
|
message = :invalid_transition
|
32
|
-
# TODO: differentiate messages for :from errors vs. :guard errors
|
33
42
|
|
34
43
|
options.each do |key, val|
|
35
44
|
case key
|
36
45
|
when :from
|
37
|
-
from_vals = Array(val).map(&:to_s)
|
46
|
+
from_vals = Array(val).compact.map(&:to_s)
|
38
47
|
from_vals << nil # for new records
|
39
|
-
|
40
|
-
val_was = r.send("#{column}_was")
|
41
|
-
val_was &&= val_was.to_s
|
48
|
+
requirements << lambda do |r|
|
49
|
+
val_was = r.send("#{column}_was")&.to_s
|
42
50
|
from_vals.include? val_was
|
43
51
|
end
|
44
|
-
when :
|
45
|
-
|
52
|
+
when :require
|
53
|
+
requirements += Array(val)
|
46
54
|
when :message
|
47
55
|
message = val
|
48
56
|
when :timestamp
|
49
|
-
@triggers <<
|
57
|
+
@triggers << Trigger.new(self, from: '*', to: state_name, trigger: lambda{|r| r.send("#{val}=", Time.now.utc)}, type: :timestamp)
|
50
58
|
when :on_enter
|
51
|
-
@triggers <<
|
59
|
+
@triggers << Trigger.new(self, from: '*', to: state_name, trigger: val, type: :on_enter)
|
52
60
|
when :on_enter_deferred
|
53
|
-
|
54
|
-
@triggers << {:from=>["*"], :to=>[state_name], :trigger=>Array(val), :type=>:on_enter, :deferred=>true}
|
61
|
+
@triggers << Trigger.new(self, from: '*', to: state_name, trigger: val, type: :on_enter, deferred: true)
|
55
62
|
when :on_exit
|
56
|
-
@triggers <<
|
63
|
+
@triggers << Trigger.new(self, from: state_name, to: '*', trigger: val, type: :on_exit)
|
57
64
|
when :on_exit_deferred
|
58
|
-
|
59
|
-
|
65
|
+
@triggers << Trigger.new(self, from: state_name, to: '*', trigger: val, type: :on_exit, deferred: true)
|
66
|
+
else
|
67
|
+
raise ArgumentError, "Unknown argument #{key.inspect}"
|
60
68
|
end
|
61
69
|
end
|
62
70
|
|
63
|
-
@states[state_name] = {:
|
71
|
+
@states[state_name] = {requirements: requirements, message: message}
|
64
72
|
end
|
65
73
|
|
66
74
|
|
67
75
|
def on(pairs)
|
68
76
|
trigger = pairs.delete :trigger
|
69
77
|
deferred = pairs.delete :deferred
|
70
|
-
raise(ArgumentError, "use of deferred triggers requires support for #after_save callbacks") if deferred && !@parent_context.respond_to?(:after_save)
|
71
78
|
pairs.each do |from, to|
|
72
|
-
@triggers <<
|
73
|
-
:trigger=>Array(trigger), :type=>:trigger, :deferred=>!!deferred}
|
79
|
+
@triggers << Trigger.new(self, from: from, to: to, trigger: trigger, type: :trigger, deferred: deferred)
|
74
80
|
end
|
75
81
|
end
|
76
82
|
|
77
83
|
|
78
84
|
|
79
85
|
def known?(to)
|
80
|
-
to
|
81
|
-
|
86
|
+
to = to&.to_s
|
87
|
+
states.keys.include? to
|
82
88
|
end
|
83
89
|
|
84
90
|
def allow?(record, to)
|
85
|
-
to
|
91
|
+
to = to&.to_s
|
86
92
|
return false unless known?(to)
|
87
|
-
states[to][:
|
93
|
+
states[to][:requirements].all? do |g|
|
88
94
|
case g
|
89
95
|
when Proc
|
90
|
-
g.
|
96
|
+
if g.arity.zero?
|
97
|
+
record.instance_eval(&g)
|
98
|
+
else
|
99
|
+
g.call record
|
100
|
+
end
|
91
101
|
when Symbol, String
|
92
102
|
record.send g
|
93
103
|
else
|
94
|
-
raise ArgumentError, "Expecing Symbol or Proc for :
|
104
|
+
raise ArgumentError, "Expecing Symbol or Proc for :require, got #{g.class} : #{g}"
|
95
105
|
end
|
96
106
|
end
|
97
107
|
end
|
98
108
|
|
99
109
|
def message(to)
|
100
|
-
to
|
110
|
+
to = to&.to_s
|
101
111
|
states[to][:message]
|
102
112
|
end
|
103
113
|
|
104
114
|
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
115
|
+
# conditions - :deferred
|
116
|
+
def triggers_for(from:, to:, **conditions)
|
117
|
+
from = from&.to_s
|
118
|
+
to = to&.to_s
|
119
|
+
# Rails.logger.debug "Checking triggers for transition #{from.inspect} to #{to.inspect} (#{conditions.inspect})"
|
109
120
|
@triggers.select do |trigger|
|
110
|
-
|
111
|
-
end.select do |trigger|
|
112
|
-
(trigger[:from].include?("*") || trigger[:from].include?(from)) &&
|
113
|
-
(trigger[:to].include?("*") || trigger[:to].include?(to))
|
121
|
+
trigger.matches? from: from, to: to, **conditions
|
114
122
|
# end.each do |trigger|
|
115
|
-
# Rails.logger.debug " Matched trigger: #{trigger
|
116
|
-
end.each do |trigger|
|
117
|
-
call_triggers record, trigger
|
118
|
-
end
|
119
|
-
end
|
120
|
-
|
121
|
-
|
122
|
-
private
|
123
|
-
|
124
|
-
def call_triggers(record, trigger)
|
125
|
-
trigger[:trigger].each do |m|
|
126
|
-
case m
|
127
|
-
when Proc
|
128
|
-
m.call record
|
129
|
-
when Symbol, String
|
130
|
-
record.send m
|
131
|
-
else
|
132
|
-
raise ArgumentError, "Expecing Symbol or Proc for #{trigger[:type].inspect}, got #{m.class} : #{m}"
|
133
|
-
end
|
123
|
+
# Rails.logger.debug " Matched trigger: #{trigger.from.inspect} -- #{trigger.to.inspect}"
|
134
124
|
end
|
135
125
|
end
|
136
126
|
|
@@ -5,107 +5,136 @@ module CanHasState
|
|
5
5
|
module ClassMethods
|
6
6
|
|
7
7
|
def state_machine(column, &block)
|
8
|
+
column = column.to_sym
|
9
|
+
raise(ArgumentError, "State machine for #{column} already exists") if state_machines.key?(column)
|
10
|
+
|
8
11
|
d = Definition.new(column, self, &block)
|
9
12
|
|
10
13
|
define_method "allow_#{column}?" do |to|
|
11
14
|
state_machine_allow?(column.to_sym, to.to_s)
|
12
15
|
end
|
13
16
|
|
14
|
-
self.state_machines
|
17
|
+
self.state_machines = state_machines.merge(column => d)
|
18
|
+
column
|
15
19
|
end
|
16
20
|
|
17
21
|
def extend_state_machine(column, &block)
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
sm
|
22
|
-
sm
|
22
|
+
column = column.to_sym
|
23
|
+
sm = state_machines[column] || raise(ArgumentError, "Unknown state machine #{column}")
|
24
|
+
|
25
|
+
# handle when sm is inherited from a parent class
|
26
|
+
if sm.parent_context != self
|
27
|
+
sm = sm.dup
|
28
|
+
sm.parent_context = self
|
29
|
+
self.state_machines = state_machines.merge(column => sm)
|
30
|
+
end
|
31
|
+
|
32
|
+
sm.extend_machine(&block)
|
33
|
+
column
|
23
34
|
end
|
24
35
|
|
25
36
|
end
|
26
37
|
|
27
38
|
included do
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
validate :can_has_valid_state_machines
|
35
|
-
after_save :can_has_deferred_state_triggers if respond_to?(:after_save)
|
39
|
+
class_attribute :state_machines, instance_writer: false, default: {}
|
40
|
+
before_validation :set_initial_state_machine_values
|
41
|
+
before_validation :reset_state_triggers
|
42
|
+
before_validation :run_state_triggers
|
43
|
+
validate :validate_state_machines
|
44
|
+
after_save :run_deferred_state_triggers if respond_to?(:after_save)
|
36
45
|
end
|
37
46
|
|
38
47
|
|
39
48
|
private
|
40
49
|
|
41
|
-
def
|
42
|
-
state_machines.each do |
|
50
|
+
def set_initial_state_machine_values
|
51
|
+
state_machines.each do |column, sm|
|
43
52
|
if send(column).blank?
|
44
53
|
send("#{column}=", sm.initial_state)
|
45
54
|
end
|
46
55
|
end
|
47
56
|
end
|
48
57
|
|
49
|
-
def
|
58
|
+
def reset_state_triggers
|
59
|
+
@triggers_called = {}
|
60
|
+
end
|
61
|
+
|
62
|
+
def run_state_triggers
|
50
63
|
# skip triggers if any state machine isn't valid
|
51
64
|
return if can_has_state_errors.any?
|
52
65
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
66
|
+
state_machines.size.times do
|
67
|
+
called = false
|
68
|
+
state_machines.each do |column, sm|
|
69
|
+
from, to = send("#{column}_was"), send(column)
|
70
|
+
next if from == to
|
57
71
|
|
58
|
-
|
59
|
-
|
72
|
+
@triggers_called[column] ||= []
|
73
|
+
triggers = sm.triggers_for(from: from, to: to)
|
60
74
|
|
61
|
-
|
75
|
+
triggers.each do |trigger|
|
76
|
+
# skip trigger if it's already been called
|
77
|
+
next if @triggers_called[column].include? trigger
|
62
78
|
|
63
|
-
|
64
|
-
|
79
|
+
trigger.call self
|
80
|
+
@triggers_called[column] << trigger
|
81
|
+
called = true
|
82
|
+
end
|
83
|
+
end
|
84
|
+
break unless called
|
65
85
|
end
|
66
86
|
end
|
67
87
|
|
68
|
-
def
|
69
|
-
@triggers_called ||= {}
|
70
|
-
|
71
|
-
#
|
72
|
-
|
73
|
-
|
88
|
+
def run_deferred_state_triggers
|
89
|
+
tg = @triggers_called ||= {}
|
90
|
+
# if a trigger causes a circular save, @triggers_called can be reset mid-stream,
|
91
|
+
# causing `... << trigger` at the bottom to fail. this ensure we have a local
|
92
|
+
# copy that won't be reset.
|
93
|
+
|
94
|
+
state_machines.each do |column, sm|
|
74
95
|
if respond_to?("#{column}_before_last_save") # rails 5.1+
|
75
96
|
from, to = send("#{column}_before_last_save"), send(column)
|
76
97
|
else
|
77
98
|
from, to = send("#{column}_was"), send(column)
|
78
99
|
end
|
79
100
|
next if from == to
|
80
|
-
|
101
|
+
|
102
|
+
tg[column] ||= []
|
103
|
+
triggers = sm.triggers_for(from: from, to: to, deferred: true)
|
104
|
+
|
105
|
+
triggers.each do |trigger|
|
106
|
+
next if tg[column].include? trigger
|
107
|
+
|
108
|
+
trigger.call self
|
109
|
+
tg[column] << trigger
|
110
|
+
end
|
81
111
|
end
|
82
112
|
end
|
83
113
|
|
84
|
-
def can_has_state_errors
|
85
|
-
|
86
|
-
state_machines.each do |
|
114
|
+
def can_has_state_errors(reset: true)
|
115
|
+
@can_has_state_errors = {} if reset || !@can_has_state_errors
|
116
|
+
state_machines.each do |column, sm|
|
87
117
|
from, to = send("#{column}_was"), send(column)
|
88
|
-
next if from == to
|
89
118
|
if !sm.known?(to)
|
90
|
-
|
119
|
+
@can_has_state_errors[column] = [:invalid_state]
|
120
|
+
elsif from == to
|
121
|
+
next
|
91
122
|
elsif !sm.allow?(self, to) #state_machine_allow?(column, to)
|
92
|
-
|
123
|
+
@can_has_state_errors[column] = [sm.message(to), {from: "'#{from}'", to: "'#{to}'"}]
|
93
124
|
end
|
94
125
|
end
|
95
|
-
|
126
|
+
@can_has_state_errors
|
96
127
|
end
|
97
128
|
|
98
|
-
def
|
99
|
-
can_has_state_errors.each do |
|
129
|
+
def validate_state_machines
|
130
|
+
can_has_state_errors(reset: false).each do |column, (msg, opts)|
|
100
131
|
errors.add column, msg, **(opts||{})
|
101
132
|
end
|
102
133
|
end
|
103
134
|
|
104
135
|
def state_machine_allow?(column, to)
|
105
|
-
sm = state_machines.
|
106
|
-
|
107
|
-
raise("Unknown state machine #{column}") unless sm
|
108
|
-
sm[1].allow?(self, to)
|
136
|
+
sm = state_machines[column.to_sym] || raise("Unknown state machine #{column}")
|
137
|
+
sm.allow?(self, to)
|
109
138
|
end
|
110
139
|
|
111
140
|
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module CanHasState
|
2
|
+
class Trigger
|
3
|
+
|
4
|
+
attr_accessor :state_machine
|
5
|
+
attr_reader :from, :to, :type, :deferred, :perform
|
6
|
+
|
7
|
+
def initialize(definition, from:, to:, type:, deferred: false, trigger:)
|
8
|
+
@state_machine = definition
|
9
|
+
@from = Array(from).map{|v| v&.to_s}
|
10
|
+
@to = Array(to).map{|v| v&.to_s}
|
11
|
+
@type = type
|
12
|
+
@deferred = !!deferred
|
13
|
+
@perform = Array(trigger)
|
14
|
+
|
15
|
+
if @deferred && !state_machine.parent_context.respond_to?(:after_save)
|
16
|
+
raise ArgumentError, 'use of deferred triggers requires support for #after_save callbacks'
|
17
|
+
end
|
18
|
+
@perform.each do |m|
|
19
|
+
unless [Proc, String, Symbol].include?(m.class)
|
20
|
+
raise ArgumentError, "Expecing Symbol or Proc for #{@type.inspect}, got #{m.class} : #{m}"
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def matches?(from:, to:, deferred: false)
|
26
|
+
matches_from?(from) &&
|
27
|
+
matches_to?(to) &&
|
28
|
+
matches_deferred?(deferred)
|
29
|
+
end
|
30
|
+
|
31
|
+
def call(record)
|
32
|
+
perform.each do |m|
|
33
|
+
case m
|
34
|
+
when Proc
|
35
|
+
if m.arity.zero?
|
36
|
+
record.instance_eval(&m)
|
37
|
+
else
|
38
|
+
m.call record
|
39
|
+
end
|
40
|
+
when Symbol, String
|
41
|
+
record.send m
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def matches_from?(state)
|
50
|
+
from.include?('*') || from.include?(state)
|
51
|
+
end
|
52
|
+
|
53
|
+
def matches_to?(state)
|
54
|
+
to.include?('*') || to.include?(state)
|
55
|
+
end
|
56
|
+
|
57
|
+
def matches_deferred?(defer)
|
58
|
+
defer ? self.deferred : !self.deferred
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
data/lib/can_has_state.rb
CHANGED
data/test/can_has_state_test.rb
CHANGED
@@ -1,5 +1,31 @@
|
|
1
1
|
require 'test_helper'
|
2
2
|
|
3
|
+
class Skeleton
|
4
|
+
include ActiveModel::Validations
|
5
|
+
include ActiveModel::Validations::Callbacks
|
6
|
+
include CanHasState::DirtyHelper
|
7
|
+
include CanHasState::Machine
|
8
|
+
|
9
|
+
track_dirty :state
|
10
|
+
|
11
|
+
state_machine :state do
|
12
|
+
state :awesome
|
13
|
+
state :fabulous, :initial
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.after_save(...)
|
17
|
+
# dummy to allow deferred triggers
|
18
|
+
end
|
19
|
+
|
20
|
+
def fake_persist
|
21
|
+
if valid?
|
22
|
+
# reset dirty tracking as if we had persisted
|
23
|
+
changes_applied
|
24
|
+
true
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
3
29
|
class Account
|
4
30
|
include ActiveModel::Validations
|
5
31
|
include ActiveModel::Validations::Callbacks
|
@@ -41,21 +67,7 @@ class UserState
|
|
41
67
|
state :fabulous, :initial
|
42
68
|
end
|
43
69
|
end
|
44
|
-
|
45
|
-
include ActiveModel::Validations
|
46
|
-
include ActiveModel::Validations::Callbacks
|
47
|
-
include CanHasState::Machine
|
48
|
-
state_machine :state do
|
49
|
-
state :awesome
|
50
|
-
state :fabulous, :initial
|
51
|
-
end
|
52
|
-
extend_state_machine :state do
|
53
|
-
state :incredible, :initial
|
54
|
-
end
|
55
|
-
extend_state_machine :state do
|
56
|
-
state :fantastic
|
57
|
-
end
|
58
|
-
end
|
70
|
+
|
59
71
|
class UserPreState
|
60
72
|
include ActiveModel::Validations
|
61
73
|
include ActiveModel::Validations::Callbacks
|
@@ -78,16 +90,36 @@ class CanHasStateTest < Minitest::Test
|
|
78
90
|
assert UserState.respond_to?(:state_machines)
|
79
91
|
assert UserState.new.respond_to?(:allow_state?)
|
80
92
|
assert_equal 1, UserState.state_machines.size
|
81
|
-
sm = UserState.state_machines[
|
93
|
+
sm = UserState.state_machines[:state]
|
82
94
|
assert_equal :state, sm.column
|
83
95
|
assert_equal 2, sm.states.size
|
84
96
|
assert_equal 0, sm.triggers.size
|
85
97
|
assert_equal 'fabulous', sm.initial_state
|
86
98
|
end
|
87
99
|
|
100
|
+
def test_invalid_state_option
|
101
|
+
assert_raises(ArgumentError) do
|
102
|
+
build_from_skeleton do
|
103
|
+
state_machine :color do
|
104
|
+
state :red,
|
105
|
+
made_up: :option
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
88
111
|
def test_builder_extended
|
89
|
-
|
90
|
-
|
112
|
+
kl = build_from_skeleton do
|
113
|
+
extend_state_machine :state do
|
114
|
+
state :incredible, :initial
|
115
|
+
end
|
116
|
+
extend_state_machine :state do
|
117
|
+
state :fantastic
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
assert_equal 1, kl.state_machines.size
|
122
|
+
sm = kl.state_machines[:state]
|
91
123
|
assert_equal :state, sm.column
|
92
124
|
assert_equal 4, sm.states.size
|
93
125
|
assert_equal 0, sm.triggers.size
|
@@ -96,7 +128,7 @@ class CanHasStateTest < Minitest::Test
|
|
96
128
|
|
97
129
|
def test_extending
|
98
130
|
assert_raises(ArgumentError) do
|
99
|
-
|
131
|
+
build_from_skeleton do
|
100
132
|
extend_state_machine :doesnt_exist do
|
101
133
|
state :phantom
|
102
134
|
end
|
@@ -104,11 +136,34 @@ class CanHasStateTest < Minitest::Test
|
|
104
136
|
end
|
105
137
|
end
|
106
138
|
|
139
|
+
def test_extending_child_doesnt_affect_parent
|
140
|
+
child = build_from_skeleton do
|
141
|
+
attr_accessor :trigger_called
|
142
|
+
extend_state_machine :state do
|
143
|
+
on :* => :*, trigger: ->(r){ r.trigger_called = true}
|
144
|
+
end
|
145
|
+
end
|
146
|
+
assert_equal 0, Skeleton.state_machines[:state].triggers.size
|
147
|
+
assert_equal 1, child.state_machines[:state].triggers.size
|
148
|
+
|
149
|
+
m = Skeleton.new
|
150
|
+
m.state = 'awesome'
|
151
|
+
refute m.respond_to?(:trigger_called=)
|
152
|
+
assert m.fake_persist
|
153
|
+
# should not raise error calling trigger_called=
|
154
|
+
|
155
|
+
m = child.new
|
156
|
+
m.state = 'awesome'
|
157
|
+
assert m.fake_persist
|
158
|
+
assert_equal true, m.trigger_called
|
159
|
+
end
|
160
|
+
|
161
|
+
|
107
162
|
def test_deferred_unavailable
|
108
163
|
assert_raises(ArgumentError) do
|
109
164
|
UserPreState.class_eval do
|
110
165
|
state_machine :state_one do
|
111
|
-
state :one, on_enter_deferred:
|
166
|
+
state :one, on_enter_deferred: proc{ raise "Shouldn't get here" }
|
112
167
|
end
|
113
168
|
end
|
114
169
|
end
|
@@ -116,7 +171,7 @@ class CanHasStateTest < Minitest::Test
|
|
116
171
|
assert_raises(ArgumentError) do
|
117
172
|
UserPreState.class_eval do
|
118
173
|
state_machine :state_two do
|
119
|
-
state :two, on_exit_deferred:
|
174
|
+
state :two, on_exit_deferred: proc{ raise "Shouldn't get here" }
|
120
175
|
end
|
121
176
|
end
|
122
177
|
end
|
@@ -125,7 +180,7 @@ class CanHasStateTest < Minitest::Test
|
|
125
180
|
UserPreState.class_eval do
|
126
181
|
state_machine :state_three do
|
127
182
|
state :three
|
128
|
-
on :* => :*, trigger:
|
183
|
+
on :* => :*, trigger: proc{ raise "Shouldn't get here" }, deferred: true
|
129
184
|
end
|
130
185
|
end
|
131
186
|
end
|
@@ -134,27 +189,28 @@ class CanHasStateTest < Minitest::Test
|
|
134
189
|
def test_deferred_available
|
135
190
|
UserPreState2.class_eval do
|
136
191
|
state_machine :state_one do
|
137
|
-
state :one, on_enter_deferred:
|
192
|
+
state :one, on_enter_deferred: proc{ puts 'Hello' }
|
138
193
|
end
|
139
194
|
end
|
140
195
|
|
141
196
|
UserPreState2.class_eval do
|
142
197
|
state_machine :state_two do
|
143
|
-
state :two, on_exit_deferred:
|
198
|
+
state :two, on_exit_deferred: proc{ puts 'Hello' }
|
144
199
|
end
|
145
200
|
end
|
146
201
|
|
147
202
|
UserPreState2.class_eval do
|
148
203
|
state_machine :state_three do
|
149
204
|
state :three
|
150
|
-
on :* => :*, trigger:
|
205
|
+
on :* => :*, trigger: proc{ puts 'Hello' }, deferred: true
|
151
206
|
end
|
152
207
|
end
|
153
208
|
end
|
154
209
|
|
210
|
+
|
155
211
|
def test_builder_complex
|
156
212
|
assert_equal 1, Account.state_machines.size
|
157
|
-
sm = Account.state_machines[
|
213
|
+
sm = Account.state_machines[:account_state]
|
158
214
|
assert_equal :account_state, sm.column
|
159
215
|
assert_equal 4, sm.states.size
|
160
216
|
assert_equal 2, sm.triggers.size
|
@@ -162,10 +218,10 @@ class CanHasStateTest < Minitest::Test
|
|
162
218
|
end
|
163
219
|
|
164
220
|
def test_state_builder
|
165
|
-
sm_acct = Account.state_machines[
|
166
|
-
sm_user = UserState.state_machines[
|
167
|
-
assert_equal 0, sm_user.states['awesome'][:
|
168
|
-
assert_equal 1, sm_acct.states['active'][:
|
221
|
+
sm_acct = Account.state_machines[:account_state]
|
222
|
+
sm_user = UserState.state_machines[:state]
|
223
|
+
assert_equal 0, sm_user.states['awesome'][:requirements].size
|
224
|
+
assert_equal 1, sm_acct.states['active'][:requirements].size
|
169
225
|
end
|
170
226
|
|
171
227
|
|
@@ -185,6 +241,7 @@ class CanHasStateTest < Minitest::Test
|
|
185
241
|
assert_equal 'inactive', a.account_state_was
|
186
242
|
end
|
187
243
|
|
244
|
+
|
188
245
|
def test_state_vals
|
189
246
|
a = Account.new
|
190
247
|
a.account_state = :inactive
|
@@ -200,6 +257,19 @@ class CanHasStateTest < Minitest::Test
|
|
200
257
|
assert_equal ['Account state is not in a known state'], a.errors.to_a
|
201
258
|
end
|
202
259
|
|
260
|
+
def test_state_with_invalid_default
|
261
|
+
kl = build_from_skeleton do
|
262
|
+
include ActiveModel::Attributes
|
263
|
+
attribute :state, :string, default: 'madeup'
|
264
|
+
end
|
265
|
+
|
266
|
+
m = kl.new
|
267
|
+
assert_equal 'madeup', m.state
|
268
|
+
refute m.valid?
|
269
|
+
assert m.errors.of_kind?(:state, :invalid_state)
|
270
|
+
end
|
271
|
+
|
272
|
+
|
203
273
|
def test_triggers
|
204
274
|
a = Account.new
|
205
275
|
a.fake_persist
|
@@ -217,7 +287,63 @@ class CanHasStateTest < Minitest::Test
|
|
217
287
|
assert a.undeleted, 'Expecting undeleted to be set'
|
218
288
|
end
|
219
289
|
|
220
|
-
def
|
290
|
+
def test_trigger_symbol
|
291
|
+
kl = build_from_skeleton do
|
292
|
+
attr_accessor :trigger_called
|
293
|
+
extend_state_machine :state do
|
294
|
+
on :* => :*, trigger: :call_trigger
|
295
|
+
end
|
296
|
+
def call_trigger
|
297
|
+
self.trigger_called = true
|
298
|
+
end
|
299
|
+
end
|
300
|
+
a = kl.new
|
301
|
+
refute a.trigger_called
|
302
|
+
|
303
|
+
a.state = 'awesome'
|
304
|
+
assert a.fake_persist
|
305
|
+
assert a.trigger_called
|
306
|
+
end
|
307
|
+
|
308
|
+
def test_trigger_proc_arity_0
|
309
|
+
kl = build_from_skeleton do
|
310
|
+
attr_accessor :trigger_called
|
311
|
+
extend_state_machine :state do
|
312
|
+
on :* => :*, trigger: Proc.new{self.trigger_called = true}
|
313
|
+
end
|
314
|
+
end
|
315
|
+
a = kl.new
|
316
|
+
refute a.trigger_called
|
317
|
+
|
318
|
+
a.state = 'awesome'
|
319
|
+
assert a.fake_persist
|
320
|
+
assert a.trigger_called
|
321
|
+
end
|
322
|
+
|
323
|
+
def test_trigger_proc_arity_1
|
324
|
+
kl = build_from_skeleton do
|
325
|
+
attr_accessor :trigger_called
|
326
|
+
extend_state_machine :state do
|
327
|
+
on :* => :*, trigger: lambda{|r| r.trigger_called = true}
|
328
|
+
end
|
329
|
+
end
|
330
|
+
a = kl.new
|
331
|
+
refute a.trigger_called
|
332
|
+
|
333
|
+
a.state = 'awesome'
|
334
|
+
assert a.fake_persist
|
335
|
+
assert a.trigger_called
|
336
|
+
end
|
337
|
+
|
338
|
+
|
339
|
+
def test_transition_messagea_when_from_nil
|
340
|
+
a = Account.new
|
341
|
+
a.account_state = 'special'
|
342
|
+
a.validate
|
343
|
+
assert_match(/has an invalid transition from '' to 'special'/, a.errors.to_a.first)
|
344
|
+
end
|
345
|
+
|
346
|
+
def test_requirements
|
221
347
|
a = Account.new
|
222
348
|
a.account_state = 'deleted'
|
223
349
|
assert a.fake_persist
|
@@ -238,4 +364,121 @@ class CanHasStateTest < Minitest::Test
|
|
238
364
|
assert a.valid?, "Errors: #{a.errors.to_a}"
|
239
365
|
end
|
240
366
|
|
367
|
+
|
368
|
+
def test_interacting_triggers
|
369
|
+
kl = build_from_skeleton do
|
370
|
+
track_dirty :mood, :outlook
|
371
|
+
attr_accessor :called_on_state, :called_on_mood, :called_on_outlook
|
372
|
+
extend_state_machine :state do
|
373
|
+
on :* => :awesome, trigger: proc{ self.called_on_state += 1 }
|
374
|
+
end
|
375
|
+
state_machine :mood do
|
376
|
+
state :happy
|
377
|
+
state :delighted,
|
378
|
+
on_enter: proc{
|
379
|
+
self.state = 'awesome'
|
380
|
+
self.outlook = 'outstanding'
|
381
|
+
self.called_on_mood += 1
|
382
|
+
}
|
383
|
+
end
|
384
|
+
state_machine :outlook do
|
385
|
+
state :positive
|
386
|
+
state :outstanding
|
387
|
+
on :positive => :*, trigger: proc{ self.called_on_outlook += 1 }
|
388
|
+
end
|
389
|
+
def initialize
|
390
|
+
@called_on_state = 0
|
391
|
+
@called_on_mood = 0
|
392
|
+
@called_on_outlook = 0
|
393
|
+
end
|
394
|
+
end
|
395
|
+
m = kl.new
|
396
|
+
assert m.fake_persist
|
397
|
+
assert_equal 0, m.called_on_mood
|
398
|
+
assert_equal 0, m.called_on_outlook
|
399
|
+
assert_equal 0, m.called_on_state
|
400
|
+
|
401
|
+
m.mood = 'delighted'
|
402
|
+
assert m.fake_persist
|
403
|
+
assert_equal 1, m.called_on_mood
|
404
|
+
assert_equal 1, m.called_on_outlook, 'should trigger on later state_machines'
|
405
|
+
assert_equal 1, m.called_on_state, 'should trigger on earlier state_machines'
|
406
|
+
end
|
407
|
+
|
408
|
+
|
409
|
+
def test_errors_resolved_in_later_callback_are_kept
|
410
|
+
# Triggers are skipped when the state_machine has validation errors.
|
411
|
+
# Whether or not to skip is decided in an early before_validation callback.
|
412
|
+
# Even if a later callback resolves the validation error, it's important to
|
413
|
+
# still bubble up the errors since triggers did not run.
|
414
|
+
kl = build_from_skeleton do
|
415
|
+
attr_accessor :have_beans, :coffee_in_hand
|
416
|
+
extend_state_machine :state do
|
417
|
+
state :incredible,
|
418
|
+
require: proc{ coffee_in_hand > 0 }
|
419
|
+
end
|
420
|
+
before_validation { self.coffee_in_hand += 1 if have_beans }
|
421
|
+
def initialize
|
422
|
+
@coffee_in_hand = 0
|
423
|
+
end
|
424
|
+
end
|
425
|
+
m = kl.new
|
426
|
+
m.state = 'incredible'
|
427
|
+
refute m.valid?
|
428
|
+
assert m.errors.of_kind?(:state, :invalid_transition)
|
429
|
+
assert_equal 0, m.coffee_in_hand
|
430
|
+
|
431
|
+
m = kl.new
|
432
|
+
m.have_beans = true
|
433
|
+
m.state = 'incredible'
|
434
|
+
refute m.valid?
|
435
|
+
assert m.errors.of_kind?(:state, :invalid_transition)
|
436
|
+
assert_equal 1, m.coffee_in_hand
|
437
|
+
end
|
438
|
+
|
439
|
+
def test_rerunning_triggers_clears_pre_callback_errors
|
440
|
+
# To resolve the issue above, it is possible to re-eval the state_machine
|
441
|
+
# and run triggers if validations now pass. This is highly discouraged as
|
442
|
+
# complex callback callback interactions like this should be refactored out
|
443
|
+
# if at all possible.
|
444
|
+
# Not an officially supported solution and subject to change, but
|
445
|
+
# documented here to show that it is (presently) possible.
|
446
|
+
kl = build_from_skeleton do
|
447
|
+
attr_accessor :have_beans, :coffee_in_hand, :we_are_incredible
|
448
|
+
extend_state_machine :state do
|
449
|
+
state :incredible,
|
450
|
+
require: proc{ coffee_in_hand > 0 },
|
451
|
+
on_enter: proc{ self.we_are_incredible = true }
|
452
|
+
end
|
453
|
+
before_validation do
|
454
|
+
self.coffee_in_hand += 1 if have_beans
|
455
|
+
run_state_triggers
|
456
|
+
end
|
457
|
+
def initialize
|
458
|
+
@coffee_in_hand = 0
|
459
|
+
end
|
460
|
+
end
|
461
|
+
m = kl.new
|
462
|
+
m.state = 'incredible'
|
463
|
+
refute m.valid?
|
464
|
+
assert m.errors.of_kind?(:state, :invalid_transition)
|
465
|
+
assert_equal 0, m.coffee_in_hand
|
466
|
+
refute m.we_are_incredible
|
467
|
+
|
468
|
+
m = kl.new
|
469
|
+
m.have_beans = true
|
470
|
+
m.state = 'incredible'
|
471
|
+
assert m.valid?
|
472
|
+
assert_equal 1, m.coffee_in_hand
|
473
|
+
assert m.we_are_incredible
|
474
|
+
end
|
475
|
+
|
476
|
+
|
477
|
+
|
478
|
+
def build_from_skeleton(&block)
|
479
|
+
Class.new(Skeleton).tap do |kl|
|
480
|
+
kl.class_eval(&block)
|
481
|
+
end
|
482
|
+
end
|
483
|
+
|
241
484
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: can_has_state
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.9.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- thomas morgan
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2023-01-25 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activemodel
|
@@ -16,20 +16,20 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
19
|
+
version: '6.0'
|
20
20
|
- - "<"
|
21
21
|
- !ruby/object:Gem::Version
|
22
|
-
version: '7.
|
22
|
+
version: '7.2'
|
23
23
|
type: :runtime
|
24
24
|
prerelease: false
|
25
25
|
version_requirements: !ruby/object:Gem::Requirement
|
26
26
|
requirements:
|
27
27
|
- - ">="
|
28
28
|
- !ruby/object:Gem::Version
|
29
|
-
version: '
|
29
|
+
version: '6.0'
|
30
30
|
- - "<"
|
31
31
|
- !ruby/object:Gem::Version
|
32
|
-
version: '7.
|
32
|
+
version: '7.2'
|
33
33
|
- !ruby/object:Gem::Dependency
|
34
34
|
name: minitest
|
35
35
|
requirement: !ruby/object:Gem::Requirement
|
@@ -92,6 +92,7 @@ files:
|
|
92
92
|
- lib/can_has_state/locale/ja.yml
|
93
93
|
- lib/can_has_state/machine.rb
|
94
94
|
- lib/can_has_state/railtie.rb
|
95
|
+
- lib/can_has_state/trigger.rb
|
95
96
|
- lib/can_has_state/version.rb
|
96
97
|
- lib/tasks/can_has_state_tasks.rake
|
97
98
|
- test/can_has_state_test.rb
|
@@ -108,14 +109,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
108
109
|
requirements:
|
109
110
|
- - ">="
|
110
111
|
- !ruby/object:Gem::Version
|
111
|
-
version: '
|
112
|
+
version: '2.7'
|
112
113
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
113
114
|
requirements:
|
114
115
|
- - ">="
|
115
116
|
- !ruby/object:Gem::Version
|
116
117
|
version: '0'
|
117
118
|
requirements: []
|
118
|
-
rubygems_version: 3.
|
119
|
+
rubygems_version: 3.3.26
|
119
120
|
signing_key:
|
120
121
|
specification_version: 4
|
121
122
|
summary: Super simple state machine for ActiveModel
|