circulator 2.1.10 → 2.1.12
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 +11 -2
- data/README.md +33 -0
- data/lib/circulator/flow.rb +99 -4
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +70 -8
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d7dbf52b54ebf9536c2d5cac336376e6fa1fadbdd614794bd7322349c714bbc0
|
|
4
|
+
data.tar.gz: c81382ec182f2d2b2b4f13d73c8228675efed7f5f5ee80886a58d8b77820db76
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c996bdd7a378930fbf14cdfabad9b83b099db386932ea6fab946c38bcd5d649dce25bbc2730b87747b5f46937b55727a06964e8930154b4da23745cf27cdb757
|
|
7
|
+
data.tar.gz: 24cd2abfe4e710269d4a3a39ed9606bb158b395fa66fa83c5892e9cc060850131c9d9704693a214762eed2efe97ec39a64e4b97378f2e6bda98a1f52ef5adcdf
|
data/CHANGELOG.md
CHANGED
|
@@ -5,8 +5,17 @@ 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.12] - 2026-02-17
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- Named test sampler classes for reuse across test files (b6bcb4e)
|
|
13
|
+
- bang method variants for flow actions (673d8a0)
|
|
14
|
+
- Circulator::InvalidTransition exception class (673d8a0)
|
|
15
|
+
- host-namespaced InvalidTransition subclass per class with flows (673d8a0)
|
|
16
|
+
- flow! instance method and variant: keyword on flow (673d8a0)
|
|
17
|
+
- FLOW_VARIANTS registry that drives method definition (673d8a0)
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Flow attribute writers are private after flow definition Version: minor (35ea18f)
|
data/README.md
CHANGED
|
@@ -294,6 +294,39 @@ class Payment
|
|
|
294
294
|
end
|
|
295
295
|
```
|
|
296
296
|
|
|
297
|
+
#### Handling Missing Transitions with `action_missing`
|
|
298
|
+
|
|
299
|
+
By default, if you call an action on an object whose current state doesn't define a transition for that action, Circulator raises an error. Use `action_missing` (aliased as `no_action`) to customize this behavior.
|
|
300
|
+
|
|
301
|
+
This is triggered when, for example, you call `order.status_ship` but the object is in `:pending` and `:ship` only has a transition defined from `:processing`.
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
class Order
|
|
305
|
+
extend Circulator
|
|
306
|
+
|
|
307
|
+
attr_accessor :status
|
|
308
|
+
|
|
309
|
+
flow :status do
|
|
310
|
+
action_missing do |attribute_name, action|
|
|
311
|
+
Rails.logger.warn "No #{action} transition from #{send(attribute_name)} for #{attribute_name}"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
state :pending do
|
|
315
|
+
action :process, to: :processing
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
state :processing do
|
|
319
|
+
action :ship, to: :shipped
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
order = Order.new
|
|
325
|
+
order.status = :pending
|
|
326
|
+
|
|
327
|
+
order.status_ship # => logs warning instead of raising
|
|
328
|
+
```
|
|
329
|
+
|
|
297
330
|
#### Wrapping Transitions with `around`
|
|
298
331
|
|
|
299
332
|
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:
|
data/lib/circulator/flow.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Circulator
|
|
|
6
6
|
@klass = klass
|
|
7
7
|
@attribute_name = attribute_name
|
|
8
8
|
@states = states
|
|
9
|
-
@
|
|
9
|
+
@action_missing = ->(attribute_name, action) { raise "No action found for the current state of #{attribute_name} (#{send(attribute_name)}): #{action}" }
|
|
10
10
|
@flows_proc = flows_proc
|
|
11
11
|
@transition_map = flows_proc.call
|
|
12
12
|
|
|
@@ -18,6 +18,18 @@ module Circulator
|
|
|
18
18
|
end
|
|
19
19
|
attr_reader :transition_map
|
|
20
20
|
|
|
21
|
+
# Declares a state in the flow. The block is evaluated in the context
|
|
22
|
+
# of the flow, allowing you to define actions that transition from
|
|
23
|
+
# this state. A state with no block acts as a terminal state.
|
|
24
|
+
#
|
|
25
|
+
# flow(:status) do
|
|
26
|
+
# state :pending do
|
|
27
|
+
# action :approve, to: :approved
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# state :approved # terminal state
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
21
33
|
def state(name, &block)
|
|
22
34
|
name = name.to_sym if name.respond_to?(:to_sym)
|
|
23
35
|
@states.add(name)
|
|
@@ -26,6 +38,34 @@ module Circulator
|
|
|
26
38
|
remove_instance_variable(:@current_state)
|
|
27
39
|
end
|
|
28
40
|
|
|
41
|
+
# Declares a transition from one or more states to a destination state.
|
|
42
|
+
# Must be called inside a +state+ block, or with an explicit +from:+ option.
|
|
43
|
+
#
|
|
44
|
+
# Generates a method named +<attribute>_<action>+ on the class that
|
|
45
|
+
# performs the transition when called.
|
|
46
|
+
#
|
|
47
|
+
# ==== Options
|
|
48
|
+
#
|
|
49
|
+
# +to+:: The destination state (Symbol) or a callable that returns
|
|
50
|
+
# the destination state at runtime.
|
|
51
|
+
# +from+:: One or more source states. Defaults to the enclosing
|
|
52
|
+
# +state+ block. Pass an Array to define the same action
|
|
53
|
+
# from multiple states.
|
|
54
|
+
# +allow_if+:: A guard condition that must be truthy for the transition
|
|
55
|
+
# to proceed. Accepts a Proc, Symbol (method name), Hash
|
|
56
|
+
# (checks another flow's state), or Array of those.
|
|
57
|
+
# +&block+:: A block executed on the instance during the transition,
|
|
58
|
+
# before the state is changed.
|
|
59
|
+
#
|
|
60
|
+
# state :pending do
|
|
61
|
+
# action :approve, to: :approved, allow_if: :reviewer? do
|
|
62
|
+
# self.approved_at = Time.now
|
|
63
|
+
# end
|
|
64
|
+
# end
|
|
65
|
+
#
|
|
66
|
+
# # Or with an explicit from:
|
|
67
|
+
# action :cancel, from: [:pending, :processing], to: :cancelled
|
|
68
|
+
#
|
|
29
69
|
def action(name, to:, from: :__not_specified__, allow_if: nil, &block)
|
|
30
70
|
raise "You must be in a state block or have a `from` option to declare an action" unless defined?(@current_state) || from != :__not_specified__
|
|
31
71
|
|
|
@@ -61,6 +101,22 @@ module Circulator
|
|
|
61
101
|
end
|
|
62
102
|
end
|
|
63
103
|
|
|
104
|
+
# Attaches an +allow_if+ guard to an existing action after it has been
|
|
105
|
+
# defined. This is useful when the guard logic needs to be defined
|
|
106
|
+
# separately from the action itself, such as in an extension.
|
|
107
|
+
#
|
|
108
|
+
# Must be called inside a +state+ block, or with an explicit +from:+ option.
|
|
109
|
+
# The action must already exist in the transition map.
|
|
110
|
+
#
|
|
111
|
+
# Example:
|
|
112
|
+
#
|
|
113
|
+
# flow(:status) do
|
|
114
|
+
# state :pending do
|
|
115
|
+
# action :approve, to: :approved
|
|
116
|
+
# action_allowed(:approve) { current_user.admin? }
|
|
117
|
+
# end
|
|
118
|
+
# end
|
|
119
|
+
#
|
|
64
120
|
def action_allowed(name, from: :__not_specified__, &block)
|
|
65
121
|
raise "You must be in a state block or have a `from` option to declare an action" unless defined?(@current_state) || from != :__not_specified__
|
|
66
122
|
|
|
@@ -80,14 +136,53 @@ module Circulator
|
|
|
80
136
|
end
|
|
81
137
|
end
|
|
82
138
|
|
|
83
|
-
|
|
139
|
+
# Called when an action is invoked but no transition is defined for the
|
|
140
|
+
# current state. For example, if an object is in :approved and you call
|
|
141
|
+
# an action that only has a transition from :pending, this block runs.
|
|
142
|
+
#
|
|
143
|
+
# By default, raises an error. Override to silently ignore, log, or
|
|
144
|
+
# handle the missing transition however you like.
|
|
145
|
+
#
|
|
146
|
+
# The block receives two arguments: the attribute name and the action name.
|
|
147
|
+
#
|
|
148
|
+
# Example:
|
|
149
|
+
#
|
|
150
|
+
# flow(:status) do
|
|
151
|
+
# action_missing do |attribute_name, action|
|
|
152
|
+
# Rails.logger.warn("No transition for #{attribute_name} in #{action}")
|
|
153
|
+
# end
|
|
154
|
+
# end
|
|
155
|
+
def action_missing(&block)
|
|
84
156
|
if block_given?
|
|
85
|
-
@
|
|
157
|
+
@action_missing = block
|
|
86
158
|
else
|
|
87
|
-
@
|
|
159
|
+
@action_missing
|
|
88
160
|
end
|
|
89
161
|
end
|
|
162
|
+
alias_method :no_action, :action_missing
|
|
90
163
|
|
|
164
|
+
# Wraps every transition in this flow. The block receives a lambda that
|
|
165
|
+
# executes the transition logic (guard check, state change, and any
|
|
166
|
+
# transition block). You must call +transition.call+ for the transition
|
|
167
|
+
# to actually happen — omitting it prevents the state change entirely.
|
|
168
|
+
#
|
|
169
|
+
# Useful for wrapping transitions in database transactions, logging,
|
|
170
|
+
# instrumentation, or any before/after behavior.
|
|
171
|
+
#
|
|
172
|
+
# Example:
|
|
173
|
+
#
|
|
174
|
+
# flow(:status) do
|
|
175
|
+
# around do |transition|
|
|
176
|
+
# ActiveRecord::Base.transaction do
|
|
177
|
+
# transition.call
|
|
178
|
+
# end
|
|
179
|
+
# end
|
|
180
|
+
#
|
|
181
|
+
# state :pending do
|
|
182
|
+
# action :approve, to: :approved
|
|
183
|
+
# end
|
|
184
|
+
# end
|
|
185
|
+
#
|
|
91
186
|
def around(&block)
|
|
92
187
|
if block_given?
|
|
93
188
|
@around = block
|
data/lib/circulator/version.rb
CHANGED
data/lib/circulator.rb
CHANGED
|
@@ -2,6 +2,8 @@ require "circulator/version"
|
|
|
2
2
|
require "circulator/flow"
|
|
3
3
|
|
|
4
4
|
module Circulator
|
|
5
|
+
class InvalidTransition < StandardError; end
|
|
6
|
+
|
|
5
7
|
# Global registry for extensions
|
|
6
8
|
@extensions = Hash.new { |h, k| h[k] = [] }
|
|
7
9
|
|
|
@@ -109,14 +111,17 @@ module Circulator
|
|
|
109
111
|
|
|
110
112
|
# Define or redefine methods for actions (need to redefine if transitions changed)
|
|
111
113
|
flow.transition_map.each do |action, transitions|
|
|
112
|
-
|
|
114
|
+
base_name = [object, attribute_name, action].compact.join("_")
|
|
113
115
|
|
|
114
|
-
# Remove existing
|
|
115
|
-
|
|
116
|
-
|
|
116
|
+
# Remove existing methods so they can be redefined with updated transitions
|
|
117
|
+
FLOW_VARIANTS.each_value do |suffix|
|
|
118
|
+
full_name = "#{base_name}#{suffix}"
|
|
119
|
+
flow_module.remove_method(full_name) if flow_module.method_defined?(full_name)
|
|
117
120
|
end
|
|
118
121
|
|
|
119
|
-
|
|
122
|
+
FLOW_VARIANTS.each_key do |variant|
|
|
123
|
+
klass.send(["define", variant, "flow_method"].compact.join("_"), attribute_name:, action:, transitions:, object:, owner: flow_module)
|
|
124
|
+
end
|
|
120
125
|
end
|
|
121
126
|
|
|
122
127
|
# Define predicate methods for any new states
|
|
@@ -129,6 +134,7 @@ module Circulator
|
|
|
129
134
|
end
|
|
130
135
|
|
|
131
136
|
FLOW_MODULE_NAME = "FlowMethods"
|
|
137
|
+
FLOW_VARIANTS = {:bang => "!", nil => ""}.freeze
|
|
132
138
|
|
|
133
139
|
# Declare a flow for an attribute.
|
|
134
140
|
#
|
|
@@ -275,6 +281,11 @@ module Circulator
|
|
|
275
281
|
# Pass the flows_proc to Flow so it can create transition_maps of the same type
|
|
276
282
|
@flows[model_key][attribute_name] = Flow.new(self, attribute_name, flows_proc:, &block)
|
|
277
283
|
|
|
284
|
+
# Define InvalidTransition exception on the host class if not already defined
|
|
285
|
+
unless const_defined?(:InvalidTransition, false)
|
|
286
|
+
const_set(:InvalidTransition, Class.new(Circulator::InvalidTransition))
|
|
287
|
+
end
|
|
288
|
+
|
|
278
289
|
flow_module = ancestors.find { |ancestor|
|
|
279
290
|
ancestor.name.to_s =~ /#{FLOW_MODULE_NAME}/o
|
|
280
291
|
} || Module.new.tap do |mod|
|
|
@@ -298,7 +309,9 @@ module Circulator
|
|
|
298
309
|
states.add(transition_data[:to])
|
|
299
310
|
end
|
|
300
311
|
end
|
|
301
|
-
|
|
312
|
+
FLOW_VARIANTS.each_key do |variant|
|
|
313
|
+
send(["define", variant, "flow_method"].compact.join("_"), attribute_name:, action:, transitions:, object:, owner: flow_module)
|
|
314
|
+
end
|
|
302
315
|
end
|
|
303
316
|
|
|
304
317
|
# Define predicate methods for each state (skip nil)
|
|
@@ -306,6 +319,15 @@ module Circulator
|
|
|
306
319
|
next if state.nil?
|
|
307
320
|
define_state_method(attribute_name:, state:, object:, owner: flow_module)
|
|
308
321
|
end
|
|
322
|
+
|
|
323
|
+
# Make the attribute writer private so that state changes must go through
|
|
324
|
+
# the generated flow methods. This prevents bypassing guards and transition
|
|
325
|
+
# logic by directly assigning the attribute. The writer is only made private
|
|
326
|
+
# for self-managed flows (not model-based flows where the attribute belongs
|
|
327
|
+
# to a different class).
|
|
328
|
+
if object.nil? && method_defined?("#{attribute_name}=")
|
|
329
|
+
private "#{attribute_name}="
|
|
330
|
+
end
|
|
309
331
|
end
|
|
310
332
|
alias_method :circulator, :flow
|
|
311
333
|
|
|
@@ -363,6 +385,30 @@ module Circulator
|
|
|
363
385
|
end
|
|
364
386
|
end
|
|
365
387
|
|
|
388
|
+
def define_bang_flow_method(attribute_name:, action:, transitions:, object:, owner:)
|
|
389
|
+
bang_method = [object, attribute_name, action].compact.join("_") << "!"
|
|
390
|
+
return if owner.method_defined?(bang_method)
|
|
391
|
+
|
|
392
|
+
non_bang_method = [object, attribute_name, action].compact.join("_")
|
|
393
|
+
owner.define_method(bang_method) do |*args, flow_target: self, **kwargs, &block|
|
|
394
|
+
current_value = flow_target.send(attribute_name)
|
|
395
|
+
current_state = current_value.respond_to?(:to_sym) ? current_value.to_sym : current_value
|
|
396
|
+
|
|
397
|
+
transition = transitions[current_state]
|
|
398
|
+
unless transition
|
|
399
|
+
raise self.class.const_get(:InvalidTransition),
|
|
400
|
+
"No transition #{action} on #{attribute_name} from #{current_state.inspect}"
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
if transition[:allow_if] && !Circulator.evaluate_guard(flow_target, transition[:allow_if], *args, **kwargs)
|
|
404
|
+
raise self.class.const_get(:InvalidTransition),
|
|
405
|
+
"Guard prevented #{action} on #{attribute_name} from #{current_state.inspect}"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
send(non_bang_method, *args, flow_target:, **kwargs, &block)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
366
412
|
module_function def evaluate_guard(target, allow_if, *args, **kwargs)
|
|
367
413
|
case allow_if
|
|
368
414
|
when Array
|
|
@@ -409,16 +455,21 @@ module Circulator
|
|
|
409
455
|
module InstanceMethods
|
|
410
456
|
# Use this method to call an action on the attribute.
|
|
411
457
|
#
|
|
458
|
+
# Accepts an optional +variant:+ keyword to select a method variant.
|
|
459
|
+
# See +FLOW_VARIANTS+ for the available variants and their suffixes.
|
|
460
|
+
#
|
|
412
461
|
# Example:
|
|
413
462
|
#
|
|
414
463
|
# test_object.flow(:approve, :status)
|
|
415
464
|
# test_object.flow(:approve, :status, "arg1", "arg2", key: "value")
|
|
416
|
-
|
|
465
|
+
# test_object.flow(:approve, :status, variant: :bang)
|
|
466
|
+
def flow(action, attribute, *args, flow_target: self, variant: nil, **kwargs, &block)
|
|
417
467
|
target_name = if flow_target != self
|
|
418
468
|
Circulator.methodize_name(Circulator.model_key(flow_target))
|
|
419
469
|
end
|
|
420
470
|
external_attribute_name = [target_name, attribute].compact.join("_")
|
|
421
|
-
|
|
471
|
+
suffix = Circulator::FLOW_VARIANTS.fetch(variant)
|
|
472
|
+
method_name = "#{external_attribute_name}_#{action}#{suffix}"
|
|
422
473
|
if respond_to?(method_name)
|
|
423
474
|
send(method_name, *args, flow_target:, **kwargs, &block)
|
|
424
475
|
elsif flow_target.respond_to?(method_name)
|
|
@@ -426,6 +477,17 @@ module Circulator
|
|
|
426
477
|
else
|
|
427
478
|
raise "Invalid action for the current state of #{attribute} (#{flow_target.send(attribute).inspect}): #{action}"
|
|
428
479
|
end
|
|
480
|
+
rescue KeyError
|
|
481
|
+
raise ArgumentError, "Invalid variant: #{variant.inspect}"
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Bang variant of flow that raises InvalidTransition on failure.
|
|
485
|
+
#
|
|
486
|
+
# Example:
|
|
487
|
+
#
|
|
488
|
+
# test_object.flow!(:approve, :status)
|
|
489
|
+
def flow!(action, attribute, *args, **kwargs, &block)
|
|
490
|
+
flow(action, attribute, *args, variant: :bang, **kwargs, &block)
|
|
429
491
|
end
|
|
430
492
|
|
|
431
493
|
# Get available actions for an attribute based on current state
|
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.
|
|
4
|
+
version: 2.1.12
|
|
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:
|
|
49
|
+
rubygems_version: 3.6.9
|
|
50
50
|
specification_version: 4
|
|
51
51
|
summary: Simple state machine
|
|
52
52
|
test_files: []
|