circulator 2.1.9 → 2.1.11

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: 7d069df555d7f1ac9aa1865cacef2c3bda5373a287c899f23068abe56cf3c379
4
- data.tar.gz: c79dbba353dbe721587f3361599385a280a93f9c9655b4fcae01de5359ec8f1e
3
+ metadata.gz: 9232620710fbfe075e26c0ae3bcf1dea0794cdca188b0a9a0ff8529362573ede
4
+ data.tar.gz: c45d7ac1bdf89fe21107905811d36d0ef4dca37370ea89686d5433cef1aeaaf5
5
5
  SHA512:
6
- metadata.gz: ca22e7ae65647826445f17464a74427524bda9fa4d3fa8cb34a1ff47bc8f6c036c5f35a624de6c6853334677add37decd5f27fcbe19bfcb5e4c9edd54606d8f9
7
- data.tar.gz: d7c0c520cda68eee6e470a66fa8be95c3f332a3b36bb1314964a2c55f51a763667a2de745c0ea65a319e83eaf5b475cf1303a5961e67877fdbadb4154596ed37
6
+ metadata.gz: bfbc391566c29b7dd18005c56c37c21c9d976eeebc2705f92ea726074b93807daf47528f34e07bb449be3a38bca797067742abc64e1d862162cc18924049b47d
7
+ data.tar.gz: 9edd59a4985b266ad4c590d3e4d05bbe41f381e85a149b84de08adeabf9e1ff141d0a2b5d5dee759e1e1ba8d4dd740c8e33036331694614951994fddab6d4448
data/CHANGELOG.md CHANGED
@@ -5,12 +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.9] - 2026-01-08
8
+ ## [2.1.11] - 2026-02-09
9
9
 
10
10
  ### Added
11
11
 
12
- - Flow#merge to merge existing flows with extensions (d1eb47d)
12
+ - action_missing alias for no_action to clarify intent (c576a7c)
13
13
 
14
14
  ### Changed
15
15
 
16
- - Circulator.extension applies immediately to existing flows (d1eb47d)
16
+ - Improved inline documentation for no_action and around methods (c576a7c)
17
+ - Added method documentation for Flow DSL methods (2546989)
18
+
19
+ ### Removed
20
+
21
+ - @no_action internal instance_variable and switched to @action_missing Version: minor (c576a7c)
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,101 @@ class Payment
293
294
  end
294
295
  ```
295
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
+
330
+ #### Wrapping Transitions with `around`
331
+
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:
333
+
334
+ ```ruby
335
+ class Order
336
+ extend Circulator
337
+
338
+ attr_accessor :status
339
+
340
+ flow :status do
341
+ around do |transition|
342
+ puts "before transition"
343
+ transition.call
344
+ puts "after transition"
345
+ end
346
+
347
+ state :pending do
348
+ action :approve, to: :approved
349
+ end
350
+ end
351
+ end
352
+ ```
353
+
354
+ 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.
355
+
356
+ **Transactional safety with ActiveRecord:**
357
+
358
+ This is particularly useful for wrapping transitions in a database lock to prevent race conditions:
359
+
360
+ ```ruby
361
+ class Order < ApplicationRecord
362
+ extend Circulator
363
+
364
+ flow :status do
365
+ around do |transition|
366
+ with_lock { transition.call }
367
+ end
368
+
369
+ state :pending do
370
+ action :approve, to: :approved do
371
+ self.approved_at = Time.current
372
+ end
373
+ end
374
+
375
+ state :approved do
376
+ action :ship, to: :shipped
377
+ end
378
+ end
379
+ end
380
+
381
+ # The guard check, transition block, state change, and caller block
382
+ # all execute inside the lock — no wrapper methods needed
383
+ order.status_approve
384
+ ```
385
+
386
+ **Key behaviors:**
387
+
388
+ - The state read, guard checks (`allow_if`), and state change all run **inside** the wrapper, so the entire check-then-act sequence is atomic
389
+ - If `transition.call` is never called, the transition does not execute
390
+ - Each flow can have its own `around` block (or none) — flows without one behave exactly as before
391
+
296
392
  #### Extending Flows
297
393
 
298
394
  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 +593,7 @@ To install this gem onto your local machine, run bundle exec rake install.
497
593
 
498
594
  This project is managed with [Reissue](https://github.com/SOFware/reissue).
499
595
 
500
- To release a new version, make your changes and be sure to update the CHANGELOG.md.
501
-
502
- To release a new version:
503
-
504
- bundle exec rake build:checksum
505
- bundle exec rake release
596
+ 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
597
 
507
598
  ## Contributing
508
599
 
data/Rakefile CHANGED
@@ -22,4 +22,5 @@ Reissue::Task.create :reissue do |task|
22
22
  task.changelog_file = "CHANGELOG.md"
23
23
  task.version_limit = 1
24
24
  task.fragment = :git
25
+ task.push_finalize = :branch
25
26
  end
@@ -6,7 +6,7 @@ module Circulator
6
6
  @klass = klass
7
7
  @attribute_name = attribute_name
8
8
  @states = states
9
- @no_action = ->(attribute_name, action) { raise "No action found for the current state of #{attribute_name} (#{send(attribute_name)}): #{action}" }
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,11 +136,58 @@ module Circulator
80
136
  end
81
137
  end
82
138
 
83
- def no_action(&block)
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)
156
+ if block_given?
157
+ @action_missing = block
158
+ else
159
+ @action_missing
160
+ end
161
+ end
162
+ alias_method :no_action, :action_missing
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
+ #
186
+ def around(&block)
84
187
  if block_given?
85
- @no_action = block
188
+ @around = block
86
189
  else
87
- @no_action
190
+ @around
88
191
  end
89
192
  end
90
193
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.9"
4
+ VERSION = "2.1.11"
5
5
  end
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: attribute_name, action: action, transitions: transitions, object: object, owner: flow_module)
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: attribute_name, state: state, object: object, owner: flow_module)
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
- current_value = flow_target.send(attribute_name)
329
+ flow_logic = -> {
330
+ current_value = flow_target.send(attribute_name)
315
331
 
316
- transition = if current_value.respond_to?(:to_sym)
317
- transitions[current_value.to_sym]
318
- else
319
- transitions[current_value]
320
- end
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
- unless transition
323
- flow_target.instance_exec(attribute_name, action, &flows.dig(Circulator.model_key(flow_target), attribute_name).no_action)
324
- return
325
- end
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
- if transition[:allow_if]
328
- # Handle array-based allow_if (array of symbols and/or procs)
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
- # Handle proc-based allow_if (original behavior)
356
- return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
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
- end
354
+ }
359
355
 
360
- if transition[:block]
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 transition[:to].respond_to?(:call)
365
- flow_target.send("#{attribute_name}=", flow_target.instance_exec(*args, **kwargs, &transition[:to]))
358
+ if around_block
359
+ flow_target.instance_exec(flow_logic, &around_block)
366
360
  else
367
- flow_target.send("#{attribute_name}=", transition[:to])
368
- end.tap do
369
- if block
370
- flow_target.instance_exec(*args, **kwargs, &block)
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
- case allow_if
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
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.9
4
+ version: 2.1.11
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: 4.0.3
49
+ rubygems_version: 3.6.9
50
50
  specification_version: 4
51
51
  summary: Simple state machine
52
52
  test_files: []