has_state_machine 0.6.1 → 1.1.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: 7d4ecde120f4c419dfe63154f2e61cbb8459a77aedf0b45cc169a3eeac3bcb29
4
- data.tar.gz: df52aa5a5a23abd0d230281e3d124eacbd56e9bbb17cc094a202a02a208fb732
3
+ metadata.gz: 902498121bd57b97de373beaa68557c3591de8a221d524af5b9033be1cca8a73
4
+ data.tar.gz: fb4806d204772d488c4fb2bc69360cd989edf4c8a5312508b37868d961adf059
5
5
  SHA512:
6
- metadata.gz: 6c2e58ee081bdbd77e5b9b04adfc8eff0ff94f2bd50c63ed06317be69ae1ff18f28eb32bb149629f06ab41f285c7a4fa4e18788d7a8140b1d91372d3cc3ce8c1
7
- data.tar.gz: f4aefba6c6484abf2dfb0f79a390ca80c8e686637accd18869f3c0991b74496f38bbbe65b47958213ebf12cc48079d3b01ba373a0f9bff16fbe03c11f64a357e
6
+ metadata.gz: 1baeee9d74f64325427b52e2e867cab05db7848f384316b8175cbb5d49a2c4030dcf905020ac8091c495f6cfa20f8dcae657bcffd78b86987e9c867665f5cf42
7
+ data.tar.gz: 7e998865b342452f802266b3408bd673b26fb622672ed48ff18cb384259c34a7ec1f591a9ed99efb12f33364e5a1c9bff33d9b95bd4fbac3794ff9d3f2ccdd25
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # HasStateMachine
2
2
 
