circulator 2.1.9 → 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 -6
- data/README.md +64 -6
- data/Rakefile +1 -0
- data/lib/circulator/flow.rb +8 -0
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +64 -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,12 +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
|
-
-
|
|
13
|
-
|
|
14
|
-
### Changed
|
|
15
|
-
|
|
16
|
-
- Circulator.extension applies immediately to existing flows (d1eb47d)
|
|
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.
|
|
@@ -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,14 @@ 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
|
+
|
|
91
99
|
# Merge an extension block into this flow
|
|
92
100
|
#
|
|
93
101
|
# Creates an extension flow from the block and merges its transitions
|
data/lib/circulator/version.rb
CHANGED
data/lib/circulator.rb
CHANGED
|
@@ -116,14 +116,14 @@ module Circulator
|
|
|
116
116
|
flow_module.remove_method(method_name)
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
klass.send(:define_flow_method, attribute_name
|
|
119
|
+
klass.send(:define_flow_method, attribute_name:, action:, transitions:, object:, owner: flow_module)
|
|
120
120
|
end
|
|
121
121
|
|
|
122
122
|
# Define predicate methods for any new states
|
|
123
123
|
states = flow.instance_variable_get(:@states)
|
|
124
124
|
states.each do |state|
|
|
125
125
|
next if state.nil?
|
|
126
|
-
klass.send(:define_state_method, attribute_name
|
|
126
|
+
klass.send(:define_state_method, attribute_name:, state:, object:, owner: flow_module)
|
|
127
127
|
end
|
|
128
128
|
end
|
|
129
129
|
end
|
|
@@ -253,6 +253,21 @@ module Circulator
|
|
|
253
253
|
# test_object.flow(:unknown, :status, "signal")
|
|
254
254
|
# # Will raise an UnhandledSignalError
|
|
255
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
|
+
#
|
|
256
271
|
def flow(attribute_name, model: to_s, flows_proc: Circulator.default_flow_proc, &block)
|
|
257
272
|
@flows ||= flows_proc.call
|
|
258
273
|
model_key = Circulator.model_key(model)
|
|
@@ -311,65 +326,62 @@ module Circulator
|
|
|
311
326
|
raise ArgumentError, "Method already defined: #{object_attribute_method}" if owner.method_defined?(object_attribute_method)
|
|
312
327
|
|
|
313
328
|
owner.define_method(object_attribute_method) do |*args, flow_target: self, **kwargs, &block|
|
|
314
|
-
|
|
329
|
+
flow_logic = -> {
|
|
330
|
+
current_value = flow_target.send(attribute_name)
|
|
315
331
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
332
|
+
transition = if current_value.respond_to?(:to_sym)
|
|
333
|
+
transitions[current_value.to_sym]
|
|
334
|
+
else
|
|
335
|
+
transitions[current_value]
|
|
336
|
+
end
|
|
321
337
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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]
|
|
326
346
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if transition[:allow_if].is_a?(Array)
|
|
330
|
-
return unless transition[:allow_if].all? do |guard|
|
|
331
|
-
case guard
|
|
332
|
-
when Symbol
|
|
333
|
-
flow_target.send(guard, *args, **kwargs)
|
|
334
|
-
when Proc
|
|
335
|
-
flow_target.instance_exec(*args, **kwargs, &guard)
|
|
336
|
-
end
|
|
337
|
-
end
|
|
338
|
-
# Handle hash-based allow_if (checking other attribute states)
|
|
339
|
-
elsif transition[:allow_if].is_a?(Hash)
|
|
340
|
-
attribute_name_to_check, valid_states = transition[:allow_if].first
|
|
341
|
-
current_state = flow_target.send(attribute_name_to_check)
|
|
342
|
-
|
|
343
|
-
# Convert current state to symbol if possible
|
|
344
|
-
current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
|
|
345
|
-
|
|
346
|
-
# Convert valid_states to array of symbols
|
|
347
|
-
valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
|
|
348
|
-
|
|
349
|
-
# Return early if current state is not in the valid states
|
|
350
|
-
return unless valid_states_array.include?(current_state)
|
|
351
|
-
elsif transition[:allow_if].is_a?(Symbol)
|
|
352
|
-
# Handle symbol-based allow_if (method name)
|
|
353
|
-
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]))
|
|
354
349
|
else
|
|
355
|
-
#
|
|
356
|
-
|
|
350
|
+
flow_target.send("#{attribute_name}=", transition[:to])
|
|
351
|
+
end.tap do
|
|
352
|
+
flow_target.instance_exec(*args, **kwargs, &block) if block
|
|
357
353
|
end
|
|
358
|
-
|
|
354
|
+
}
|
|
359
355
|
|
|
360
|
-
|
|
361
|
-
flow_target.instance_exec(*args, **kwargs, &transition[:block])
|
|
362
|
-
end
|
|
356
|
+
around_block = flows.dig(Circulator.model_key(flow_target), attribute_name)&.around
|
|
363
357
|
|
|
364
|
-
if
|
|
365
|
-
flow_target.
|
|
358
|
+
if around_block
|
|
359
|
+
flow_target.instance_exec(flow_logic, &around_block)
|
|
366
360
|
else
|
|
367
|
-
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
|
|
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)
|
|
371
373
|
end
|
|
372
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)
|
|
373
385
|
end
|
|
374
386
|
end
|
|
375
387
|
|
|
@@ -499,28 +511,7 @@ module Circulator
|
|
|
499
511
|
end
|
|
500
512
|
|
|
501
513
|
def check_allow_if(allow_if, *args, **kwargs)
|
|
502
|
-
|
|
503
|
-
when Array
|
|
504
|
-
# All guards in array must be true (AND logic)
|
|
505
|
-
allow_if.all? do |guard|
|
|
506
|
-
case guard
|
|
507
|
-
when Symbol
|
|
508
|
-
send(guard, *args, **kwargs)
|
|
509
|
-
when Proc
|
|
510
|
-
instance_exec(*args, **kwargs, &guard)
|
|
511
|
-
end
|
|
512
|
-
end
|
|
513
|
-
when Hash
|
|
514
|
-
attribute_name, valid_states = allow_if.first
|
|
515
|
-
current_state = send(attribute_name)
|
|
516
|
-
current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
|
|
517
|
-
valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
|
|
518
|
-
valid_states_array.include?(current_state)
|
|
519
|
-
when Symbol
|
|
520
|
-
send(allow_if, *args, **kwargs)
|
|
521
|
-
else # Proc
|
|
522
|
-
instance_exec(*args, **kwargs, &allow_if)
|
|
523
|
-
end
|
|
514
|
+
Circulator.evaluate_guard(self, allow_if, *args, **kwargs)
|
|
524
515
|
end
|
|
525
516
|
end
|
|
526
517
|
end
|