circulator 2.1.1 → 2.1.3

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: 31b604ea699b2cbe133905f3078e81aa7860bc4e70e52cfd928611b92140b3f8
4
- data.tar.gz: e264bd9c50f93fb1ab9f606a0d6b670ce4708304ccdf4a04a67a3a2e588b5bf5
3
+ metadata.gz: bdd2e4e17046d72b53d25ea469b8f037316abf74cb7a34fe010091ff4f8b5bfa
4
+ data.tar.gz: 879a93ab33bb08c07fb6239ce490c5d900272cbd3cd03a65c66516f95439499e
5
5
  SHA512:
6
- metadata.gz: 80262bad01b9861f1f20459a67c80fbbd77c1f4cf4bc661d5b5918bd727582e8e099871cfd63b29a973fc5998a0c942a0f63aa2ced5894d41de91b5722517b19
7
- data.tar.gz: fcbd512e14e020ee4ede8cb34b0432b46c38fbfee246c43a14906f0711302825e633376e8bb1b98c00eee55a24396c5e5814e1fd394dbc8f5a106737a615cb39
6
+ metadata.gz: 910cd9755d9b9c02996d9d38329082733d019019151c19bc839663893e3523a6c64be9149485317bc7a0979f10223d15c4e0bb34ae0a9942f30d1c473acf8378
7
+ data.tar.gz: a98cad092d4da49cf93c38db6462489730061c38439f9825e2d2378ef58def38d3db243784c570f50697704442d271d8ac3aa0b1eddd5147e7f4a65b2a346c8a
data/CHANGELOG.md CHANGED
@@ -5,9 +5,12 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [2.1.1] - 2025-10-06
8
+ ## [2.1.3] - 2025-10-27
9
+
10
+ ### Fixed
11
+
12
+ - Ignore nil states in predicate methods (73c74c2)
9
13
 
10
14
  ### Added
11
15
 
12
- - Visual organization for separate flows in generated diagrams
13
- - Ability to generate separate diagrams for each flow in a class.
16
+ - Attribute predicate methods and documentation (8f9cc33)
data/README.md CHANGED
@@ -6,8 +6,9 @@ A lightweight and flexible state machine implementation for Ruby that allows you
6
6
 
7
7
  - **Lightweight**: Minimal dependencies and simple implementation
8
8
  - **Flexible DSL**: Intuitive syntax for defining states and transitions
9
- - **Dynamic Method Generation**: Automatically creates helper methods for state transitions
9
+ - **Dynamic Method Generation**: Automatically creates action methods for transitions and predicate methods for state checks
10
10
  - **Conditional Transitions**: Support for guards and conditional logic
11
+ - **Nested State Dependencies**: State machines can depend on the state of other attributes
11
12
  - **Transition Callbacks**: Execute code before, during, or after transitions
12
13
  - **Multiple State Machines**: Define multiple independent state machines per class
13
14
  - **Framework Agnostic**: Works with plain Ruby objects, no Rails or ActiveRecord required
@@ -71,6 +72,37 @@ order.status_ship # => :shipped
71
72
  order.status_deliver # => :delivered
72
73
  ```
73
74
 
75
+ ### Generated Methods
76
+
77
+ Circulator automatically generates two types of helper methods for your state machines:
78
+
79
+ #### Action Methods
80
+
81
+ For each action defined in your state machine, Circulator creates a method that performs the transition:
82
+
83
+ ```ruby
84
+ order.status_process # Transitions from :pending to :processing
85
+ order.status_cancel # Transitions to :cancelled
86
+ ```
87
+
88
+ #### State Predicate Methods
89
+
90
+ For each state in your state machine, Circulator creates a predicate method to check the current state:
91
+
92
+ ```ruby
93
+ order.status = :pending
94
+
95
+ order.status_pending? # => true
96
+ order.status_processing? # => false
97
+ order.status_shipped? # => false
98
+
99
+ order.status_process
100
+ order.status_processing? # => true
101
+ order.status_pending? # => false
102
+ ```
103
+
104
+ These predicate methods work with both symbol and string values, automatically converting strings to symbols for comparison.
105
+
74
106
  ### Advanced Features
75
107
 
76
108
  #### Conditional Transitions with Guards
@@ -93,6 +125,43 @@ class Document
93
125
  end
94
126
  ```
95
127
 
