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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5cac7a900895eb96dbf1b12c619e8ff0ea0cb54557b8d605694710984b4baa37
4
- data.tar.gz: 16b610374e8bbfaedeae1e01b57878e52ff2399ff4a566a62ff5926203ece4c5
3
+ metadata.gz: 659dfe833fdf98b7d1b446e08d02464545fd0fe9b61badd5cf8e7ca73beb6a1b
4
+ data.tar.gz: 5af168ce8c6fad1c18dd0d9703281ea64062c3a6d3e954b12172f29210e12c5b
5
5
  SHA512:
6
- metadata.gz: 28cddf140914921cf31d05255122aeebcb892f5cae91daf27eb37ec4433be7e96b640a9c82175ef4e2995085cd036762d38963d275e4916b1ecbc5f9225cf18c
7
- data.tar.gz: 2fbea23399d3d17d453ea83b9bf747a3a73d45eeb3cf01546d6e73e64c9354808414c7821c6dab28b070133a11d31440108aca3fe63a5a4ee0838407b207732c
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`, per-item routing, and nested diagnostics semantics |
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: :call_inputs,
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
- compute :routing_summary, with: %i[calls call_summaries extension_id telephony_status has_calls] do |calls:, call_summaries:, extension_id:, telephony_status:, has_calls:|
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
- map :body, from: :payload do |payload:|
208
- payload.fetch("body", {})
209
- end
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, with: :telephony_status, inputs: {
225
- extension_id: :extension_id,
226
- telephony_status: :telephony_status,
227
- active_calls: :active_calls
228
- } do
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
 
@@ -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(report[: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(report[:outputs])}"
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.inspect}" }.join(", ")
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:, **metadata, &block)
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 an `inputs:` hash" unless inputs.is_a?(Hash)
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
- child_inputs = node.input_mapping.each_with_object({}) do |(child_input_name, dependency_name), memo|
127
- memo[child_input_name] = resolve_dependency_value(dependency_name)
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
- normalized_items = normalize_collection_items(node, items)
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
- items.each do |item|
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, items)
331
- items.map { |item| item.transform_keys(&:to_sym) }
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Igniter
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: igniter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander