Skip to content

Commit d989890

Browse files
committed
Merge branch 'feature/factory-style' into develop
* feature/factory-style: feat(factory): add Factory style for inline node creation
2 parents 8ad51fa + bd657f7 commit d989890

File tree

16 files changed

+999
-3
lines changed

16 files changed

+999
-3
lines changed

.rubocop_todo.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ Lint/UnderscorePrefixedVariableName:
2727
- 'examples/builder/identity.rb'
2828
- 'examples/builder/logging.rb'
2929
- 'examples/class/logging.rb'
30+
- 'examples/factory/identity.rb'
31+
- 'examples/factory/logging.rb'
3032

3133
# Offense count: 8
3234
# This cop supports safe autocorrection (--autocorrect).
@@ -74,6 +76,10 @@ Naming/FileName:
7476
- 'examples/class/internal-broadcastable.rb'
7577
- 'examples/class/internal-composable.rb'
7678
- 'examples/class/internal-seekable.rb'
79+
- 'examples/factory/external-verbosify.rb'
80+
- 'examples/factory/internal-broadcastable.rb'
81+
- 'examples/factory/internal-composable.rb'
82+
- 'examples/factory/internal-seekable.rb'
7783

7884
# Offense count: 1
7985
# Configuration parameters: Mode, AllowedMethods, AllowedPatterns, AllowBangMethods, WaywardPredicates.
@@ -103,6 +109,8 @@ Style/MultilineBlockChain:
103109
- 'examples/builder/logging.rb'
104110
- 'examples/class/hooks.rb'
105111
- 'examples/class/logging.rb'
112+
- 'examples/factory/hooks.rb'
113+
- 'examples/factory/logging.rb'
106114
- 'lib/callable_tree/node/internal/strategy/seek.rb'
107115

108116
# Offense count: 1

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
- `examples/`: Usage examples.
3131
- `examples/class/`: Class-style node definitions (using `include CallableTree::Node::*`).
3232
- `examples/builder/`: Builder-style definitions (using `Builder.new.matcher { }.caller { }.build`).
33+
- `examples/factory/`: Factory-style definitions (using `External.create(caller: ...)` or `External::Pod.new`).
3334
- `examples/docs/`: Sample data files (JSON, XML) used by examples.
3435

3536
## Development
@@ -52,4 +53,4 @@
5253
## Architecture
5354
- **Composite Pattern**: Used for `Internal` nodes to treat individual objects and compositions uniformly.
5455
- **Builder Pattern**: `CallableTree::Node::Internal::Builder` and `CallableTree::Node::External::Builder` provide a fluent interface for constructing complex trees.
55-
56+
- **Pod Pattern**: `CallableTree::Node::Internal::Pod` and `CallableTree::Node::External::Pod` enable inline node creation via `External.create` / `Internal.create` factory methods with proc-based behaviors.

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
## [Unreleased]
22

3+
- Add Factory style for inline node creation as a third option alongside Class and Builder styles.
4+
- `CallableTree::Node::External::Pod` and `CallableTree::Node::Internal::Pod` classes
5+
- `CallableTree::Node::External.create` and `CallableTree::Node::Internal.create` factory methods
6+
- Supports `hookable: true` option for Hooks (before/around/after callbacks)
7+
- See `examples/factory/*.rb` for details.
8+
39
## [0.3.11] - 2026-01-03
410

511
- Fix a typo in `Strategizable#strategize` where it incorrectly called `strategy!` instead of `strategize!`.`

README.md

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Builds a tree of `CallableTree` nodes. Invokes the `call` method on nodes where
3636

3737
### Basic
3838

39-
There are two ways to define the nodes: class style and builder style.
39+
There are three ways to define nodes: class style, builder style, and factory style.
4040

4141
#### `CallableTree::Node::Internal#seekable` (default strategy)
4242

