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 +4 -4
- data/CHANGELOG.md +5 -7
- data/README.md +89 -2
- data/lib/circulator/flow.rb +22 -1
- data/lib/circulator/version.rb +1 -1
- data/lib/circulator.rb +105 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 63f6c8044ac3e21dffede73e1345d33d2e1e3f77350bcd6b332df8508d810f1f
|
|
4
|
+
data.tar.gz: 65ef2f903e7ec29993e1bbf7cf7b522c0d7bcba0cd424c75a7271ff0a89478db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
8
|
+
## [2.1.7] - 2026-01-06
|
|
9
9
|
|
|
10
|
-
###
|
|
10
|
+
### Added
|
|
11
11
|
|
|
12
|
-
-
|
|
12
|
+
- Use an array of mulitple symbols with allow_if guard clauses. (96b66e6)
|
|
13
13
|
|
|
14
|
-
###
|
|
14
|
+
### Changed
|
|
15
15
|
|
|
16
|
-
- Documentation
|
|
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
|
-
#
|
|
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.
|
data/lib/circulator/flow.rb
CHANGED
|
@@ -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
|
|
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)
|
data/lib/circulator/version.rb
CHANGED
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
|
-
#
|
|
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
|
-
|
|
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.
|
|
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:
|
|
49
|
+
rubygems_version: 4.0.3
|
|
50
50
|
specification_version: 4
|
|
51
51
|
summary: Simple state machine
|
|
52
52
|
test_files: []
|