128
+ #### Nested State Dependencies
129
+
130
+ You can make one state machine depend on another using hash-based `allow_if`:
131
+
132
+ ```ruby
133
+ class Document
134
+ extend Circulator
135
+
136
+ attr_accessor :status, :review_status
137
+
138
+ # Review must be completed first
139
+ flow :review_status do
140
+ state :pending do
141
+ action :approve, to: :approved
142
+ end
143
+ state :approved
144
+ end
145
+
146
+ # Document status depends on review status
147
+ flow :status do
148
+ state :draft do
149
+ # Can only publish if review is approved
150
+ action :publish, to: :published, allow_if: {review_status: [:approved]}
151
+ end
152
+ end
153
+ end
154
+
155
+ doc = Document.new
156
+ doc.status = :draft
157
+ doc.review_status = :pending
158
+
159
+ doc.status_publish # => blocked, status remains :draft
160
+
161
+ doc.review_status_approve # => :approved
162
+ doc.status_publish # => :published ✓
163
+ ```
164
+
96
165
  #### Dynamic Destination States
97
166
 
98
167
  ```ruby
@@ -189,7 +258,7 @@ Circulator distinguishes itself from other Ruby state machine libraries through
189
258
  - **Minimal Magic**: Unlike AASM and state_machines, Circulator uses straightforward Ruby metaprogramming without complex DSL magic
190
259
  - **No Dependencies**: Works with plain Ruby objects without requiring Rails, ActiveRecord, or other frameworks
191
260
  - **Lightweight**: Smaller footprint compared to feature-heavy alternatives
192
- - **Clear Method Names**: Generated methods follow predictable naming patterns (`status_approve`, `priority_escalate`)
261
+ - **Clear Method Names**: Generated methods follow predictable naming patterns (`status_approve`, `status_pending?`)
193
262
  - **Flexible Architecture**: Easy to extend and customize for specific needs
194
263
 
195
264
  ### When to Use Circulator
@@ -23,6 +23,11 @@ module Circulator
23
23
  def action(name, to:, from: :__not_specified__, allow_if: nil, &block)
24
24
  raise "You must be in a state block or have a `from` option to declare an action" unless defined?(@current_state) || from != :__not_specified__
25
25
 
26
+ # Validate allow_if parameter
27
+ if allow_if
28
+ validate_allow_if(allow_if)
29
+ end
30
+
26
31
  @transition_map[name] ||= {}
27
32
  selected_state = (from == :__not_specified__) ? @current_state : from
28
33
 
@@ -36,6 +41,13 @@ module Circulator
36
41
  states_to_process.each do |from_state|
37
42
  from_state = from_state.to_sym if from_state.respond_to?(:to_sym)
38
43
  @states.add(from_state)
44
+
45
+ # Add the target state to @states if it's not a callable
46
+ unless to.respond_to?(:call)
47
+ to_state = to.respond_to?(:to_sym) ? to.to_sym : to
48
+ @states.add(to_state)
49
+ end
50
+
39
51
  @transition_map[name][from_state] = {to:, block:}
40
52
  @transition_map[name][from_state][:allow_if] = allow_if if allow_if
41
53
  end
@@ -67,5 +79,51 @@ module Circulator
67
79
  @no_action
68
80
  end
69
81
  end
82
+
83
+ private
84
+
85
+ def validate_allow_if(allow_if)
86
+ # Must be either a Proc or a Hash
87
+ unless allow_if.is_a?(Proc) || allow_if.is_a?(Hash)
88
+ raise ArgumentError, "allow_if must be a Proc or Hash, got: #{allow_if.class}"
89
+ end
90
+
91
+ # If it's a Hash, validate the structure
92
+ if allow_if.is_a?(Hash)
93
+ validate_hash_allow_if(allow_if)
94
+ end
95
+ end
96
+
97
+ def validate_hash_allow_if(allow_if_hash)
98
+ # Must have exactly one key
99
+ if allow_if_hash.size != 1
100
+ raise ArgumentError, "allow_if hash must contain exactly one attribute, got: #{allow_if_hash.keys.inspect}"
101
+ end
102
+
103
+ attribute_name, valid_states = allow_if_hash.first
104
+
105
+ # Convert attribute name to symbol
106
+ attribute_name = attribute_name.to_sym if attribute_name.respond_to?(:to_sym)
107
+
108
+ # Get model_key from the class name string, not the Class object
109
+ model_key = Circulator.model_key(@klass.to_s)
110
+ unless @klass.flows&.dig(model_key, attribute_name)
111
+ available_flows = @klass.flows&.dig(model_key)&.keys || []
112
+ raise ArgumentError, "allow_if references undefined flow attribute :#{attribute_name}. Available flows: #{available_flows.inspect}"
113
+ end
114
+
115
+ # Get the states from the referenced flow
116
+ referenced_flow = @klass.flows.dig(model_key, attribute_name)
117
+ referenced_states = referenced_flow.instance_variable_get(:@states)
118
+
119
+ # Convert valid_states to array of symbols
120
+ valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
121
+
122
+ # Check if all specified states exist in the referenced flow
123
+ invalid_states = valid_states_array - referenced_states.to_a
124
+ if invalid_states.any?
125
+ raise ArgumentError, "allow_if references invalid states #{invalid_states.inspect} for :#{attribute_name}. Valid states: #{referenced_states.to_a.inspect}"
126
+ end
127
+ end
70
128
  end
71
129
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.1"
4
+ VERSION = "2.1.3"
5
5
  end
data/lib/circulator.rb CHANGED
@@ -129,7 +129,7 @@ module Circulator
129
129
  @flows ||= {}
130
130
  model_key = Circulator.model_key(model)
131
131
  @flows[model_key] ||= {}
132
- @flows[model_key][attribute_name] = Flow.new(model, attribute_name, &block)
132
+ @flows[model_key][attribute_name] = Flow.new(self, attribute_name, &block)
133
133
 
134
134
  flow_module = ancestors.find { |ancestor|
135
135
  ancestor.name.to_s =~ /FlowMethods/
@@ -145,12 +145,38 @@ module Circulator
145
145
  Circulator.methodize_name(model)
146
146
  end
147
147
 
148
+ states = Set.new
148
149
  @flows.dig(model_key, attribute_name).transition_map.each do |action, transitions|
150
+ transitions.each do |from_state, transition_data|
151
+ states.add(from_state)
152
+ # Add the 'to' state if it's not a callable
153
+ unless transition_data[:to].respond_to?(:call)
154
+ states.add(transition_data[:to])
155
+ end
156
+ end
149
157
  define_flow_method(attribute_name:, action:, transitions:, object:, owner: flow_module)
150
158
  end
159
+
160
+ # Define predicate methods for each state (skip nil)
161
+ states.each do |state|
162
+ next if state.nil?
163
+ define_state_method(attribute_name:, state:, object:, owner: flow_module)
164
+ end
151
165
  end
152
166
  alias_method :circulator, :flow
153
167
 
168
+ def define_state_method(attribute_name:, state:, object:, owner:)
169
+ object_attribute_method = [object, attribute_name, state].compact.join("_") << "?"
170
+ return if owner.method_defined?(object_attribute_method)
171
+
172
+ owner.define_method(object_attribute_method) do
173
+ current_value = send(attribute_name)
174
+ # Convert to symbol for comparison if possible
175
+ current_value = current_value.to_sym if current_value.respond_to?(:to_sym)
176
+ current_value == state
177
+ end
178
+ end
179
+
154
180
  def define_flow_method(attribute_name:, action:, transitions:, object:, owner:)
155
181
  object_attribute_method = [object, attribute_name, action].compact.join("_")
156
182
  raise ArgumentError, "Method already defined: #{object_attribute_method}" if owner.method_defined?(object_attribute_method)
@@ -170,7 +196,23 @@ module Circulator
170
196
  end
171
197
 
172
198
  if transition[:allow_if]
173
- return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
199
+ # Handle hash-based allow_if (checking other attribute states)
200
+ if transition[:allow_if].is_a?(Hash)
201
+ attribute_name_to_check, valid_states = transition[:allow_if].first
202
+ current_state = flow_target.send(attribute_name_to_check)
203
+
204
+ # Convert current state to symbol if possible
205
+ current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
206
+
207
+ # Convert valid_states to array of symbols
208
+ valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
209
+
210
+ # Return early if current state is not in the valid states
211
+ return unless valid_states_array.include?(current_state)
212
+ else
213
+ # Handle proc-based allow_if (original behavior)
214
+ return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
215
+ end
174
216
  end
175
217
 
176
218
  if transition[:block]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: circulator
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 2.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
@@ -46,7 +46,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
46
  - !ruby/object:Gem::Version
47
47
  version: '0'
48
48
  requirements: []
49
- rubygems_version: 3.6.7
49
+ rubygems_version: 3.7.2
50
50
  specification_version: 4
51
51
  summary: Simple state machine
52
52
  test_files: []