can_has_state 0.7.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 830a444d5fc61e931ce78814643df95ecbd3fbf3b7a7d3c6e8a7cfff2cd8d5dc
4
- data.tar.gz: 4eb9957f407096c867d939f6391ff14ff53df0ec6adc36b9c60d9c58eb784d58
3
+ metadata.gz: 0b2d2008ba870961e06cebe22f027b133b46c5f078315938c9338edfd99678f5
4
+ data.tar.gz: 2d4e7dbfd0bb896e01dfdaf66933b3b483f71915579f5d66d42fde1cbd0416de
5
5
  SHA512:
6
- metadata.gz: 96a50045a8c2933b37a54b56dfd105e5f0ecb2f53fce23034d4245f6f74651e8e2b950ada2ece3985d721cbdfe124a068e68aa713a1fe2e44c4ecec4a80cdf75
7
- data.tar.gz: 2a03221fe58298f25309dec5edc51e5fe14114f99c1d48d133594dc40722d647ea6a4283837c37abb75a87df8cfea166f30e6e2683f121a9ebe5745a4fb5e6d9
6
+ metadata.gz: 9e18bb675dd1acc0b84bb8539f67ca9c483f7ff618927b9baef5cdc738ba7a723cac0cccbaed68dfd0291925a2af5a8cda4ebf22b250ead0d39d813e389ecd86
7
+ data.tar.gz: 2cc03d656532de9061758242d7daaab074f979665323a071d432b1c220e050951da7750ca6f38c5ef1c8e1e4633f76ce16bd8f404fbe9e5e31ba8fe0a6426f17
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright 2012-2022 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
 
@@ -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.7.0'
2
+ VERSION = '0.8.0'
3
3
  end
data/lib/can_has_state.rb CHANGED
@@ -1,7 +1,7 @@
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
 
@@ -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.7.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: 2022-01-27 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
@@ -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: '0'
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.2.22
119
+ rubygems_version: 3.3.26
119
120
  signing_key:
120
121
  specification_version: 4
121
122
  summary: Super simple state machine for ActiveModel