circulator 2.1.11 → 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: 9232620710fbfe075e26c0ae3bcf1dea0794cdca188b0a9a0ff8529362573ede
4
- data.tar.gz: c45d7ac1bdf89fe21107905811d36d0ef4dca37370ea89686d5433cef1aeaaf5
3
+ metadata.gz: d7dbf52b54ebf9536c2d5cac336376e6fa1fadbdd614794bd7322349c714bbc0
4
+ data.tar.gz: c81382ec182f2d2b2b4f13d73c8228675efed7f5f5ee80886a58d8b77820db76
5
5
  SHA512:
6
- metadata.gz: bfbc391566c29b7dd18005c56c37c21c9d976eeebc2705f92ea726074b93807daf47528f34e07bb449be3a38bca797067742abc64e1d862162cc18924049b47d
7
- data.tar.gz: 9edd59a4985b266ad4c590d3e4d05bbe41f381e85a149b84de08adeabf9e1ff141d0a2b5d5dee759e1e1ba8d4dd740c8e33036331694614951994fddab6d4448
6
+ metadata.gz: c996bdd7a378930fbf14cdfabad9b83b099db386932ea6fab946c38bcd5d649dce25bbc2730b87747b5f46937b55727a06964e8930154b4da23745cf27cdb757
7
+ data.tar.gz: 24cd2abfe4e710269d4a3a39ed9606bb158b395fa66fa83c5892e9cc060850131c9d9704693a214762eed2efe97ec39a64e4b97378f2e6bda98a1f52ef5adcdf
data/CHANGELOG.md CHANGED
@@ -5,17 +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.11] - 2026-02-09
8
+ ## [2.1.12] - 2026-02-17
9
9
 
10
10
  ### Added
11
11
 
12
- - action_missing alias for no_action to clarify intent (c576a7c)
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)
13
18
 
14
19
  ### Changed
15
20
 
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)
21
+ - Flow attribute writers are private after flow definition Version: minor (35ea18f)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.11"
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.11
4
+ version: 2.1.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay