1use crate::engine::functions::FunctionConfig;
6use crate::engine::utils::compute_path_parts;
7use crate::engine::workflow::{is_slot_path, over_literal_problem, slots_overlap};
8use datalogic_rs::Logic;
9use serde::Deserialize;
10use serde_json::Value;
11use std::sync::Arc;
12
13#[derive(Clone, Debug, Deserialize)]
70#[non_exhaustive]
71pub struct ForEach {
72 pub over: Value,
76
77 #[serde(rename = "as")]
80 pub item: String,
81
82 #[serde(default = "one")]
84 pub max_concurrency: usize,
85
86 #[serde(default)]
90 pub collect: Option<String>,
91
92 #[serde(default)]
95 pub into: Option<String>,
96
97 #[doc(hidden)]
99 #[serde(skip)]
100 pub compiled_over: Option<Arc<Logic>>,
101
102 #[doc(hidden)]
104 #[serde(skip)]
105 pub item_parts: Arc<[Arc<str>]>,
106
107 #[doc(hidden)]
109 #[serde(skip)]
110 pub index_parts: Arc<[Arc<str>]>,
111
112 #[doc(hidden)]
114 #[serde(skip)]
115 pub collect_parts: Arc<[Arc<str>]>,
116
117 #[doc(hidden)]
119 #[serde(skip)]
120 pub into_parts: Arc<[Arc<str>]>,
121}
122
123fn one() -> usize {
124 1
125}
126
127fn no_parts() -> Arc<[Arc<str>]> {
128 Arc::from([] as [Arc<str>; 0])
129}
130
131const ROOTS: [&str; 3] = ["data", "metadata", "temp_data"];
133
134fn is_context_path(path: &str) -> bool {
136 let Some((root, rest)) = path.split_once('.') else {
137 return false;
138 };
139 ROOTS.contains(&root) && is_slot_path(rest)
140}
141
142pub(crate) struct ForEachProblem {
147 pub field: &'static str,
148 pub message: String,
149}
150
151impl ForEach {
152 pub fn index_slot(&self) -> String {
154 format!("{}_index", self.item)
155 }
156
157 pub(crate) fn problem(&self, function: &FunctionConfig) -> Option<ForEachProblem> {
163 let at = |field, message: String| Some(ForEachProblem { field, message });
164
165 if function.is_sync_builtin() {
166 return at(
167 "",
168 format!(
169 "for_each runs a handler-backed function once per element, and '{}' is \
170 a built-in that runs inline — use JSONLogic's map, filter or reduce over \
171 the array instead",
172 function.function_name()
173 ),
174 );
175 }
176 if let Some(problem) = over_literal_problem(&self.over) {
177 return at("over", problem.replacen("loop over", "for_each over", 1));
178 }
179 if !is_slot_path(&self.item) {
180 return at(
181 "as",
182 format!(
183 "for_each as must be a non-empty temp_data field path, got {:?}",
184 self.item
185 ),
186 );
187 }
188 if self.max_concurrency == 0 {
189 return at(
190 "max_concurrency",
191 "for_each max_concurrency must be at least 1".to_string(),
192 );
193 }
194
195 let (collect, into) = match (&self.collect, &self.into) {
196 (None, None) => return None,
197 (Some(collect), Some(into)) => (collect, into),
198 (collect, _) => {
199 return at(
200 if collect.is_some() { "collect" } else { "into" },
201 "for_each collect and into go together: collect names what each call \
202 writes, into names the array the results land in"
203 .to_string(),
204 );
205 }
206 };
207 for (field, path) in [("collect", collect), ("into", into)] {
208 if !is_context_path(path) {
209 return at(
210 field,
211 format!(
212 "for_each {field} must be a context path below data, metadata or \
213 temp_data, got {path:?}"
214 ),
215 );
216 }
217 }
218 let item = format!("temp_data.{}", self.item);
219 let index = format!("temp_data.{}", self.index_slot());
220 for binding in [&item, &index] {
221 if slots_overlap(collect, binding) {
222 return at(
223 "collect",
224 format!(
225 "for_each collect ({collect:?}) and {binding:?} overlap — the bindings \
226 live only in each call's copy, and a result written under one would be \
227 replayed into the message and left behind"
228 ),
229 );
230 }
231 }
232 for other in [collect.as_str(), &item, &index] {
233 if slots_overlap(into, other) {
234 return at(
235 "into",
236 format!(
237 "for_each into ({into:?}) and {other:?} overlap — the results array \
238 would overwrite, or be overwritten by, a slot written per call"
239 ),
240 );
241 }
242 }
243 None
244 }
245
246 #[doc(hidden)]
249 pub fn precompute_paths(&mut self) {
250 fn context(path: &Option<String>) -> Arc<[Arc<str>]> {
251 match path.as_deref().and_then(|p| p.split_once('.')) {
252 Some((root, rest)) => compute_path_parts(root, rest),
253 None => no_parts(),
254 }
255 }
256 self.item_parts = compute_path_parts("temp_data", &self.item);
257 self.index_parts = compute_path_parts("temp_data", &self.index_slot());
258 self.collect_parts = context(&self.collect);
259 self.into_parts = context(&self.into);
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use crate::engine::workflow::Workflow;
266 use std::sync::Arc;
267
268 fn task_with(for_each: &str, function: &str) -> Result<Workflow, String> {
269 let wf = Workflow::from_json(&format!(
270 r#"{{ "id": "w", "name": "w", "tasks": [
271 {{ "id": "t", "name": "t", "for_each": {for_each}, "function": {function} }}] }}"#
272 ))
273 .map_err(|e| e.to_string())?;
274 wf.validate().map_err(|e| e.to_string())?;
275 Ok(wf)
276 }
277
278 const CUSTOM: &str = r#"{"name": "infer", "input": {}}"#;
279
280 #[test]
281 fn a_for_each_parses_with_its_defaults() {
282 let wf = task_with(r#"{"over": {"var": "data.ps"}, "as": "p"}"#, CUSTOM).unwrap();
283 let fe = wf.tasks[0].for_each.as_ref().unwrap();
284 assert_eq!(fe.item, "p");
285 assert_eq!(fe.max_concurrency, 1, "sequential by default");
286 assert!(fe.collect.is_none() && fe.into.is_none());
287 assert_eq!(fe.index_slot(), "p_index");
288 assert!(fe.compiled_over.is_none(), "compiled at build");
289 }
290
291 #[test]
292 fn a_task_without_for_each_has_none() {
293 let wf = Workflow::from_json(
294 r#"{"id": "w", "name": "w", "tasks": [
295 {"id": "t", "name": "t", "function": {"name": "infer", "input": {}}}]}"#,
296 )
297 .unwrap();
298 assert!(wf.tasks[0].for_each.is_none());
299 }
300
301 #[test]
302 fn every_rule_refuses_its_shape() {
303 for (for_each, needle) in [
304 (
305 r#"{"over": "data.ps", "as": "p"}"#,
306 "can never evaluate to an array",
307 ),
308 (
309 r#"{"over": 3, "as": "p"}"#,
310 "can never evaluate to an array",
311 ),
312 (r#"{"over": [], "as": "a..b"}"#, "for_each as"),
313 (r#"{"over": [], "as": ""}"#, "for_each as"),
314 (
315 r#"{"over": [], "as": "p", "max_concurrency": 0}"#,
316 "max_concurrency",
317 ),
318 (
319 r#"{"over": [], "as": "p", "collect": "temp_data.m"}"#,
320 "collect and into",
321 ),
322 (
323 r#"{"over": [], "as": "p", "into": "temp_data.ms"}"#,
324 "collect and into",
325 ),
326 (
327 r#"{"over": [], "as": "p", "collect": "m", "into": "temp_data.ms"}"#,
328 "context path",
329 ),
330 (
331 r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "temp_data"}"#,
332 "context path",
333 ),
334 (
335 r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "payload.ms"}"#,
336 "context path",
337 ),
338 (
339 r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "temp_data.m.x"}"#,
340 "overlap",
341 ),
342 (
343 r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "temp_data.p_index"}"#,
344 "overlap",
345 ),
346 (
347 r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "temp_data.p"}"#,
348 "overlap",
349 ),
350 (
354 r#"{"over": [], "as": "p", "collect": "temp_data.p.out", "into": "temp_data.outs"}"#,
355 "for_each collect (",
356 ),
357 (
358 r#"{"over": [], "as": "p", "collect": "temp_data.p", "into": "temp_data.outs"}"#,
359 "for_each collect (",
360 ),
361 (
362 r#"{"over": [], "as": "p", "collect": "temp_data.p_index", "into": "temp_data.outs"}"#,
363 "for_each collect (",
364 ),
365 ] {
366 let err = task_with(for_each, CUSTOM).expect_err(for_each);
367 assert!(err.contains(needle), "{for_each}: {err}");
368 }
369 assert!(
370 task_with(r#"{"as": "p"}"#, CUSTOM).is_err(),
371 "over is required"
372 );
373 assert!(
374 task_with(r#"{"over": []}"#, CUSTOM).is_err(),
375 "as is required"
376 );
377 }
378
379 #[test]
380 fn an_explicit_null_over_is_refused() {
381 let err = task_with(r#"{"over": null, "as": "p"}"#, CUSTOM).expect_err("null");
384 assert!(err.contains("null"), "{err}");
385 }
386
387 #[test]
388 fn valid_shapes_are_accepted() {
389 for for_each in [
390 r#"{"over": [1, 2], "as": "p"}"#,
391 r#"{"over": {"var": "data.ps"}, "as": "cur.p", "max_concurrency": 8}"#,
392 r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "data.ms"}"#,
393 r#"{"over": [], "as": "p", "collect": "temp_data.p_out", "into": "temp_data.outs"}"#,
396 ] {
397 assert!(task_with(for_each, CUSTOM).is_ok(), "{for_each}");
398 }
399 }
400
401 #[test]
402 fn a_for_each_on_a_sync_builtin_is_refused() {
403 let err = task_with(
404 r#"{"over": [], "as": "p"}"#,
405 r#"{"name": "map", "input": {"mappings": []}}"#,
406 )
407 .expect_err("map is a sync built-in");
408 assert!(err.contains("handler-backed"), "{err}");
409 for (name, input) in [
410 ("http_call", r#"{"connector": "c"}"#),
411 ("enrich", r#"{"connector": "c", "merge_path": "data.x"}"#),
412 ("publish_kafka", r#"{"connector": "c", "topic": "t"}"#),
413 ] {
414 let function = format!(r#"{{"name": "{name}", "input": {input}}}"#);
415 assert!(
416 task_with(r#"{"over": [], "as": "p"}"#, &function).is_ok(),
417 "{name} is handler-backed"
418 );
419 }
420 }
421
422 #[test]
423 fn a_group_carrying_for_each_is_refused_at_parse() {
424 let err = Workflow::from_json(
425 r#"{ "id": "w", "name": "w", "tasks": [
426 {"id": "g", "for_each": {"over": [], "as": "p"}, "tasks": [
427 {"id": "t", "name": "t", "function": {"name": "infer", "input": {}}}]}] }"#,
428 )
429 .expect_err("groups cannot fan out");
430 assert!(err.to_string().contains("for_each"), "{err}");
431 }
432
433 #[test]
434 fn a_setup_step_is_validated_too() {
435 let wf = Workflow::from_json(
436 r#"{ "id": "w", "name": "w",
437 "loop": {"max": 2, "setup": [
438 {"id": "s", "name": "s",
439 "for_each": {"over": [], "as": "p", "max_concurrency": 0},
440 "function": {"name": "infer", "input": {}}}]},
441 "tasks": [{"id": "t", "name": "t", "function": {"name": "infer", "input": {}}}] }"#,
442 )
443 .unwrap();
444 assert!(wf.validate().is_err());
445 }
446
447 #[test]
448 fn precompute_paths_splits_every_slot() {
449 let mut fe = task_with(
450 r#"{"over": [], "as": "p", "collect": "temp_data.m", "into": "data.ms"}"#,
451 CUSTOM,
452 )
453 .unwrap()
454 .tasks[0]
455 .for_each
456 .clone()
457 .unwrap();
458 fe.precompute_paths();
459 let s = |p: &Arc<[Arc<str>]>| p.iter().map(|x| x.to_string()).collect::<Vec<_>>();
460 assert_eq!(s(&fe.item_parts), ["temp_data", "p"]);
461 assert_eq!(s(&fe.index_parts), ["temp_data", "p_index"]);
462 assert_eq!(s(&fe.collect_parts), ["temp_data", "m"]);
463 assert_eq!(s(&fe.into_parts), ["data", "ms"]);
464
465 let mut bare = task_with(r#"{"over": [], "as": "p"}"#, CUSTOM)
466 .unwrap()
467 .tasks[0]
468 .for_each
469 .clone()
470 .unwrap();
471 bare.precompute_paths();
472 assert!(bare.collect_parts.is_empty() && bare.into_parts.is_empty());
473 }
474}