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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f9619070408aa690d1e8b70c31baf787c91d949c0e28665a42af8b17ff79955
4
- data.tar.gz: '099784a11f816d49d75b18432a1497688838aac08a775d15fe5d29827eb7ac38'
3
+ metadata.gz: 94b18269b2a7ac5e0b6ca2aedf9591a78111e30089dfe8a000190bad02725d71
4
+ data.tar.gz: 93ce1793915219f7b00725c2faf977cdbd62f40d20fefa21b680b281378ed242
5
5
  SHA512:
6
- metadata.gz: 749b711582fc925fd8efbfcb7dcdee7dfb3f9902e945d5cc26e1f7f3737ea77f646087a117ccf9e6675444a858acb2497ae52dba0a3c8b2777628ebae33297af
7
- data.tar.gz: a8cbe864ada7ab74316c55c14704ea33fa18bae5050d3a728d2f5458cb16b561419643805af0824fd7bdcca10cf42e5023e708d84f88ed1e3478a9b24bad2d43
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
- # Configures the state machine for the ActiveRecord object and adds some
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>] the list of possible states in a state machine
16
- # @note the first state is used as the initial state
17
- # @param options [Hash] a hash of additional options for the state machine
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
- raise ArgumentError, "Please define at least one state to use has_state_machine." if states.empty?
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, :state
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
- # state = Workflow::Post::Draft.new(post) #=> "draft"
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
- # 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
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
- # @return [Boolean] whether or not the transition took place
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
- # Makes the actual transition from one state to the next and
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
- object.update("#{object.state_attribute}": state)
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
- # Same as {#perform_transition!}, but wrapped in a transaction so
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 object.update("#{object.state_attribute}": state)
113
+ rollback_transition unless update_state_attribute
111
114
  end
112
115
  end
113
116
 
114
- @previous_state = previous_state
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
- # Runs the after_transition_commit callbacks once the outermost open
125
- # transaction commits, discarding them on rollback.
126
- #
127
- # @note Registers on the connection's current transaction because
128
- # +ActiveRecord.after_all_transactions_commit+ ignores non-joinable
129
- # transactions (like the gem's own) and would fire immediately.
130
- # @return [void]
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, so callbacks fire
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[object.state_attribute]&.first
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 = "#{object.workflow_namespace}::#{desired_state.to_s.classify}".safe_constantize
155
- klass&.new(object, transient_values)
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.each do |transient_name|
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: "self.class"
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
- workflow_states.each do |state|
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
- # Defines boolean helpers to determine if the active state matches
64
- # the specified state.
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
- private
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
- # Getter for the current state of the model based on the configured state
79
- # attribute.
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
- # Predicate method for determining whether or not the state validations
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
- !skip_state_validations
91
- end
38
+ errors.add(machine.state_attribute, :not_implemented, message: "class must be implemented")
39
+ end
92
40
 
93
- ##
94
- # Gets the HasStateMachine::State class that represents the current state
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
- "#{workflow_namespace}::#{current_state.classify}".safe_constantize
100
- end
44
+ !skip_state_validations
45
+ end
101
46
 
102
- ##
103
- # True unless unable to find the HasStateMachine::State class for the current
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
- errors.add(state_attribute, :not_implemented, message: "class must be implemented")
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
- # Run the validations defined on the current HasStateMachine::State. Errors found there
113
- # should be added to this object.
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
- current_state_instance = public_send(state_attribute.to_s)
118
- return if current_state_instance.valid?
62
+ def workflow_namespace
63
+ primary_state_machine_definition.workflow_namespace_for(self)
64
+ end
119
65
 
120
- current_state_instance.errors.each do |error|
121
- errors.add(error.attribute, error.type)
122
- end
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
- # The initial state of the workflow based on the first state defined in the model
131
- # has_state_machine states array.
132
- def initial_state
133
- workflow_states.first
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HasStateMachine
4
- VERSION = "1.2.2"
4
+ VERSION = "1.3.0"
5
5
  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
- next unless model.respond_to?(:workflow_namespace)
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
- index[model.workflow_namespace] = model
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.2.2
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