@@ -261,6 +261,84 @@ Run `examples/builder/internal-seekable.rb`:
261261
---
262262
```
263263

264+
##### Factory style
265+
266+
Factory style defines behaviors as procs first, then assembles the tree structure separately. This makes the tree structure clearly visible.
267+
268+
`examples/factory/internal-seekable.rb`:
269+
```ruby
270+
# === Behavior Definitions ===
271+
272+
json_matcher = ->(input, **) { File.extname(input) == '.json' }
273+
json_caller = lambda do |input, **options, &original|
274+
File.open(input) do |file|
275+
json = JSON.parse(file.read)
276+
original.call(json, **options)
277+
end
278+
end
279+
280+
xml_matcher = ->(input, **) { File.extname(input) == '.xml' }
281+
xml_caller = lambda do |input, **options, &original|
282+
File.open(input) do |file|
283+
original.call(REXML::Document.new(file), **options)
284+
end
285+
end
286+
287+
animals_json_matcher = ->(input, **) { !input['animals'].nil? }
288+
animals_json_caller = ->(input, **) { input['animals'].to_h { |e| [e['name'], e['emoji']] } }
289+
290+
fruits_json_matcher = ->(input, **) { !input['fruits'].nil? }
291+
fruits_json_caller = ->(input, **) { input['fruits'].to_h { |e| [e['name'], e['emoji']] } }
292+
293+
animals_xml_matcher = ->(input, **) { !input.get_elements('//animals').empty? }
294+
animals_xml_caller = ->(input, **) { input.get_elements('//animals').first.to_h { |e| [e['name'], e['emoji']] } }
295+
296+
fruits_xml_matcher = ->(input, **) { !input.get_elements('//fruits').empty? }
297+
fruits_xml_caller = ->(input, **) { input.get_elements('//fruits').first.to_h { |e| [e['name'], e['emoji']] } }
298+
299+
terminator_true = ->(*) { true }
300+
301+
# === Tree Structure (clearly visible!) ===
302+
303+
tree = CallableTree::Node::Root.new.seekable.append(
304+
CallableTree::Node::Internal.create(
305+
matcher: json_matcher,
306+
caller: json_caller,
307+
terminator: terminator_true
308+
).seekable.append(
309+
CallableTree::Node::External.create(matcher: animals_json_matcher, caller: animals_json_caller),
310+
CallableTree::Node::External.create(matcher: fruits_json_matcher, caller: fruits_json_caller)
311+
),
312+
CallableTree::Node::Internal.create(
313+
matcher: xml_matcher,
314+
caller: xml_caller,
315+
terminator: terminator_true
316+
).seekable.append(
317+
CallableTree::Node::External.create(matcher: animals_xml_matcher, caller: animals_xml_caller),
318+
CallableTree::Node::External.create(matcher: fruits_xml_matcher, caller: fruits_xml_caller)
319+
)
320+
)
321+
322+
Dir.glob("#{__dir__}/../docs/*") do |file|
323+
options = { foo: :bar }
324+
pp tree.call(file, **options)
325+
puts '---'
326+
end
327+
```
328+
329+
Run `examples/factory/internal-seekable.rb`:
330+
```sh
331+
% ruby examples/factory/internal-seekable.rb
332+
{"Dog"=>"🐶", "Cat"=>"🐱"}
333+
---
334+
{"Dog"=>"🐶", "Cat"=>"🐱"}
335+
---
336+
{"Red Apple"=>"🍎", "Green Apple"=>"🍏"}
337+
---
338+
{"Red Apple"=>"🍎", "Green Apple"=>"🍏"}
339+
---
340+
```
341+
264342
#### `CallableTree::Node::Internal#broadcastable`
265343

