igniter 0.3.0 → 0.3.1
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 +9 -0
- data/README.md +2 -2
- data/docs/API_V2.md +58 -0
- data/examples/README.md +3 -0
- data/examples/ringcentral_routing.rb +26 -35
- data/lib/igniter/compiler/compiled_graph.rb +8 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +2 -0
- data/lib/igniter/contract.rb +16 -0
- data/lib/igniter/diagnostics/report.rb +102 -3
- data/lib/igniter/dsl/contract_builder.rb +67 -4
- data/lib/igniter/extensions/introspection/graph_formatter.rb +4 -0
- data/lib/igniter/model/branch_node.rb +9 -3
- data/lib/igniter/model/collection_node.rb +9 -3
- data/lib/igniter/runtime/resolver.rb +48 -7
- data/lib/igniter/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 659dfe833fdf98b7d1b446e08d02464545fd0fe9b61badd5cf8e7ca73beb6a1b
|
|
4
|
+
data.tar.gz: 5af168ce8c6fad1c18dd0d9703281ea64062c3a6d3e954b12172f29210e12c5b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5865bc3fb30c137baa5bdf87b63f9023c95c50afa87701a052a47726d139ab6113ed52820f629d0b45de421247778d560133e2c2d2f5e756681a14af2cac0026
|
|
7
|
+
data.tar.gz: de8fa4b7f92a566016f21d81acdbf9d5ec4f4f0fa458ac99b18f8c5cced124390276ea541a1f252067009fd4e3ccf688d97d239080b8995227fc697f0d5b9e6e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.1] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
- Add DX-oriented DSL helpers `project` and `aggregate` for compact extraction and summary nodes.
|
|
6
|
+
- Extend `branch` and `collection` with `map_inputs:` and named `using:` mappers to reduce orchestration wiring noise.
|
|
7
|
+
- Allow `collection` mapper mode to iterate over hash-like sources directly without a preparatory `to_a` node.
|
|
8
|
+
- Add diagnostics-only output presenters via `present` for compact human-facing summaries without changing raw machine-readable outputs.
|
|
9
|
+
- Improve diagnostics formatting for nested branch/collection outputs and clean up inline value rendering for hashes and symbol-heavy summaries.
|
|
10
|
+
- Validate and exercise the new DX surface against private production-like scheduler migration POCs.
|
|
11
|
+
|
|
3
12
|
## [0.3.0] - 2026-03-19
|
|
4
13
|
|
|
5
14
|
- Add executor metadata and global executor registry for self-describing, schema-friendly execution steps.
|
data/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Igniter is a Ruby gem for expressing business logic as a validated dependency gr
|
|
|
9
9
|
- runtime auditing
|
|
10
10
|
- diagnostics reports
|
|
11
11
|
- reactive side effects
|
|
12
|
-
- ergonomic DSL helpers (`with`, `const`, `lookup`, `map`, `guard`, `export`, `expose`, `effect`, `on_success`, `scope`, `namespace`, `branch`, `collection`)
|
|
12
|
+
- ergonomic DSL helpers (`with`, `const`, `lookup`, `map`, `project`, `aggregate`, `guard`, `export`, `expose`, `effect`, `on_success`, `scope`, `namespace`, `branch`, `collection`)
|
|
13
13
|
- graph and runtime introspection
|
|
14
14
|
- async-capable pending nodes with snapshot/restore
|
|
15
15
|
- store-backed execution resume flows
|
|
@@ -87,7 +87,7 @@ There is also a short patterns guide in [`docs/PATTERNS.md`](docs/PATTERNS.md).
|
|
|
87
87
|
| `marketing_ergonomics.rb` | `ruby examples/marketing_ergonomics.rb` | compact domain DSL with `with`, matcher-style `guard`, `scope`/`namespace`, `expose`, `on_success`, and `explain_plan` |
|
|
88
88
|
| `collection.rb` | `ruby examples/collection.rb` | declarative fan-out, stable item keys, and `CollectionResult` |
|
|
89
89
|
| `collection_partial_failure.rb` | `ruby examples/collection_partial_failure.rb` | `:collect` mode, partial failure summary, and collection diagnostics |
|
|
90
|
-
| `ringcentral_routing.rb` | `ruby examples/ringcentral_routing.rb` | top-level `branch`, nested `collection`,
|
|
90
|
+
| `ringcentral_routing.rb` | `ruby examples/ringcentral_routing.rb` | top-level `branch`, nested `collection`, `project`, `aggregate`, `using:`/`map_inputs`, and nested diagnostics semantics |
|
|
91
91
|
|
|
92
92
|
There are also matching living examples in `spec/igniter/examples_spec.rb`.
|
|
93
93
|
Those are useful if you want to read the examples in test form.
|
data/docs/API_V2.md
CHANGED
|
@@ -146,6 +146,12 @@ map :normalized_trade_name, from: :service do |service:|
|
|
|
146
146
|
service.downcase == "heating" ? "HVAC" : service
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
+
project :telephony_status, from: :body, key: "telephonyStatus"
|
|
150
|
+
|
|
151
|
+
aggregate :available_slots, with: :technicians do |technicians:|
|
|
152
|
+
technicians.successes.values.sum { |item| item.result.summary[:available_slots] }
|
|
153
|
+
end
|
|
154
|
+
|
|
149
155
|
guard :business_hours_valid, depends_on: %i[vendor current_time], message: "Closed" do |vendor:, current_time:|
|
|
150
156
|
current_time.between?(vendor.start_at, vendor.stop_at)
|
|
151
157
|
end
|
|
@@ -180,6 +186,21 @@ branch :delivery_strategy, with: :country, inputs: {
|
|
|
180
186
|
on "UA", contract: LocalDeliveryContract
|
|
181
187
|
default contract: DefaultDeliveryContract
|
|
182
188
|
end
|
|
189
|
+
|
|
190
|
+
branch :status_route,
|
|
191
|
+
with: :telephony_status,
|
|
192
|
+
depends_on: %i[extension_id active_calls],
|
|
193
|
+
map_inputs: ->(selector:, extension_id:, active_calls:) {
|
|
194
|
+
{
|
|
195
|
+
extension_id: extension_id,
|
|
196
|
+
telephony_status: selector,
|
|
197
|
+
active_calls: active_calls
|
|
198
|
+
}
|
|
199
|
+
} do
|
|
200
|
+
on "CallConnected", contract: CallConnectedContract
|
|
201
|
+
on "NoCall", contract: NoCallContract
|
|
202
|
+
default contract: UnknownStatusContract
|
|
203
|
+
end
|
|
183
204
|
```
|
|
184
205
|
|
|
185
206
|
Declarative fan-out:
|
|
@@ -190,6 +211,43 @@ collection :technicians,
|
|
|
190
211
|
each: TechnicianContract,
|
|
191
212
|
key: :technician_id,
|
|
192
213
|
mode: :collect
|
|
214
|
+
|
|
215
|
+
collection :calls,
|
|
216
|
+
with: :active_calls,
|
|
217
|
+
each: CallEventContract,
|
|
218
|
+
key: :session_id,
|
|
219
|
+
mode: :collect,
|
|
220
|
+
map_inputs: ->(item:) {
|
|
221
|
+
{
|
|
222
|
+
session_id: item.fetch("telephonySessionId"),
|
|
223
|
+
direction: item.fetch("direction"),
|
|
224
|
+
from: item.fetch("from"),
|
|
225
|
+
to: item.fetch("to"),
|
|
226
|
+
start_time: item.fetch("startTime")
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
For repeated mappers, prefer `using:` over inline lambdas:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
collection :company_locations,
|
|
235
|
+
with: :locations_map,
|
|
236
|
+
each: CompanyLocationSchedulerContract,
|
|
237
|
+
key: :location_id,
|
|
238
|
+
depends_on: %i[services_map property_type date],
|
|
239
|
+
using: :build_company_location_inputs
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
branch :status_route,
|
|
244
|
+
with: :telephony_status,
|
|
245
|
+
depends_on: %i[extension_id active_calls],
|
|
246
|
+
using: :build_status_route_inputs do
|
|
247
|
+
on "CallConnected", contract: CallConnectedContract
|
|
248
|
+
on "NoCall", contract: NoCallContract
|
|
249
|
+
default contract: UnknownStatusContract
|
|
250
|
+
end
|
|
193
251
|
```
|
|
194
252
|
|
|
195
253
|
Rules:
|
data/examples/README.md
CHANGED
|
@@ -181,6 +181,9 @@ Shows:
|
|
|
181
181
|
|
|
182
182
|
- top-level routing via `branch`
|
|
183
183
|
- nested fan-out via `collection`
|
|
184
|
+
- trivial field extraction via `project`
|
|
185
|
+
- compact summary building via `aggregate`
|
|
186
|
+
- item input shaping via `collection map_inputs:` or `using:`
|
|
184
187
|
- per-item nested routing via another `branch`
|
|
185
188
|
- `CollectionResult` summary on the selected child contract
|
|
186
189
|
- the practical boundary between parent diagnostics and child diagnostics
|
|
@@ -104,29 +104,26 @@ class CallConnectedContract < Igniter::Contract
|
|
|
104
104
|
active_calls.any?
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
map :call_inputs, from: :active_calls do |active_calls:|
|
|
108
|
-
active_calls.map do |call|
|
|
109
|
-
{
|
|
110
|
-
session_id: call.fetch("telephonySessionId"),
|
|
111
|
-
direction: call.fetch("direction"),
|
|
112
|
-
from: call.fetch("from"),
|
|
113
|
-
to: call.fetch("to"),
|
|
114
|
-
start_time: call.fetch("startTime")
|
|
115
|
-
}
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
107
|
collection :calls,
|
|
120
|
-
with: :
|
|
108
|
+
with: :active_calls,
|
|
121
109
|
each: CallEventContract,
|
|
122
110
|
key: :session_id,
|
|
123
|
-
mode: :collect
|
|
111
|
+
mode: :collect,
|
|
112
|
+
map_inputs: lambda { |item:|
|
|
113
|
+
{
|
|
114
|
+
session_id: item.fetch("telephonySessionId"),
|
|
115
|
+
direction: item.fetch("direction"),
|
|
116
|
+
from: item.fetch("from"),
|
|
117
|
+
to: item.fetch("to"),
|
|
118
|
+
start_time: item.fetch("startTime")
|
|
119
|
+
}
|
|
120
|
+
}
|
|
124
121
|
|
|
125
122
|
compute :call_summaries, with: :calls do |calls:|
|
|
126
123
|
calls.successes.values.map { |item| item.result.summary }
|
|
127
124
|
end
|
|
128
125
|
|
|
129
|
-
|
|
126
|
+
aggregate :routing_summary, with: %i[calls call_summaries extension_id telephony_status has_calls] do |calls:, call_summaries:, extension_id:, telephony_status:, has_calls:|
|
|
130
127
|
has_calls
|
|
131
128
|
{
|
|
132
129
|
extension_id: extension_id,
|
|
@@ -204,28 +201,22 @@ class RingcentralWebhookContract < Igniter::Contract
|
|
|
204
201
|
input :payload
|
|
205
202
|
|
|
206
203
|
scope :parse do
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
map :telephony_status, from: :body do |body:|
|
|
212
|
-
body["telephonyStatus"]
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
map :extension_id, from: :body do |body:|
|
|
216
|
-
body["extensionId"]
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
map :active_calls, from: :body do |body:|
|
|
220
|
-
body["activeCalls"] || []
|
|
221
|
-
end
|
|
204
|
+
project :body, from: :payload, key: :body, default: {}
|
|
205
|
+
project :telephony_status, from: :body, key: "telephonyStatus"
|
|
206
|
+
project :extension_id, from: :body, key: "extensionId"
|
|
207
|
+
project :active_calls, from: :body, key: "activeCalls", default: []
|
|
222
208
|
end
|
|
223
209
|
|
|
224
|
-
branch :status_route,
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
210
|
+
branch :status_route,
|
|
211
|
+
with: :telephony_status,
|
|
212
|
+
depends_on: %i[extension_id active_calls],
|
|
213
|
+
map_inputs: lambda { |selector:, extension_id:, active_calls:|
|
|
214
|
+
{
|
|
215
|
+
extension_id: extension_id,
|
|
216
|
+
telephony_status: selector,
|
|
217
|
+
active_calls: active_calls
|
|
218
|
+
}
|
|
219
|
+
} do
|
|
229
220
|
on "CallConnected", contract: CallConnectedContract
|
|
230
221
|
on "NoCall", contract: NoCallContract
|
|
231
222
|
on "Ringing", contract: RingingContract
|
|
@@ -66,15 +66,19 @@ module Igniter
|
|
|
66
66
|
end
|
|
67
67
|
if node.kind == :branch
|
|
68
68
|
base[:selector] = node.selector_dependency
|
|
69
|
+
base[:depends_on] = node.context_dependencies if node.context_dependencies.any?
|
|
69
70
|
base[:cases] = node.cases.map { |entry| { match: entry[:match], contract: entry[:contract].name } }
|
|
70
71
|
base[:default_contract] = node.default_contract.name
|
|
71
72
|
base[:inputs] = node.input_mapping
|
|
73
|
+
base[:mapper] = node.input_mapper.to_s if node.input_mapper?
|
|
72
74
|
end
|
|
73
75
|
if node.kind == :collection
|
|
74
76
|
base[:with] = node.source_dependency
|
|
77
|
+
base[:depends_on] = node.context_dependencies if node.context_dependencies.any?
|
|
75
78
|
base[:each] = node.contract_class.name
|
|
76
79
|
base[:key] = node.key_name
|
|
77
80
|
base[:mode] = node.mode
|
|
81
|
+
base[:mapper] = node.input_mapper.to_s if node.input_mapper?
|
|
78
82
|
end
|
|
79
83
|
base
|
|
80
84
|
end,
|
|
@@ -117,7 +121,9 @@ module Igniter
|
|
|
117
121
|
{
|
|
118
122
|
name: node.name,
|
|
119
123
|
with: node.selector_dependency,
|
|
124
|
+
depends_on: node.context_dependencies,
|
|
120
125
|
inputs: node.input_mapping,
|
|
126
|
+
map_inputs: (node.input_mapper if node.input_mapper?),
|
|
121
127
|
cases: node.cases.map { |entry| { on: entry[:match], contract: entry[:contract] } },
|
|
122
128
|
default: node.default_contract,
|
|
123
129
|
metadata: node.metadata.reject { |key, _| key == :source_location }
|
|
@@ -127,9 +133,11 @@ module Igniter
|
|
|
127
133
|
{
|
|
128
134
|
name: node.name,
|
|
129
135
|
with: node.source_dependency,
|
|
136
|
+
depends_on: node.context_dependencies,
|
|
130
137
|
each: node.contract_class,
|
|
131
138
|
key: node.key_name,
|
|
132
139
|
mode: node.mode,
|
|
140
|
+
map_inputs: (node.input_mapper if node.input_mapper?),
|
|
133
141
|
metadata: node.metadata.reject { |key, _| key == :source_location }
|
|
134
142
|
}
|
|
135
143
|
end,
|
|
@@ -100,6 +100,8 @@ module Igniter
|
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
def validate_branch_input_mapping!(node, child_graph)
|
|
103
|
+
return if node.input_mapper?
|
|
104
|
+
|
|
103
105
|
child_input_nodes = child_graph.nodes.select { |child_node| child_node.kind == :input }
|
|
104
106
|
child_input_names = child_input_nodes.map(&:name)
|
|
105
107
|
|
data/lib/igniter/contract.rb
CHANGED
|
@@ -103,6 +103,13 @@ module Igniter
|
|
|
103
103
|
react_to(:execution_failed, once_per_execution: true, &terminal_hook)
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
def present(output_name, with: nil, &block)
|
|
107
|
+
raise CompileError, "present requires a block or `with:`" unless block || with
|
|
108
|
+
raise CompileError, "present cannot use both a block and `with:`" if block && with
|
|
109
|
+
|
|
110
|
+
own_output_presenters[output_name.to_sym] = with || block
|
|
111
|
+
end
|
|
112
|
+
|
|
106
113
|
def compiled_graph
|
|
107
114
|
@compiled_graph || superclass_compiled_graph
|
|
108
115
|
end
|
|
@@ -116,8 +123,17 @@ module Igniter
|
|
|
116
123
|
@execution_options || superclass_execution_options || {}
|
|
117
124
|
end
|
|
118
125
|
|
|
126
|
+
def output_presenters
|
|
127
|
+
inherited = superclass.respond_to?(:output_presenters) ? superclass.output_presenters : {}
|
|
128
|
+
inherited.merge(own_output_presenters)
|
|
129
|
+
end
|
|
130
|
+
|
|
119
131
|
private
|
|
120
132
|
|
|
133
|
+
def own_output_presenters
|
|
134
|
+
@output_presenters ||= {}
|
|
135
|
+
end
|
|
136
|
+
|
|
121
137
|
def contract_name
|
|
122
138
|
name || "AnonymousContract"
|
|
123
139
|
end
|
|
@@ -32,7 +32,7 @@ module Igniter
|
|
|
32
32
|
lines << "Diagnostics #{report[:graph]}"
|
|
33
33
|
lines << "Execution #{report[:execution_id]}"
|
|
34
34
|
lines << "Status: #{report[:status]}"
|
|
35
|
-
lines << format_outputs(
|
|
35
|
+
lines << format_outputs(presented_outputs)
|
|
36
36
|
lines << format_nodes(report[:nodes])
|
|
37
37
|
lines << format_collection_nodes(report[:collection_nodes])
|
|
38
38
|
lines << format_errors(report[:errors])
|
|
@@ -47,7 +47,7 @@ module Igniter
|
|
|
47
47
|
lines << ""
|
|
48
48
|
lines << "- Execution: `#{report[:execution_id]}`"
|
|
49
49
|
lines << "- Status: `#{report[:status]}`"
|
|
50
|
-
lines << "- Outputs: #{inline_hash(
|
|
50
|
+
lines << "- Outputs: #{inline_hash(presented_outputs)}"
|
|
51
51
|
lines << "- Nodes: total=#{report[:nodes][:total]}, succeeded=#{report[:nodes][:succeeded]}, failed=#{report[:nodes][:failed]}, stale=#{report[:nodes][:stale]}"
|
|
52
52
|
unless report[:collection_nodes].empty?
|
|
53
53
|
lines << "- Collections: #{report[:collection_nodes].map { |node| "#{node[:node_name]} total=#{node[:total]} succeeded=#{node[:succeeded]} failed=#{node[:failed]} status=#{node[:status]}" }.join('; ')}"
|
|
@@ -170,6 +170,13 @@ module Igniter
|
|
|
170
170
|
"Outputs: #{inline_hash(outputs)}"
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
+
def presented_outputs
|
|
174
|
+
@presented_outputs ||= execution.compiled_graph.outputs.each_with_object({}) do |output_node, memo|
|
|
175
|
+
raw_value = to_h[:outputs][output_node.name]
|
|
176
|
+
memo[output_node.name] = present_output(output_node.name, raw_value)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
173
180
|
def format_nodes(nodes)
|
|
174
181
|
line = "Nodes: total=#{nodes[:total]}, succeeded=#{nodes[:succeeded]}, failed=#{nodes[:failed]}, stale=#{nodes[:stale]}"
|
|
175
182
|
line = "Nodes: total=#{nodes[:total]}, succeeded=#{nodes[:succeeded]}, failed=#{nodes[:failed]}, pending=#{nodes[:pending]}, stale=#{nodes[:stale]}"
|
|
@@ -203,7 +210,27 @@ module Igniter
|
|
|
203
210
|
end
|
|
204
211
|
|
|
205
212
|
def inline_hash(hash)
|
|
206
|
-
hash.map { |key, value| "#{key}=#{value
|
|
213
|
+
hash.map { |key, value| "#{key}=#{inline_value(value)}" }.join(", ")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def present_output(output_name, raw_value)
|
|
217
|
+
presenter = execution.contract_instance.class.output_presenters[output_name.to_sym]
|
|
218
|
+
return raw_value unless presenter
|
|
219
|
+
|
|
220
|
+
if presenter.is_a?(Symbol) || presenter.is_a?(String)
|
|
221
|
+
execution.contract_instance.public_send(
|
|
222
|
+
presenter,
|
|
223
|
+
value: raw_value,
|
|
224
|
+
contract: execution.contract_instance,
|
|
225
|
+
execution: execution
|
|
226
|
+
)
|
|
227
|
+
else
|
|
228
|
+
presenter.call(
|
|
229
|
+
value: raw_value,
|
|
230
|
+
contract: execution.contract_instance,
|
|
231
|
+
execution: execution
|
|
232
|
+
)
|
|
233
|
+
end
|
|
207
234
|
end
|
|
208
235
|
|
|
209
236
|
def serialize_value(value)
|
|
@@ -221,6 +248,78 @@ module Igniter
|
|
|
221
248
|
end
|
|
222
249
|
end
|
|
223
250
|
|
|
251
|
+
def inline_value(value)
|
|
252
|
+
case value
|
|
253
|
+
when Hash
|
|
254
|
+
return summarize_serialized_collection_hash(value) if serialized_collection_hash?(value)
|
|
255
|
+
return summarize_serialized_collection_items_hash(value) if serialized_collection_items_hash?(value)
|
|
256
|
+
|
|
257
|
+
"{#{value.map { |key, nested| "#{key}: #{inline_value(nested)}" }.join(', ')}}"
|
|
258
|
+
when Array
|
|
259
|
+
"[#{value.map { |item| inline_value(item) }.join(', ')}]"
|
|
260
|
+
when Runtime::Result
|
|
261
|
+
summarize_nested_result(value)
|
|
262
|
+
when Runtime::CollectionResult
|
|
263
|
+
summarize_collection_result(value)
|
|
264
|
+
else
|
|
265
|
+
value.inspect
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def summarize_nested_result(result)
|
|
270
|
+
outputs = result.to_h.keys
|
|
271
|
+
"{graph=#{result.execution.compiled_graph.name.inspect}, status=#{nested_result_status(result).inspect}, outputs=#{outputs.inspect}}"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def summarize_collection_result(result)
|
|
275
|
+
summary = result.summary
|
|
276
|
+
failed_keys = result.failures.keys
|
|
277
|
+
"{mode=#{result.mode.inspect}, total=#{summary[:total]}, succeeded=#{summary[:succeeded]}, failed=#{summary[:failed]}, status=#{summary[:status].inspect}, keys=#{result.keys.inspect}, failed_keys=#{failed_keys.inspect}}"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def serialized_collection_hash?(value)
|
|
281
|
+
value.key?(:mode) && value.key?(:summary) && value.key?(:items)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def summarize_serialized_collection_hash(value)
|
|
285
|
+
summary = value[:summary] || {}
|
|
286
|
+
items = value[:items] || {}
|
|
287
|
+
failed_keys = items.each_with_object([]) do |(key, item), memo|
|
|
288
|
+
memo << key if item[:status] == :failed || item["status"] == :failed
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
"{mode=#{value[:mode].inspect}, total=#{summary[:total]}, succeeded=#{summary[:succeeded]}, failed=#{summary[:failed]}, status=#{summary[:status].inspect}, keys=#{items.keys.inspect}, failed_keys=#{failed_keys.inspect}}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def serialized_collection_items_hash?(value)
|
|
295
|
+
return false if value.empty?
|
|
296
|
+
|
|
297
|
+
value.values.all? do |item|
|
|
298
|
+
item.is_a?(Hash) && (item.key?(:key) || item.key?("key")) && (item.key?(:status) || item.key?("status"))
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def summarize_serialized_collection_items_hash(value)
|
|
303
|
+
failed_keys = value.each_with_object([]) do |(key, item), memo|
|
|
304
|
+
status = item[:status] || item["status"]
|
|
305
|
+
memo << key if status == :failed || status == "failed"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
total = value.size
|
|
309
|
+
failed = failed_keys.size
|
|
310
|
+
succeeded = total - failed
|
|
311
|
+
status = failed.zero? ? :succeeded : :partial_failure
|
|
312
|
+
|
|
313
|
+
"{mode=:collect, total=#{total}, succeeded=#{succeeded}, failed=#{failed}, status=#{status.inspect}, keys=#{value.keys.inspect}, failed_keys=#{failed_keys.inspect}}"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def nested_result_status(result)
|
|
317
|
+
return :failed if result.failed?
|
|
318
|
+
return :pending if result.pending?
|
|
319
|
+
|
|
320
|
+
:succeeded
|
|
321
|
+
end
|
|
322
|
+
|
|
224
323
|
def summarize_collection_nodes
|
|
225
324
|
execution.cache.values.filter_map do |state|
|
|
226
325
|
next unless state.value.is_a?(Runtime::CollectionResult)
|
|
@@ -17,6 +17,7 @@ module Igniter
|
|
|
17
17
|
UNDEFINED_INPUT_DEFAULT = :__igniter_undefined__
|
|
18
18
|
UNDEFINED_CONST_VALUE = :__igniter_const_undefined__
|
|
19
19
|
UNDEFINED_GUARD_MATCHER = :__igniter_guard_matcher_undefined__
|
|
20
|
+
UNDEFINED_PROJECT_OPTION = :__igniter_project_undefined__
|
|
20
21
|
|
|
21
22
|
def input(name, type: nil, required: nil, default: UNDEFINED_INPUT_DEFAULT, **metadata)
|
|
22
23
|
input_metadata = with_source_location(metadata)
|
|
@@ -71,6 +72,33 @@ module Igniter
|
|
|
71
72
|
compute(name, with: from, call: call, executor: executor, **{ category: :map }.merge(metadata), &block)
|
|
72
73
|
end
|
|
73
74
|
|
|
75
|
+
def project(name, from:, key: UNDEFINED_PROJECT_OPTION, dig: UNDEFINED_PROJECT_OPTION, default: UNDEFINED_PROJECT_OPTION, **metadata)
|
|
76
|
+
if key != UNDEFINED_PROJECT_OPTION && dig != UNDEFINED_PROJECT_OPTION
|
|
77
|
+
raise CompileError, "project :#{name} cannot use both `key:` and `dig:`"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if key == UNDEFINED_PROJECT_OPTION && dig == UNDEFINED_PROJECT_OPTION
|
|
81
|
+
raise CompileError, "project :#{name} requires either `key:` or `dig:`"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
callable = proc do |**values|
|
|
85
|
+
source = values.fetch(from.to_sym)
|
|
86
|
+
extract_projected_value(
|
|
87
|
+
source,
|
|
88
|
+
key: key,
|
|
89
|
+
dig: dig,
|
|
90
|
+
default: default,
|
|
91
|
+
node_name: name
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
compute(name, with: from, call: callable, **{ category: :project }.merge(metadata))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def aggregate(name, depends_on: nil, with: nil, call: nil, executor: nil, **metadata, &block)
|
|
99
|
+
compute(name, depends_on: depends_on, with: with, call: call, executor: executor, **{ category: :aggregate }.merge(metadata), &block)
|
|
100
|
+
end
|
|
101
|
+
|
|
74
102
|
def guard(name, depends_on: nil, with: nil, call: nil, executor: nil, message: nil,
|
|
75
103
|
eq: UNDEFINED_GUARD_MATCHER, in: UNDEFINED_GUARD_MATCHER, matches: UNDEFINED_GUARD_MATCHER,
|
|
76
104
|
**metadata, &block)
|
|
@@ -156,9 +184,12 @@ module Igniter
|
|
|
156
184
|
)
|
|
157
185
|
end
|
|
158
186
|
|
|
159
|
-
def branch(name, with:, inputs
|
|
187
|
+
def branch(name, with:, inputs: nil, depends_on: nil, map_inputs: nil, using: nil, **metadata, &block)
|
|
160
188
|
raise CompileError, "branch :#{name} requires a block" unless block
|
|
161
|
-
raise CompileError, "branch :#{name} requires
|
|
189
|
+
raise CompileError, "branch :#{name} requires either `inputs:` or `map_inputs:`/`using:`" if inputs.nil? && map_inputs.nil? && using.nil?
|
|
190
|
+
raise CompileError, "branch :#{name} cannot combine `inputs:` with `map_inputs:` or `using:`" if inputs && (map_inputs || using)
|
|
191
|
+
raise CompileError, "branch :#{name} cannot use both `map_inputs:` and `using:`" if map_inputs && using
|
|
192
|
+
raise CompileError, "branch :#{name} requires an `inputs:` hash" if inputs && !inputs.is_a?(Hash)
|
|
162
193
|
|
|
163
194
|
definition = BranchBuilder.build(&block)
|
|
164
195
|
|
|
@@ -169,14 +200,18 @@ module Igniter
|
|
|
169
200
|
selector_dependency: with,
|
|
170
201
|
cases: definition[:cases],
|
|
171
202
|
default_contract: definition[:default_contract],
|
|
172
|
-
input_mapping: inputs,
|
|
203
|
+
input_mapping: inputs || {},
|
|
204
|
+
context_dependencies: normalize_dependencies(depends_on: depends_on, with: nil),
|
|
205
|
+
input_mapper: map_inputs || using,
|
|
173
206
|
path: scoped_path(name),
|
|
174
207
|
metadata: with_source_location(metadata)
|
|
175
208
|
)
|
|
176
209
|
)
|
|
177
210
|
end
|
|
178
211
|
|
|
179
|
-
def collection(name, with:, each:, key:, mode: :collect, **metadata)
|
|
212
|
+
def collection(name, with:, each:, key:, mode: :collect, depends_on: nil, map_inputs: nil, using: nil, **metadata)
|
|
213
|
+
raise CompileError, "collection :#{name} cannot use both `map_inputs:` and `using:`" if map_inputs && using
|
|
214
|
+
|
|
180
215
|
add_node(
|
|
181
216
|
Model::CollectionNode.new(
|
|
182
217
|
id: next_id,
|
|
@@ -185,6 +220,8 @@ module Igniter
|
|
|
185
220
|
contract_class: each,
|
|
186
221
|
key_name: key,
|
|
187
222
|
mode: mode,
|
|
223
|
+
context_dependencies: normalize_dependencies(depends_on: depends_on, with: nil),
|
|
224
|
+
input_mapper: map_inputs || using,
|
|
188
225
|
path: scoped_path(name),
|
|
189
226
|
metadata: with_source_location(metadata)
|
|
190
227
|
)
|
|
@@ -254,6 +291,32 @@ module Igniter
|
|
|
254
291
|
end
|
|
255
292
|
end
|
|
256
293
|
|
|
294
|
+
def extract_projected_value(source, key:, dig:, default:, node_name:)
|
|
295
|
+
if key != UNDEFINED_PROJECT_OPTION
|
|
296
|
+
return fetch_project_value(source, key, default, node_name)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
current = source
|
|
300
|
+
Array(dig).each do |part|
|
|
301
|
+
current = fetch_project_value(current, part, default, node_name)
|
|
302
|
+
end
|
|
303
|
+
current
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def fetch_project_value(source, part, default, node_name)
|
|
307
|
+
if source.is_a?(Hash)
|
|
308
|
+
return source.fetch(part) if source.key?(part)
|
|
309
|
+
return source.fetch(part.to_s) if source.key?(part.to_s)
|
|
310
|
+
return source.fetch(part.to_sym) if source.key?(part.to_sym)
|
|
311
|
+
elsif source.respond_to?(part)
|
|
312
|
+
return source.public_send(part)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
return default unless default == UNDEFINED_PROJECT_OPTION
|
|
316
|
+
|
|
317
|
+
raise ResolutionError, "project :#{node_name} could not extract #{part.inspect}"
|
|
318
|
+
end
|
|
319
|
+
|
|
257
320
|
def scoped_path(name)
|
|
258
321
|
return name.to_s if @scope_stack.empty?
|
|
259
322
|
|
|
@@ -39,14 +39,18 @@ module Igniter
|
|
|
39
39
|
if node.kind == :branch
|
|
40
40
|
cases = node.cases.map { |entry| "#{entry[:match].inspect}:#{entry[:contract].name || 'AnonymousContract'}" }
|
|
41
41
|
line += " selector=#{node.selector_dependency}"
|
|
42
|
+
line += " depends_on=#{node.context_dependencies.join(',')}" if node.context_dependencies.any?
|
|
42
43
|
line += " cases=#{cases.join('|')}"
|
|
43
44
|
line += " default=#{node.default_contract.name || 'AnonymousContract'}"
|
|
45
|
+
line += " mapper=#{node.input_mapper}" if node.input_mapper?
|
|
44
46
|
end
|
|
45
47
|
if node.kind == :collection
|
|
46
48
|
line += " with=#{node.source_dependency}"
|
|
49
|
+
line += " depends_on=#{node.context_dependencies.join(',')}" if node.context_dependencies.any?
|
|
47
50
|
line += " each=#{node.contract_class.name || 'AnonymousContract'}"
|
|
48
51
|
line += " key=#{node.key_name}"
|
|
49
52
|
line += " mode=#{node.mode}"
|
|
53
|
+
line += " mapper=#{node.input_mapper}" if node.input_mapper?
|
|
50
54
|
end
|
|
51
55
|
lines << line
|
|
52
56
|
end
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
module Model
|
|
5
5
|
class BranchNode < Node
|
|
6
|
-
attr_reader :selector_dependency, :cases, :default_contract, :input_mapping
|
|
6
|
+
attr_reader :selector_dependency, :cases, :default_contract, :input_mapping, :context_dependencies, :input_mapper
|
|
7
7
|
|
|
8
|
-
def initialize(id:, name:, selector_dependency:, cases:, default_contract:, input_mapping:, path: nil, metadata: {})
|
|
9
|
-
dependencies = ([selector_dependency] + input_mapping.values).uniq
|
|
8
|
+
def initialize(id:, name:, selector_dependency:, cases:, default_contract:, input_mapping:, context_dependencies: [], input_mapper: nil, path: nil, metadata: {})
|
|
9
|
+
dependencies = ([selector_dependency] + input_mapping.values + context_dependencies).uniq
|
|
10
10
|
|
|
11
11
|
super(
|
|
12
12
|
id: id,
|
|
@@ -21,12 +21,18 @@ module Igniter
|
|
|
21
21
|
@cases = cases.map { |entry| normalize_case(entry) }.freeze
|
|
22
22
|
@default_contract = default_contract
|
|
23
23
|
@input_mapping = input_mapping.transform_keys(&:to_sym).transform_values(&:to_sym).freeze
|
|
24
|
+
@context_dependencies = Array(context_dependencies).map(&:to_sym).freeze
|
|
25
|
+
@input_mapper = input_mapper
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def possible_contracts
|
|
27
29
|
(cases.map { |entry| entry[:contract] } + [default_contract]).uniq
|
|
28
30
|
end
|
|
29
31
|
|
|
32
|
+
def input_mapper?
|
|
33
|
+
!input_mapper.nil?
|
|
34
|
+
end
|
|
35
|
+
|
|
30
36
|
private
|
|
31
37
|
|
|
32
38
|
def normalize_case(entry)
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
module Model
|
|
5
5
|
class CollectionNode < Node
|
|
6
|
-
attr_reader :source_dependency, :contract_class, :key_name, :mode
|
|
6
|
+
attr_reader :source_dependency, :contract_class, :key_name, :mode, :context_dependencies, :input_mapper
|
|
7
7
|
|
|
8
|
-
def initialize(id:, name:, source_dependency:, contract_class:, key_name:, mode:, path: nil, metadata: {})
|
|
8
|
+
def initialize(id:, name:, source_dependency:, contract_class:, key_name:, mode:, context_dependencies: [], input_mapper: nil, path: nil, metadata: {})
|
|
9
9
|
super(
|
|
10
10
|
id: id,
|
|
11
11
|
kind: :collection,
|
|
12
12
|
name: name,
|
|
13
13
|
path: (path || name),
|
|
14
|
-
dependencies: [source_dependency],
|
|
14
|
+
dependencies: [source_dependency, *context_dependencies],
|
|
15
15
|
metadata: metadata
|
|
16
16
|
)
|
|
17
17
|
|
|
@@ -19,6 +19,12 @@ module Igniter
|
|
|
19
19
|
@contract_class = contract_class
|
|
20
20
|
@key_name = key_name.to_sym
|
|
21
21
|
@mode = mode.to_sym
|
|
22
|
+
@context_dependencies = Array(context_dependencies).map(&:to_sym)
|
|
23
|
+
@input_mapper = input_mapper
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def input_mapper?
|
|
27
|
+
!input_mapper.nil?
|
|
22
28
|
end
|
|
23
29
|
end
|
|
24
30
|
end
|
|
@@ -123,10 +123,18 @@ module Igniter
|
|
|
123
123
|
|
|
124
124
|
raise BranchSelectionError, "Branch '#{node.name}' has no matching case and no default" unless selected_contract
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
memo[
|
|
126
|
+
context_values = node.context_dependencies.each_with_object({}) do |dependency_name, memo|
|
|
127
|
+
memo[dependency_name] = resolve_dependency_value(dependency_name)
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
child_inputs = if node.input_mapper?
|
|
131
|
+
map_branch_inputs(node, selector_value, context_values)
|
|
132
|
+
else
|
|
133
|
+
node.input_mapping.each_with_object({}) do |(child_input_name, dependency_name), memo|
|
|
134
|
+
memo[child_input_name] = resolve_dependency_value(dependency_name)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
130
138
|
@execution.events.emit(
|
|
131
139
|
:branch_selected,
|
|
132
140
|
node: node,
|
|
@@ -147,9 +155,22 @@ module Igniter
|
|
|
147
155
|
NodeState.new(node: node, status: :succeeded, value: child_contract.result)
|
|
148
156
|
end
|
|
149
157
|
|
|
158
|
+
def map_branch_inputs(node, selector_value, context_values)
|
|
159
|
+
mapper = node.input_mapper
|
|
160
|
+
|
|
161
|
+
if mapper.is_a?(Symbol) || mapper.is_a?(String)
|
|
162
|
+
return @execution.contract_instance.public_send(mapper, selector: selector_value, **context_values)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
mapper.call(selector: selector_value, **context_values)
|
|
166
|
+
end
|
|
167
|
+
|
|
150
168
|
def resolve_collection(node)
|
|
151
169
|
items = resolve_dependency_value(node.source_dependency)
|
|
152
|
-
|
|
170
|
+
context_values = node.context_dependencies.each_with_object({}) do |dependency_name, memo|
|
|
171
|
+
memo[dependency_name] = resolve_dependency_value(dependency_name)
|
|
172
|
+
end
|
|
173
|
+
normalized_items = normalize_collection_items(node, items, context_values)
|
|
153
174
|
collection_items = {}
|
|
154
175
|
|
|
155
176
|
normalized_items.each do |item_inputs|
|
|
@@ -310,7 +331,11 @@ module Igniter
|
|
|
310
331
|
)
|
|
311
332
|
end
|
|
312
333
|
|
|
313
|
-
def normalize_collection_items(node, items)
|
|
334
|
+
def normalize_collection_items(node, items, context_values = {})
|
|
335
|
+
if node.input_mapper? && items.is_a?(Hash)
|
|
336
|
+
items = items.to_a
|
|
337
|
+
end
|
|
338
|
+
|
|
314
339
|
unless items.is_a?(Array)
|
|
315
340
|
raise CollectionInputError.new(
|
|
316
341
|
"Collection '#{node.name}' expects an array, got #{items.class}",
|
|
@@ -318,7 +343,13 @@ module Igniter
|
|
|
318
343
|
)
|
|
319
344
|
end
|
|
320
345
|
|
|
321
|
-
|
|
346
|
+
mapped_items = if node.input_mapper?
|
|
347
|
+
items.map { |item| map_collection_item_inputs(node, item, context_values) }
|
|
348
|
+
else
|
|
349
|
+
items
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
mapped_items.each do |item|
|
|
322
353
|
next if item.is_a?(Hash)
|
|
323
354
|
|
|
324
355
|
raise CollectionInputError.new(
|
|
@@ -327,8 +358,18 @@ module Igniter
|
|
|
327
358
|
)
|
|
328
359
|
end
|
|
329
360
|
|
|
330
|
-
ensure_unique_collection_keys!(node,
|
|
331
|
-
|
|
361
|
+
ensure_unique_collection_keys!(node, mapped_items)
|
|
362
|
+
mapped_items.map { |item| item.transform_keys(&:to_sym) }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def map_collection_item_inputs(node, item, context_values)
|
|
366
|
+
mapper = node.input_mapper
|
|
367
|
+
|
|
368
|
+
if mapper.is_a?(Symbol) || mapper.is_a?(String)
|
|
369
|
+
return @execution.contract_instance.public_send(mapper, item: item, **context_values)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
mapper.call(item: item, **context_values)
|
|
332
373
|
end
|
|
333
374
|
|
|
334
375
|
def extract_collection_key(node, item_inputs)
|
data/lib/igniter/version.rb
CHANGED