diff --git a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncDSL.java b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncDSL.java index cbe692b0..96cd3e6c 100644 --- a/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncDSL.java +++ b/experimental/fluent/func/src/main/java/io/serverlessworkflow/fluent/func/dsl/FuncDSL.java @@ -942,7 +942,22 @@ public static FuncTaskConfigurer tryCatch(String name, Consumer FuncTaskConfigurer switchWhen( Predicate pred, String thenTask, Class predClass) { - return list -> list.switchCase(cases(caseOf(pred, predClass).then(thenTask))); + return switchWhen(null, pred, thenTask, predClass); + } + + /** + * Named variant of {@link #switchWhen(Predicate, String, Class)}. + * + * @param taskName task name for the switch task + * @param pred predicate + * @param thenTask task name when predicate is true + * @param predClass predicate class + * @param predicate input type + * @return list configurer + */ + public static FuncTaskConfigurer switchWhen( + String taskName, Predicate pred, String thenTask, Class predClass) { + return list -> list.switchCase(taskName, cases(caseOf(pred, predClass).then(thenTask))); } /** @@ -961,7 +976,20 @@ public static FuncTaskConfigurer switchWhen( * @return list configurer */ public static FuncTaskConfigurer switchWhen(String jqExpression, String thenTask) { - return list -> list.switchCase(sw -> sw.on(c -> c.when(jqExpression).then(thenTask))); + return switchWhen(null, jqExpression, thenTask); + } + + /** + * Named variant of {@link #switchWhen(String, String)}. + * + * @param taskName task name for the switch task + * @param jqExpression JQ expression evaluated against the current task input + * @param thenTask task name to jump to when the expression evaluates truthy + * @return list configurer + */ + public static FuncTaskConfigurer switchWhen( + String taskName, String jqExpression, String thenTask) { + return list -> list.switchCase(taskName, sw -> sw.on(c -> c.when(jqExpression).then(thenTask))); } /** @@ -976,14 +1004,44 @@ public static FuncTaskConfigurer switchWhen(String jqExpression, String thenTask */ public static FuncTaskConfigurer switchWhenOrElse( Predicate pred, String thenTask, FlowDirectiveEnum otherwise, Class predClass) { + return switchWhenOrElse(null, pred, thenTask, otherwise, predClass); + } + + public static FuncTaskConfigurer switchWhenOrElse( + SerializablePredicate pred, String thenTask, FlowDirectiveEnum otherwise) { + return switchWhenOrElse(null, pred, thenTask, otherwise); + } + + /** + * Named variant of {@link #switchWhenOrElse(Predicate, String, FlowDirectiveEnum, Class)}. + * + * @param taskName task name for the switch task + * @param pred predicate + * @param thenTask task name when predicate is true + * @param otherwise default flow directive when predicate is false + * @param predClass predicate class + * @param predicate input type + * @return list configurer + */ + public static FuncTaskConfigurer switchWhenOrElse( + String taskName, + Predicate pred, + String thenTask, + FlowDirectiveEnum otherwise, + Class predClass) { return list -> list.switchCase( + taskName, FuncDSL.cases(caseOf(pred, predClass).then(thenTask), caseDefault(otherwise))); } public static FuncTaskConfigurer switchWhenOrElse( - SerializablePredicate pred, String thenTask, FlowDirectiveEnum otherwise) { - return switchWhenOrElse(pred, thenTask, otherwise, ReflectionUtils.inferInputType(pred)); + String taskName, + SerializablePredicate pred, + String thenTask, + FlowDirectiveEnum otherwise) { + return switchWhenOrElse( + taskName, pred, thenTask, otherwise, ReflectionUtils.inferInputType(pred)); } /** @@ -998,13 +1056,40 @@ public static FuncTaskConfigurer switchWhenOrElse( */ public static FuncTaskConfigurer switchWhenOrElse( Predicate pred, String thenTask, String otherwiseTask, Class predClass) { - return list -> - list.switchCase(cases(caseOf(pred, predClass).then(thenTask), caseDefault(otherwiseTask))); + return switchWhenOrElse(null, pred, thenTask, otherwiseTask, predClass); } public static FuncTaskConfigurer switchWhenOrElse( SerializablePredicate pred, String thenTask, String otherwiseTask) { - return switchWhenOrElse(pred, thenTask, otherwiseTask, ReflectionUtils.inferInputType(pred)); + return switchWhenOrElse(null, pred, thenTask, otherwiseTask); + } + + /** + * Named variant of {@link #switchWhenOrElse(Predicate, String, String, Class)}. + * + * @param taskName task name for the switch task + * @param pred predicate + * @param thenTask task name when predicate is true + * @param otherwiseTask task name when predicate is false + * @param predClass predicate class + * @param predicate input type + * @return list configurer + */ + public static FuncTaskConfigurer switchWhenOrElse( + String taskName, + Predicate pred, + String thenTask, + String otherwiseTask, + Class predClass) { + return list -> + list.switchCase( + taskName, cases(caseOf(pred, predClass).then(thenTask), caseDefault(otherwiseTask))); + } + + public static FuncTaskConfigurer switchWhenOrElse( + String taskName, SerializablePredicate pred, String thenTask, String otherwiseTask) { + return switchWhenOrElse( + taskName, pred, thenTask, otherwiseTask, ReflectionUtils.inferInputType(pred)); } /** @@ -1024,13 +1109,28 @@ public static FuncTaskConfigurer switchWhenOrElse( */ public static FuncTaskConfigurer switchWhenOrElse( String jqExpression, String thenTask, FlowDirectiveEnum otherwise) { + return switchWhenOrElse(null, jqExpression, thenTask, otherwise); + } + + /** + * Named variant of {@link #switchWhenOrElse(String, String, FlowDirectiveEnum)}. + * + * @param taskName task name for the switch task + * @param jqExpression JQ expression evaluated against the current task input + * @param thenTask task to jump to if the expression evaluates truthy + * @param otherwise default flow directive when the expression is falsy + * @return list configurer + */ + public static FuncTaskConfigurer switchWhenOrElse( + String taskName, String jqExpression, String thenTask, FlowDirectiveEnum otherwise) { Objects.requireNonNull(jqExpression, "jqExpression"); Objects.requireNonNull(thenTask, "thenTask"); Objects.requireNonNull(otherwise, "otherwise"); return list -> - list.switchCase(sw -> sw.on(c -> c.when(jqExpression).then(thenTask)).onDefault(otherwise)); + list.switchCase( + taskName, sw -> sw.on(c -> c.when(jqExpression).then(thenTask)).onDefault(otherwise)); } /** @@ -1050,6 +1150,20 @@ public static FuncTaskConfigurer switchWhenOrElse( */ public static FuncTaskConfigurer switchWhenOrElse( String jqExpression, String thenTask, String otherwiseTask) { + return switchWhenOrElse(null, jqExpression, thenTask, otherwiseTask); + } + + /** + * Named variant of {@link #switchWhenOrElse(String, String, String)}. + * + * @param taskName task name for the switch task + * @param jqExpression JQ expression evaluated against the current task input + * @param thenTask task name when truthy + * @param otherwiseTask task name when falsy + * @return list configurer + */ + public static FuncTaskConfigurer switchWhenOrElse( + String taskName, String jqExpression, String thenTask, String otherwiseTask) { Objects.requireNonNull(jqExpression, "jqExpression"); Objects.requireNonNull(thenTask, "thenTask"); @@ -1057,6 +1171,7 @@ public static FuncTaskConfigurer switchWhenOrElse( return list -> list.switchCase( + taskName, sw -> sw.on(c -> c.when(jqExpression).then(thenTask)).onDefault(otherwiseTask)); } @@ -1070,15 +1185,40 @@ public static FuncTaskConfigurer switchWhenOrElse( */ public static FuncTaskConfigurer forEach( SerializableFunction> collection, Consumer body) { + return forEach(null, collection, body); + } + + /** + * Named variant of {@link #forEach(SerializableFunction, Consumer)}. + * + * @param taskName task name for the for-loop task + * @param collection function that returns the collection to iterate + * @param body inner task list body + * @param input type for the collection function + * @return list configurer + */ + public static FuncTaskConfigurer forEach( + String taskName, + SerializableFunction> collection, + Consumer body) { return list -> list.forEach( + taskName, j -> j.collection(collection, ReflectionUtils.inferInputType(collection)).tasks(body)); } public static FuncTaskConfigurer forEach( SerializableFunction> collection, LoopFunction function) { + return forEach(null, collection, function); + } + + public static FuncTaskConfigurer forEach( + String taskName, + SerializableFunction> collection, + LoopFunction function) { return list -> list.forEach( + taskName, j -> j.collection(collection, ReflectionUtils.inferInputType(collection)) .tasks(function)); @@ -1086,7 +1226,12 @@ public static FuncTaskConfigurer forEach( public static FuncTaskConfigurer forEachItem( SerializableFunction> collection, Function function) { - return forEach(collection, ((t, v) -> function.apply((V) v))); + return forEachItem(null, collection, function); + } + + public static FuncTaskConfigurer forEachItem( + String taskName, SerializableFunction> collection, Function function) { + return forEach(taskName, collection, ((t, v) -> function.apply((V) v))); } /** @@ -1099,8 +1244,22 @@ public static FuncTaskConfigurer forEachItem( */ public static FuncTaskConfigurer forEach( Collection collection, Consumer body) { + return forEach(null, collection, body); + } + + /** + * Named variant of {@link #forEach(Collection, Consumer)}. + * + * @param taskName task name for the for-loop task + * @param collection static collection to iterate + * @param body inner task list body + * @param ignored (kept for signature consistency) + * @return list configurer + */ + public static FuncTaskConfigurer forEach( + String taskName, Collection collection, Consumer body) { Function> f = ctx -> collection; - return list -> list.forEach(j -> j.collection(f).tasks(body)); + return list -> list.forEach(taskName, j -> j.collection(f).tasks(body)); } /** @@ -1113,7 +1272,21 @@ public static FuncTaskConfigurer forEach( */ public static FuncTaskConfigurer forEach( List collection, Consumer body) { - return list -> list.forEach(j -> j.collection(ctx -> collection).tasks(body)); + return forEach(null, collection, body); + } + + /** + * Named variant of {@link #forEach(List, Consumer)}. + * + * @param taskName task name for the for-loop task + * @param collection list to iterate + * @param body inner task list body + * @param element type + * @return list configurer + */ + public static FuncTaskConfigurer forEach( + String taskName, List collection, Consumer body) { + return list -> list.forEach(taskName, j -> j.collection(ctx -> collection).tasks(body)); } /** diff --git a/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTaskNameTest.java b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTaskNameTest.java new file mode 100644 index 00000000..90af3b68 --- /dev/null +++ b/experimental/fluent/func/src/test/java/io/serverlessworkflow/fluent/func/FuncDSLTaskNameTest.java @@ -0,0 +1,297 @@ +/* + * Copyright 2020-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.serverlessworkflow.fluent.func; + +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.consume; +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.forEach; +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.forEachItem; +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.function; +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.switchWhen; +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.switchWhenOrElse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.serverlessworkflow.api.types.FlowDirectiveEnum; +import io.serverlessworkflow.api.types.SwitchCase; +import io.serverlessworkflow.api.types.TaskItem; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.api.types.func.LoopFunction; +import io.serverlessworkflow.api.types.func.SwitchCasePredicate; +import io.serverlessworkflow.fluent.func.configurers.FuncTaskConfigurer; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("FuncDSL — named control-flow overloads") +class FuncDSLTaskNameTest { + + private static Workflow buildWorkflow(FuncTaskConfigurer... steps) { + return FuncWorkflowBuilder.workflow("taskNameTest").tasks(steps).build(); + } + + private static List buildItems(FuncTaskConfigurer... steps) { + return buildWorkflow(steps).getDo(); + } + + @Nested + @DisplayName("switchWhen — named overloads") + class SwitchWhenTest { + + @Test + @DisplayName("named predicate overload uses taskName and stores predicate case") + void namedPredicateOverload() { + var items = + buildItems(switchWhen("checkSign", (Integer v) -> v > 0, "positive", Integer.class)); + assertEquals("checkSign", items.get(0).getName()); + SwitchCase sc = items.get(0).getTask().getSwitchTask().getSwitch().get(0).getSwitchCase(); + assertInstanceOf(SwitchCasePredicate.class, sc); + assertEquals("positive", sc.getThen().getString()); + } + + @Test + @DisplayName("named JQ overload uses taskName and configures when expression") + void namedJqOverload() { + var items = buildItems(switchWhen("approvalGate", ".approved == true", "approveOrder")); + assertEquals("approvalGate", items.get(0).getName()); + assertEquals( + ".approved == true", + items.get(0).getTask().getSwitchTask().getSwitch().get(0).getSwitchCase().getWhen()); + } + + @Test + @DisplayName("unnamed overloads produce auto-generated switch- names") + void unnamedAutoNames() { + var items = + buildItems( + switchWhen((Integer v) -> v > 0, "pos", Integer.class), switchWhen(".ok", "go")); + assertTrue(items.get(0).getName().startsWith("switch-")); + assertTrue(items.get(1).getName().startsWith("switch-")); + } + } + + @Nested + @DisplayName("switchWhenOrElse — named overloads") + class SwitchWhenOrElseTest { + + @Test + @DisplayName("named Predicate + directive overload") + void namedPredicateDirective() { + var items = + buildItems( + switchWhenOrElse( + "scoreGate", + (Integer v) -> v >= 80, + "pass", + FlowDirectiveEnum.END, + Integer.class)); + assertEquals("scoreGate", items.get(0).getName()); + var cases = items.get(0).getTask().getSwitchTask().getSwitch(); + assertEquals(2, cases.size()); + assertInstanceOf(SwitchCasePredicate.class, cases.get(0).getSwitchCase()); + assertEquals("pass", cases.get(0).getSwitchCase().getThen().getString()); + assertEquals( + FlowDirectiveEnum.END, cases.get(1).getSwitchCase().getThen().getFlowDirectiveEnum()); + } + + @Test + @DisplayName("named SerializablePredicate + orElseTask overload") + void namedSerializablePredicateTask() { + var items = + buildItems(switchWhenOrElse("signGate", (Integer v) -> v > 0, "positive", "negative")); + assertEquals("signGate", items.get(0).getName()); + var cases = items.get(0).getTask().getSwitchTask().getSwitch(); + assertEquals("positive", cases.get(0).getSwitchCase().getThen().getString()); + assertEquals("negative", cases.get(1).getSwitchCase().getThen().getString()); + } + + @Test + @DisplayName("named JQ + directive overload") + void namedJqDirective() { + var items = + buildItems(switchWhenOrElse("examGate", ".score >= 80", "pass", FlowDirectiveEnum.END)); + assertEquals("examGate", items.get(0).getName()); + var cases = items.get(0).getTask().getSwitchTask().getSwitch(); + assertEquals(".score >= 80", cases.get(0).getSwitchCase().getWhen()); + assertEquals( + FlowDirectiveEnum.END, cases.get(1).getSwitchCase().getThen().getFlowDirectiveEnum()); + } + + @Test + @DisplayName("named JQ + orElseTask overload") + void namedJqTask() { + var items = buildItems(switchWhenOrElse("approvalGate", ".approved", "send", "draft")); + assertEquals("approvalGate", items.get(0).getName()); + var cases = items.get(0).getTask().getSwitchTask().getSwitch(); + assertEquals(".approved", cases.get(0).getSwitchCase().getWhen()); + assertEquals("draft", cases.get(1).getSwitchCase().getThen().getString()); + } + + @Test + @DisplayName("unnamed overloads produce auto-generated switch- names") + void unnamedAutoNames() { + var items = + buildItems( + switchWhenOrElse((Integer v) -> v > 0, "pos", FlowDirectiveEnum.END, Integer.class), + switchWhenOrElse((Integer v) -> v > 0, "pos", "neg"), + switchWhenOrElse(".ok", "go", FlowDirectiveEnum.END), + switchWhenOrElse(".ok", "go", "nope")); + for (int i = 0; i < items.size(); i++) { + assertTrue(items.get(i).getName().startsWith("switch-"), "item " + i); + } + } + + @Test + @DisplayName("JQ overloads throw NPE on null args") + void jqNullArgValidation() { + assertThrows( + NullPointerException.class, + () -> switchWhenOrElse((String) null, "pass", FlowDirectiveEnum.END)); + assertThrows( + NullPointerException.class, + () -> switchWhenOrElse("gate", (String) null, "pass", FlowDirectiveEnum.END)); + assertThrows( + NullPointerException.class, + () -> switchWhenOrElse("gate", ".x", (String) null, FlowDirectiveEnum.END)); + assertThrows( + NullPointerException.class, + () -> switchWhenOrElse("gate", ".x", "pass", (FlowDirectiveEnum) null)); + assertThrows( + NullPointerException.class, () -> switchWhenOrElse((String) null, "send", "draft")); + assertThrows( + NullPointerException.class, + () -> switchWhenOrElse("gate", (String) null, "send", "draft")); + assertThrows( + NullPointerException.class, () -> switchWhenOrElse("gate", ".x", (String) null, "draft")); + assertThrows( + NullPointerException.class, () -> switchWhenOrElse("gate", ".x", "send", (String) null)); + } + } + + @Nested + @DisplayName("forEach / forEachItem — named overloads") + class ForEachTest { + + @Test + @DisplayName("named SerializableFunction + body overload") + void namedFunctionBody() { + var items = buildItems(forEach("splitItems", (String s) -> List.of(s.split(",")), tb -> {})); + assertEquals("splitItems", items.get(0).getName()); + assertNotNull(items.get(0).getTask().getForTask()); + } + + @Test + @DisplayName("named SerializableFunction + LoopFunction overload") + void namedFunctionLoop() { + LoopFunction loopFn = (ctx, item) -> ctx; + var items = buildItems(forEach("mapItems", (String s) -> List.of(s.split(",")), loopFn)); + assertEquals("mapItems", items.get(0).getName()); + } + + @Test + @DisplayName("named forEachItem overload") + void namedForEachItem() { + var items = + buildItems( + forEachItem( + "transformEach", (String s) -> List.of(s.split(",")), (String item) -> item)); + assertEquals("transformEach", items.get(0).getName()); + } + + @Test + @DisplayName("named Collection + body overload") + void namedCollectionBody() { + var items = buildItems(forEach("iterateItems", List.of("a", "b"), tb -> {})); + assertEquals("iterateItems", items.get(0).getName()); + } + + @Test + @DisplayName("unnamed overloads produce auto-generated for- names") + void unnamedAutoNames() { + LoopFunction loopFn = (ctx, item) -> ctx; + var items = + buildItems( + forEach((String s) -> List.of(s), tb -> {}), + forEach((String s) -> List.of(s), loopFn), + forEachItem((String s) -> List.of(s), (String x) -> x), + forEach(List.of("a"), tb -> {}), + forEach(List.of("x", "y"), tb -> {})); + for (int i = 0; i < items.size(); i++) { + assertTrue(items.get(i).getName().startsWith("for-"), "item " + i); + } + } + } + + @Nested + @DisplayName("Integration — named control flow in realistic workflows") + class IntegrationTest { + + @Test + @DisplayName("named switchWhenOrElse with named branches") + void namedSwitchWithNamedBranches() { + Workflow wf = + buildWorkflow( + function("loadData", (String s) -> s, String.class), + switchWhenOrElse( + "validateData", + (String s) -> !s.isEmpty(), + "processValid", + "handleInvalid", + String.class), + consume("processValid", (String s) -> {}, String.class), + consume("handleInvalid", (String s) -> {}, String.class)); + var items = wf.getDo(); + assertEquals("loadData", items.get(0).getName()); + assertEquals("validateData", items.get(1).getName()); + assertEquals("processValid", items.get(2).getName()); + assertEquals("handleInvalid", items.get(3).getName()); + } + + @Test + @DisplayName("mixed named and unnamed control flow tasks") + void mixedNamedAndUnnamed() { + var items = + buildItems( + switchWhen("firstGate", (Integer v) -> v > 0, "pos", Integer.class), + switchWhen((String s) -> s.isEmpty(), "empty", String.class), + forEach("namedLoop", List.of(1, 2), tb -> {}), + forEach(List.of("x"), tb -> {})); + assertEquals("firstGate", items.get(0).getName()); + assertTrue(items.get(1).getName().startsWith("switch-")); + assertEquals("namedLoop", items.get(2).getName()); + assertTrue(items.get(3).getName().startsWith("for-")); + } + + @Test + @DisplayName("named JQ switchWhenOrElse in workflow") + void namedJqSwitchIntegration() { + var items = + buildItems( + function("fetchOrder", (String s) -> s, String.class), + switchWhenOrElse("checkApproval", ".approved == true", "fulfillOrder", "rejectOrder"), + consume("fulfillOrder", (String s) -> {}, String.class), + consume("rejectOrder", (String s) -> {}, String.class)); + assertEquals("checkApproval", items.get(1).getName()); + var cases = items.get(1).getTask().getSwitchTask().getSwitch(); + assertEquals(2, cases.size()); + assertEquals(".approved == true", cases.get(0).getSwitchCase().getWhen()); + assertEquals("rejectOrder", cases.get(1).getSwitchCase().getThen().getString()); + } + } +} diff --git a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ForExecutor.java b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ForExecutor.java index 26ca371b..ce9fb6bf 100644 --- a/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ForExecutor.java +++ b/impl/core/src/main/java/io/serverlessworkflow/impl/executors/ForExecutor.java @@ -80,17 +80,19 @@ protected CompletableFuture internalExecute( CompletableFuture future = CompletableFuture.completedFuture(taskContext.input()); while (iter.hasNext()) { - taskContext.variables().put(task.getFor().getEach(), iter.next()); - taskContext.variables().put(task.getFor().getAt(), i++); - if (whileExpr.map(w -> w.test(workflow, taskContext, taskContext.input())).orElse(true)) { - future = - future.thenCompose( - input -> - TaskExecutorHelper.processTaskList( - taskExecutor, workflow, Optional.of(taskContext), input)); - } else { - break; - } + Object currentItem = iter.next(); + int currentIndex = i++; + future = + future.thenCompose( + input -> { + taskContext.variables().put(task.getFor().getEach(), currentItem); + taskContext.variables().put(task.getFor().getAt(), currentIndex); + if (whileExpr.isPresent() && !whileExpr.get().test(workflow, taskContext, input)) { + return CompletableFuture.completedFuture(input); + } + return TaskExecutorHelper.processTaskList( + taskExecutor, workflow, Optional.of(taskContext), input); + }); } return future; }