266344
This strategy broadcasts input to all child nodes and returns their results as an array. It ignores child `terminate?` methods by default.
@@ -407,6 +485,55 @@ Run `examples/builder/internal-broadcastable.rb`:
407485
10 -> [nil, nil]
408486
```
409487
488+
##### Factory style
489+
490+
`examples/factory/internal-broadcastable.rb`:
491+
```ruby
492+
# === Behavior Definitions ===
493+
494+
less_than_5_matcher = ->(input, **, &original) { original.call(input) && input < 5 }
495+
less_than_10_matcher = ->(input, **, &original) { original.call(input) && input < 10 }
496+
497+
multiply_2_caller = ->(input, **) { input * 2 }
498+
add_1_caller = ->(input, **) { input + 1 }
499+
multiply_3_caller = ->(input, **) { input * 3 }
500+
subtract_1_caller = ->(input, **) { input - 1 }
501+
502+
# === Tree Structure ===
503+
504+
tree = CallableTree::Node::Root.new.broadcastable.append(
505+
CallableTree::Node::Internal.create(matcher: less_than_5_matcher).broadcastable.append(
506+
CallableTree::Node::External.create(caller: multiply_2_caller),
507+
CallableTree::Node::External.create(caller: add_1_caller)
508+
),
509+
CallableTree::Node::Internal.create(matcher: less_than_10_matcher).broadcastable.append(
510+
CallableTree::Node::External.create(caller: multiply_3_caller),
511+
CallableTree::Node::External.create(caller: subtract_1_caller)
512+
)
513+
)
514+
515+
(0..10).each do |input|
516+
output = tree.call(input)
517+
puts "#{input} -> #{output}"
518+
end
519+
```
520+
521+
Run `examples/factory/internal-broadcastable.rb`:
522+
```sh
523+
% ruby examples/factory/internal-broadcastable.rb
524+
0 -> [[0, 1], [0, -1]]
525+
1 -> [[2, 2], [3, 0]]
526+
2 -> [[4, 3], [6, 1]]
527+
3 -> [[6, 4], [9, 2]]
528+
4 -> [[8, 5], [12, 3]]
529+
5 -> [nil, [15, 4]]
530+
6 -> [nil, [18, 5]]
531+
7 -> [nil, [21, 6]]
532+
8 -> [nil, [24, 7]]
533+
9 -> [nil, [27, 8]]
534+
10 -> [nil, nil]
535+
```
536+
410537
#### `CallableTree::Node::Internal#composable`
411538
412539
This strategy chains child nodes, passing the output of the previous node as input to the next.
@@ -554,6 +681,55 @@ Run `examples/builder/internal-composable.rb`:
554681
10 -> 10
555682
```
556683
684+
##### Factory style
685+
686+
`examples/factory/internal-composable.rb`:
687+
```ruby
688+
# === Behavior Definitions ===
689+
690+
less_than_5_matcher = ->(input, **, &original) { original.call(input) && input < 5 }
691+
less_than_10_matcher = ->(input, **, &original) { original.call(input) && input < 10 }
692+
693+
multiply_2_caller = ->(input, **) { input * 2 }
694+
add_1_caller = ->(input, **) { input + 1 }
695+
multiply_3_caller = ->(input, **) { input * 3 }
696+
subtract_1_caller = ->(input, **) { input - 1 }
697+
698+
# === Tree Structure ===
699+
700+
tree = CallableTree::Node::Root.new.composable.append(
701+
CallableTree::Node::Internal.create(matcher: less_than_5_matcher).composable.append(
702+
CallableTree::Node::External.create(caller: multiply_2_caller),
703+
CallableTree::Node::External.create(caller: add_1_caller)
704+
),
705+
CallableTree::Node::Internal.create(matcher: less_than_10_matcher).composable.append(
706+
CallableTree::Node::External.create(caller: multiply_3_caller),
707+
CallableTree::Node::External.create(caller: subtract_1_caller)
708+
)
709+
)
710+
711+
(0..10).each do |input|
712+
output = tree.call(input)
713+
puts "#{input} -> #{output}"
714+
end
715+
```
716+
717+
Run `examples/factory/internal-composable.rb`:
718+
```sh
719+
% ruby examples/factory/internal-composable.rb
720+
0 -> 2
721+
1 -> 8
722+
2 -> 14
723+
3 -> 20
724+
4 -> 26
725+
5 -> 14
726+
6 -> 17
727+
7 -> 20
728+
8 -> 23
729+
9 -> 26
730+
10 -> 10
731+
```
732+
557733
### Advanced
558734
559735
#### `CallableTree::Node::External#verbosify`

callable_tree.gemspec

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ Gem::Specification.new do |spec|
99
spec.email = ['jsmmr@icloud.com']
1010

