circulator 2.1.6 → 2.1.7

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: 128d49edd784a3a0cc8ebc72ee4ca9be581bbea4ab57ff795a7c7435a52e3ddb
4
- data.tar.gz: ae4a7ca3dd48197e944c95516f862e9f08cbc082ad098d0ef3d589fe351d59c7
3
+ metadata.gz: 63f6c8044ac3e21dffede73e1345d33d2e1e3f77350bcd6b332df8508d810f1f
4
+ data.tar.gz: 65ef2f903e7ec29993e1bbf7cf7b522c0d7bcba0cd424c75a7271ff0a89478db
5
5
  SHA512:
6
- metadata.gz: 5792c5c3fc86dc3a7d75fd619229426a3a8ae8198bee46fe28d7ec54f3d78ca374732c71281177fb53a8e54d5f1ed256af8163cd70420739b4450fb0f5b0589d
7
- data.tar.gz: f7fc9e7bf3e10331306a3161c7feff64e09b0dd1933f52f054f98076256c2287e40c6ddd7ac7b6c7eee39bb55442ca3f3045179cc889378d87b9c59eb74dc971
6
+ metadata.gz: c06f5b31e89fdd3832c8339b6884546b72b651395c2ca0a81cd37767f0a482dac2507ab45120bfe95fcaa950d6359080170859cb6ff1db61c485b165386df99d
7
+ data.tar.gz: 7a9f34b128d42a5c1ef9c45b806de26ac318bd822df6fbb6d35ffaa3df8384ac0dda2833057076cbb6715a30d98685a4eda03c73e6c2352a2d5682fa8a0c3260
data/CHANGELOG.md CHANGED
@@ -5,14 +5,12 @@ 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.6] - 2025-12-08
8
+ ## [2.1.7] - 2026-01-06
9
9
 
10
- ### Changed
10
+ ### Added
11
11
 
12
- - Dependabot config to follow a 14 day cooldown. (6aabc0a)
12
+ - Use an array of mulitple symbols with allow_if guard clauses. (96b66e6)
13
13
 
14
- ### Added
14
+ ### Changed
15
15
 
16
- - Documentation for extension system and Circulator.extension API (be7c6fc)
17
- - --all flag to automatically discover and generate diagrams for all classes with Circulator flows (f4dbbfc)
18
- - Automatic eager loading of Rails application classes when using --all (f4dbbfc)
16
+ - Documentation of lib/circulator.rb and explain extensions in README.md (7a04d58)
data/README.md CHANGED
@@ -173,6 +173,14 @@ end
173
173
 
174
174
  This is equivalent to the proc-based approach but cleaner when you have a dedicated method for the condition.
175
175
 
176
+ You can also use an array of symbols to represent multiple conditions:
177
+
178
+ ```ruby
179
+ action :publish, to: :published, allow_if: [:ready_to_publish?, :reviewed_by_present?]
180
+ ```
181
+
182
+ This is equivalent to the proc-based approach but cleaner when you have multiple conditions.
183
+
176
184
  **Hash-based guards** check the state of another attribute:
177
185
 
178
186
  You can make one state machine depend on another using hash-based `allow_if`:
@@ -287,7 +295,9 @@ end
287
295
 
288
296
  #### Extending Flows
289
297
 
290
- You can extend existing flows using `Circulator.extension`:
298
+ 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.
299
+
300
+ **Basic Extension Example:**
291
301
 
292
302
  ```ruby
293
303
  class Document
@@ -308,7 +318,7 @@ class Document
308
318
  end
309
319
  end
310
320
 
311
- # Add additional states and transitions
321
+ # Register extension - can be in a separate file or initializer
312
322
  Circulator.extension(:Document, :status) do
313
323
  state :review do
314
324
  action :reject, to: :rejected
@@ -319,12 +329,89 @@ Circulator.extension(:Document, :status) do
319
329
  end
320
330
  end
321
331
 
332
+ # Extensions are automatically applied when class is loaded
322
333
  doc = Document.new
323
334
  doc.status = :review
324
335
  doc.status_reject # => :rejected (from extension)
325
336
  doc.status_revise # => :draft (from extension)
326
337
  ```
327
338
 
