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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad89c887b2b9cb0b3fc2a12bb0a8da92480828f4d8bb594700233f4b1c0ee9fd
4
- data.tar.gz: 4400d66545332b1bd4bc09028611efe7ce04a06076520e1f8a860f3089bb8d49
3
+ metadata.gz: 8c4d63bee9efd4c305309b4e77b502e378ad2a8ec7f550b85a25d9f9b1963f9f
4
+ data.tar.gz: 281ebeb9de99be8dac7b6925ae7d3d51727c54916bca3f9df7c86b6aa5014966
5
5
  SHA512:
6
- metadata.gz: 31d7f74c6167dd0c0e51180d8cb585a5da27a9cab543bc32b0ec2da1c819ccd0cff3920446b94496baa36cf2b4005610f535054ce863a08f2ac691ca6ee70ff2
7
- data.tar.gz: 4fcf8a370b45aa9d883c5ce4b10f4b714b66c5d5f4f5cdf638281ee01f5636687505c1be94297e5abb072f1de5db8b082edd500cd0b9ccaa769a08a280de743e
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] - 2026-01-07
8
+ ## [2.1.10] - 2026-02-06
9
9
 
10
10
  ### Added
11
11
 
12
- - Detailed conditional information display in DOT and PlantUML diagrams (dce5b69)
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 must be registered before the class definition (typically in initializers).
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
- 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
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
@@ -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
@@ -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 by creating a new Flow and merging its transition_map
211
+ # Apply each extension using merge
176
212
  extensions.each do |extension_block|
177
- extension_flow = Flow.new(@klass, @attribute_name, @states, extension: true, flows_proc: @flows_proc, &extension_block)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.8"
4
+ VERSION = "2.1.10"
5
5
  end
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 =~ /FlowMethods/
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(:FlowMethods, mod)
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
- current_value = flow_target.send(attribute_name)
329
+ flow_logic = -> {
330
+ current_value = flow_target.send(attribute_name)
257
331
 
258
- transition = if current_value.respond_to?(:to_sym)
259
- transitions[current_value.to_sym]
260
- else
261
- transitions[current_value]
262
- end
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
- unless transition
265
- flow_target.instance_exec(attribute_name, action, &flows.dig(Circulator.model_key(flow_target), attribute_name).no_action)
266
- return
267
- 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]
268
346
 
269
- if transition[:allow_if]
270
- # Handle array-based allow_if (array of symbols and/or procs)
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
- # Handle proc-based allow_if (original behavior)
298
- 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
299
353
  end
300
- end
354
+ }
301
355
 
302
- if transition[:block]
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 transition[:to].respond_to?(:call)
307
- 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)
308
360
  else
309
- flow_target.send("#{attribute_name}=", transition[:to])
310
- end.tap do
311
- if block
312
- 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)
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
- case allow_if
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
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.8
4
+ version: 2.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay