can_has_state 0.6.2 → 0.8.0
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/MIT-LICENSE +1 -1
- data/README.md +51 -36
- data/lib/can_has_state/definition.rb +47 -57
- data/lib/can_has_state/locale/ja.yml +5 -0
- data/lib/can_has_state/machine.rb +73 -44
- data/lib/can_has_state/trigger.rb +62 -0
- data/lib/can_has_state/version.rb +1 -1
- data/lib/can_has_state.rb +6 -4
- data/test/can_has_state_test.rb +206 -31
- metadata +24 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0b2d2008ba870961e06cebe22f027b133b46c5f078315938c9338edfd99678f5
|
4
|
+
data.tar.gz: 2d4e7dbfd0bb896e01dfdaf66933b3b483f71915579f5d66d42fde1cbd0416de
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9e18bb675dd1acc0b84bb8539f67ca9c483f7ff618927b9baef5cdc738ba7a723cac0cccbaed68dfd0291925a2af5a8cda4ebf22b250ead0d39d813e389ecd86
|
7
|
+
data.tar.gz: 2cc03d656532de9061758242d7daaab074f979665323a071d432b1c220e050951da7750ca6f38c5ef1c8e1e4633f76ce16bd8f404fbe9e5e31ba8fe0a6426f17
|
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
114
|
def can_has_state_errors
|
85
|
-
err =
|
86
|
-
state_machines.each do |
|
115
|
+
err = {}
|
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
|
-
err
|
119
|
+
err[column] = [:invalid_state]
|
120
|
+
elsif from == to
|
121
|
+
next
|
91
122
|
elsif !sm.allow?(self, to) #state_machine_allow?(column, to)
|
92
|
-
err
|
123
|
+
err[column] = [sm.message(to), {from: "'#{from}'", to: "'#{to}'"}]
|
93
124
|
end
|
94
125
|
end
|
95
126
|
err
|
96
127
|
end
|
97
128
|
|
98
|
-
def
|
99
|
-
can_has_state_errors.each do |
|
129
|
+
def validate_state_machines
|
130
|
+
can_has_state_errors.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
@@ -1,11 +1,13 @@
|
|
1
|
-
require 'active_support'
|
1
|
+
require 'active_support/all'
|
2
2
|
require 'active_model'
|
3
3
|
|
4
|
-
%w(definition dirty_helper machine).each do |f|
|
4
|
+
%w(definition dirty_helper machine trigger).each do |f|
|
5
5
|
require "can_has_state/#{f}"
|
6
6
|
end
|
7
7
|
|
8
|
-
|
9
|
-
|
8
|
+
require 'active_support/i18n'
|
9
|
+
Dir[File.join(__dir__, 'can_has_state', 'locale', '*.yml')].each do |fn|
|
10
|
+
I18n.load_path << fn
|
11
|
+
end
|
10
12
|
|
11
13
|
require 'can_has_state/railtie' if defined?(Rails)
|
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,53 @@ 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
|
+
|
410
|
+
def build_from_skeleton(&block)
|
411
|
+
Class.new(Skeleton).tap do |kl|
|
412
|
+
kl.class_eval(&block)
|
413
|
+
end
|
414
|
+
end
|
415
|
+
|
241
416
|
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.8.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
|
@@ -58,6 +58,20 @@ dependencies:
|
|
58
58
|
- - ">="
|
59
59
|
- !ruby/object:Gem::Version
|
60
60
|
version: '0'
|
61
|
+
- !ruby/object:Gem::Dependency
|
62
|
+
name: rake
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
64
|
+
requirements:
|
65
|
+
- - ">="
|
66
|
+
- !ruby/object:Gem::Version
|
67
|
+
version: '0'
|
68
|
+
type: :development
|
69
|
+
prerelease: false
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
71
|
+
requirements:
|
72
|
+
- - ">="
|
73
|
+
- !ruby/object:Gem::Version
|
74
|
+
version: '0'
|
61
75
|
description: |-
|
62
76
|
can_has_state is a simplified state machine gem. It relies on ActiveModel and
|
63
77
|
should be compatible with any ActiveModel-compatible persistence layer.
|
@@ -75,8 +89,10 @@ files:
|
|
75
89
|
- lib/can_has_state/dirty_helper.rb
|
76
90
|
- lib/can_has_state/locale/en.yml
|
77
91
|
- lib/can_has_state/locale/es.yml
|
92
|
+
- lib/can_has_state/locale/ja.yml
|
78
93
|
- lib/can_has_state/machine.rb
|
79
94
|
- lib/can_has_state/railtie.rb
|
95
|
+
- lib/can_has_state/trigger.rb
|
80
96
|
- lib/can_has_state/version.rb
|
81
97
|
- lib/tasks/can_has_state_tasks.rake
|
82
98
|
- test/can_has_state_test.rb
|
@@ -93,14 +109,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
93
109
|
requirements:
|
94
110
|
- - ">="
|
95
111
|
- !ruby/object:Gem::Version
|
96
|
-
version: '
|
112
|
+
version: '2.7'
|
97
113
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
98
114
|
requirements:
|
99
115
|
- - ">="
|
100
116
|
- !ruby/object:Gem::Version
|
101
117
|
version: '0'
|
102
118
|
requirements: []
|
103
|
-
rubygems_version: 3.
|
119
|
+
rubygems_version: 3.3.26
|
104
120
|
signing_key:
|
105
121
|
specification_version: 4
|
106
122
|
summary: Super simple state machine for ActiveModel
|