339
+ **How Extensions Work:**
340
+
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).
342
+
343
+ 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
+
345
+ **Plugin-Style Extensions:**
346
+
347
+ Extensions are perfect for gems that want to extend Circulator workflows without modifying the host application:
348
+
349
+ ```ruby
350
+ # gem_name/lib/gem_name.rb
351
+ Circulator.extension(:BlogPost, :status) do
352
+ state :draft do
353
+ action :generate_seo, to: :draft do
354
+ generate_meta_tags
355
+ end
356
+ end
357
+
358
+ state :published do
359
+ action :schedule_social, to: :published do
360
+ queue_social_sharing
361
+ end
362
+ end
363
+ end
364
+
365
+ # Host application doesn't need to know about the plugin's extensions
366
+ class BlogPost
367
+ extend Circulator
368
+
369
+ flow :status do
370
+ state :draft do
371
+ action :publish, to: :published
372
+ end
373
+ state :published
374
+ end
375
+ end
376
+
377
+ # Plugin actions are automatically available
378
+ post = BlogPost.new
379
+ post.status = :draft
380
+ post.status_generate_seo # From plugin extension
381
+ post.status_publish # From base flow
382
+ ```
383
+
384
+ **Conditional Extensions Based on Feature Flags:**
385
+
386
+ ```ruby
387
+ # config/initializers/circulator_extensions.rb
388
+ if ENV['ENABLE_APPROVAL_WORKFLOW']
389
+ Circulator.extension(:Document, :status) do
390
+ state :draft do
391
+ action :submit_for_approval, to: :approval
392
+ end
393
+
394
+ state :approval do
395
+ action :approve, to: :approved
396
+ action :reject, to: :draft
397
+ end
398
+
399
+ state :approved
400
+ end
401
+ end
402
+
403
+ # Base flow always available, additional workflow only when enabled
404
+ class Document
405
+ extend Circulator
406
+
407
+ flow :status do
408
+ state :draft do
409
+ action :save, to: :draft
410
+ end
411
+ end
412
+ end
413
+ ```
414
+
328
415
  ### Generating Diagrams
329
416
 
330
417
  You can generate diagrams for your Circulator models using the `circulator-diagram` executable. By default, it will generate a DOT file. You can also generate a PlantUML file by passing the `-f plantuml` option.
@@ -98,8 +98,10 @@ module Circulator
98
98
  validate_symbol_allow_if(allow_if)
99
99
  in Hash
100
100
  validate_hash_allow_if(allow_if)
101
+ in Array
102
+ validate_array_allow_if(allow_if)
101
103
  else
102
- raise ArgumentError, "allow_if must be a Proc, Hash, or Symbol, got: #{allow_if.class}"
104
+ raise ArgumentError, "allow_if must be a Proc, Hash, Symbol, or Array, got: #{allow_if.class}"
103
105
  end
104
106
  end
105
107
 
@@ -141,6 +143,25 @@ module Circulator
141
143
  end
142
144
  end
143
145
 
146
+ def validate_array_allow_if(allow_if_array)
147
+ # Array must not be empty
148
+ if allow_if_array.empty?
149
+ raise ArgumentError, "allow_if array must not be empty"
150
+ end
151
+
152
+ # First, validate all element types
153
+ allow_if_array.each do |element|
154
+ unless element.is_a?(Symbol) || element.is_a?(Proc)
155
+ raise ArgumentError, "allow_if array elements must be Symbols or Procs, got: #{element.class}"
156
+ end
157
+ end
158
+
159
+ # Then, validate that Symbol methods exist
160
+ allow_if_array.each do |element|
161
+ validate_symbol_allow_if(element) if element.is_a?(Symbol)
162
+ end
163
+ end
164
+
144
165
  def apply_extensions
145
166
  # Look up extensions for this class and attribute
146
167
  class_name = if @klass.is_a?(Class)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.6"
4
+ VERSION = "2.1.7"
5
5
  end
data/lib/circulator.rb CHANGED
@@ -7,20 +7,64 @@ module Circulator
7
7
 
8
8
  @default_flow_proc = ::Hash.method(:new)
9
9
  class << self
10
+ # Returns the global registry of registered extensions
11
+ #
12
+ # The registry is a Hash where keys are "ClassName:attribute_name" strings
13
+ # and values are Arrays of extension blocks.
14
+ #
15
+ # Example:
16
+ #
17
+ # Circulator.extensions["Document:status"]
18
+ # # => [<Proc>, <Proc>] # Array of extension blocks
10
19
  attr_reader :extensions
20
+
21
+ # The default Proc used to create transition_maps for flows
22
+ #
23
+ # By default, this returns Hash.method(:new), which creates regular Hashes.
24
+ # Can be overridden to use custom storage implementations (e.g., any Hash-like object
25
+ # with custom merge behavior) by setting @default_flow_proc before defining flows.
11
26
  attr_reader :default_flow_proc
12
27
 
13
28
  # Register an extension for a specific class and attribute
14
29
  #
30
+ # Extensions allow you to add additional states and transitions to existing flows
31
+ # without modifying the original class definition. This is useful for:
32
+ # - Plugin gems extending host application workflows
33
+ # - Multi-tenant applications with customer-specific flows
34
+ # - Conditional feature enhancement based on configuration
35
+ #
36
+ # Extensions are registered globally and automatically applied when the class
37
+ # defines its flow. Multiple extensions can be registered for the same class/attribute.
38
+ #
15
39
  # Example:
16
40
  #
17
41
  # Circulator.extension(:Document, :status) do
18
42
  # state :pending do
19
43
  # action :send_to_legal, to: :legal_review
20
44
  # end
45
+ #
46
+ # state :legal_review do
47
+ # action :approve, to: :approved
48
+ # end
21
49
  # end
22
50
  #
23
- # Extensions are automatically applied when the class defines its flow
51
+ # Merging behavior (default):
52
+ # When an extension defines the same action from the same state as the base flow,
53
+ # the extension completely replaces the base definition (last-defined wins).
54
+ #
55
+ # Custom merging:
56
+ # To implement intelligent composition where extensions add conditions/blocks additively,
57
+ # pass a custom flows_proc parameter to your flow() definition that creates a Hash-like
58
+ # object with custom merge logic.
59
+ #
60
+ # Extension registration must happen before class definition (typically in initializers).
61
+ #
62
+ # Arguments:
63
+ # - class_name: Symbol or String - Name of class being extended
64
+ # - attribute_name: Symbol or String - Name of the flow attribute
65
+ # - block: Required block containing state and action definitions
66
+ #
67
+ # Raises ArgumentError if no block provided.
24
68
  def extension(class_name, attribute_name, &block)
25
69
  raise ArgumentError, "Block required for extension" unless block_given?
26
70
 
@@ -223,8 +267,18 @@ module Circulator
223
267
  end
224
268
 
225
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
226
280
  # Handle hash-based allow_if (checking other attribute states)
227
- if transition[:allow_if].is_a?(Hash)
281
+ elsif transition[:allow_if].is_a?(Hash)
228
282
  attribute_name_to_check, valid_states = transition[:allow_if].first
229
283
  current_state = flow_target.send(attribute_name_to_check)
230
284
 
@@ -341,6 +395,45 @@ module Circulator
341
395
  available_flows(attribute, *args, **kwargs).include?(action)
342
396
  end
343
397
 
398
+ # Get the guard methods for a specific transition
399
+ #
400
+ # Returns an array of Symbol method names if the guard is an array of symbols,
401
+ # or nil if no guard or guard is not an array.
402
+ #
403
+ # Example:
404
+ #
405
+ # class Order
406
+ # extend Circulator
407
+ # flow(:status) do
408
+ # state :pending do
409
+ # action :approve, to: :approved, allow_if: [:approved?, :in_budget?]
410
+ # end
411
+ # end
412
+ # end
413
+ #
414
+ # order = Order.new
415
+ # order.guards_for(:status, :approve)
416
+ # # => [:approved?, :in_budget?]
417
+ def guards_for(attribute, action)
418
+ model_key = Circulator.model_key(self)
419
+ flow = flows.dig(model_key, attribute)
420
+ return nil unless flow
421
+
422
+ current_value = send(attribute)
423
+ current_state = current_value.respond_to?(:to_sym) ? current_value.to_sym : current_value
424
+
425
+ transition = flow.transition_map.dig(action, current_state)
426
+ return nil unless transition
427
+
428
+ guard = transition[:allow_if]
429
+ return nil unless guard
430
+
431
+ # If guard is an array, return only the Symbol elements
432
+ if guard.is_a?(Array)
433
+ guard.select { |g| g.is_a?(Symbol) }
434
+ end
435
+ end
436
+
344
437
  private
345
438
 
346
439
  def flows
@@ -349,6 +442,16 @@ module Circulator
349
442
 
350
443
  def check_allow_if(allow_if, *args, **kwargs)
351
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
352
455
  when Hash
353
456
  attribute_name, valid_states = allow_if.first
354
457
  current_state = send(attribute_name)
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.6
4
+ version: 2.1.7
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: 3.7.2
49
+ rubygems_version: 4.0.3
50
50
  specification_version: 4
51
51
  summary: Simple state machine
52
52
  test_files: []