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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a3d83bc194cf2440d13e088b1b23e72cf3ec89071ae9d046d575a7e5d1014c7
4
- data.tar.gz: 4c1afec2234a2edf3675048519aac13e4b64f2c8bbb08b98387603fc5f354ea8
3
+ metadata.gz: 9126192a7ce18956bc8c27842e52765dac25e9436e322ad259100e5138a5b97f
4
+ data.tar.gz: f1e15524d5806a3f0b0455bd006e8ba5e3914b13e046ab0d2acd1dbcb897ea27
5
5
  SHA512:
6
- metadata.gz: a5bf023b56fe2f8352fbb2fadce2c8a29cb9a47cfd5b011c80c098646696b9dcd5735c2b027c8f4a126c285005f11c8b46e9c009ac8ad243df3e1bc8a5979523
7
- data.tar.gz: e4f99d8cd3e1cdbf87a15eb0b588ffc778f0fdf412c897fb5bf6796ff1295351fcf542c6ea80d1f883b1c3fa6afd817925ecddbda39ac1e16ffc9a59665754cb
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.2] - 2025-10-17
8
+ ## [2.1.4] - 2025-11-03
9
9
 
10
10
  ### Added
11
11
 
12
- - Hash-based allow_if for nested state dependencies (411a6b5)
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 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
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
- #### Nested State Dependencies
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`, `priority_escalate`)
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
@@ -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
- output << " #{prefixed_name} [label=\"#{state_label}\", shape=circle];"
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
- output << " #{prefixed_from} -> #{prefixed_to} [label=\"#{transition[:label]}\"];"
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
- output << " #{state_label} [shape=circle];"
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
- output << " #{from_label} -> #{to_label} [label=\"#{transition[:label]}\"];"
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
 
@@ -83,14 +83,21 @@ module Circulator
83
83
  private
84
84
 
85
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}"
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
- # If it's a Hash, validate the structure
92
- if allow_if.is_a?(Hash)
93
- validate_hash_allow_if(allow_if)
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.2"
4
+ VERSION = "2.1.4"
5
5
  end
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.2
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.6.7
49
+ rubygems_version: 3.7.2
50
50
  specification_version: 4
51
51
  summary: Simple state machine
52
52
  test_files: []