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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2334cbda7473385440338a16481d9a2b5aa93fb4384f2f3022cc09f3afd465b
4
- data.tar.gz: e3ae9bbe1fec61fcad71017ccf5edf6ff1b81ded5fb4b652486c6fe73cbc5f35
3
+ metadata.gz: 0b2d2008ba870961e06cebe22f027b133b46c5f078315938c9338edfd99678f5
4
+ data.tar.gz: 2d4e7dbfd0bb896e01dfdaf66933b3b483f71915579f5d66d42fde1cbd0416de
5
5
  SHA512:
6
- metadata.gz: 7d07b514d381be7e775b4919069697d41fddc87dd7867ad11af8d7c233b700c09f785d4d7e48764cd0cb336258ceb4dc6b3f9435491385b607d0bd66b5f3323e
7
- data.tar.gz: 0364537dd48fa00927a0aa2e480f917966044f48fc9cfcbbb41738d2c7887368ad6298c9137de6b2e7ff80f6b7204906f3ca03bbeb86a458450da985420baeef
6
+ metadata.gz: 9e18bb675dd1acc0b84bb8539f67ca9c483f7ff618927b9baef5cdc738ba7a723cac0cccbaed68dfd0291925a2af5a8cda4ebf22b250ead0d39d813e389ecd86
7
+ data.tar.gz: 2cc03d656532de9061758242d7daaab074f979665323a071d432b1c220e050951da7750ca6f38c5ef1c8e1e4633f76ce16bd8f404fbe9e5e31ba8fe0a6426f17
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2012-2021 thomas morgan
1
+ Copyright 2012-2023 thomas morgan
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
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
- `:state_column => 'new_state'`
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
- :from => :inactive,
47
- :on_enter => :update_plan_details,
48
- :on_enter_deferred => :start_billing,
49
- :on_exit_deferred => lambda { |r| r.stop_billing }
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 :from => [] .
56
+ # state is also the initial state), use `from: []`.
57
57
  #
58
58
  state :inactive,
59
- :from => [:active]
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 true value (anything except nil or false) for the
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 states, respectively.
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
- :from => [:active, :inactive],
79
- :timestamp => :deleted_at,
80
- :on_enter_deferred => [:delete_record, :delete_payment_info],
81
- :require => lambda {|r| !r.active_services? },
82
- :message => "has invalid transition from %{from} to %{to}"
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, :trigger => [:do_one, :do_two]
99
- on :active => [:inactive, :deleted], :trigger => lambda {|r| r.act }
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 :* => :*, :trigger => :log_state_change, :deferred=>true
121
- on :* => :deleted, :trigger => :same_as_on_enter
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
- :from => :inactive
141
+ from: :inactive
141
142
  state :inactive,
142
- :from => :active
143
+ from: :active
143
144
  state :deleted,
144
- :from => [:active, :inactive],
145
- :timestamp => :deleted_at
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
- :from => :inactive,
190
- :require => lambda{|r| r.payment_status=='current'}
190
+ from: :inactive,
191
+ require: lambda{|r| r.payment_status=='current'}
191
192
  state :inactive,
192
- :from => :active
193
+ from: :active
193
194
  state :deleted,
194
- :from => [:active, :inactive]
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
- ## Notes on triggers and initial state ##
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
- ## Compatibility ##
255
+ ## Modifying state attributes in `before_validation`
255
256
 
256
- Tested with Ruby 2.3-2.6+ and ActiveSupport and ActiveModel 5.2-6.1+.
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
- attr_reader :column, :states, :initial_state, :triggers
4
+ attr_accessor :parent_context
5
+ attr_reader :column, :states, :triggers, :initial_state
5
6
 
6
- def initialize(column_name, parent_context, &block)
7
- @parent_context = 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
- # TODO: turn even guards into types of triggers ... then support :guard as a trigger param
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
- guards << Proc.new do |r|
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 :guard, :require
45
- guards += Array(val)
52
+ when :require
53
+ requirements += Array(val)
46
54
  when :message
47
55
  message = val
48
56
  when :timestamp
49
- @triggers << {:from=>["*"], :to=>[state_name], :trigger=>[Proc.new{|r| r.send("#{val}=", Time.now.utc)}]}
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 << {:from=>["*"], :to=>[state_name], :trigger=>Array(val), :type=>:on_enter}
59
+ @triggers << Trigger.new(self, from: '*', to: state_name, trigger: val, type: :on_enter)
52
60
  when :on_enter_deferred
53
- raise(ArgumentError, "use of deferred triggers requires support for #after_save callbacks") unless @parent_context.respond_to?(:after_save)
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 << {:from=>[state_name], :to=>["*"], :trigger=>Array(val), :type=>:on_exit}
63
+ @triggers << Trigger.new(self, from: state_name, to: '*', trigger: val, type: :on_exit)
57
64
  when :on_exit_deferred
58
- raise(ArgumentError, "use of deferred triggers requires support for #after_save callbacks") unless @parent_context.respond_to?(:after_save)
59
- @triggers << {:from=>[state_name], :to=>["*"], :trigger=>Array(val), :type=>:on_exit, :deferred=>true}
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] = {:guards=>guards, :message=>message}
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 << {:from=>Array(from).map(&:to_s), :to=>Array(to).map(&:to_s),
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 &&= to.to_s
81
- @states.keys.include? to
86
+ to = to&.to_s
87
+ states.keys.include? to
82
88
  end
83
89
 
84
90
  def allow?(record, to)
85
- to &&= to.to_s
91
+ to = to&.to_s
86
92
  return false unless known?(to)
87
- states[to][:guards].all? do |g|
93
+ states[to][:requirements].all? do |g|
88
94
  case g
89
95
  when Proc
90
- g.call record
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 :guard, got #{g.class} : #{g}"
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 &&= to.to_s
110
+ to = to&.to_s
101
111
  states[to][:message]
102
112
  end
103
113
 
104
114
 
105
- def trigger(record, from, to, deferred=false)
106
- from &&= from.to_s
107
- to &&= to.to_s
108
- # Rails.logger.debug "Checking triggers for transition #{from} to #{to} (deferred:#{deferred.inspect})"
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
- deferred ? trigger[:deferred] : !trigger[:deferred]
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[:from].inspect} -- #{trigger[:to].inspect}"
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
 
@@ -0,0 +1,5 @@
1
+ ja:
2
+ errors:
3
+ messages:
4
+ invalid_state: は既知の状態ではありません。
5
+ invalid_transition: に、 %{from} から %{to} への無効な移行があります。
@@ -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 += [[column.to_sym, d]]
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
- sm = state_machines.detect{|(col, _)| col == column}
19
- # |(col, stm)|
20
- raise(ArgumentError, "Unknown state machine #{column}") unless sm
21
- sm[1].extend_machine(&block)
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
- unless method_defined? :state_machines
29
- class_attribute :state_machines, :instance_writer=>false
30
- self.state_machines = []
31
- end
32
- before_validation :can_has_initial_states
33
- before_validation :can_has_state_triggers
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 can_has_initial_states
42
- state_machines.each do |(column, sm)|
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 can_has_state_triggers
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
- @triggers_called ||= {}
54
- state_machines.each do |(column, sm)|
55
- from, to = send("#{column}_was"), send(column)
56
- next if from == to
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
- # skip triggers if they've already been called for this from/to transition
59
- next if @triggers_called[column] == [from, to]
72
+ @triggers_called[column] ||= []
73
+ triggers = sm.triggers_for(from: from, to: to)
60
74
 
61
- sm.trigger(self, from, to)
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
- # record that triggers were called
64
- @triggers_called[column] = [from, to]
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 can_has_deferred_state_triggers
69
- @triggers_called ||= {}
70
- state_machines.each do |(column, sm)|
71
- # clear record of called triggers
72
- @triggers_called[column] = nil
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
- sm.trigger(self, from, to, :deferred)
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 |(column, sm)|
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 << [column, :invalid_state]
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 << [column, sm.message(to), {from: from, to: to}]
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 can_has_valid_state_machines
99
- can_has_state_errors.each do |(column, msg, opts)|
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.detect{|(col, _)| col == column}
106
- # |(col, stm)|
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
@@ -1,3 +1,3 @@
1
1
  module CanHasState
2
- VERSION = '0.6.2'
2
+ VERSION = '0.8.0'
3
3
  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
- # require 'active_support/i18n'
9
- I18n.load_path << File.dirname(__FILE__) + '/can_has_state/locale/en.yml'
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)
@@ -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
- class UserExtended
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[0][1]
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
- assert_equal 1, UserExtended.state_machines.size
90
- sm = UserExtended.state_machines[0][1]
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
- UserState.class_eval do
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: ->{ raise "Shouldn't get here" }
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: ->{ raise "Shouldn't get here" }
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: ->{ raise "Shouldn't get here" }, deferred: true
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: ->{ puts 'Hello' }
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: ->{ puts 'Hello' }
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: ->{ puts 'Hello' }, deferred: true
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[0][1]
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[0][1]
166
- sm_user = UserState.state_machines[0][1]
167
- assert_equal 0, sm_user.states['awesome'][:guards].size
168
- assert_equal 1, sm_acct.states['active'][:guards].size
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 test_guards
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.6.2
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: 2021-10-20 00:00:00.000000000 Z
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: '5.2'
19
+ version: '6.0'
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
- version: '7.1'
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: '5.2'
29
+ version: '6.0'
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
- version: '7.1'
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: '0'
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.2.22
119
+ rubygems_version: 3.3.26
104
120
  signing_key:
105
121
  specification_version: 4
106
122
  summary: Super simple state machine for ActiveModel