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 +4 -4
- data/CHANGELOG.md +6 -3
- data/README.md +71 -2
- data/lib/circulator/flow.rb +58 -0
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +44 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bdd2e4e17046d72b53d25ea469b8f037316abf74cb7a34fe010091ff4f8b5bfa
|
|
4
|
+
data.tar.gz: 879a93ab33bb08c07fb6239ce490c5d900272cbd3cd03a65c66516f95439499e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
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`, `
|
|
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
|
data/lib/circulator/flow.rb
CHANGED
|
@@ -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
|
data/lib/circulator/version.rb
CHANGED
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(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
49
|
+
rubygems_version: 3.7.2
|
|
50
50
|
specification_version: 4
|
|
51
51
|
summary: Simple state machine
|
|
52
52
|
test_files: []
|