circulator 2.1.5 → 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 +6 -4
- data/README.md +153 -0
- data/exe/circulator-diagram +119 -21
- 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,10 +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
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
- Use an array of mulitple symbols with allow_if guard clauses. (96b66e6)
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
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`:
|
|
@@ -285,10 +293,131 @@ class Payment
|
|
|
285
293
|
end
|
|
286
294
|
```
|
|
287
295
|
|
|
296
|
+
#### Extending Flows
|
|
297
|
+
|
|
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:**
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
class Document
|
|
304
|
+
extend Circulator
|
|
305
|
+
|
|
306
|
+
attr_accessor :status
|
|
307
|
+
|
|
308
|
+
flow :status do
|
|
309
|
+
state :draft do
|
|
310
|
+
action :submit, to: :review
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
state :review do
|
|
314
|
+
action :approve, to: :approved
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
state :approved
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Register extension - can be in a separate file or initializer
|
|
322
|
+
Circulator.extension(:Document, :status) do
|
|
323
|
+
state :review do
|
|
324
|
+
action :reject, to: :rejected
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
state :rejected do
|
|
328
|
+
action :revise, to: :draft
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Extensions are automatically applied when class is loaded
|
|
333
|
+
doc = Document.new
|
|
334
|
+
doc.status = :review
|
|
335
|
+
doc.status_reject # => :rejected (from extension)
|
|
336
|
+
doc.status_revise # => :draft (from extension)
|
|
337
|
+
```
|
|
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
|
+
|
|
288
415
|
### Generating Diagrams
|
|
289
416
|
|
|
290
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.
|
|
291
418
|
|
|
419
|
+
#### Generate a diagram for a specific model:
|
|
420
|
+
|
|
292
421
|
```bash
|
|
293
422
|
bundle exec circulator-diagram MODEL_NAME
|
|
294
423
|
```
|
|
@@ -297,6 +426,30 @@ bundle exec circulator-diagram MODEL_NAME
|
|
|
297
426
|
bundle exec circulator-diagram MODEL_NAME -f plantuml
|
|
298
427
|
```
|
|
299
428
|
|
|
429
|
+
#### Generate diagrams for all models with Circulator flows:
|
|
430
|
+
|
|
431
|
+
Use the `--all` option to automatically find and generate diagrams for all classes that have Circulator flows defined:
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
bundle exec circulator-diagram --all
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
```bash
|
|
438
|
+
bundle exec circulator-diagram --all -f plantuml
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
The `--all` option will:
|
|
442
|
+
- Automatically discover all classes with Circulator flows (including classes that inherit from a parent that extends Circulator)
|
|
443
|
+
- Eager load Rails application classes if running in a Rails environment
|
|
444
|
+
- Generate diagrams for each class found
|
|
445
|
+
- Use the same output directory and format options as single-model generation
|
|
446
|
+
|
|
447
|
+
#### Other options:
|
|
448
|
+
|
|
449
|
+
- `-d, --directory DIRECTORY` - Specify output directory (default: `docs`)
|
|
450
|
+
- `-s, --separate` - Generate separate diagram files for each flow attribute
|
|
451
|
+
- `-r, --require FILE` - Require a file before loading models (e.g., `config/environment`)
|
|
452
|
+
|
|
300
453
|
## Why Circulator?
|
|
301
454
|
|
|
302
455
|
Circulator distinguishes itself from other Ruby state machine libraries through its simplicity and flexibility:
|
data/exe/circulator-diagram
CHANGED
|
@@ -8,17 +8,21 @@ require_relative "../lib/circulator/dot"
|
|
|
8
8
|
require_relative "../lib/circulator/plantuml"
|
|
9
9
|
|
|
10
10
|
# Parse command-line options
|
|
11
|
-
options = {format: "dot", require: nil, directory: "docs", separate: false}
|
|
11
|
+
options = {format: "dot", require: nil, directory: "docs", separate: false, all: false}
|
|
12
12
|
parser = OptionParser.new do |opts|
|
|
13
|
-
opts.banner = "Usage: circulator-diagram MODEL_NAME [options]"
|
|
13
|
+
opts.banner = "Usage: circulator-diagram [MODEL_NAME | --all] [options]"
|
|
14
14
|
opts.separator ""
|
|
15
15
|
opts.separator "Generate diagram files for Circulator state machine flows"
|
|
16
16
|
opts.separator ""
|
|
17
17
|
opts.separator "Arguments:"
|
|
18
|
-
opts.separator " MODEL_NAME Name of the model class with Circulator flows"
|
|
18
|
+
opts.separator " MODEL_NAME Name of the model class with Circulator flows (required unless --all is used)"
|
|
19
19
|
opts.separator ""
|
|
20
20
|
opts.separator "Options:"
|
|
21
21
|
|
|
22
|
+
opts.on("-a", "--all", "Generate diagrams for all classes with Circulator in their ancestry") do
|
|
23
|
+
options[:all] = true
|
|
24
|
+
end
|
|
25
|
+
|
|
22
26
|
opts.on("-f", "--format FORMAT", ["dot", "plantuml"], "Output format (dot, plantuml). Default: dot") do |format|
|
|
23
27
|
options[:format] = format
|
|
24
28
|
end
|
|
@@ -54,15 +58,15 @@ rescue OptionParser::InvalidOption => e
|
|
|
54
58
|
exit 1
|
|
55
59
|
end
|
|
56
60
|
|
|
57
|
-
# Check for required model name argument
|
|
58
|
-
if ARGV.empty?
|
|
59
|
-
warn "Error: MODEL_NAME is required"
|
|
61
|
+
# Check for required model name argument or --all flag
|
|
62
|
+
if ARGV.empty? && !options[:all]
|
|
63
|
+
warn "Error: Either MODEL_NAME or --all is required"
|
|
60
64
|
warn ""
|
|
61
65
|
warn parser
|
|
62
66
|
exit 1
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
model_name = ARGV[0]
|
|
69
|
+
model_name = ARGV[0] unless options[:all]
|
|
66
70
|
|
|
67
71
|
# Load the application environment
|
|
68
72
|
# Priority: -r option > config/environment.rb > nothing
|
|
@@ -79,22 +83,63 @@ elsif File.exist?("config/environment.rb")
|
|
|
79
83
|
require File.expand_path("config/environment.rb")
|
|
80
84
|
end
|
|
81
85
|
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
# Find all classes with Circulator in their ancestry
|
|
87
|
+
def find_circulator_classes
|
|
88
|
+
classes = []
|
|
89
|
+
|
|
90
|
+
# Try to force-load classes that might not be loaded yet
|
|
91
|
+
# This is especially important for Rails apps with lazy loading
|
|
92
|
+
if defined?(Rails) && Rails.application
|
|
93
|
+
begin
|
|
94
|
+
Rails.application.eager_load!
|
|
95
|
+
rescue => e
|
|
96
|
+
warn "Warning: Could not eager load Rails application: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
ObjectSpace.each_object(Class) do |klass|
|
|
101
|
+
# Skip anonymous classes and classes without names
|
|
102
|
+
next if klass.name.nil? || klass.name.empty?
|
|
103
|
+
|
|
104
|
+
# Check if the class responds to :flows (set up when Circulator is extended)
|
|
105
|
+
next unless klass.respond_to?(:flows)
|
|
106
|
+
|
|
107
|
+
begin
|
|
108
|
+
flows = klass.flows
|
|
109
|
+
next if flows.nil? || flows.empty?
|
|
110
|
+
rescue => e
|
|
111
|
+
# Skip classes where flows method raises an error
|
|
112
|
+
warn "Warning: Could not get flows for #{klass.name}: #{e.class} - #{e.message}"
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
classes << klass
|
|
117
|
+
rescue => e
|
|
118
|
+
# Skip any class that causes an error during inspection
|
|
119
|
+
if ENV["DEBUG"]
|
|
120
|
+
warn "Warning: Error inspecting class #{begin
|
|
121
|
+
klass.name
|
|
122
|
+
rescue
|
|
123
|
+
"unknown"
|
|
124
|
+
end}: #{e.class} - #{e.message}"
|
|
125
|
+
end
|
|
126
|
+
next
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
classes.sort_by(&:name)
|
|
89
130
|
end
|
|
90
131
|
|
|
91
|
-
# Generate diagram
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
132
|
+
# Generate diagram for a single model class
|
|
133
|
+
def generate_diagram_for_class(model_class, options)
|
|
134
|
+
begin
|
|
135
|
+
generator = case options[:format]
|
|
136
|
+
when "plantuml"
|
|
137
|
+
Circulator::PlantUml.new(model_class)
|
|
138
|
+
else
|
|
139
|
+
Circulator::Dot.new(model_class)
|
|
140
|
+
end
|
|
141
|
+
rescue => e
|
|
142
|
+
raise "Failed to initialize diagram generator for #{model_class.name}: #{e.class} - #{e.message}"
|
|
98
143
|
end
|
|
99
144
|
|
|
100
145
|
# Determine base output filename and extension
|
|
@@ -163,6 +208,59 @@ begin
|
|
|
163
208
|
puts " dot -Tpng #{output_file} -o #{File.join(options[:directory], base_name)}.png"
|
|
164
209
|
end
|
|
165
210
|
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Collect classes to generate diagrams for
|
|
214
|
+
begin
|
|
215
|
+
if options[:all]
|
|
216
|
+
# Find all classes with Circulator in their ancestry
|
|
217
|
+
begin
|
|
218
|
+
classes_to_process = find_circulator_classes
|
|
219
|
+
rescue => e
|
|
220
|
+
warn "Error finding Circulator classes: #{e.class} - #{e.message}"
|
|
221
|
+
warn e.backtrace.first(10).join("\n")
|
|
222
|
+
exit 1
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if classes_to_process.empty?
|
|
226
|
+
warn "No classes with Circulator flows found"
|
|
227
|
+
exit 1
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
puts "Found #{classes_to_process.size} class(es) with Circulator flows:"
|
|
231
|
+
classes_to_process.each do |klass|
|
|
232
|
+
puts " - #{klass.name}"
|
|
233
|
+
end
|
|
234
|
+
puts ""
|
|
235
|
+
else
|
|
236
|
+
# Try to constantize the model name
|
|
237
|
+
begin
|
|
238
|
+
model_class = Object.const_get(model_name)
|
|
239
|
+
rescue NameError
|
|
240
|
+
warn "Error: Model '#{model_name}' not found"
|
|
241
|
+
warn "Make sure the model is loaded in your environment"
|
|
242
|
+
exit 1
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
classes_to_process = [model_class]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Generate diagrams for all classes in the array
|
|
249
|
+
classes_to_process.each do |model_class|
|
|
250
|
+
if classes_to_process.size > 1
|
|
251
|
+
puts "Generating diagram for #{model_class.name}..."
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
begin
|
|
255
|
+
generate_diagram_for_class(model_class, options)
|
|
256
|
+
rescue => e
|
|
257
|
+
warn "Error generating diagram for #{model_class.name}: #{e.class} - #{e.message}"
|
|
258
|
+
warn e.backtrace.first(5).join("\n") if ENV["DEBUG"]
|
|
259
|
+
next
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
puts "" if classes_to_process.size > 1
|
|
263
|
+
end
|
|
166
264
|
|
|
167
265
|
exit 0
|
|
168
266
|
rescue ArgumentError => e
|
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: []
|