circulator 2.1.8 → 2.1.10
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 +2 -2
- data/README.md +65 -7
- data/Rakefile +1 -0
- data/lib/circulator/flow.rb +38 -14
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +122 -73
- 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: 8c4d63bee9efd4c305309b4e77b502e378ad2a8ec7f550b85a25d9f9b1963f9f
|
|
4
|
+
data.tar.gz: 281ebeb9de99be8dac7b6925ae7d3d51727c54916bca3f9df7c86b6aa5014966
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9d5d21fb96c45580fd3051b084403aba315d058eee4edb56dfdb4c7b10cc49105b277e60d96c895d4ae0014e0a0298ff79878f4f3a1b61f35b8c480543f491a
|
|
7
|
+
data.tar.gz: fd20a62b795f3817877c48b8ca810dde022615df11cd100506265436958e4e7326791d8a2dda6b13f898327e5c295f076800d541bc900986524b000ebdc812d9
|
data/CHANGELOG.md
CHANGED
|
@@ -5,8 +5,8 @@ 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.10] - 2026-02-06
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- `around` block in flow DSL to wrap transition logic (state read, guards, state change, callbacks) (184527b)
|
data/README.md
CHANGED
|
@@ -10,6 +10,7 @@ A lightweight and flexible state machine implementation for Ruby that allows you
|
|
|
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
|
|
13
|
+
- **Around Wrapping**: Wrap all transitions in a flow with shared logic (e.g., `with_lock`)
|
|
13
14
|
- **Multiple State Machines**: Define multiple independent state machines per class
|
|
14
15
|
- **Framework Agnostic**: Works with plain Ruby objects, no Rails or ActiveRecord required
|
|
15
16
|
- **100% Test Coverage**: Thoroughly tested with comprehensive test suite
|
|
@@ -293,6 +294,68 @@ class Payment
|
|
|
293
294
|
end
|
|
294
295
|
```
|
|
295
296
|
|
|
297
|
+
#### Wrapping Transitions with `around`
|
|
298
|
+
|
|
299
|
+
Use the `around` block to wrap all transitions in a flow with shared logic. The block receives a `transition` proc that you must call for the transition to execute:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
class Order
|
|
303
|
+
extend Circulator
|
|
304
|
+
|
|
305
|
+
attr_accessor :status
|
|
306
|
+
|
|
307
|
+
flow :status do
|
|
308
|
+
around do |transition|
|
|
309
|
+
puts "before transition"
|
|
310
|
+
transition.call
|
|
311
|
+
puts "after transition"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
state :pending do
|
|
315
|
+
action :approve, to: :approved
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The `around` block is `instance_exec`'d on the instance (consistent with transition blocks and `allow_if` procs), so `self` is the object being transitioned.
|
|
322
|
+
|
|
323
|
+
**Transactional safety with ActiveRecord:**
|
|
324
|
+
|
|
325
|
+
This is particularly useful for wrapping transitions in a database lock to prevent race conditions:
|
|
326
|
+
|
|
327
|
+
```ruby
|
|
328
|
+
class Order < ApplicationRecord
|
|
329
|
+
extend Circulator
|
|
330
|
+
|
|
331
|
+
flow :status do
|
|
332
|
+
around do |transition|
|
|
333
|
+
with_lock { transition.call }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
state :pending do
|
|
337
|
+
action :approve, to: :approved do
|
|
338
|
+
self.approved_at = Time.current
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
state :approved do
|
|
343
|
+
action :ship, to: :shipped
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# The guard check, transition block, state change, and caller block
|
|
349
|
+
# all execute inside the lock — no wrapper methods needed
|
|
350
|
+
order.status_approve
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Key behaviors:**
|
|
354
|
+
|
|
355
|
+
- The state read, guard checks (`allow_if`), and state change all run **inside** the wrapper, so the entire check-then-act sequence is atomic
|
|
356
|
+
- If `transition.call` is never called, the transition does not execute
|
|
357
|
+
- Each flow can have its own `around` block (or none) — flows without one behave exactly as before
|
|
358
|
+
|
|
296
359
|
#### Extending Flows
|
|
297
360
|
|
|
298
361
|
You can extend existing flows using `Circulator.extension`. This is useful for plugins, multi-tenant applications, or conditional feature enhancement. Extensions are registered globally and automatically applied when a class defines its flow.
|
|
@@ -338,7 +401,7 @@ doc.status_revise # => :draft (from extension)
|
|
|
338
401
|
|
|
339
402
|
**How Extensions Work:**
|
|
340
403
|
|
|
341
|
-
Extensions are registered globally using `Circulator.extension(class_name, attribute)` and are automatically applied when the class defines its flow. Multiple extensions can be registered for the same class/attribute and are applied in registration order. Extensions
|
|
404
|
+
Extensions are registered globally using `Circulator.extension(class_name, attribute)` and are automatically applied when the class defines its flow. Multiple extensions can be registered for the same class/attribute and are applied in registration order. Extensions can be registered before or after the class definition—if registered after, they are applied immediately to the existing flow.
|
|
342
405
|
|
|
343
406
|
By default, when an extension defines the same action from the same state as the base flow, the extension completely replaces the base definition (last-defined wins). To implement intelligent composition where extensions add their conditions/blocks additively, your application can configure a custom `flows_proc` that uses a Hash-like object with merge logic. Circulator remains dependency-free and supports any compatible Hash implementation.
|
|
344
407
|
|
|
@@ -497,12 +560,7 @@ To install this gem onto your local machine, run bundle exec rake install.
|
|
|
497
560
|
|
|
498
561
|
This project is managed with [Reissue](https://github.com/SOFware/reissue).
|
|
499
562
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
To release a new version:
|
|
503
|
-
|
|
504
|
-
bundle exec rake build:checksum
|
|
505
|
-
bundle exec rake release
|
|
563
|
+
Releases are automated via the [shared release workflow](https://github.com/SOFware/reissue/blob/main/.github/workflows/SHARED_WORKFLOW_README.md). Trigger a release by running the "Release gem to RubyGems.org" workflow from the Actions tab.
|
|
506
564
|
|
|
507
565
|
## Contributing
|
|
508
566
|
|
data/Rakefile
CHANGED
data/lib/circulator/flow.rb
CHANGED
|
@@ -88,6 +88,42 @@ module Circulator
|
|
|
88
88
|
end
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
def around(&block)
|
|
92
|
+
if block_given?
|
|
93
|
+
@around = block
|
|
94
|
+
else
|
|
95
|
+
@around
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Merge an extension block into this flow
|
|
100
|
+
#
|
|
101
|
+
# Creates an extension flow from the block and merges its transitions
|
|
102
|
+
# and states into this flow. Returns self for convenience.
|
|
103
|
+
#
|
|
104
|
+
# Example:
|
|
105
|
+
#
|
|
106
|
+
# existing_flow.merge do
|
|
107
|
+
# state :pending do
|
|
108
|
+
# action :send_to_legal, to: :legal_review
|
|
109
|
+
# end
|
|
110
|
+
# end
|
|
111
|
+
#
|
|
112
|
+
def merge(&block)
|
|
113
|
+
extension_flow = Flow.new(@klass, @attribute_name, @states, extension: true, flows_proc: @flows_proc, &block)
|
|
114
|
+
|
|
115
|
+
# Merge transition map
|
|
116
|
+
extension_flow.transition_map.each do |action, transitions|
|
|
117
|
+
@transition_map[action] = if @transition_map[action]
|
|
118
|
+
@transition_map[action].merge(transitions)
|
|
119
|
+
else
|
|
120
|
+
transitions
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
self
|
|
125
|
+
end
|
|
126
|
+
|
|
91
127
|
private
|
|
92
128
|
|
|
93
129
|
def validate_allow_if(allow_if)
|
|
@@ -172,21 +208,9 @@ module Circulator
|
|
|
172
208
|
key = "#{class_name}:#{@attribute_name}"
|
|
173
209
|
extensions = Circulator.extensions[key]
|
|
174
210
|
|
|
175
|
-
# Apply each extension
|
|
211
|
+
# Apply each extension using merge
|
|
176
212
|
extensions.each do |extension_block|
|
|
177
|
-
|
|
178
|
-
extension_flow.transition_map.each do |action, transitions|
|
|
179
|
-
@transition_map[action] = if @transition_map[action]
|
|
180
|
-
@transition_map[action].merge(transitions)
|
|
181
|
-
else
|
|
182
|
-
transitions
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# Merge states from extension
|
|
187
|
-
extension_flow.instance_variable_get(:@states).each do |state|
|
|
188
|
-
@states.add(state)
|
|
189
|
-
end
|
|
213
|
+
merge(&extension_block)
|
|
190
214
|
end
|
|
191
215
|
end
|
|
192
216
|
end
|
data/lib/circulator/version.rb
CHANGED
data/lib/circulator.rb
CHANGED
|
@@ -70,8 +70,66 @@ module Circulator
|
|
|
70
70
|
|
|
71
71
|
key = "#{class_name}:#{attribute_name}"
|
|
72
72
|
@extensions[key] << block
|
|
73
|
+
|
|
74
|
+
# If the class already exists and has flows defined, apply the extension immediately
|
|
75
|
+
apply_extension_to_existing_flow(class_name, attribute_name, block)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def apply_extension_to_existing_flow(class_name, attribute_name, block)
|
|
81
|
+
# Try to get the class constant
|
|
82
|
+
klass = begin
|
|
83
|
+
Object.const_get(class_name.to_s)
|
|
84
|
+
rescue NameError
|
|
85
|
+
return # Class doesn't exist yet, extension will be applied when flow is defined
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Check if the class has flows and the specific attribute flow
|
|
89
|
+
return unless klass.respond_to?(:flows) && klass.flows
|
|
90
|
+
|
|
91
|
+
model_key = Circulator.model_key(klass.to_s)
|
|
92
|
+
existing_flow = klass.flows.dig(model_key, attribute_name.to_sym)
|
|
93
|
+
return unless existing_flow
|
|
94
|
+
|
|
95
|
+
# Merge the extension into the existing flow
|
|
96
|
+
existing_flow.merge(&block)
|
|
97
|
+
|
|
98
|
+
# Re-define flow methods for any new actions/states
|
|
99
|
+
redefine_flow_methods(klass, attribute_name, existing_flow)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def redefine_flow_methods(klass, attribute_name, flow)
|
|
103
|
+
flow_module = klass.ancestors.find { |ancestor|
|
|
104
|
+
ancestor.name.to_s =~ /#{FLOW_MODULE_NAME}/o
|
|
105
|
+
}
|
|
106
|
+
return unless flow_module
|
|
107
|
+
|
|
108
|
+
object = nil # Extensions only work on the same class model
|
|
109
|
+
|
|
110
|
+
# Define or redefine methods for actions (need to redefine if transitions changed)
|
|
111
|
+
flow.transition_map.each do |action, transitions|
|
|
112
|
+
method_name = [object, attribute_name, action].compact.join("_")
|
|
113
|
+
|
|
114
|
+
# Remove existing method so it can be redefined with updated transitions
|
|
115
|
+
if flow_module.method_defined?(method_name)
|
|
116
|
+
flow_module.remove_method(method_name)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
klass.send(:define_flow_method, attribute_name:, action:, transitions:, object:, owner: flow_module)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Define predicate methods for any new states
|
|
123
|
+
states = flow.instance_variable_get(:@states)
|
|
124
|
+
states.each do |state|
|
|
125
|
+
next if state.nil?
|
|
126
|
+
klass.send(:define_state_method, attribute_name:, state:, object:, owner: flow_module)
|
|
127
|
+
end
|
|
73
128
|
end
|
|
74
129
|
end
|
|
130
|
+
|
|
131
|
+
FLOW_MODULE_NAME = "FlowMethods"
|
|
132
|
+
|
|
75
133
|
# Declare a flow for an attribute.
|
|
76
134
|
#
|
|
77
135
|
# Specify the attribute to be used for states and actions.
|
|
@@ -195,6 +253,21 @@ module Circulator
|
|
|
195
253
|
# test_object.flow(:unknown, :status, "signal")
|
|
196
254
|
# # Will raise an UnhandledSignalError
|
|
197
255
|
#
|
|
256
|
+
# You can also provide an around block to wrap the flow logic.
|
|
257
|
+
#
|
|
258
|
+
# Example:
|
|
259
|
+
#
|
|
260
|
+
# flow(:status) do
|
|
261
|
+
# around do |flow_logic|
|
|
262
|
+
# with_logging do
|
|
263
|
+
# flow_logic.call
|
|
264
|
+
# end
|
|
265
|
+
# end
|
|
266
|
+
# end
|
|
267
|
+
#
|
|
268
|
+
# test_object.status_approve
|
|
269
|
+
# # Will log the flow logic according to the with_logging block behavior
|
|
270
|
+
#
|
|
198
271
|
def flow(attribute_name, model: to_s, flows_proc: Circulator.default_flow_proc, &block)
|
|
199
272
|
@flows ||= flows_proc.call
|
|
200
273
|
model_key = Circulator.model_key(model)
|
|
@@ -203,11 +276,11 @@ module Circulator
|
|
|
203
276
|
@flows[model_key][attribute_name] = Flow.new(self, attribute_name, flows_proc:, &block)
|
|
204
277
|
|
|
205
278
|
flow_module = ancestors.find { |ancestor|
|
|
206
|
-
ancestor.name.to_s =~ /
|
|
279
|
+
ancestor.name.to_s =~ /#{FLOW_MODULE_NAME}/o
|
|
207
280
|
} || Module.new.tap do |mod|
|
|
208
281
|
include mod
|
|
209
282
|
|
|
210
|
-
const_set(
|
|
283
|
+
const_set(FLOW_MODULE_NAME.to_sym, mod)
|
|
211
284
|
end
|
|
212
285
|
|
|
213
286
|
object = if model == to_s
|
|
@@ -253,65 +326,62 @@ module Circulator
|
|
|
253
326
|
raise ArgumentError, "Method already defined: #{object_attribute_method}" if owner.method_defined?(object_attribute_method)
|
|
254
327
|
|
|
255
328
|
owner.define_method(object_attribute_method) do |*args, flow_target: self, **kwargs, &block|
|
|
256
|
-
|
|
329
|
+
flow_logic = -> {
|
|
330
|
+
current_value = flow_target.send(attribute_name)
|
|
257
331
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
332
|
+
transition = if current_value.respond_to?(:to_sym)
|
|
333
|
+
transitions[current_value.to_sym]
|
|
334
|
+
else
|
|
335
|
+
transitions[current_value]
|
|
336
|
+
end
|
|
263
337
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
338
|
+
unless transition
|
|
339
|
+
flow_target.instance_exec(attribute_name, action, &flows.dig(Circulator.model_key(flow_target), attribute_name).no_action)
|
|
340
|
+
return
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
return if transition[:allow_if] && !Circulator.evaluate_guard(flow_target, transition[:allow_if], *args, **kwargs)
|
|
344
|
+
|
|
345
|
+
flow_target.instance_exec(*args, **kwargs, &transition[:block]) if transition[:block]
|
|
268
346
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if transition[:allow_if].is_a?(Array)
|
|
272
|
-
return unless transition[:allow_if].all? do |guard|
|
|
273
|
-
case guard
|
|
274
|
-
when Symbol
|
|
275
|
-
flow_target.send(guard, *args, **kwargs)
|
|
276
|
-
when Proc
|
|
277
|
-
flow_target.instance_exec(*args, **kwargs, &guard)
|
|
278
|
-
end
|
|
279
|
-
end
|
|
280
|
-
# Handle hash-based allow_if (checking other attribute states)
|
|
281
|
-
elsif transition[:allow_if].is_a?(Hash)
|
|
282
|
-
attribute_name_to_check, valid_states = transition[:allow_if].first
|
|
283
|
-
current_state = flow_target.send(attribute_name_to_check)
|
|
284
|
-
|
|
285
|
-
# Convert current state to symbol if possible
|
|
286
|
-
current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
|
|
287
|
-
|
|
288
|
-
# Convert valid_states to array of symbols
|
|
289
|
-
valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
|
|
290
|
-
|
|
291
|
-
# Return early if current state is not in the valid states
|
|
292
|
-
return unless valid_states_array.include?(current_state)
|
|
293
|
-
elsif transition[:allow_if].is_a?(Symbol)
|
|
294
|
-
# Handle symbol-based allow_if (method name)
|
|
295
|
-
return unless flow_target.send(transition[:allow_if])
|
|
347
|
+
if transition[:to].respond_to?(:call)
|
|
348
|
+
flow_target.send("#{attribute_name}=", flow_target.instance_exec(*args, **kwargs, &transition[:to]))
|
|
296
349
|
else
|
|
297
|
-
#
|
|
298
|
-
|
|
350
|
+
flow_target.send("#{attribute_name}=", transition[:to])
|
|
351
|
+
end.tap do
|
|
352
|
+
flow_target.instance_exec(*args, **kwargs, &block) if block
|
|
299
353
|
end
|
|
300
|
-
|
|
354
|
+
}
|
|
301
355
|
|
|
302
|
-
|
|
303
|
-
flow_target.instance_exec(*args, **kwargs, &transition[:block])
|
|
304
|
-
end
|
|
356
|
+
around_block = flows.dig(Circulator.model_key(flow_target), attribute_name)&.around
|
|
305
357
|
|
|
306
|
-
if
|
|
307
|
-
flow_target.
|
|
358
|
+
if around_block
|
|
359
|
+
flow_target.instance_exec(flow_logic, &around_block)
|
|
308
360
|
else
|
|
309
|
-
|
|
310
|
-
end
|
|
311
|
-
|
|
312
|
-
|
|
361
|
+
flow_logic.call
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
module_function def evaluate_guard(target, allow_if, *args, **kwargs)
|
|
367
|
+
case allow_if
|
|
368
|
+
when Array
|
|
369
|
+
allow_if.all? do |guard|
|
|
370
|
+
case guard
|
|
371
|
+
when Symbol then target.send(guard, *args, **kwargs)
|
|
372
|
+
when Proc then target.instance_exec(*args, **kwargs, &guard)
|
|
313
373
|
end
|
|
314
374
|
end
|
|
375
|
+
when Hash
|
|
376
|
+
attribute_name, valid_states = allow_if.first
|
|
377
|
+
current_state = target.send(attribute_name)
|
|
378
|
+
current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
|
|
379
|
+
valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
|
|
380
|
+
valid_states_array.include?(current_state)
|
|
381
|
+
when Symbol
|
|
382
|
+
target.send(allow_if, *args, **kwargs)
|
|
383
|
+
else
|
|
384
|
+
target.instance_exec(*args, **kwargs, &allow_if)
|
|
315
385
|
end
|
|
316
386
|
end
|
|
317
387
|
|
|
@@ -441,28 +511,7 @@ module Circulator
|
|
|
441
511
|
end
|
|
442
512
|
|
|
443
513
|
def check_allow_if(allow_if, *args, **kwargs)
|
|
444
|
-
|
|
445
|
-
when Array
|
|
446
|
-
# All guards in array must be true (AND logic)
|
|
447
|
-
allow_if.all? do |guard|
|
|
448
|
-
case guard
|
|
449
|
-
when Symbol
|
|
450
|
-
send(guard, *args, **kwargs)
|
|
451
|
-
when Proc
|
|
452
|
-
instance_exec(*args, **kwargs, &guard)
|
|
453
|
-
end
|
|
454
|
-
end
|
|
455
|
-
when Hash
|
|
456
|
-
attribute_name, valid_states = allow_if.first
|
|
457
|
-
current_state = send(attribute_name)
|
|
458
|
-
current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
|
|
459
|
-
valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
|
|
460
|
-
valid_states_array.include?(current_state)
|
|
461
|
-
when Symbol
|
|
462
|
-
send(allow_if, *args, **kwargs)
|
|
463
|
-
else # Proc
|
|
464
|
-
instance_exec(*args, **kwargs, &allow_if)
|
|
465
|
-
end
|
|
514
|
+
Circulator.evaluate_guard(self, allow_if, *args, **kwargs)
|
|
466
515
|
end
|
|
467
516
|
end
|
|
468
517
|
end
|