circulator 2.1.3 → 2.1.5
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 +4 -6
- data/README.md +49 -1
- data/lib/circulator/dot.rb +18 -4
- data/lib/circulator/flow.rb +55 -12
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +86 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4c511772e424334259794bfff3d3507e5740fc98f0a130c66bf1d77a41862f6a
|
|
4
|
+
data.tar.gz: 7ee239ac23cbd6998a6d1689f8d6729983b42a6d6081e092465483ee19013107
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 63e6165e93a276d89ac6c469c3e8b5db41e6b184cde3290a9ff1332992e7f27a06cf1cd8f8b93c32100986c27be3761d0cfbc9ef7c69414b0cd379f32813c018
|
|
7
|
+
data.tar.gz: 586cdd39db8b98b10465e84c02de948a44f39c556ae270ea6a68ea8f90ca8c0b61af37917419dfe599e39e882584101169f932671996edbeeb97d6e9debe806d
|
data/CHANGELOG.md
CHANGED
|
@@ -5,12 +5,10 @@ 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.
|
|
9
|
-
|
|
10
|
-
### Fixed
|
|
11
|
-
|
|
12
|
-
- Ignore nil states in predicate methods (73c74c2)
|
|
8
|
+
## [2.1.5] - 2025-11-15
|
|
13
9
|
|
|
14
10
|
### Added
|
|
15
11
|
|
|
16
|
-
-
|
|
12
|
+
- Circulator.extension to define changes to existing state machines. (0f0a50f)
|
|
13
|
+
- Circulator.default_flow_proc to allow for custom storage objects. (7ddc442)
|
|
14
|
+
- Test support for custom flows storage with libraries like Contours::BlendedHash. (c7f1f26)
|
data/README.md
CHANGED
|
@@ -103,10 +103,36 @@ order.status_pending? # => false
|
|
|
103
103
|
|
|
104
104
|
These predicate methods work with both symbol and string values, automatically converting strings to symbols for comparison.
|
|
105
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
|
+
|
|
106
128
|
### Advanced Features
|
|
107
129
|
|
|
108
130
|
#### Conditional Transitions with Guards
|
|
109
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
|
+
|
|
110
136
|
```ruby
|
|
111
137
|
class Document
|
|
112
138
|
extend Circulator
|
|
@@ -125,7 +151,29 @@ class Document
|
|
|
125
151
|
end
|
|
126
152
|
```
|
|
127
153
|
|
|
128
|
-
|
|
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:
|
|
129
177
|
|
|
130
178
|
You can make one state machine depend on another using hash-based `allow_if`:
|
|
131
179
|
|
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
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module Circulator
|
|
4
4
|
class Flow
|
|
5
|
-
def initialize(klass, attribute_name, states = Set.new, &block)
|
|
5
|
+
def initialize(klass, attribute_name, states = Set.new, extension: false, flows_proc: Circulator.default_flow_proc, &block)
|
|
6
6
|
@klass = klass
|
|
7
7
|
@attribute_name = attribute_name
|
|
8
8
|
@states = states
|
|
9
9
|
@no_action = ->(attribute_name, action) { raise "No action found for the current state of #{attribute_name} (#{send(attribute_name)}): #{action}" }
|
|
10
|
-
@
|
|
11
|
-
|
|
10
|
+
@flows_proc = flows_proc
|
|
11
|
+
@transition_map = flows_proc.call
|
|
12
|
+
|
|
13
|
+
# Execute the main flow block
|
|
14
|
+
instance_eval(&block) if block
|
|
15
|
+
|
|
16
|
+
# Apply any registered extensions (unless explicitly disabled)
|
|
17
|
+
apply_extensions unless extension
|
|
12
18
|
end
|
|
13
19
|
attr_reader :transition_map
|
|
14
20
|
|
|
@@ -28,7 +34,7 @@ module Circulator
|
|
|
28
34
|
validate_allow_if(allow_if)
|
|
29
35
|
end
|
|
30
36
|
|
|
31
|
-
@transition_map[name] ||=
|
|
37
|
+
@transition_map[name] ||= @flows_proc.call
|
|
32
38
|
selected_state = (from == :__not_specified__) ? @current_state : from
|
|
33
39
|
|
|
34
40
|
# Handle nil case specially - convert to [nil] instead of []
|
|
@@ -48,8 +54,10 @@ module Circulator
|
|
|
48
54
|
@states.add(to_state)
|
|
49
55
|
end
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
# Build transition data hash with all keys at once
|
|
58
|
+
transition_data = {to:, block:}
|
|
59
|
+
transition_data[:allow_if] = allow_if if allow_if
|
|
60
|
+
@transition_map[name][from_state] = transition_data
|
|
53
61
|
end
|
|
54
62
|
end
|
|
55
63
|
|
|
@@ -83,14 +91,21 @@ module Circulator
|
|
|
83
91
|
private
|
|
84
92
|
|
|
85
93
|
def validate_allow_if(allow_if)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
case allow_if
|
|
95
|
+
in Proc
|
|
96
|
+
# Valid, no additional validation needed
|
|
97
|
+
in Symbol
|
|
98
|
+
validate_symbol_allow_if(allow_if)
|
|
99
|
+
in Hash
|
|
100
|
+
validate_hash_allow_if(allow_if)
|
|
101
|
+
else
|
|
102
|
+
raise ArgumentError, "allow_if must be a Proc, Hash, or Symbol, got: #{allow_if.class}"
|
|
89
103
|
end
|
|
104
|
+
end
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
106
|
+
def validate_symbol_allow_if(method_name)
|
|
107
|
+
unless @klass.method_defined?(method_name)
|
|
108
|
+
raise ArgumentError, "allow_if references undefined method :#{method_name} on #{@klass}"
|
|
94
109
|
end
|
|
95
110
|
end
|
|
96
111
|
|
|
@@ -125,5 +140,33 @@ module Circulator
|
|
|
125
140
|
raise ArgumentError, "allow_if references invalid states #{invalid_states.inspect} for :#{attribute_name}. Valid states: #{referenced_states.to_a.inspect}"
|
|
126
141
|
end
|
|
127
142
|
end
|
|
143
|
+
|
|
144
|
+
def apply_extensions
|
|
145
|
+
# Look up extensions for this class and attribute
|
|
146
|
+
class_name = if @klass.is_a?(Class)
|
|
147
|
+
@klass.name || @klass.to_s
|
|
148
|
+
else
|
|
149
|
+
Circulator.model_key(@klass)
|
|
150
|
+
end
|
|
151
|
+
key = "#{class_name}:#{@attribute_name}"
|
|
152
|
+
extensions = Circulator.extensions[key]
|
|
153
|
+
|
|
154
|
+
# Apply each extension by creating a new Flow and merging its transition_map
|
|
155
|
+
extensions.each do |extension_block|
|
|
156
|
+
extension_flow = Flow.new(@klass, @attribute_name, @states, extension: true, flows_proc: @flows_proc, &extension_block)
|
|
157
|
+
extension_flow.transition_map.each do |action, transitions|
|
|
158
|
+
@transition_map[action] = if @transition_map[action]
|
|
159
|
+
@transition_map[action].merge(transitions)
|
|
160
|
+
else
|
|
161
|
+
transitions
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Merge states from extension
|
|
166
|
+
extension_flow.instance_variable_get(:@states).each do |state|
|
|
167
|
+
@states.add(state)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
128
171
|
end
|
|
129
172
|
end
|
data/lib/circulator/version.rb
CHANGED
data/lib/circulator.rb
CHANGED
|
@@ -2,6 +2,32 @@ require "circulator/version"
|
|
|
2
2
|
require "circulator/flow"
|
|
3
3
|
|
|
4
4
|
module Circulator
|
|
5
|
+
# Global registry for extensions
|
|
6
|
+
@extensions = Hash.new { |h, k| h[k] = [] }
|
|
7
|
+
|
|
8
|
+
@default_flow_proc = ::Hash.method(:new)
|
|
9
|
+
class << self
|
|
10
|
+
attr_reader :extensions
|
|
11
|
+
attr_reader :default_flow_proc
|
|
12
|
+
|
|
13
|
+
# Register an extension for a specific class and attribute
|
|
14
|
+
#
|
|
15
|
+
# Example:
|
|
16
|
+
#
|
|
17
|
+
# Circulator.extension(:Document, :status) do
|
|
18
|
+
# state :pending do
|
|
19
|
+
# action :send_to_legal, to: :legal_review
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# Extensions are automatically applied when the class defines its flow
|
|
24
|
+
def extension(class_name, attribute_name, &block)
|
|
25
|
+
raise ArgumentError, "Block required for extension" unless block_given?
|
|
26
|
+
|
|
27
|
+
key = "#{class_name}:#{attribute_name}"
|
|
28
|
+
@extensions[key] << block
|
|
29
|
+
end
|
|
30
|
+
end
|
|
5
31
|
# Declare a flow for an attribute.
|
|
6
32
|
#
|
|
7
33
|
# Specify the attribute to be used for states and actions.
|
|
@@ -125,11 +151,12 @@ module Circulator
|
|
|
125
151
|
# test_object.flow(:unknown, :status, "signal")
|
|
126
152
|
# # Will raise an UnhandledSignalError
|
|
127
153
|
#
|
|
128
|
-
def flow(attribute_name, model: to_s, &block)
|
|
129
|
-
@flows ||=
|
|
154
|
+
def flow(attribute_name, model: to_s, flows_proc: Circulator.default_flow_proc, &block)
|
|
155
|
+
@flows ||= flows_proc.call
|
|
130
156
|
model_key = Circulator.model_key(model)
|
|
131
|
-
@flows[model_key] ||=
|
|
132
|
-
|
|
157
|
+
@flows[model_key] ||= flows_proc.call
|
|
158
|
+
# Pass the flows_proc to Flow so it can create transition_maps of the same type
|
|
159
|
+
@flows[model_key][attribute_name] = Flow.new(self, attribute_name, flows_proc:, &block)
|
|
133
160
|
|
|
134
161
|
flow_module = ancestors.find { |ancestor|
|
|
135
162
|
ancestor.name.to_s =~ /FlowMethods/
|
|
@@ -209,6 +236,9 @@ module Circulator
|
|
|
209
236
|
|
|
210
237
|
# Return early if current state is not in the valid states
|
|
211
238
|
return unless valid_states_array.include?(current_state)
|
|
239
|
+
elsif transition[:allow_if].is_a?(Symbol)
|
|
240
|
+
# Handle symbol-based allow_if (method name)
|
|
241
|
+
return unless flow_target.send(transition[:allow_if])
|
|
212
242
|
else
|
|
213
243
|
# Handle proc-based allow_if (original behavior)
|
|
214
244
|
return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
|
|
@@ -274,10 +304,62 @@ module Circulator
|
|
|
274
304
|
end
|
|
275
305
|
end
|
|
276
306
|
|
|
307
|
+
# Get available actions for an attribute based on current state
|
|
308
|
+
#
|
|
309
|
+
# Example:
|
|
310
|
+
#
|
|
311
|
+
# test_object.available_flows(:status)
|
|
312
|
+
# # => [:approve, :reject]
|
|
313
|
+
def available_flows(attribute, *args, **kwargs)
|
|
314
|
+
model_key = Circulator.model_key(self)
|
|
315
|
+
flow = flows.dig(model_key, attribute)
|
|
316
|
+
return [] unless flow
|
|
317
|
+
|
|
318
|
+
current_value = send(attribute)
|
|
319
|
+
current_state = current_value.respond_to?(:to_sym) ? current_value.to_sym : current_value
|
|
320
|
+
|
|
321
|
+
flow.transition_map.select do |action, transitions|
|
|
322
|
+
transition = transitions[current_state]
|
|
323
|
+
next false unless transition
|
|
324
|
+
|
|
325
|
+
# Check allow_if condition if present
|
|
326
|
+
if transition[:allow_if]
|
|
327
|
+
check_allow_if(transition[:allow_if], *args, **kwargs)
|
|
328
|
+
else
|
|
329
|
+
true
|
|
330
|
+
end
|
|
331
|
+
end.keys
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Check if a specific action is available for an attribute
|
|
335
|
+
#
|
|
336
|
+
# Example:
|
|
337
|
+
#
|
|
338
|
+
# test_object.available_flow?(:status, :approve)
|
|
339
|
+
# # => true
|
|
340
|
+
def available_flow?(attribute, action, *args, **kwargs)
|
|
341
|
+
available_flows(attribute, *args, **kwargs).include?(action)
|
|
342
|
+
end
|
|
343
|
+
|
|
277
344
|
private
|
|
278
345
|
|
|
279
346
|
def flows
|
|
280
347
|
self.class.flows
|
|
281
348
|
end
|
|
349
|
+
|
|
350
|
+
def check_allow_if(allow_if, *args, **kwargs)
|
|
351
|
+
case allow_if
|
|
352
|
+
when Hash
|
|
353
|
+
attribute_name, valid_states = allow_if.first
|
|
354
|
+
current_state = send(attribute_name)
|
|
355
|
+
current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
|
|
356
|
+
valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
|
|
357
|
+
valid_states_array.include?(current_state)
|
|
358
|
+
when Symbol
|
|
359
|
+
send(allow_if, *args, **kwargs)
|
|
360
|
+
else # Proc
|
|
361
|
+
instance_exec(*args, **kwargs, &allow_if)
|
|
362
|
+
end
|
|
363
|
+
end
|
|
282
364
|
end
|
|
283
365
|
end
|