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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c4d63bee9efd4c305309b4e77b502e378ad2a8ec7f550b85a25d9f9b1963f9f
4
- data.tar.gz: 281ebeb9de99be8dac7b6925ae7d3d51727c54916bca3f9df7c86b6aa5014966
3
+ metadata.gz: d7dbf52b54ebf9536c2d5cac336376e6fa1fadbdd614794bd7322349c714bbc0
4
+ data.tar.gz: c81382ec182f2d2b2b4f13d73c8228675efed7f5f5ee80886a58d8b77820db76
5
5
  SHA512:
6
- metadata.gz: b9d5d21fb96c45580fd3051b084403aba315d058eee4edb56dfdb4c7b10cc49105b277e60d96c895d4ae0014e0a0298ff79878f4f3a1b61f35b8c480543f491a
7
- data.tar.gz: fd20a62b795f3817877c48b8ca810dde022615df11cd100506265436958e4e7326791d8a2dda6b13f898327e5c295f076800d541bc900986524b000ebdc812d9
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.10] - 2026-02-06
8
+ ## [2.1.12] - 2026-02-17
9
9
 
10
10
  ### Added
11
11
 
12
- - `around` block in flow DSL to wrap transition logic (state read, guards, state change, callbacks) (184527b)
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:
@@ -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,14 +136,53 @@ 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)
84
156
  if block_given?
85
- @no_action = block
157
+ @action_missing = block
86
158
  else
87
- @no_action
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.10"
4
+ VERSION = "2.1.12"
5
5
  end
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
- method_name = [object, attribute_name, action].compact.join("_")
114
+ base_name = [object, attribute_name, action].compact.join("_")
113
115
 
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)
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
- klass.send(:define_flow_method, attribute_name:, action:, transitions:, object:, owner: flow_module)
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
- define_flow_method(attribute_name:, action:, transitions:, object:, owner: flow_module)
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
- def flow(action, attribute, *args, flow_target: self, **kwargs, &block)
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
- method_name = "#{external_attribute_name}_#{action}"
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.10
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: 4.0.3
49
+ rubygems_version: 3.6.9
50
50
  specification_version: 4
51
51
  summary: Simple state machine
52
52
  test_files: []