1111
spec.summary = 'Builds executable trees of callable nodes with flexible strategies like seek, broadcast, and compose.'
12-
spec.description = 'CallableTree provides a framework for organizing complex logic into a tree of callable nodes. It allows you to chain execution from a root node to leaf nodes based on matching conditions. Key features include multiple traversal strategies: `seekable` (like nested `if`/`case`), `broadcastable` (one-to-many execution), and `composable` (pipelined processing). Supports both class-based and builder-style definitions.'
12+
spec.description = <<~DESC
13+
CallableTree provides a framework for organizing complex logic into a tree of callable nodes.
14+
It allows you to chain execution from a root node to leaf nodes based on matching conditions.
15+
Key features include multiple traversal strategies: `seekable` (like nested `if`/`case`),
16+
`broadcastable` (one-to-many execution), and `composable` (pipelined processing).
17+
Supports class-based, builder-style and factory-style definitions.
18+
DESC
1319
spec.homepage = 'https://github.com/jsmmr/ruby_callable_tree'
1420
spec.license = 'MIT'
1521
spec.required_ruby_version = Gem::Requirement.new('>= 2.4.0')
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../../lib/callable_tree'
4+
require 'json'
5+
require 'rexml/document'
6+
7+
# Verbosify example using Pod style with pre-defined procs
8+
# Shows verbose output including route information
9+
10+
# === Behavior Definitions ===
11+
12+
json_matcher = ->(input, **) { File.extname(input) == '.json' }
13+
json_caller = lambda do |input, **options, &block|
14+
File.open(input) do |file|
15+
json = JSON.parse(file.read)
16+
block.call(json, **options)
17+
end
18+
end
19+
20+
xml_matcher = ->(input, **) { File.extname(input) == '.xml' }
21+
xml_caller = lambda do |input, **options, &block|
22+
File.open(input) do |file|
23+
block.call(REXML::Document.new(file), **options)
24+
end
25+
end
26+
27+
animals_json_matcher = ->(input, **) { !input['animals'].nil? }
28+
animals_json_caller = ->(input, **) { input['animals'].to_h { |e| [e['name'], e['emoji']] } }
29+
30+
fruits_json_matcher = ->(input, **) { !input['fruits'].nil? }
31+
fruits_json_caller = ->(input, **) { input['fruits'].to_h { |e| [e['name'], e['emoji']] } }
32+
33+
animals_xml_matcher = ->(input, **) { !input.get_elements('//animals').empty? }
34+
animals_xml_caller = ->(input, **) { input.get_elements('//animals').first.to_h { |e| [e['name'], e['emoji']] } }
35+
36+
fruits_xml_matcher = ->(input, **) { !input.get_elements('//fruits').empty? }
37+
fruits_xml_caller = ->(input, **) { input.get_elements('//fruits').first.to_h { |e| [e['name'], e['emoji']] } }
38+
39+
terminator_true = ->(*) { true }
40+
41+
# === Tree Structure ===
42+
43+
tree = CallableTree::Node::Root.new.seekable.append(
44+
CallableTree::Node::Internal.create(
45+
matcher: json_matcher,
46+
caller: json_caller,
47+
terminator: terminator_true
48+
).seekable.append(
49+
CallableTree::Node::External.create(matcher: animals_json_matcher, caller: animals_json_caller).verbosify,
50+
CallableTree::Node::External.create(matcher: fruits_json_matcher, caller: fruits_json_caller).verbosify
51+
),
52+
CallableTree::Node::Internal.create(
53+
matcher: xml_matcher,
54+
caller: xml_caller,
55+
terminator: terminator_true
56+
).seekable.append(
57+
CallableTree::Node::External.create(matcher: animals_xml_matcher, caller: animals_xml_caller).verbosify,
58+
CallableTree::Node::External.create(matcher: fruits_xml_matcher, caller: fruits_xml_caller).verbosify
59+
)
60+
)
61+
62+
# === Execution ===
63+
64+
Dir.glob("#{__dir__}/../docs/*") do |file|
65+
options = { foo: :bar }
66+
pp tree.call(file, **options)
67+
puts '---'
68+
end

0 commit comments

Comments
 (0)