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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d069df555d7f1ac9aa1865cacef2c3bda5373a287c899f23068abe56cf3c379
4
- data.tar.gz: c79dbba353dbe721587f3361599385a280a93f9c9655b4fcae01de5359ec8f1e
3
+ metadata.gz: 8c4d63bee9efd4c305309b4e77b502e378ad2a8ec7f550b85a25d9f9b1963f9f
4
+ data.tar.gz: 281ebeb9de99be8dac7b6925ae7d3d51727c54916bca3f9df7c86b6aa5014966
5
5
  SHA512:
6
- metadata.gz: ca22e7ae65647826445f17464a74427524bda9fa4d3fa8cb34a1ff47bc8f6c036c5f35a624de6c6853334677add37decd5f27fcbe19bfcb5e4c9edd54606d8f9
7
- data.tar.gz: d7c0c520cda68eee6e470a66fa8be95c3f332a3b36bb1314964a2c55f51a763667a2de745c0ea65a319e83eaf5b475cf1303a5961e67877fdbadb4154596ed37
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.9] - 2026-01-08
8
+ ## [2.1.10] - 2026-02-06
9
9
 
10
10
  ### Added
11
11
 
12
- - Flow#merge to merge existing flows with extensions (d1eb47d)
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
- 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,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
@@ -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.10"
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.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay