has_state_machine 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +53 -0
- data/lib/has_state_machine/definition.rb +17 -47
- data/lib/has_state_machine/definition_builder.rb +116 -0
- data/lib/has_state_machine/machine.rb +57 -0
- data/lib/has_state_machine/state.rb +69 -72
- data/lib/has_state_machine/state_helpers.rb +75 -101
- data/lib/has_state_machine/version.rb +1 -1
- data/lib/ruby_lsp/has_state_machine/rails_server_addon.rb +19 -6
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94b18269b2a7ac5e0b6ca2aedf9591a78111e30089dfe8a000190bad02725d71
|
|
4
|
+
data.tar.gz: 93ce1793915219f7b00725c2faf977cdbd62f40d20fefa21b680b281378ed242
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 82344021e6dd63dde02dbfcb261b39b8773b51cff31ab37846441e655d420dfa9ba63128a893d9c7a11a83c15371320575871bd0e2f32f6c6061c84a8fe72984
|
|
7
|
+
data.tar.gz: a2cef6e05f4eb1c63fd2002478c0d2fc9a673cbc1b6be9ec2c13288eee5d0d12b7438514489fc6dc75e9fa257f7571f6f67b3694351be33fe041c5095b115dc3
|
data/README.md
CHANGED
|
@@ -202,6 +202,59 @@ post.status.transition_to(:published, skip_validations: true)
|
|
|
202
202
|
|
|
203
203
|
### Advanced Usage
|
|
204
204
|
|
|
205
|
+
#### Multiple State Machines
|
|
206
|
+
|
|
207
|
+
Call `has_state_machine` once per state column to give a model independent workflows:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
class Post < ApplicationRecord
|
|
211
|
+
has_state_machine states: %i[draft published archived]
|
|
212
|
+
has_state_machine states: %i[available removing removed],
|
|
213
|
+
state_attribute: :deletion_state,
|
|
214
|
+
workflow_namespace: "Workflow::PostDeletion",
|
|
215
|
+
prefix: :deletion
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
post.status.transition_to(:published)
|
|
219
|
+
post.deletion_state.transition_to(:removing)
|
|
220
|
+
|
|
221
|
+
Post.deletion_removing # Scope on deletion_state
|
|
222
|
+
post.deletion_removing? # Predicate on deletion_state
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Each machine needs its own state column and workflow namespace, with state classes
|
|
226
|
+
inheriting from `HasStateMachine::State`. Each column defaults to its machine's
|
|
227
|
+
first state. Transitions update their own column, and model validations run the
|
|
228
|
+
state validations of every machine unless disabled with
|
|
229
|
+
`state_validations_on_object: false` or `skip_state_validations`.
|
|
230
|
+
|
|
231
|
+
Use `prefix:` and/or `suffix:` to distinguish generated scopes and predicates:
|
|
232
|
+
|
|
233
|
+
| Options for `deletion_state` | Scope | Predicate |
|
|
234
|
+
| --- | --- | --- |
|
|
235
|
+
| `prefix: true` | `deletion_state_removing` | `deletion_state_removing?` |
|
|
236
|
+
| `prefix: :deletion` | `deletion_removing` | `deletion_removing?` |
|
|
237
|
+
| `suffix: true` | `removing_deletion_state` | `removing_deletion_state?` |
|
|
238
|
+
| `suffix: "deletion"` | `removing_deletion` | `removing_deletion?` |
|
|
239
|
+
| `prefix: :deletion, suffix: :workflow` | `deletion_removing_workflow` | `deletion_removing_workflow?` |
|
|
240
|
+
|
|
241
|
+
Both options accept `true` (use the state attribute), a string or symbol (use a
|
|
242
|
+
custom name), or `false`/`nil` (omit the affix). `scopes: false` disables scopes
|
|
243
|
+
while retaining predicates. Additional machines raise `ArgumentError` if their
|
|
244
|
+
generated helpers collide with existing methods or their namespace matches
|
|
245
|
+
another machine's. An omitted or blank namespace defaults to
|
|
246
|
+
`Workflow::<ModelClass>`.
|
|
247
|
+
|
|
248
|
+
The first machine remains the primary machine for `workflow_states`,
|
|
249
|
+
`state_attribute`, `workflow_namespace`, and `state_validations_on_object?`.
|
|
250
|
+
Subclasses inherit all machine definitions and may add their own.
|
|
251
|
+
|
|
252
|
+
For 1.x compatibility, redeclaring the same state attribute replaces its
|
|
253
|
+
configuration in place. This supports concern and subclass overrides of namespace
|
|
254
|
+
or state-validation settings. It does **not** regenerate the original default,
|
|
255
|
+
inclusion validation, scopes, or predicates, so keep the states and helper-naming
|
|
256
|
+
options consistent across redeclarations.
|
|
257
|
+
|
|
205
258
|
#### Transactional Transitions
|
|
206
259
|
|
|
207
260
|
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.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "has_state_machine/definition_builder"
|
|
3
4
|
require "has_state_machine/state"
|
|
4
5
|
require "has_state_machine/state_helpers"
|
|
5
6
|
|
|
@@ -8,60 +9,29 @@ module HasStateMachine
|
|
|
8
9
|
extend ActiveSupport::Concern
|
|
9
10
|
|
|
10
11
|
class_methods do
|
|
11
|
-
|
|
12
|
-
#
|
|
13
|
-
# useful helper methods such as scopes, boolean checks, etc.
|
|
12
|
+
# Declares one machine per state attribute. The first machine is primary
|
|
13
|
+
# for the class and instance readers. Unknown options warn and are ignored.
|
|
14
14
|
#
|
|
15
|
-
# @param states [Array<Symbol>]
|
|
16
|
-
#
|
|
17
|
-
# @
|
|
15
|
+
# @param states [Array<String, Symbol>] allowed states; the first is the default
|
|
16
|
+
# @param options [Hash]
|
|
17
|
+
# @option options [String, Symbol] :state_attribute (:status) state column
|
|
18
|
+
# @option options [String, Symbol] :attribute alias for :state_attribute
|
|
19
|
+
# @option options [String] :workflow_namespace ("Workflow::<Model>") state-class namespace; unique per model
|
|
20
|
+
# @option options [Boolean] :state_validations_on_object (true) run state validations on the model
|
|
21
|
+
# @option options [Boolean, String, Symbol] :prefix scope/predicate prefix; true uses the state attribute
|
|
22
|
+
# @option options [Boolean, String, Symbol] :suffix scope/predicate suffix; true uses the state attribute
|
|
23
|
+
# @option options [Boolean] :scopes (true) generate scopes
|
|
18
24
|
#
|
|
19
25
|
# @example
|
|
20
26
|
# class Post < ApplicationRecord
|
|
21
27
|
# has_state_machine states: %i(draft published archived)
|
|
28
|
+
# has_state_machine states: %i(available removing),
|
|
29
|
+
# state_attribute: :deletion_state,
|
|
30
|
+
# workflow_namespace: "Workflow::PostDeletion",
|
|
31
|
+
# prefix: :deletion
|
|
22
32
|
# end
|
|
23
33
|
def has_state_machine(states: [], **options)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
define_helper_methods(
|
|
27
|
-
states: states.map(&:to_s),
|
|
28
|
-
options: options.with_indifferent_access
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
include HasStateMachine::StateHelpers
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def define_helper_methods(states:, options:)
|
|
37
|
-
##
|
|
38
|
-
# The list of possible states in the state machine.
|
|
39
|
-
define_singleton_method :workflow_states do
|
|
40
|
-
states
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
##
|
|
44
|
-
# Defines the column name for the attribute holding the current status.
|
|
45
|
-
# Can be overwritten to use a different column name.
|
|
46
|
-
define_singleton_method :state_attribute do
|
|
47
|
-
options[:state_attribute]&.to_sym || :status
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
##
|
|
51
|
-
# Defines the namespace of the models possible states.
|
|
52
|
-
# Can be overwritten to use a different namespace.
|
|
53
|
-
define_singleton_method :workflow_namespace do
|
|
54
|
-
options[:workflow_namespace] || "Workflow::#{self}"
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
##
|
|
58
|
-
# Determines whether or not the state validations should be run
|
|
59
|
-
# as part of the object validations; they are by default.
|
|
60
|
-
define_singleton_method :state_validations_on_object? do
|
|
61
|
-
return true unless options.key?(:state_validations_on_object)
|
|
62
|
-
|
|
63
|
-
options[:state_validations_on_object]
|
|
64
|
-
end
|
|
34
|
+
HasStateMachine::DefinitionBuilder.new(self, states: states, **options).call
|
|
65
35
|
end
|
|
66
36
|
end
|
|
67
37
|
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "has_state_machine/deprecation"
|
|
4
|
+
require "has_state_machine/machine"
|
|
5
|
+
require "has_state_machine/state_helpers"
|
|
6
|
+
|
|
7
|
+
module HasStateMachine
|
|
8
|
+
class DefinitionBuilder
|
|
9
|
+
OPTION_KEYS = %i[
|
|
10
|
+
state_attribute attribute workflow_namespace state_validations_on_object prefix suffix scopes
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(model, states:, **options)
|
|
14
|
+
@model = model
|
|
15
|
+
@states = states
|
|
16
|
+
@options = options.symbolize_keys
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
raise ArgumentError, "Please define at least one state to use has_state_machine." if states.empty?
|
|
21
|
+
|
|
22
|
+
@machine = HasStateMachine::Machine.new(states: states, **normalize_options)
|
|
23
|
+
redeclared = register_machine
|
|
24
|
+
|
|
25
|
+
model.include HasStateMachine::StateHelpers
|
|
26
|
+
|
|
27
|
+
# Preserve 1.x concern/subclass overrides: replace configuration without
|
|
28
|
+
# regenerating methods, defaults, validations, or callbacks.
|
|
29
|
+
model.send(:define_state_machine_methods, machine) unless redeclared
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :model, :states, :options, :machine
|
|
35
|
+
|
|
36
|
+
# Assign a new registry so subclass declarations cannot mutate the parent's.
|
|
37
|
+
# @return [Boolean] whether an existing definition was replaced
|
|
38
|
+
def register_machine
|
|
39
|
+
unless model.respond_to?(:state_machine_definitions)
|
|
40
|
+
model.class_attribute :state_machine_definitions, instance_accessor: false, instance_predicate: false,
|
|
41
|
+
default: {}.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
existing = model.state_machine_definitions
|
|
45
|
+
replaced = existing.key?(machine.state_attribute)
|
|
46
|
+
others = existing.except(machine.state_attribute)
|
|
47
|
+
|
|
48
|
+
ensure_unique_namespace!(others)
|
|
49
|
+
detect_conflicts! if !replaced && others.any?
|
|
50
|
+
|
|
51
|
+
model.state_machine_definitions = existing.merge(machine.state_attribute => machine).freeze
|
|
52
|
+
replaced
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize_options
|
|
56
|
+
warn_about_unknown_options
|
|
57
|
+
|
|
58
|
+
if options[:state_attribute] && options[:attribute] &&
|
|
59
|
+
options[:state_attribute].to_sym != options[:attribute].to_sym
|
|
60
|
+
HasStateMachine::Deprecation.warn(
|
|
61
|
+
"has_state_machine on #{model} received both state_attribute: #{options[:state_attribute].inspect} " \
|
|
62
|
+
"and attribute: #{options[:attribute].inspect}; attribute: is ignored."
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
options[:state_attribute] = (options[:state_attribute] || options[:attribute])&.to_sym || :status
|
|
67
|
+
options.slice(*OPTION_KEYS).except(:attribute)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def warn_about_unknown_options
|
|
71
|
+
unknown = options.keys - OPTION_KEYS
|
|
72
|
+
return if unknown.empty?
|
|
73
|
+
|
|
74
|
+
HasStateMachine::Deprecation.warn(
|
|
75
|
+
"has_state_machine on #{model} received unknown option(s) #{unknown.map(&:inspect).join(", ")}, " \
|
|
76
|
+
"which are ignored. Known options: #{OPTION_KEYS.map(&:inspect).join(", ")}."
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def ensure_unique_namespace!(existing)
|
|
81
|
+
namespace = machine.workflow_namespace_for(model).to_s
|
|
82
|
+
clash = existing.each_value.find { |other| other.workflow_namespace_for(model).to_s == namespace }
|
|
83
|
+
return unless clash
|
|
84
|
+
|
|
85
|
+
raise ArgumentError,
|
|
86
|
+
"The state machines on #{clash.state_attribute.inspect} and #{machine.state_attribute.inspect} " \
|
|
87
|
+
"of #{model} would both use the workflow namespace #{namespace.inspect}. " \
|
|
88
|
+
"Pass a distinct workflow_namespace: to has_state_machine."
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The first machine may override existing methods for 1.x compatibility;
|
|
92
|
+
# additional machines must not overwrite scopes or predicates.
|
|
93
|
+
def detect_conflicts!
|
|
94
|
+
active_record = defined?(ActiveRecord::Base) && model < ActiveRecord::Base
|
|
95
|
+
|
|
96
|
+
machine.states.each do |state|
|
|
97
|
+
predicate = machine.predicate_name(state)
|
|
98
|
+
if model.method_defined?(predicate) || model.private_method_defined?(predicate)
|
|
99
|
+
raise_conflict("instance", predicate)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
next unless active_record && machine.scopes?
|
|
103
|
+
|
|
104
|
+
scope_name = machine.scope_name(state)
|
|
105
|
+
raise_conflict("class", scope_name) if model.respond_to?(scope_name, true)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def raise_conflict(type, method_name)
|
|
110
|
+
advice = (type == "class") ? "prefix:, suffix:, or scopes: false" : "prefix: or suffix:"
|
|
111
|
+
raise ArgumentError,
|
|
112
|
+
"has_state_machine #{machine.state_attribute.inspect} on #{model} conflicts with existing " \
|
|
113
|
+
"#{type} method #{method_name.to_s.inspect}. Pass #{advice}."
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module HasStateMachine
|
|
4
|
+
class Machine
|
|
5
|
+
attr_reader :states, :state_attribute, :state_validations_on_object
|
|
6
|
+
|
|
7
|
+
alias_method :state_validations_on_object?, :state_validations_on_object
|
|
8
|
+
|
|
9
|
+
def initialize(states:, state_attribute: :status, workflow_namespace: nil, state_validations_on_object: true,
|
|
10
|
+
prefix: nil, suffix: nil, scopes: true)
|
|
11
|
+
@states = states.map(&:to_s).freeze
|
|
12
|
+
@state_attribute = state_attribute.to_sym
|
|
13
|
+
@workflow_namespace = workflow_namespace
|
|
14
|
+
@state_validations_on_object = state_validations_on_object
|
|
15
|
+
@prefix = prefix
|
|
16
|
+
@suffix = suffix
|
|
17
|
+
@scopes = scopes != false
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initial_state
|
|
22
|
+
states.first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Resolve defaults against the concrete class so STI subclasses get their own namespace.
|
|
26
|
+
def workflow_namespace_for(model_class)
|
|
27
|
+
@workflow_namespace.presence || "Workflow::#{model_class}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def state_class_for(state, model_class)
|
|
31
|
+
return if state.blank?
|
|
32
|
+
|
|
33
|
+
"#{workflow_namespace_for(model_class)}::#{state.to_s.classify}".safe_constantize
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def scopes?
|
|
37
|
+
@scopes
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def scope_name(state)
|
|
41
|
+
[method_affix(@prefix), state, method_affix(@suffix)].compact.join("_").to_sym
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def predicate_name(state)
|
|
45
|
+
:"#{scope_name(state)}?"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def method_affix(value)
|
|
51
|
+
return unless value
|
|
52
|
+
return state_attribute if value == true
|
|
53
|
+
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -8,30 +8,16 @@ module HasStateMachine
|
|
|
8
8
|
extend ActiveModel::Callbacks
|
|
9
9
|
include ActiveModel::Validations
|
|
10
10
|
|
|
11
|
-
attr_reader :object
|
|
11
|
+
attr_reader :object
|
|
12
12
|
|
|
13
|
-
##
|
|
14
|
-
# Defines the before_transition and after_transition callbacks
|
|
15
|
-
# for use on a HasStateMachine::State instance.
|
|
16
13
|
define_model_callbacks :transition, only: %i[before after]
|
|
17
14
|
|
|
18
|
-
##
|
|
19
|
-
# Defines the after_transition_commit callback, which runs once a
|
|
20
|
-
# successful transition is committed to the database.
|
|
21
15
|
define_model_callbacks :transition_commit, only: %i[after]
|
|
22
16
|
|
|
23
|
-
|
|
24
|
-
# possible_transitions - Retrieves the next available transitions for a given state.
|
|
25
|
-
# transactional? - Determines whether or not the transition should happen with a transactional block.
|
|
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"
|
|
17
|
+
delegate :possible_transitions, :transactional?, :state, :transients, to: :class
|
|
29
18
|
|
|
30
|
-
##
|
|
31
|
-
# Initializes the HasStateMachine::State instance.
|
|
32
|
-
#
|
|
33
19
|
# @example
|
|
34
|
-
#
|
|
20
|
+
# Workflow::Post::Draft.new(post) #=> "draft"
|
|
35
21
|
def initialize(object, transient_values = {})
|
|
36
22
|
@object = object
|
|
37
23
|
|
|
@@ -42,25 +28,48 @@ module HasStateMachine
|
|
|
42
28
|
super(state)
|
|
43
29
|
end
|
|
44
30
|
|
|
45
|
-
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
# @
|
|
49
|
-
|
|
31
|
+
# Bound by the model getter; direct instances resolve by namespace,
|
|
32
|
+
# falling back to the primary machine.
|
|
33
|
+
#
|
|
34
|
+
# @return [HasStateMachine::Machine, nil]
|
|
35
|
+
def state_machine
|
|
36
|
+
return @state_machine if @state_machine
|
|
37
|
+
|
|
38
|
+
model_class = object.class
|
|
39
|
+
definitions = model_class.try(:state_machine_definitions)
|
|
40
|
+
return unless definitions
|
|
41
|
+
|
|
42
|
+
machines = definitions.each_value
|
|
43
|
+
namespace = self.class.name&.deconstantize
|
|
44
|
+
|
|
45
|
+
@state_machine = machines.find do |machine|
|
|
46
|
+
machine.workflow_namespace_for(model_class).to_s == namespace
|
|
47
|
+
end || machines.first
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Symbol]
|
|
51
|
+
def state_attribute
|
|
52
|
+
state_machine&.state_attribute || object.state_attribute
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
def bind_state_machine(machine)
|
|
57
|
+
@state_machine = machine
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Checks the allowed transition list without running validations.
|
|
62
|
+
# @param desired_state [String, Symbol]
|
|
50
63
|
def can_transition?(desired_state)
|
|
51
64
|
possible_transitions.include? desired_state.to_s
|
|
52
65
|
end
|
|
53
66
|
|
|
54
|
-
|
|
55
|
-
# Checks to see if the desired state is valid and then gives
|
|
56
|
-
# responsibility to the desired state's instance to make the
|
|
57
|
-
# transition.
|
|
58
|
-
#
|
|
59
|
-
# @param desired_state [String] the state to transition to
|
|
60
|
-
# @param options [Hash] a hash of additional options for
|
|
61
|
-
# transitioning the object
|
|
67
|
+
# Validates and transitions to the target state, copying its errors to the model.
|
|
62
68
|
#
|
|
63
|
-
# @
|
|
69
|
+
# @param desired_state [String, Symbol]
|
|
70
|
+
# @param options [Hash] target-state transients and transition options
|
|
71
|
+
# @option options [Boolean] :skip_validations (false) bypass transition checks and state validations
|
|
72
|
+
# @return [Boolean] whether the transition succeeded
|
|
64
73
|
def transition_to(desired_state, **options)
|
|
65
74
|
transitioned = false
|
|
66
75
|
options = options.symbolize_keys
|
|
@@ -83,36 +92,29 @@ module HasStateMachine
|
|
|
83
92
|
end
|
|
84
93
|
end
|
|
85
94
|
|
|
86
|
-
|
|
87
|
-
#
|
|
88
|
-
# runs the before and after transition callbacks.
|
|
89
|
-
#
|
|
90
|
-
# @return [Boolean] whether or not the transition succeeded
|
|
95
|
+
# Persists the target state and runs transition callbacks.
|
|
96
|
+
# @return [Boolean] whether the transition succeeded
|
|
91
97
|
def perform_transition! # rubocop:disable Naming/PredicateMethod -- public API
|
|
92
98
|
transitioned = run_callbacks :transition do
|
|
93
|
-
|
|
99
|
+
update_state_attribute
|
|
94
100
|
end
|
|
101
|
+
|
|
95
102
|
return false unless transitioned
|
|
96
103
|
|
|
97
|
-
@previous_state = previous_state
|
|
98
104
|
enqueue_transition_commit_callbacks
|
|
99
105
|
true
|
|
100
106
|
end
|
|
101
107
|
|
|
102
|
-
|
|
103
|
-
#
|
|
104
|
-
# callbacks can roll the transition back.
|
|
105
|
-
#
|
|
106
|
-
# @return [Boolean] whether or not the transition succeeded
|
|
108
|
+
# Wraps the transition in a transaction that callbacks can roll back.
|
|
109
|
+
# @return [Boolean] whether the transition succeeded
|
|
107
110
|
def perform_transactional_transition! # rubocop:disable Naming/PredicateMethod -- public API
|
|
108
111
|
ActiveRecord::Base.transaction(requires_new: true, joinable: false) do
|
|
109
112
|
run_callbacks :transition do
|
|
110
|
-
rollback_transition unless
|
|
113
|
+
rollback_transition unless update_state_attribute
|
|
111
114
|
end
|
|
112
115
|
end
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
return false unless object.reload.public_send(object.state_attribute) == state
|
|
117
|
+
return false unless object.reload.public_send(state_attribute) == state
|
|
116
118
|
|
|
117
119
|
enqueue_transition_commit_callbacks
|
|
118
120
|
true
|
|
@@ -120,19 +122,20 @@ module HasStateMachine
|
|
|
120
122
|
|
|
121
123
|
private
|
|
122
124
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
# Capture the previous state before transition callbacks can save the model again.
|
|
126
|
+
def update_state_attribute # rubocop:disable Naming/PredicateMethod -- returns update's result
|
|
127
|
+
return false unless object.update(state_attribute => state)
|
|
128
|
+
|
|
129
|
+
@previous_state = previous_state
|
|
130
|
+
true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Use the current transaction: after_all_transactions_commit ignores our
|
|
134
|
+
# non-joinable transactions and would run callbacks before commit.
|
|
131
135
|
def enqueue_transition_commit_callbacks
|
|
132
136
|
current_transaction = object.class.connection.current_transaction
|
|
133
137
|
|
|
134
|
-
# Rails < 7.2 has no Transaction#after_commit
|
|
135
|
-
# immediately with no deferral. Drop this guard at Rails 7.2+.
|
|
138
|
+
# Rails < 7.2 has no Transaction#after_commit; run callbacks immediately.
|
|
136
139
|
return run_callbacks(:transition_commit) { true } unless current_transaction.respond_to?(:after_commit)
|
|
137
140
|
|
|
138
141
|
current_transaction.after_commit { run_callbacks(:transition_commit) { true } }
|
|
@@ -142,17 +145,18 @@ module HasStateMachine
|
|
|
142
145
|
raise ActiveRecord::Rollback
|
|
143
146
|
end
|
|
144
147
|
|
|
145
|
-
|
|
146
|
-
# Helper method for grabbing the previous state of the object after
|
|
147
|
-
# it has been transitioned to the new state. Useful in
|
|
148
|
-
# after_transition blocks
|
|
148
|
+
# Available in after_transition and after_transition_commit callbacks.
|
|
149
149
|
def previous_state
|
|
150
|
-
@previous_state.presence || object.previous_changes[
|
|
150
|
+
@previous_state.presence || object.previous_changes[state_attribute]&.first
|
|
151
151
|
end
|
|
152
152
|
|
|
153
153
|
def state_instance(desired_state, transient_values)
|
|
154
|
-
klass =
|
|
155
|
-
|
|
154
|
+
klass = if state_machine
|
|
155
|
+
state_machine.state_class_for(desired_state, object.class)
|
|
156
|
+
else
|
|
157
|
+
"#{object.workflow_namespace}::#{desired_state.to_s.classify}".safe_constantize
|
|
158
|
+
end
|
|
159
|
+
klass&.new(object, transient_values)&.bind_state_machine(state_machine)
|
|
156
160
|
end
|
|
157
161
|
|
|
158
162
|
def valid_transition?(desired_state_instance)
|
|
@@ -186,20 +190,13 @@ module HasStateMachine
|
|
|
186
190
|
@transients || []
|
|
187
191
|
end
|
|
188
192
|
|
|
189
|
-
|
|
190
|
-
# Set the options for the HasStateMachine::State classes to define the possible
|
|
191
|
-
# states the current state can transition to and whether or not transitioning
|
|
192
|
-
# to the state should be performed within a transaction.
|
|
193
|
+
# transitions_to applies when leaving this state; transactional and transients apply when entering it.
|
|
193
194
|
def state_options(transitions_to: [], transactional: false, transients: [])
|
|
194
195
|
@possible_transitions = transitions_to.map(&:to_s)
|
|
195
196
|
@transactional = transactional
|
|
196
197
|
@transients = transients.map(&:to_sym)
|
|
197
198
|
|
|
198
|
-
transients
|
|
199
|
-
define_method(transient_name) do
|
|
200
|
-
instance_variable_get(:"@#{transient_name}")
|
|
201
|
-
end
|
|
202
|
-
end
|
|
199
|
+
attr_reader(*@transients)
|
|
203
200
|
end
|
|
204
201
|
end
|
|
205
202
|
end
|
|
@@ -5,10 +5,7 @@ module HasStateMachine
|
|
|
5
5
|
extend ActiveSupport::Concern
|
|
6
6
|
|
|
7
7
|
included do
|
|
8
|
-
|
|
9
|
-
# Sometimes you may want to skip the validations defined on
|
|
10
|
-
# the state when validating your object; set this accessor
|
|
11
|
-
# to true to do so.
|
|
8
|
+
# Skips state-instance validations for every machine on this object.
|
|
12
9
|
attr_accessor :skip_state_validations
|
|
13
10
|
|
|
14
11
|
delegate \
|
|
@@ -16,121 +13,98 @@ module HasStateMachine
|
|
|
16
13
|
:state_validations_on_object?,
|
|
17
14
|
:workflow_namespace,
|
|
18
15
|
:workflow_states,
|
|
19
|
-
to:
|
|
20
|
-
|
|
21
|
-
##
|
|
22
|
-
# Sets the default value of the state method to the initial state
|
|
23
|
-
# defined in the state machine.
|
|
24
|
-
attribute state_attribute, :string, default: initial_state
|
|
25
|
-
|
|
26
|
-
##
|
|
27
|
-
# Validate that the current state is a possible state, that there is a
|
|
28
|
-
# state class defined for it, and run the validations from the state
|
|
29
|
-
# class instance if need be.
|
|
30
|
-
validates state_attribute, inclusion: {in: workflow_states}, presence: true
|
|
31
|
-
validate :state_class_defined?
|
|
32
|
-
validate :state_instance_validations, if: :should_validate_state?
|
|
33
|
-
|
|
34
|
-
##
|
|
35
|
-
# Overwrites the default getter for the state attribute to
|
|
36
|
-
# instantiate a HasStateMachine::State instance instead. If the state
|
|
37
|
-
# class does not exist, it simply returns a string.
|
|
38
|
-
#
|
|
39
|
-
# @return [HasStateMachine::State] the current state represented by a instance
|
|
40
|
-
#
|
|
41
|
-
# @example
|
|
42
|
-
# post = Post.new(status: "draft")
|
|
43
|
-
# post.status.class #=> Workflow::Post::Draft
|
|
44
|
-
define_method state_attribute.to_s do
|
|
45
|
-
return state_class.new(self) if state_class.present?
|
|
46
|
-
|
|
47
|
-
current_state
|
|
48
|
-
end
|
|
16
|
+
to: :class
|
|
17
|
+
end
|
|
49
18
|
|
|
50
|
-
|
|
51
|
-
##
|
|
52
|
-
# Defines scopes based on the state machine's possible states
|
|
53
|
-
#
|
|
54
|
-
# @return [ActiveRecord_Relation]
|
|
55
|
-
# @example Retreiving a users published posts
|
|
56
|
-
# > Post.published.where(user: user)
|
|
57
|
-
# #=> [#<Post>]
|
|
58
|
-
if defined?(ActiveRecord) && (self < ActiveRecord::Base)
|
|
59
|
-
scope state, -> { where("#{table_name}.#{state_attribute} = ?", state) }
|
|
60
|
-
end
|
|
19
|
+
private
|
|
61
20
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
# @return [Boolean] whether or not the active state matches the call
|
|
67
|
-
# @example Check if a post is published
|
|
68
|
-
# > post.published?
|
|
69
|
-
# #=> true
|
|
70
|
-
define_method :"#{state}?" do
|
|
71
|
-
current_state == state
|
|
72
|
-
end
|
|
73
|
-
end
|
|
21
|
+
# Resolve at call time so redeclarations take effect.
|
|
22
|
+
def state_machine_definition_for(attr)
|
|
23
|
+
self.class.state_machine_definitions.fetch(attr)
|
|
24
|
+
end
|
|
74
25
|
|
|
75
|
-
|
|
26
|
+
# Default to the primary machine for 1.x compatibility.
|
|
27
|
+
def current_state(machine = self.class.primary_state_machine_definition)
|
|
28
|
+
self[machine.state_attribute]
|
|
29
|
+
end
|
|
76
30
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def current_state
|
|
81
|
-
self[state_attribute]
|
|
82
|
-
end
|
|
31
|
+
def state_class(machine = self.class.primary_state_machine_definition)
|
|
32
|
+
machine.state_class_for(current_state(machine), self.class)
|
|
33
|
+
end
|
|
83
34
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
# should be run as part of the object validations.
|
|
87
|
-
def should_validate_state?
|
|
88
|
-
return false unless state_validations_on_object?
|
|
35
|
+
def state_class_defined?(machine = self.class.primary_state_machine_definition)
|
|
36
|
+
return if state_class(machine)
|
|
89
37
|
|
|
90
|
-
|
|
91
|
-
|
|
38
|
+
errors.add(machine.state_attribute, :not_implemented, message: "class must be implemented")
|
|
39
|
+
end
|
|
92
40
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# of the model.
|
|
96
|
-
def state_class
|
|
97
|
-
return unless current_state.present?
|
|
41
|
+
def should_validate_state?(machine = self.class.primary_state_machine_definition)
|
|
42
|
+
return false unless machine.state_validations_on_object?
|
|
98
43
|
|
|
99
|
-
|
|
100
|
-
|
|
44
|
+
!skip_state_validations
|
|
45
|
+
end
|
|
101
46
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# state.
|
|
105
|
-
def state_class_defined?
|
|
106
|
-
return if state_class.present?
|
|
47
|
+
def state_instance_validations(machine = self.class.primary_state_machine_definition)
|
|
48
|
+
return unless state_class(machine)
|
|
107
49
|
|
|
108
|
-
|
|
50
|
+
current_state_instance = public_send(machine.state_attribute)
|
|
51
|
+
return if current_state_instance.valid?
|
|
52
|
+
|
|
53
|
+
current_state_instance.errors.each do |error|
|
|
54
|
+
errors.add(error.attribute, error.type)
|
|
109
55
|
end
|
|
56
|
+
end
|
|
110
57
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def state_instance_validations
|
|
115
|
-
return unless state_class.present?
|
|
58
|
+
class_methods do
|
|
59
|
+
delegate :state_attribute, :state_validations_on_object?, to: :primary_state_machine_definition
|
|
60
|
+
delegate :states, to: :primary_state_machine_definition, prefix: :workflow
|
|
116
61
|
|
|
117
|
-
|
|
118
|
-
|
|
62
|
+
def workflow_namespace
|
|
63
|
+
primary_state_machine_definition.workflow_namespace_for(self)
|
|
64
|
+
end
|
|
119
65
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
66
|
+
# First declaration, including inherited machines.
|
|
67
|
+
# @return [HasStateMachine::Machine]
|
|
68
|
+
def primary_state_machine_definition
|
|
69
|
+
state_machine_definitions.each_value.first
|
|
123
70
|
end
|
|
124
|
-
end
|
|
125
71
|
|
|
126
|
-
class_methods do
|
|
127
72
|
private
|
|
128
73
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
74
|
+
def define_state_machine_methods(machine)
|
|
75
|
+
attr = machine.state_attribute
|
|
76
|
+
|
|
77
|
+
attribute attr, :string, default: machine.initial_state
|
|
78
|
+
|
|
79
|
+
validates attr, inclusion: {in: machine.states}, presence: true
|
|
80
|
+
|
|
81
|
+
if machine.equal?(primary_state_machine_definition)
|
|
82
|
+
validate :state_class_defined?
|
|
83
|
+
validate :state_instance_validations, if: :should_validate_state?
|
|
84
|
+
else
|
|
85
|
+
validate { state_class_defined?(state_machine_definition_for(attr)) }
|
|
86
|
+
validate(if: -> { should_validate_state?(state_machine_definition_for(attr)) }) do
|
|
87
|
+
state_instance_validations(state_machine_definition_for(attr))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
define_method attr do
|
|
92
|
+
current_machine = state_machine_definition_for(attr)
|
|
93
|
+
klass = state_class(current_machine)
|
|
94
|
+
return klass.new(self).bind_state_machine(current_machine) if klass
|
|
95
|
+
|
|
96
|
+
current_state(current_machine)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
machine.states.each do |state|
|
|
100
|
+
if machine.scopes? && defined?(ActiveRecord) && (self < ActiveRecord::Base)
|
|
101
|
+
scope machine.scope_name(state), -> { where(attr => state) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
define_method machine.predicate_name(state) do
|
|
105
|
+
self[attr] == state
|
|
106
|
+
end
|
|
107
|
+
end
|
|
134
108
|
end
|
|
135
109
|
end
|
|
136
110
|
end
|
|
@@ -29,10 +29,7 @@ module RubyLsp
|
|
|
29
29
|
{name: model.name}
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
# Fast path: for the default "Workflow::<Model>" namespace, autoload just
|
|
34
|
-
# that one constant instead of eager loading the whole application. Only
|
|
35
|
-
# custom workflow_namespace configurations need the full scan below.
|
|
32
|
+
# Resolve conventional namespaces without eager-loading the application.
|
|
36
33
|
def conventional_model_for(workflow_namespace)
|
|
37
34
|
workflow_namespace = workflow_namespace.to_s
|
|
38
35
|
return unless workflow_namespace.start_with?("Workflow::")
|
|
@@ -43,12 +40,28 @@ module RubyLsp
|
|
|
43
40
|
|
|
44
41
|
def models_by_workflow_namespace
|
|
45
42
|
@models_by_workflow_namespace ||= active_record_models.each_with_object({}) do |model, index|
|
|
46
|
-
|
|
43
|
+
workflow_namespaces_for(model).each { |namespace| index[namespace] = model }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Keep inherited namespaces mapped to the model that declared them.
|
|
48
|
+
def workflow_namespaces_for(model)
|
|
49
|
+
return [] if model.name.nil?
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
definitions = model.try(:state_machine_definitions)
|
|
52
|
+
return Array(model.try(:workflow_namespace)) unless definitions
|
|
53
|
+
|
|
54
|
+
definitions.each_value.filter_map do |machine|
|
|
55
|
+
namespace = machine.workflow_namespace_for(model).to_s
|
|
56
|
+
namespace unless inherited_namespace?(model.superclass, machine, namespace)
|
|
49
57
|
end
|
|
50
58
|
end
|
|
51
59
|
|
|
60
|
+
def inherited_namespace?(parent, machine, namespace)
|
|
61
|
+
parent.try(:state_machine_definitions)&.value?(machine) &&
|
|
62
|
+
machine.workflow_namespace_for(parent).to_s == namespace
|
|
63
|
+
end
|
|
64
|
+
|
|
52
65
|
def active_record_models
|
|
53
66
|
@active_record_models ||= begin
|
|
54
67
|
::Rails.application&.eager_load!
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: has_state_machine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Benjamin Hargett
|
|
@@ -136,6 +136,20 @@ dependencies:
|
|
|
136
136
|
- - ">="
|
|
137
137
|
- !ruby/object:Gem::Version
|
|
138
138
|
version: '0'
|
|
139
|
+
- !ruby/object:Gem::Dependency
|
|
140
|
+
name: json
|
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
|
142
|
+
requirements:
|
|
143
|
+
- - "<"
|
|
144
|
+
- !ruby/object:Gem::Version
|
|
145
|
+
version: '3.0'
|
|
146
|
+
type: :development
|
|
147
|
+
prerelease: false
|
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
149
|
+
requirements:
|
|
150
|
+
- - "<"
|
|
151
|
+
- !ruby/object:Gem::Version
|
|
152
|
+
version: '3.0'
|
|
139
153
|
- !ruby/object:Gem::Dependency
|
|
140
154
|
name: rspec-rails
|
|
141
155
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -263,7 +277,9 @@ files:
|
|
|
263
277
|
- lib/has_state_machine.rb
|
|
264
278
|
- lib/has_state_machine/core_ext/string.rb
|
|
265
279
|
- lib/has_state_machine/definition.rb
|
|
280
|
+
- lib/has_state_machine/definition_builder.rb
|
|
266
281
|
- lib/has_state_machine/deprecation.rb
|
|
282
|
+
- lib/has_state_machine/machine.rb
|
|
267
283
|
- lib/has_state_machine/railtie.rb
|
|
268
284
|
- lib/has_state_machine/state.rb
|
|
269
285
|
- lib/has_state_machine/state_helpers.rb
|