3
- [![Build Status](https://github.com/bharget/has_state_machine/workflows/Tests/badge.svg)](https://github.com/bharget/has_state_machine/actions)
3
+ [![CI](https://github.com/beehiiv/has_state_machine/actions/workflows/ci.yml/badge.svg)](https://github.com/beehiiv/has_state_machine/actions/workflows/ci.yml)
4
4
  [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
5
 
6
6
  HasStateMachine uses ruby classes to make creating a finite state machine for your ActiveRecord models a breeze.
@@ -10,7 +10,9 @@ HasStateMachine uses ruby classes to make creating a finite state machine for yo
10
10
  - [HasStateMachine](#hasstatemachine)
11
11
  - [Contents](#contents)
12
12
  - [Installation](#installation)
13
- - [Usage](#usage)
13
+ - [Usage](#basic-usage)
14
+ - [Basic Usage](#basic-usage)
15
+ - [Validations & Error Handling](#validations-and-error-handling)
14
16
  - [Advanced Usage](#advanced-usage)
15
17
  - [Contributing](#contributing)
16
18
  - [License](#license)
@@ -35,12 +37,12 @@ Or install it yourself as:
35
37
  $ gem install has_state_machine
36
38
  ```
37
39
 
38
- ## Usage
40
+ ## Basic Usage
39
41
 
40
42
  You must first use the `has_state_machine` macro to define your state machine at
41
43
  a high level. This includes defining the possible states for your object as well
42
44
  as some optional configuration should you want to change the default behavior of
43
- the state machine.
45
+ the state machine (more on this later).
44
46
 
45
47
  ```ruby
46
48
  # By default, it is assumed that the "state" of the object is
@@ -52,7 +54,7 @@ end
52
54
 
53
55
  Now you must define the classes for the states in your state machine. By default,
54
56
  `HasStateMachine` assumes that these will be under the `Workflow` namespace following
55
- the pattern of `Workflow::#{ObjectClass}::#{State}`. The state objects must inherit
57
+ the pattern of `Workflow::#{ObjectClass}::#{State}`. The state classes must inherit
56
58
  from `HasStateMachine::State`.
57
59
 
58
60
  ```ruby
@@ -94,6 +96,13 @@ module Workflow
94
96
  # after_transition callbacks as well.
95
97
  Rails.logger.info "== Transitioned from #{previous_state} ==\n"
96
98
  end
99
+
100
+ # after_transition_commit runs only once the transition has been
101
+ # committed: after the record is saved for normal transitions, and
102
+ # outside the transaction for transactional transitions (see below).
103
+ after_transition_commit do
104
+ MyJob.perform_later(object)
105
+ end
97
106
  end
98
107
  end
99
108
  ```
@@ -117,9 +126,83 @@ post.status.transition_to(:archived)
117
126
  # => true
118
127
  ```
119
128
 
129
+ If you'd like to check that an object can be transitioned into a new state, use the `can_transition?` method. This checks to see if the provided argument is in the `transitions_to` array defined on the object's current state. (This does not run any validations that may be defined on the new state)
130
+
131
+ Example:
132
+ ```ruby
133
+ post = Post.create(status: "draft")
134
+
135
+ post.status.can_transition?(:published) # => true
136
+ post.status.can_transition?(:other_state) # => false
137
+ ```
138
+
139
+ ### Validations and Error Handling
140
+
141
+ You can define custom validations on a given state to determine whether an object in that state or a transition to that state is valid.
142
+
143
+ By default, validations defined on the state will be run as part of the object validations if the object is in that state.
144
+
145
+ ```ruby
146
+ post = Post.create(status: "published", title: "Title")
147
+
148
+ post.valid?
149
+ # => true
150
+
151
+ post.title = nil
152
+ post.valid?
153
+ # => false
154
+ ```
155
+
156
+ If you wish to change this behavior and not have the state validations run on the object, you can specify that with the `state_validations_on_object` option when defining your state machine.
157
+
158
+ ```ruby
159
+ class Post < ApplicationRecord
160
+ has_state_machine states: %i[draft published, archived], state_validations_on_object: false
161
+ end
162
+
163
+ post = Post.create(status: "published", title: "Title")
164
+
165
+ post.valid?
166
+ # => true
167
+
168
+ post.title = nil
169
+ post.valid?
170
+ # => true
171
+ ```
172
+
173
+ By default, when attempting to transition an object to another state, it checks:
174
+ * Validations defined on the object
175
+ * That the new state is one of the allowed transitions from the current state
176
+ * Any validations defined on the new state
177
+
178
+ If any are found to be invalid, the transition will fail. Any errors from validations on the new state will be added to the object.
179
+
180
+ ```ruby
181
+ post = Post.create(status: "draft")
182
+
183
+ post.title = nil
184
+ post.status.transition_to(:published)
185
+ # => false
186
+
187
+ post.errors.full_messages
188
+ # => ["Title can't be blank"]
189
+ ```
190
+
191
+ If you wish to bypass this behavior and skip validations during a transition, you can do that:
192
+
193
+ ```ruby
194
+ post = Post.create(status: "draft")
195
+
196
+ post.title = nil
197
+ post.status.transition_to(:published, skip_validations: true)
198
+ # => true
199
+ ```
200
+
120
201
  ### Advanced Usage
121
202
 
122
- Sometimes there may be a situation where you want to manually roll back a state change in one of the provided callbacks. To do this, add the `transactional: true` option to the `state_options` declaration and use the `rollback_transition` method in your callback. This will allow you to prevent the transition from persisting if something further down the line fails.
203
+ #### Transactional Transitions
204
+
205
+ There may be a situation where you want to manually rollback a state change in one of the provided transition callbacks. To do this, add the `transactional: true` option to the `state_options` declaration. This results in the transition being wrapped in a transaction. You can then use the `rollback_transition` method in your callback when you want to trigger a rollback of the transaction. This will allow you to prevent the transition from persisting if something further down the line fails.
123
206
 
124
207
  ```ruby
125
208
  module Workflow
@@ -130,15 +213,48 @@ module Workflow
130
213
  rollback_transition unless notified_watchers?
131
214
  end
132
215
 
216
+ after_transition_commit do
217
+ enqueue_external_work
218
+ end
219
+
133
220
  private
134
221
 
222
+ def enqueue_external_work
223
+ # Any work you want to happen only after the transition is committed.
224
+ # Enqueuing a job, calling an external API, sending a webhook, etc.
225
+ end
226
+
135
227
  def notified_watchers?
136
- #...
228
+ # Any dependent work that you want to run that should play a part in determining
229
+ # whether the transition was successful or not and needs to be rolled back.
137
230
  end
138
231
  end
139
232
  end
140
233
  ```
141
234
 
235
+ #### Transient Transition Variables
236
+
237
+ Sometimes you may may want to pass additional arguments to a state transition for additional context in your transition callbacks. To do this, add the `transients` option to the `state_options` declaration. This allows you to define any additional attributes you want to be able to pass along during a state transition to that state.
238
+
239
+ ```ruby
240
+ module Workflow
241
+ class Post::Archived < HasStateMachine::State
242
+ state_options transients: %i[user]
243
+
244
+ after_transition do
245
+ puts "== Post archived by #{user.name} =="
246
+ end
247
+ end
248
+ end
249
+
250
+ current_user = User.create(name: "John Doe")
251
+ post = Post.create(status: "published")
252
+
253
+ post.status.transition_to(:archived, user: current_user)
254
+ # == Post archived by John Doe ==
255
+ # => true
256
+ ```
257
+
142
258
  ## Contributing
143
259
 
144
260
  Anyone is encouraged to help improve this project. Here are a few ways you can help:
@@ -15,22 +15,42 @@ module HasStateMachine
15
15
  # for use on a HasStateMachine::State instance.
16
16
  define_model_callbacks :transition, only: %i[before after]
17
17
 
18
+ ##
19
+ # Defines the after_transition_commit callback. It runs only after a
20
+ # transition has committed.
21
+ define_model_callbacks :transition_commit, only: %i[after]
22
+
18
23
  ##
19
24
  # possible_transitions - Retrieves the next available transitions for a given state.
20
25
  # transactional? - Determines whether or not the transition should happen with a transactional block.
21
- delegate :possible_transitions, :transactional?, :state, to: "self.class"
26
+ # state - The underscored name of the state
27
+ # transients - Specified list of optional transient attributes on this state
28
+ delegate :possible_transitions, :transactional?, :state, :transients, to: "self.class"
22
29
 
23
30
  ##
24
31
  # Initializes the HasStateMachine::State instance.
25
32
  #
26
33
  # @example
27
34
  # state = Workflow::Post::Draft.new(post) #=> "draft"
28
- def initialize(object)
35
+ def initialize(object, transient_values = {})
29
36
  @object = object
30
37
 
38
+ transient_values.to_h.slice(*transients).each do |transient, value|
39
+ instance_variable_set(:"@#{transient}", value)
40
+ end
41
+
31
42
  super(state)
32
43
  end
33
44
 
45
+ ##
46
+ # Determines if the given desired state exists in the predetermined
47
+ # list of allowed transitions.
48
+ # @param desired_state [String, Symbol] the state to check if the object can transition to
49
+ # @return [Boolean] whether or not the object can transition to the desired state
50
+ def can_transition?(desired_state)
51
+ possible_transitions.include? desired_state.to_s
52
+ end
53
+
34
54
  ##
35
55
  # Checks to see if the desired state is valid and then gives
36
56
  # responsibility to the desired state's instance to make the
@@ -43,43 +63,56 @@ module HasStateMachine
43
63
  # @return [Boolean] whether or not the transition took place
44
64
  def transition_to(desired_state, **options)
45
65
  transitioned = false
66
+ options = options.transform_keys(&:to_sym)
67
+ desired_state_instance = state_instance(desired_state, options)
46
68
 
47
69
  with_transition_options(options) do
48
- return false unless valid_transition?(desired_state.to_s)
49
-
50
- desired_state = state_instance(desired_state.to_s)
70
+ return false unless valid_transition?(desired_state_instance)
51
71
 
52
- transitioned = if desired_state.transactional?
53
- desired_state.perform_transactional_transition!
72
+ transitioned = if desired_state_instance.transactional?
73
+ desired_state_instance.perform_transactional_transition!
54
74
  else
55
- desired_state.perform_transition!
75
+ desired_state_instance.perform_transition!
56
76
  end
57
77
  end
58
78
 
59
79
  transitioned
80
+ ensure
81
+ (desired_state_instance&.errors || []).each do |error|
82
+ object.errors.add(error.attribute, error.type)
83
+ end
60
84
  end
61
85
 
62
86
  ##
63
87
  # Makes the actual transition from one state to the next and
64
- # runs the before and after transition callbacks.
88
+ # runs the before and after transition callbacks. The
89
+ # after_transition_commit callbacks run after the update completes
90
+ # and only when it succeeds.
65
91
  def perform_transition!
66
- run_callbacks :transition do
67
- object.update("#{object.state_attribute}": state)
92
+ run_callbacks :transition_commit do
93
+ run_callbacks :transition do
94
+ object.update("#{object.state_attribute}": state)
95
+ end
68
96
  end
69
97
  end
70
98
 
71
99
  ##
72
100
  # Makes the actual transition from one state to the next and
73
101
  # runs the before and after transition callbacks in a transaction
74
- # to allow for roll backs.
102
+ # to allow for roll backs. The after_transition_commit callbacks run
103
+ # outside the transaction and only when it commits (not on rollback).
75
104
  def perform_transactional_transition!
76
- ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
77
- run_callbacks :transition do
78
- rollback_transition unless object.update("#{object.state_attribute}": state)
105
+ run_callbacks :transition_commit do
106
+ ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
107
+ run_callbacks :transition do
108
+ rollback_transition unless object.update("#{object.state_attribute}": state)
109
+ end
79
110
  end
80
- end
81
111
 
82
- object.reload.public_send(object.state_attribute) == state
112
+ @previous_state = previous_state
113
+
114
+ object.reload.public_send(object.state_attribute) == state
115
+ end
83
116
  end
84
117
 
85
118
  private
@@ -88,32 +121,25 @@ module HasStateMachine
88
121
  raise ActiveRecord::Rollback
89
122
  end
90
123
 
91
- ##
92
- # Determines if the given desired state exists in the predetermined
93
- # list of allowed transitions.
94
- def can_transition?(desired_state)
95
- possible_transitions.include? desired_state
96
- end
97
-
98
124
  ##
99
125
  # Helper method for grabbing the previous state of the object after
100
126
  # it has been transitioned to the new state. Useful in
101
127
  # after_transition blocks
102
128
  def previous_state
103
- object.previous_changes[object.state_attribute]&.first
129
+ @previous_state.presence || object.previous_changes[object.state_attribute]&.first
104
130
  end
105
131
 
106
- def state_instance(desired_state)
132
+ def state_instance(desired_state, transient_values)
107
133
  klass = "#{object.workflow_namespace}::#{desired_state.to_s.classify}".safe_constantize
108
- klass&.new(object)
134
+ klass&.new(object, transient_values)
109
135
  end
110
136
 
111
- def valid_transition?(desired_state)
137
+ def valid_transition?(desired_state_instance)
112
138
  return true if object.skip_state_validations
113
139
 
114
140
  object.valid? &&
115
- can_transition?(desired_state) &&
116
- state_instance(desired_state)&.valid?
141
+ can_transition?(desired_state_instance) &&
142
+ desired_state_instance&.valid?
117
143
  end
118
144
 
119
145
  def with_transition_options(options, &block)
@@ -135,21 +161,24 @@ module HasStateMachine
135
161
  @transactional || false
136
162
  end
137
163
 
138
- ##
139
- # Setter for the HasStateMachine::State classes to define the possible
140
- # states the current state can transition to.
141
- def transitions_to(states)
142
- state_options(transitions_to: states)
143
- HasStateMachine::Deprecation.deprecation_warning(:transitions_to, "use state_options instead")
164
+ def transients
165
+ @transients || []
144
166
  end
145
167
 
146
168
  ##
147
169
  # Set the options for the HasStateMachine::State classes to define the possible
148
170
  # states the current state can transition to and whether or not transitioning
149
171
  # to the state should be performed within a transaction.
150
- def state_options(transitions_to: [], transactional: false)
172
+ def state_options(transitions_to: [], transactional: false, transients: [])
151
173
  @possible_transitions = transitions_to.map(&:to_s)
152
174
  @transactional = transactional
175
+ @transients = transients.map(&:to_sym)
176
+
177
+ transients.each do |transient_name|
178
+ define_method(transient_name) do
179
+ instance_variable_get(:"@#{transient_name}")
180
+ end
181
+ end
153
182
  end
154
183
  end
155
184
  end
@@ -114,10 +114,10 @@ module HasStateMachine
114
114
  def state_instance_validations
115
115
  return unless state_class.present?
116
116
 
117
- state_instance = public_send(state_attribute.to_s)
118
- return if state_instance.valid?
117
+ current_state_instance = public_send(state_attribute.to_s)
118
+ return if current_state_instance.valid?
119
119
 
120
- state_instance.errors.each do |error|
120
+ current_state_instance.errors.each do |error|
121
121
  errors.add(error.attribute, error.type)
122
122
  end
123
123
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HasStateMachine
4
- VERSION = "0.6.1"
4
+ VERSION = "1.1.0"
5
5
  end
metadata CHANGED
@@ -1,15 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: has_state_machine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Hargett
8
8
  - Jake Hurd
9
- autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2024-10-09 00:00:00.000000000 Z
11
+ date: 1980-01-02 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: rails
@@ -29,16 +28,16 @@ dependencies:
29
28
  name: sqlite3
30
29
  requirement: !ruby/object:Gem::Requirement
31
30
  requirements:
32
- - - "~>"
31
+ - - ">="
33
32
  - !ruby/object:Gem::Version
34
- version: '1.4'
33
+ version: '1.5'
35
34
  type: :development
36
35
  prerelease: false
37
36
  version_requirements: !ruby/object:Gem::Requirement
38
37
  requirements:
39
- - - "~>"
38
+ - - ">="
40
39
  - !ruby/object:Gem::Version
41
- version: '1.4'
40
+ version: '1.5'
42
41
  - !ruby/object:Gem::Dependency
43
42
  name: pry
44
43
  requirement: !ruby/object:Gem::Requirement
@@ -95,6 +94,20 @@ dependencies:
95
94
  - - ">="
96
95
  - !ruby/object:Gem::Version
97
96
  version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: minitest
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '5.1'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '5.1'
98
111
  description: HasStateMachine uses ruby classes to make creating a finite state machine
99
112
  in your ActiveRecord models a breeze.
100
113
  email:
@@ -119,7 +132,6 @@ homepage: https://www.github.com/encampment/has_state_machine
119
132
  licenses:
120
133
  - MIT
121
134
  metadata: {}
122
- post_install_message:
123
135
  rdoc_options: []
124
136
  require_paths:
125
137
  - lib
@@ -134,8 +146,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
146
  - !ruby/object:Gem::Version
135
147
  version: '0'
136
148
  requirements: []
137
- rubygems_version: 3.1.6
138
- signing_key:
149
+ rubygems_version: 3.6.7
139
150
  specification_version: 4
140
151
  summary: Class based state machine for ActiveRecord models.
141
152
  test_files: []