circulator 2.1.2 → 2.1.4
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 +18 -2
- data/README.md +82 -3
- data/lib/circulator/dot.rb +18 -4
- data/lib/circulator/flow.rb +13 -6
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +81 -0
- 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: 9126192a7ce18956bc8c27842e52765dac25e9436e322ad259100e5138a5b97f
|
|
4
|
+
data.tar.gz: f1e15524d5806a3f0b0455bd006e8ba5e3914b13e046ab0d2acd1dbcb897ea27
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 03ad68c5d43c45fddf1b7d5c3f84dd845363b2bf8cc1a881463efbd1b1e43d233e196e965dae157726b2ac0b90066d9ec05087f12339667ffd1e9effe0213e54
|
|
7
|
+
data.tar.gz: 7590c58b27a608f4d49f6041c9fed8a4fd5a05526cd5c36eba40a12346ce5a63cd6f9f0a6253c48ec6aa7405e9e4b5ed8121fae276404a1a32f4587118453c2f
|
data/CHANGELOG.md
CHANGED
|
@@ -5,8 +5,24 @@ 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.4] - 2025-11-03
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- Symbol-based allow_if support (a96b794)
|
|
13
|
+
- Documentation for symbol-based allow_if (d3befc1)
|
|
14
|
+
- available_flows method with guard and argument support (122e2ab)
|
|
15
|
+
- available_flow? predicate method (122e2ab)
|
|
16
|
+
- Documentation for query methods (a75507e)
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Validate symbol allow_if at definition time (392eac5)
|
|
21
|
+
|
|
22
|
+
### Removed
|
|
23
|
+
|
|
24
|
+
- Redundant respond_to? check (40ff669)
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- DOT syntax error for states ending with ? (65b503e)
|
data/README.md
CHANGED
|
@@ -6,7 +6,7 @@ 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
11
|
- **Nested State Dependencies**: State machines can depend on the state of other attributes
|
|
12
12
|
- **Transition Callbacks**: Execute code before, during, or after transitions
|
|
@@ -72,10 +72,67 @@ order.status_ship # => :shipped
|
|
|
72
72
|
order.status_deliver # => :delivered
|
|
73
73
|
```
|
|
74
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
|
+
|
|
106
|
+
#### Query Available Actions
|
|
107
|
+
|
|
108
|
+
Circulator provides methods to query which actions are available from the current state:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
order.status = :pending
|
|
112
|
+
|
|
113
|
+
# Get all available actions
|
|
114
|
+
order.available_flows(:status) # => [:approve, :reject]
|
|
115
|
+
|
|
116
|
+
# Check if a specific action is available
|
|
117
|
+
order.available_flow?(:status, :approve) # => true
|
|
118
|
+
order.available_flow?(:status, :ship) # => false
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
These methods respect all `allow_if` conditions and can accept arguments to pass through to guard conditions:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
# With conditional guards
|
|
125
|
+
order.available_flow?(:status, :approve, level: 5) # => true
|
|
126
|
+
```
|
|
127
|
+
|
|
75
128
|
### Advanced Features
|
|
76
129
|
|
|
77
130
|
#### Conditional Transitions with Guards
|
|
78
131
|
|
|
132
|
+
You can control when transitions are allowed using the `allow_if` option. Circulator supports three types of guards:
|
|
133
|
+
|
|
134
|
+
**Proc-based guards** evaluate a block of code:
|
|
135
|
+
|
|
79
136
|
```ruby
|
|
80
137
|
class Document
|
|
81
138
|
extend Circulator
|
|
@@ -94,7 +151,29 @@ class Document
|
|
|
94
151
|
end
|
|
95
152
|
```
|
|
96
153
|
|
|
97
|
-
|
|
154
|
+
**Symbol-based guards** call a method on the object:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
class Document
|
|
158
|
+
extend Circulator
|
|
159
|
+
|
|
160
|
+
attr_accessor :state, :reviewed_by
|
|
161
|
+
|
|
162
|
+
circulator :state do
|
|
163
|
+
state :draft do
|
|
164
|
+
action :publish, to: :published, allow_if: :ready_to_publish?
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def ready_to_publish?
|
|
169
|
+
reviewed_by.present?
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
This is equivalent to the proc-based approach but cleaner when you have a dedicated method for the condition.
|
|
175
|
+
|
|
176
|
+
**Hash-based guards** check the state of another attribute:
|
|
98
177
|
|
|
99
178
|
You can make one state machine depend on another using hash-based `allow_if`:
|
|
100
179
|
|
|
@@ -227,7 +306,7 @@ Circulator distinguishes itself from other Ruby state machine libraries through
|
|
|
227
306
|
- **Minimal Magic**: Unlike AASM and state_machines, Circulator uses straightforward Ruby metaprogramming without complex DSL magic
|
|
228
307
|
- **No Dependencies**: Works with plain Ruby objects without requiring Rails, ActiveRecord, or other frameworks
|
|
229
308
|
- **Lightweight**: Smaller footprint compared to feature-heavy alternatives
|
|
230
|
-
- **Clear Method Names**: Generated methods follow predictable naming patterns (`status_approve`, `
|
|
309
|
+
- **Clear Method Names**: Generated methods follow predictable naming patterns (`status_approve`, `status_pending?`)
|
|
231
310
|
- **Flexible Architecture**: Easy to extend and customize for specific needs
|
|
232
311
|
|
|
233
312
|
### When to Use Circulator
|
data/lib/circulator/dot.rb
CHANGED
|
@@ -6,6 +6,14 @@ module Circulator
|
|
|
6
6
|
class Dot < Diagram
|
|
7
7
|
private
|
|
8
8
|
|
|
9
|
+
def quote_node_name(name)
|
|
10
|
+
if name.end_with?("?")
|
|
11
|
+
"\"#{name}\""
|
|
12
|
+
else
|
|
13
|
+
name
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
9
17
|
def flows_output(flows_data, output)
|
|
10
18
|
if flows_data.size == 1
|
|
11
19
|
# Single flow: no grouping needed
|
|
@@ -27,7 +35,8 @@ module Circulator
|
|
|
27
35
|
state_label = state.nil? ? "nil" : state.to_s
|
|
28
36
|
# Prefix state names with attribute to avoid conflicts
|
|
29
37
|
prefixed_name = "#{flow[:attribute_name]}_#{state_label}"
|
|
30
|
-
|
|
38
|
+
quoted_name = quote_node_name(prefixed_name)
|
|
39
|
+
output << " #{quoted_name} [label=\"#{state_label}\", shape=circle];"
|
|
31
40
|
end
|
|
32
41
|
|
|
33
42
|
output << " }"
|
|
@@ -43,7 +52,9 @@ module Circulator
|
|
|
43
52
|
# Use prefixed names
|
|
44
53
|
prefixed_from = "#{flow[:attribute_name]}_#{from_label}"
|
|
45
54
|
prefixed_to = "#{flow[:attribute_name]}_#{to_label}"
|
|
46
|
-
|
|
55
|
+
quoted_from = quote_node_name(prefixed_from)
|
|
56
|
+
quoted_to = quote_node_name(prefixed_to)
|
|
57
|
+
output << " #{quoted_from} -> #{quoted_to} [label=\"#{transition[:label]}\"];"
|
|
47
58
|
end
|
|
48
59
|
end
|
|
49
60
|
end
|
|
@@ -67,7 +78,8 @@ module Circulator
|
|
|
67
78
|
output << " // States"
|
|
68
79
|
states.sort_by { |s| s.to_s }.each do |state|
|
|
69
80
|
state_label = state.nil? ? "nil" : state.to_s
|
|
70
|
-
|
|
81
|
+
quoted_name = quote_node_name(state_label)
|
|
82
|
+
output << " #{quoted_name} [shape=circle];"
|
|
71
83
|
end
|
|
72
84
|
end
|
|
73
85
|
|
|
@@ -77,7 +89,9 @@ module Circulator
|
|
|
77
89
|
transitions.sort_by { |t| [t[:from].to_s, t[:to].to_s, t[:label]] }.each do |transition|
|
|
78
90
|
from_label = transition[:from].nil? ? "nil" : transition[:from].to_s
|
|
79
91
|
to_label = transition[:to].nil? ? "nil" : transition[:to].to_s
|
|
80
|
-
|
|
92
|
+
quoted_from = quote_node_name(from_label)
|
|
93
|
+
quoted_to = quote_node_name(to_label)
|
|
94
|
+
output << " #{quoted_from} -> #{quoted_to} [label=\"#{transition[:label]}\"];"
|
|
81
95
|
end
|
|
82
96
|
end
|
|
83
97
|
|
data/lib/circulator/flow.rb
CHANGED
|
@@ -83,14 +83,21 @@ module Circulator
|
|
|
83
83
|
private
|
|
84
84
|
|
|
85
85
|
def validate_allow_if(allow_if)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
case allow_if
|
|
87
|
+
in Proc
|
|
88
|
+
# Valid, no additional validation needed
|
|
89
|
+
in Symbol
|
|
90
|
+
validate_symbol_allow_if(allow_if)
|
|
91
|
+
in Hash
|
|
92
|
+
validate_hash_allow_if(allow_if)
|
|
93
|
+
else
|
|
94
|
+
raise ArgumentError, "allow_if must be a Proc, Hash, or Symbol, got: #{allow_if.class}"
|
|
89
95
|
end
|
|
96
|
+
end
|
|
90
97
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
def validate_symbol_allow_if(method_name)
|
|
99
|
+
unless @klass.method_defined?(method_name)
|
|
100
|
+
raise ArgumentError, "allow_if references undefined method :#{method_name} on #{@klass}"
|
|
94
101
|
end
|
|
95
102
|
end
|
|
96
103
|
|
data/lib/circulator/version.rb
CHANGED
data/lib/circulator.rb
CHANGED
|
@@ -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)
|
|
@@ -183,6 +209,9 @@ module Circulator
|
|
|
183
209
|
|
|
184
210
|
# Return early if current state is not in the valid states
|
|
185
211
|
return unless valid_states_array.include?(current_state)
|
|
212
|
+
elsif transition[:allow_if].is_a?(Symbol)
|
|
213
|
+
# Handle symbol-based allow_if (method name)
|
|
214
|
+
return unless flow_target.send(transition[:allow_if])
|
|
186
215
|
else
|
|
187
216
|
# Handle proc-based allow_if (original behavior)
|
|
188
217
|
return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
|
|
@@ -248,10 +277,62 @@ module Circulator
|
|
|
248
277
|
end
|
|
249
278
|
end
|
|
250
279
|
|
|
280
|
+
# Get available actions for an attribute based on current state
|
|
281
|
+
#
|
|
282
|
+
# Example:
|
|
283
|
+
#
|
|
284
|
+
# test_object.available_flows(:status)
|
|
285
|
+
# # => [:approve, :reject]
|
|
286
|
+
def available_flows(attribute, *args, **kwargs)
|
|
287
|
+
model_key = Circulator.model_key(self)
|
|
288
|
+
flow = flows.dig(model_key, attribute)
|
|
289
|
+
return [] unless flow
|
|
290
|
+
|
|
291
|
+
current_value = send(attribute)
|
|
292
|
+
current_state = current_value.respond_to?(:to_sym) ? current_value.to_sym : current_value
|
|
293
|
+
|
|
294
|
+
flow.transition_map.select do |action, transitions|
|
|
295
|
+
transition = transitions[current_state]
|
|
296
|
+
next false unless transition
|
|
297
|
+
|
|
298
|
+
# Check allow_if condition if present
|
|
299
|
+
if transition[:allow_if]
|
|
300
|
+
check_allow_if(transition[:allow_if], *args, **kwargs)
|
|
301
|
+
else
|
|
302
|
+
true
|
|
303
|
+
end
|
|
304
|
+
end.keys
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Check if a specific action is available for an attribute
|
|
308
|
+
#
|
|
309
|
+
# Example:
|
|
310
|
+
#
|
|
311
|
+
# test_object.available_flow?(:status, :approve)
|
|
312
|
+
# # => true
|
|
313
|
+
def available_flow?(attribute, action, *args, **kwargs)
|
|
314
|
+
available_flows(attribute, *args, **kwargs).include?(action)
|
|
315
|
+
end
|
|
316
|
+
|
|
251
317
|
private
|
|
252
318
|
|
|
253
319
|
def flows
|
|
254
320
|
self.class.flows
|
|
255
321
|
end
|
|
322
|
+
|
|
323
|
+
def check_allow_if(allow_if, *args, **kwargs)
|
|
324
|
+
case allow_if
|
|
325
|
+
when Hash
|
|
326
|
+
attribute_name, valid_states = allow_if.first
|
|
327
|
+
current_state = send(attribute_name)
|
|
328
|
+
current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
|
|
329
|
+
valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
|
|
330
|
+
valid_states_array.include?(current_state)
|
|
331
|
+
when Symbol
|
|
332
|
+
send(allow_if, *args, **kwargs)
|
|
333
|
+
else # Proc
|
|
334
|
+
instance_exec(*args, **kwargs, &allow_if)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
256
337
|
end
|
|
257
338
|
end
|
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.4
|
|
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: []
|