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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdd2e4e17046d72b53d25ea469b8f037316abf74cb7a34fe010091ff4f8b5bfa
4
- data.tar.gz: 879a93ab33bb08c07fb6239ce490c5d900272cbd3cd03a65c66516f95439499e
3
+ metadata.gz: 4c511772e424334259794bfff3d3507e5740fc98f0a130c66bf1d77a41862f6a
4
+ data.tar.gz: 7ee239ac23cbd6998a6d1689f8d6729983b42a6d6081e092465483ee19013107
5
5
  SHA512:
6
- metadata.gz: 910cd9755d9b9c02996d9d38329082733d019019151c19bc839663893e3523a6c64be9149485317bc7a0979f10223d15c4e0bb34ae0a9942f30d1c473acf8378
7
- data.tar.gz: a98cad092d4da49cf93c38db6462489730061c38439f9825e2d2378ef58def38d3db243784c570f50697704442d271d8ac3aa0b1eddd5147e7f4a65b2a346c8a
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.3] - 2025-10-27
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
- - Attribute predicate methods and documentation (8f9cc33)
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
- #### 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:
129
177
 
130
178
  You can make one state machine depend on another using hash-based `allow_if`:
131
179
 
@@ -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
 
@@ -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
- @transition_map = {}
11
- instance_eval(&block)
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
- @transition_map[name][from_state] = {to:, block:}
52
- @transition_map[name][from_state][:allow_if] = allow_if if allow_if
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
- # 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}"
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
- # If it's a Hash, validate the structure
92
- if allow_if.is_a?(Hash)
93
- validate_hash_allow_if(allow_if)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.3"
4
+ VERSION = "2.1.5"
5
5
  end
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
- @flows[model_key][attribute_name] = Flow.new(self, attribute_name, &block)
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
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.3
4
+ version: 2.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay