From 269ceab4cbdf465252376bf144e3401f016248fc Mon Sep 17 00:00:00 2001 From: Filip Hrisafov Date: Fri, 23 Oct 2026 15:32:02 +0200 Subject: [PATCH] Expose plain values for JSON array elements in multi instance and repetition collections JSON value nodes (text, number, boolean, null) used as the element variable of a BPMN multi instance or CMMN collection repetition are now set as plain Java values. Jackson value node accessor methods (e.g. asText(), asString(), asInt(), isTextual()) keep working on String, Number and Boolean values through a new EL resolver. --- .../docs/bpmn/ch07b-BPMN-Constructs.md | 2 + docs/docusaurus/docs/cmmn/ch06-cmmn.md | 2 + .../impl/util/PlanItemInstanceUtil.java | 4 +- ...RepetitionWithJsonArrayCollectionTest.java | 63 +++++ ...lectionTest.repetitionWithStringItems.cmmn | 15 ++ .../impl/el/DefaultExpressionManager.java | 9 + .../el/JsonValueNodeMethodsELResolver.java | 251 ++++++++++++++++++ .../common/engine/impl/util/JsonUtil.java | 33 +++ .../MultiInstanceActivityBehavior.java | 4 +- .../impl/scripting/JuelScriptEngine.java | 2 + ...ltiInstanceJsonCollectionJackson2Test.java | 195 ++++++++++++++ .../MultiInstanceJsonCollectionTest.java | 244 +++++++++++++++++ ...ectionTest.elementVariableTypes.bpmn20.xml | 21 ++ ...ionTest.jacksonMethodsOnElement.bpmn20.xml | 21 ++ ...ollectionTest.parallelUserTasks.bpmn20.xml | 21 ++ 15 files changed, 885 insertions(+), 2 deletions(-) create mode 100644 modules/flowable-cmmn-engine/src/test/java/org/flowable/cmmn/test/itemcontrol/PlanItemRepetitionWithJsonArrayCollectionTest.java create mode 100644 modules/flowable-cmmn-engine/src/test/resources/org/flowable/cmmn/test/itemcontrol/PlanItemRepetitionWithJsonArrayCollectionTest.repetitionWithStringItems.cmmn create mode 100644 modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/el/JsonValueNodeMethodsELResolver.java create mode 100644 modules/flowable-engine/src/test/java/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionJackson2Test.java create mode 100644 modules/flowable-engine/src/test/java/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.java create mode 100644 modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.elementVariableTypes.bpmn20.xml create mode 100644 modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.jacksonMethodsOnElement.bpmn20.xml create mode 100644 modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.parallelUserTasks.bpmn20.xml diff --git a/docs/docusaurus/docs/bpmn/ch07b-BPMN-Constructs.md b/docs/docusaurus/docs/bpmn/ch07b-BPMN-Constructs.md index 4e0273f726f..e503a2c60d4 100644 --- a/docs/docusaurus/docs/bpmn/ch07b-BPMN-Constructs.md +++ b/docs/docusaurus/docs/bpmn/ch07b-BPMN-Constructs.md @@ -3940,6 +3940,8 @@ For example, assuming the myService.getCollectionVariableName() returns a String +The collection can also be a JSON array. Elements of a JSON array that are plain values are exposed as plain Java values in the element variable: a JSON string becomes a String, a JSON number becomes a number (e.g. Integer, Long or Double), a JSON boolean becomes a Boolean and a JSON null becomes null. For example, when assigneeList is the JSON array `["kermit", "gonzo"]`, the assignee variable is the String kermit (respectively gonzo), so it can be used directly for the assignee or compared in an expression such as `${assignee == 'kermit'}`. JSON objects and arrays are exposed as JSON. For compatibility, the Jackson accessor methods (e.g. `${assignee.asText()}`, `${assignee.asString()}`, `${assignee.textValue()}`, `${assignee.asInt()}`, `${assignee.booleanValue()}` or `${assignee.isTextual()}`) can still be used on such element variables in expressions. + A multi-instance activity ends when all instances are finished. However, it is possible to specify an expression that is evaluated every time an instance ends. When this expression evaluates to true, all remaining instances are destroyed and the multi-instance activity ends, continuing the process. Such an expression must be defined in the **completionCondition** child element. diff --git a/docs/docusaurus/docs/cmmn/ch06-cmmn.md b/docs/docusaurus/docs/cmmn/ch06-cmmn.md index 41dd5f73b96..09d95ab6e20 100644 --- a/docs/docusaurus/docs/cmmn/ch06-cmmn.md +++ b/docs/docusaurus/docs/cmmn/ch06-cmmn.md @@ -1113,6 +1113,8 @@ Here is an example of a repetition rule combined with a collection variable: The example uses a collection variable named *myCollection* and has an element and even element index variable specified. They both get used for the plan item instance name used in its name expression. As there is no entry sentry, the collection gets evaluated in each evaluation cycle and as soon as *myCollection* is no longer null, it is used for repetition. +The collection can also be a JSON array. Elements of a JSON array that are plain values are exposed as plain Java values in the element variable: a JSON string becomes a String, a JSON number becomes a number (e.g. Integer, Long or Double), a JSON boolean becomes a Boolean and a JSON null becomes null. This means that for a *myCollection* JSON array `["A", "B"]` the names in the example above are *Task (A - 0)* and *Task (B - 1)*. JSON objects and arrays are exposed as JSON. For compatibility, the Jackson accessor methods (e.g. `${item.asText()}`, `${item.asString()}`, `${item.textValue()}`, `${item.asInt()}`, `${item.booleanValue()}` or `${item.isTextual()}`) can still be used on such element variables in expressions. + ### Item control: Manual Activation Rule Plan items on the case model can have a *manual activation rule*: an expression that can be used to indicate a certain plan item needs to be *manually activated by an end-user*. diff --git a/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/util/PlanItemInstanceUtil.java b/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/util/PlanItemInstanceUtil.java index 7aa83bd51de..ca233f6f30f 100644 --- a/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/util/PlanItemInstanceUtil.java +++ b/modules/flowable-cmmn-engine/src/main/java/org/flowable/cmmn/engine/impl/util/PlanItemInstanceUtil.java @@ -25,6 +25,7 @@ import org.flowable.cmmn.model.PlanItem; import org.flowable.cmmn.model.RepetitionRule; import org.flowable.common.engine.impl.interceptor.CommandContext; +import org.flowable.common.engine.impl.util.JsonUtil; public class PlanItemInstanceUtil { @@ -209,7 +210,8 @@ protected static PlanItemInstanceEntity createPlanItemInstanceDuplicateForCollec // check, if we need to set local variables as the item or item index Map localVariables = new HashMap<>(2); if (repetitionRule.hasElementVariable()) { - localVariables.put(repetitionRule.getElementVariableName(), item); + // JSON value nodes (e.g. elements of a JSON array) are exposed as plain Java values + localVariables.put(repetitionRule.getElementVariableName(), JsonUtil.unwrapJsonValueNode(item)); } if (repetitionRule.hasElementIndexVariable()) { localVariables.put(repetitionRule.getElementIndexVariableName(), index); diff --git a/modules/flowable-cmmn-engine/src/test/java/org/flowable/cmmn/test/itemcontrol/PlanItemRepetitionWithJsonArrayCollectionTest.java b/modules/flowable-cmmn-engine/src/test/java/org/flowable/cmmn/test/itemcontrol/PlanItemRepetitionWithJsonArrayCollectionTest.java new file mode 100644 index 00000000000..21d04148328 --- /dev/null +++ b/modules/flowable-cmmn-engine/src/test/java/org/flowable/cmmn/test/itemcontrol/PlanItemRepetitionWithJsonArrayCollectionTest.java @@ -0,0 +1,63 @@ +/* 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 org.flowable.cmmn.test.itemcontrol; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.flowable.cmmn.api.runtime.PlanItemInstanceState.ACTIVE; + +import java.util.List; + +import org.flowable.cmmn.api.runtime.CaseInstance; +import org.flowable.cmmn.api.runtime.PlanItemInstance; +import org.flowable.cmmn.engine.test.CmmnDeployment; +import org.flowable.cmmn.test.FlowableCmmnTestCase; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.node.ArrayNode; + +/** + * @author Filip Hrisafov + */ +public class PlanItemRepetitionWithJsonArrayCollectionTest extends FlowableCmmnTestCase { + + @Test + @CmmnDeployment + public void repetitionWithStringItems() { + ArrayNode collection = cmmnEngineConfiguration.getObjectMapper().createArrayNode() + .add("A") + .add("B"); + + CaseInstance caseInstance = cmmnRuntimeService.createCaseInstanceBuilder() + .caseDefinitionKey("repetitionWithJsonArray") + .variable("myCollection", collection) + .start(); + + List planItemInstances = getPlanItemInstances(caseInstance.getId()); + assertThat(planItemInstances).hasSize(2); + assertPlanItemInstanceState(planItemInstances, "Task (A)", ACTIVE); + assertPlanItemInstanceState(planItemInstances, "Task (B)", ACTIVE); + + for (PlanItemInstance planItemInstance : planItemInstances) { + assertThat(cmmnRuntimeService.getLocalVariable(planItemInstance.getId(), "item")).isInstanceOf(String.class); + } + + assertThat(cmmnTaskService.createTaskQuery().caseInstanceId(caseInstance.getId()).list()) + .extracting(Task::getName, Task::getAssignee) + .containsExactlyInAnyOrder( + tuple("Task (A)", "A"), + tuple("Task (B)", "B") + ); + } +} diff --git a/modules/flowable-cmmn-engine/src/test/resources/org/flowable/cmmn/test/itemcontrol/PlanItemRepetitionWithJsonArrayCollectionTest.repetitionWithStringItems.cmmn b/modules/flowable-cmmn-engine/src/test/resources/org/flowable/cmmn/test/itemcontrol/PlanItemRepetitionWithJsonArrayCollectionTest.repetitionWithStringItems.cmmn new file mode 100644 index 00000000000..94276cc0fd6 --- /dev/null +++ b/modules/flowable-cmmn-engine/src/test/resources/org/flowable/cmmn/test/itemcontrol/PlanItemRepetitionWithJsonArrayCollectionTest.repetitionWithStringItems.cmmn @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/el/DefaultExpressionManager.java b/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/el/DefaultExpressionManager.java index ed402bc9d84..0876c5d2082 100644 --- a/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/el/DefaultExpressionManager.java +++ b/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/el/DefaultExpressionManager.java @@ -141,6 +141,10 @@ protected List createDefaultElResolvers() { if (jsonNodeElResolver != null) { elResolvers.add(jsonNodeElResolver); } + ELResolver jsonValueNodeMethodsElResolver = createJsonValueNodeMethodsElResolver(); + if (jsonValueNodeMethodsElResolver != null) { + elResolvers.add(jsonValueNodeMethodsElResolver); + } if (preBeanResolvers != null) { elResolvers.addAll(preBeanResolvers); } @@ -167,6 +171,11 @@ protected ELResolver createJsonNodeElResolver() { return jsonNodeResolver == null ? new JsonNodeELResolver() : jsonNodeResolver; } + protected ELResolver createJsonValueNodeMethodsElResolver() { + // Supports invoking JSON value node methods (e.g. asText()) on plain String, Number and Boolean values + return new JsonValueNodeMethodsELResolver(); + } + protected ELResolver createBeanElResolver() { return beanResolver == null ? new BeanELResolver() : beanResolver; } diff --git a/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/el/JsonValueNodeMethodsELResolver.java b/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/el/JsonValueNodeMethodsELResolver.java new file mode 100644 index 00000000000..5044b267314 --- /dev/null +++ b/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/el/JsonValueNodeMethodsELResolver.java @@ -0,0 +1,251 @@ +/* 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 org.flowable.common.engine.impl.el; + +import java.math.BigDecimal; +import java.math.BigInteger; + +import org.flowable.common.engine.impl.javax.el.ELContext; +import org.flowable.common.engine.impl.javax.el.ELResolver; + +/** + * An {@link ELResolver} that supports invoking the Jackson (2 and 3) value node accessor methods + * (e.g. {@code asText()}, {@code asString()}, {@code textValue()}, {@code asInt()}, {@code isTextual()}) + * on plain {@link String}, {@link Number} and {@link Boolean} values. + *

+ * Plain values are used in places where JSON value nodes used to be exposed + * (e.g. the element variable of a multi instance or repetition collection that is a JSON array). + * This resolver keeps expressions that call such methods working. + * The methods behave like they do on the corresponding Jackson 2 value node, + * which is the lenient superset of the Jackson 3 behavior. + * Methods that are not supported by this resolver are left to the other resolvers. + * + * @author Filip Hrisafov + */ +public class JsonValueNodeMethodsELResolver extends ELResolver { + + protected static final Object NOT_RESOLVED = new Object(); + + @Override + public Object getValue(ELContext context, Object base, Object property) { + return null; + } + + @Override + public Class getType(ELContext context, Object base, Object property) { + return null; + } + + @Override + public void setValue(ELContext context, Object base, Object property, Object value) { + // Nothing to do, this resolver only supports invoking methods + } + + @Override + public boolean isReadOnly(ELContext context, Object base, Object property) { + return false; + } + + @Override + public Class getCommonPropertyType(ELContext context, Object base) { + return null; + } + + @Override + public Object invoke(ELContext context, Object base, Object method, Class[] paramTypes, Object[] params) { + if (method == null || !isResolvable(base)) { + return null; + } + + Object result; + if (params == null || params.length == 0) { + result = invokeWithoutParameters(base, method.toString()); + } else if (params.length == 1) { + result = invokeWithDefaultValue(base, method.toString(), params[0]); + } else { + result = NOT_RESOLVED; + } + + if (result == NOT_RESOLVED) { + return null; + } + + context.setPropertyResolved(true); + return result; + } + + protected Object invokeWithoutParameters(Object base, String methodName) { + return switch (methodName) { + case "asText", "asString" -> base.toString(); + case "textValue", "stringValue" -> base instanceof String ? base : null; + case "asInt" -> asInt(base, 0); + case "asLong" -> asLong(base, 0L); + case "asDouble" -> asDouble(base, 0.0); + case "asBoolean" -> asBoolean(base, false); + case "intValue" -> base instanceof Number number ? number.intValue() : 0; + case "longValue" -> base instanceof Number number ? number.longValue() : 0L; + case "doubleValue" -> base instanceof Number number ? number.doubleValue() : 0.0; + case "floatValue" -> base instanceof Number number ? number.floatValue() : 0.0f; + case "decimalValue" -> base instanceof Number number ? toBigDecimal(number) : BigDecimal.ZERO; + case "bigIntegerValue" -> base instanceof Number number ? toBigDecimal(number).toBigInteger() : BigInteger.ZERO; + case "numberValue" -> base instanceof Number ? base : null; + case "booleanValue" -> base instanceof Boolean ? base : Boolean.FALSE; + case "isTextual", "isString" -> base instanceof String; + case "isNumber" -> base instanceof Number; + case "isInt" -> base instanceof Integer; + case "isLong" -> base instanceof Long; + case "isDouble" -> base instanceof Double; + case "isBigDecimal" -> base instanceof BigDecimal; + case "isBigInteger" -> base instanceof BigInteger; + case "isBoolean" -> base instanceof Boolean; + case "isValueNode" -> true; + case "isNull", "isMissingNode", "isContainerNode", "isContainer", "isObject", "isArray" -> false; + default -> NOT_RESOLVED; + }; + } + + protected Object invokeWithDefaultValue(Object base, String methodName, Object defaultValue) { + return switch (methodName) { + // Jackson returns the default value only for null or missing nodes + case "asText", "asString" -> base.toString(); + case "asInt" -> defaultValue instanceof Number number ? asInt(base, number.intValue()) : NOT_RESOLVED; + case "asLong" -> defaultValue instanceof Number number ? asLong(base, number.longValue()) : NOT_RESOLVED; + case "asDouble" -> defaultValue instanceof Number number ? asDouble(base, number.doubleValue()) : NOT_RESOLVED; + case "asBoolean" -> defaultValue instanceof Boolean booleanValue ? asBoolean(base, booleanValue) : NOT_RESOLVED; + default -> NOT_RESOLVED; + }; + } + + protected int asInt(Object base, int defaultValue) { + if (base instanceof Number number) { + return number.intValue(); + } else if (base instanceof Boolean booleanValue) { + return booleanValue ? 1 : 0; + } + + String value = ((String) base).trim(); + if (value.isEmpty()) { + return defaultValue; + } + + if (isIntegral(value)) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + try { + return (int) Double.parseDouble(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + protected long asLong(Object base, long defaultValue) { + if (base instanceof Number number) { + return number.longValue(); + } else if (base instanceof Boolean booleanValue) { + return booleanValue ? 1L : 0L; + } + + String value = ((String) base).trim(); + if (value.isEmpty()) { + return defaultValue; + } + + if (isIntegral(value)) { + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + try { + return (long) Double.parseDouble(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + protected double asDouble(Object base, double defaultValue) { + if (base instanceof Number number) { + return number.doubleValue(); + } else if (base instanceof Boolean booleanValue) { + return booleanValue ? 1.0 : 0.0; + } + + String value = ((String) base).trim(); + if (value.isEmpty()) { + return defaultValue; + } + + try { + return Double.parseDouble(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + protected boolean asBoolean(Object base, boolean defaultValue) { + if (base instanceof Boolean booleanValue) { + return booleanValue; + } else if (base instanceof String string) { + String value = string.trim(); + if ("true".equals(value)) { + return true; + } else if ("false".equals(value)) { + return false; + } + return defaultValue; + } else if (base instanceof BigInteger bigInteger) { + return bigInteger.signum() != 0; + } else if (base instanceof Integer || base instanceof Long || base instanceof Short || base instanceof Byte) { + return ((Number) base).longValue() != 0; + } + + // Jackson returns the default value for floating point numbers + return defaultValue; + } + + protected boolean isIntegral(String value) { + int start = value.charAt(0) == '-' || value.charAt(0) == '+' ? 1 : 0; + if (start == value.length()) { + return false; + } + for (int i = start; i < value.length(); i++) { + char c = value.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + protected BigDecimal toBigDecimal(Number number) { + if (number instanceof BigDecimal bigDecimal) { + return bigDecimal; + } else if (number instanceof BigInteger bigInteger) { + return new BigDecimal(bigInteger); + } else if (number instanceof Double || number instanceof Float) { + return BigDecimal.valueOf(number.doubleValue()); + } + return BigDecimal.valueOf(number.longValue()); + } + + protected boolean isResolvable(Object base) { + return base instanceof String || base instanceof Number || base instanceof Boolean; + } +} diff --git a/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/util/JsonUtil.java b/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/util/JsonUtil.java index 7e3725cab48..972527b6f25 100644 --- a/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/util/JsonUtil.java +++ b/modules/flowable-engine-common/src/main/java/org/flowable/common/engine/impl/util/JsonUtil.java @@ -179,4 +179,37 @@ public static boolean isJsonNode(Object value) { return false; } + + /** + * Unwrap a Jackson (2 or 3) JSON value node into its plain Java value. + * A text node becomes a {@link String}, a boolean node a {@link Boolean} and a JSON null {@code null}. + * Number nodes become an {@link Integer} (int and short), {@link Long}, {@link Double} (double and float), + * {@link java.math.BigDecimal} or {@link java.math.BigInteger}. + * Any other value (including object and array nodes) is returned as is. + */ + public static Object unwrapJsonValueNode(Object value) { + FlowableJsonNode jsonNode = asFlowableJsonNode(value); + if (jsonNode == null || !jsonNode.isValueNode()) { + return value; + } + + if (jsonNode.isNull()) { + return null; + } else if (jsonNode.isString()) { + return jsonNode.asString(); + } else if (jsonNode.isBoolean()) { + return jsonNode.booleanValue(); + } else if (jsonNode.isShort() || jsonNode.isInt()) { + return jsonNode.intValue(); + } else if (jsonNode.isLong()) { + return jsonNode.longValue(); + } else if (jsonNode.isDouble() || jsonNode.isFloat()) { + return jsonNode.doubleValue(); + } else if (jsonNode.isBigDecimal() || jsonNode.isBigInteger()) { + return jsonNode.numberValue(); + } + + // Other value nodes (e.g. binary or POJO nodes) are kept as they are + return value; + } } diff --git a/modules/flowable-engine/src/main/java/org/flowable/engine/impl/bpmn/behavior/MultiInstanceActivityBehavior.java b/modules/flowable-engine/src/main/java/org/flowable/engine/impl/bpmn/behavior/MultiInstanceActivityBehavior.java index 78418541ec3..d1979c46bd7 100644 --- a/modules/flowable-engine/src/main/java/org/flowable/engine/impl/bpmn/behavior/MultiInstanceActivityBehavior.java +++ b/modules/flowable-engine/src/main/java/org/flowable/engine/impl/bpmn/behavior/MultiInstanceActivityBehavior.java @@ -45,6 +45,7 @@ import org.flowable.common.engine.api.scope.ScopeTypes; import org.flowable.common.engine.impl.el.ExpressionManager; import org.flowable.common.engine.impl.util.CollectionUtil; +import org.flowable.common.engine.impl.util.JsonUtil; import org.flowable.engine.DynamicBpmnConstants; import org.flowable.common.engine.api.delegate.BusinessError; import org.flowable.engine.delegate.DelegateExecution; @@ -472,7 +473,8 @@ protected void executeOriginalBehavior(DelegateExecution execution, ExecutionEnt value = it.next(); index++; } - setLoopVariable(execution, collectionElementVariable, value); + // JSON value nodes (e.g. elements of a JSON array) are exposed as plain Java values + setLoopVariable(execution, collectionElementVariable, JsonUtil.unwrapJsonValueNode(value)); } execution.setCurrentFlowElement(activity); diff --git a/modules/flowable-engine/src/main/java/org/flowable/engine/impl/scripting/JuelScriptEngine.java b/modules/flowable-engine/src/main/java/org/flowable/engine/impl/scripting/JuelScriptEngine.java index 4bab25ed287..0bdeacd9bcd 100644 --- a/modules/flowable-engine/src/main/java/org/flowable/engine/impl/scripting/JuelScriptEngine.java +++ b/modules/flowable-engine/src/main/java/org/flowable/engine/impl/scripting/JuelScriptEngine.java @@ -33,6 +33,7 @@ import org.flowable.common.engine.impl.el.DynamicBeanPropertyELResolver; import org.flowable.common.engine.impl.el.ExpressionFactoryResolver; import org.flowable.common.engine.impl.el.JsonNodeELResolver; +import org.flowable.common.engine.impl.el.JsonValueNodeMethodsELResolver; import org.flowable.common.engine.impl.javax.el.ArrayELResolver; import org.flowable.common.engine.impl.javax.el.BeanELResolver; import org.flowable.common.engine.impl.javax.el.CompositeELResolver; @@ -124,6 +125,7 @@ private ELResolver createElResolver() { compositeResolver.add(new ListELResolver()); compositeResolver.add(new MapELResolver()); compositeResolver.add(new JsonNodeELResolver()); + compositeResolver.add(new JsonValueNodeMethodsELResolver()); compositeResolver.add(new ResourceBundleELResolver()); compositeResolver.add(new DynamicBeanPropertyELResolver(ItemInstance.class, "getFieldValue", "setFieldValue")); compositeResolver.add(new BeanELResolver()); diff --git a/modules/flowable-engine/src/test/java/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionJackson2Test.java b/modules/flowable-engine/src/test/java/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionJackson2Test.java new file mode 100644 index 00000000000..fa0a4909050 --- /dev/null +++ b/modules/flowable-engine/src/test/java/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionJackson2Test.java @@ -0,0 +1,195 @@ +/* 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 org.flowable.engine.test.bpmn.multiinstance; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.SoftAssertions; +import org.flowable.common.engine.impl.json.jackson2.Jackson2VariableJsonMapper; +import org.flowable.engine.ProcessEngineConfiguration; +import org.flowable.engine.impl.cfg.ProcessEngineConfigurationImpl; +import org.flowable.engine.impl.persistence.entity.ExecutionEntity; +import org.flowable.engine.impl.test.ResourceFlowableTestCase; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.engine.test.Deployment; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * @author Filip Hrisafov + */ +public class MultiInstanceJsonCollectionJackson2Test extends ResourceFlowableTestCase { + + protected static final List JACKSON_2_METHODS = List.of( + "asText()", "textValue()", "asInt()", "asLong()", "asDouble()", "asBoolean()", + "intValue()", "longValue()", "doubleValue()", "floatValue()", "decimalValue()", "bigIntegerValue()", "numberValue()", "booleanValue()", + "isTextual()", "isNumber()", "isInt()", "isLong()", "isDouble()", "isBigDecimal()", "isBigInteger()", "isBoolean()", + "isValueNode()", "isNull()", "isMissingNode()", "isContainerNode()", "isObject()", "isArray()", + "asText('default')", "asInt(9)", "asLong(9)", "asDouble(9.5)", "asBoolean(true)" + ); + + // Jackson 3 names that do not exist in Jackson 2, mapped to their Jackson 2 counterpart + protected static final Map JACKSON_3_ONLY_METHODS = Map.of( + "asString()", "asText()", + "asString('default')", "asText('default')", + "stringValue()", "textValue()", + "isString()", "isTextual()", + "isContainer()", "isContainerNode()" + ); + + protected final ObjectMapper objectMapper = new ObjectMapper(); + + public MultiInstanceJsonCollectionJackson2Test() { + super("flowable.cfg.xml", "multiInstanceJsonCollectionJackson2"); + } + + @Override + protected void additionalConfiguration(ProcessEngineConfiguration processEngineConfiguration) { + ((ProcessEngineConfigurationImpl) processEngineConfiguration).setVariableJsonMapper(new Jackson2VariableJsonMapper(objectMapper)); + } + + @Test + @Deployment(resources = "org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.parallelUserTasks.bpmn20.xml") + public void parallelUserTasks() { + ArrayNode assignees = objectMapper.createArrayNode() + .add("kermit") + .add("gonzo"); + + ProcessInstance processInstance = runtimeService.createProcessInstanceBuilder() + .processDefinitionKey("miJsonCollection") + .variable("assignees", assignees) + .start(); + + List tasks = taskService.createTaskQuery().processInstanceId(processInstance.getId()).list(); + assertThat(tasks) + .extracting(Task::getAssignee, Task::getName) + .containsExactlyInAnyOrder( + tuple("kermit", "Task kermit"), + tuple("gonzo", "Task gonzo") + ); + + for (Task task : tasks) { + assertThat(runtimeService.getVariableLocal(task.getExecutionId(), "assignee")) + .isEqualTo(task.getAssignee()); + } + + tasks.forEach(task -> taskService.complete(task.getId())); + assertProcessEnded(processInstance.getId()); + } + + @Test + @Deployment(resources = "org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.elementVariableTypes.bpmn20.xml") + public void elementVariableTypes() { + ArrayNode items = objectMapper.createArrayNode(); + items.add(42); + items.add((short) 7); + items.add(5000000000L); + items.add(2.5); + items.add(1.5f); + items.add(new BigDecimal("1.25")); + items.add(new BigInteger("12345678901234567890123")); + items.add(true); + items.add("text"); + items.addNull(); + items.addObject().put("name", "gonzo"); + items.addArray().add(1).add(2); + + ProcessInstance processInstance = runtimeService.createProcessInstanceBuilder() + .processDefinitionKey("miJsonCollection") + .variable("items", items) + .start(); + + Map itemsByLoopCounter = new HashMap<>(); + List tasks = taskService.createTaskQuery().processInstanceId(processInstance.getId()).list(); + assertThat(tasks).hasSize(12); + for (Task task : tasks) { + Map localVariables = runtimeService.getVariablesLocal(task.getExecutionId()); + assertThat(localVariables).containsKey("item"); + itemsByLoopCounter.put((Integer) localVariables.get("loopCounter"), localVariables.get("item")); + } + + assertThat(itemsByLoopCounter.get(0)).isEqualTo(42); + assertThat(itemsByLoopCounter.get(1)).isEqualTo(7); + assertThat(itemsByLoopCounter.get(2)).isEqualTo(5000000000L); + assertThat(itemsByLoopCounter.get(3)).isEqualTo(2.5); + assertThat(itemsByLoopCounter.get(4)).isEqualTo(1.5); + assertThat(itemsByLoopCounter.get(5)).isEqualTo(new BigDecimal("1.25")); + assertThat(itemsByLoopCounter.get(6)).isEqualTo(new BigInteger("12345678901234567890123")); + assertThat(itemsByLoopCounter.get(7)).isEqualTo(true); + assertThat(itemsByLoopCounter.get(8)).isEqualTo("text"); + assertThat(itemsByLoopCounter.get(9)).isNull(); + assertThat(itemsByLoopCounter.get(10)).isInstanceOf(ObjectNode.class); + assertThatJson(itemsByLoopCounter.get(10)).isEqualTo("{ name: 'gonzo' }"); + assertThat(itemsByLoopCounter.get(11)).isInstanceOf(ArrayNode.class); + assertThatJson(itemsByLoopCounter.get(11)).isEqualTo("[1, 2]"); + } + + @Test + @Deployment(resources = "org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.elementVariableTypes.bpmn20.xml") + public void jacksonMethodsOnElementBehaveLikeJackson2ValueNode() { + List values = MultiInstanceJsonCollectionTest.VALUES; + ArrayNode items = objectMapper.createArrayNode(); + values.forEach(value -> items.add(objectMapper.valueToTree(value))); + + ProcessInstance processInstance = runtimeService.createProcessInstanceBuilder() + .processDefinitionKey("miJsonCollection") + .variable("items", items) + .start(); + + // The method invoked on the element, mapped to the method invoked on the Jackson 2 node + Map methods = new LinkedHashMap<>(); + JACKSON_2_METHODS.forEach(method -> methods.put(method, method)); + methods.putAll(JACKSON_3_ONLY_METHODS); + + SoftAssertions softly = new SoftAssertions(); + getExecutionIdsByLoopCounter(processInstance.getId()).forEach((loopCounter, executionId) -> { + Object node = items.get(loopCounter); + methods.forEach((method, jackson2Method) -> softly.assertThat(evaluate("${item." + method + "}", executionId, null)) + .as("%s.%s", values.get(loopCounter), method) + .isEqualTo(evaluate("${node." + jackson2Method + "}", executionId, node))); + }); + softly.assertAll(); + } + + protected Map getExecutionIdsByLoopCounter(String processInstanceId) { + Map executionIds = new HashMap<>(); + for (Task task : taskService.createTaskQuery().processInstanceId(processInstanceId).list()) { + executionIds.put((Integer) runtimeService.getVariableLocal(task.getExecutionId(), "loopCounter"), task.getExecutionId()); + } + return executionIds; + } + + protected Object evaluate(String expression, String executionId, Object node) { + return managementService.executeCommand(commandContext -> { + ExecutionEntity execution = (ExecutionEntity) runtimeService.createExecutionQuery().executionId(executionId).singleResult(); + if (node != null) { + execution.setTransientVariableLocal("node", node); + } + return processEngineConfiguration.getExpressionManager().createExpression(expression).getValue(execution); + }); + } +} diff --git a/modules/flowable-engine/src/test/java/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.java b/modules/flowable-engine/src/test/java/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.java new file mode 100644 index 00000000000..e2c84a7121c --- /dev/null +++ b/modules/flowable-engine/src/test/java/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.java @@ -0,0 +1,244 @@ +/* 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 org.flowable.engine.test.bpmn.multiinstance; + +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.SoftAssertions; +import org.flowable.engine.impl.persistence.entity.ExecutionEntity; +import org.flowable.engine.impl.test.PluggableFlowableTestCase; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.engine.test.Deployment; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * @author Filip Hrisafov + */ +public class MultiInstanceJsonCollectionTest extends PluggableFlowableTestCase { + + protected static final List VALUES = List.of( + "kermit", "42", " 12 ", "-7", "+5", "1.5", "5000000000", "true", " false ", "TRUE", " ", "-", + 7, 0, -3, 5000000000L, 2.75, 0.0, -1.5, new BigDecimal("1.25"), new BigInteger("12345678901234567890123"), + true, false + ); + + // The methods with a default value are not compared, + // Jackson 3 returns the default value where Jackson 2 (and the element) coerces the value + protected static final List JACKSON_3_METHODS = List.of( + "asString()", "stringValue()", "asText()", "textValue()", "asInt()", "asLong()", "asDouble()", "asBoolean()", + "intValue()", "longValue()", "doubleValue()", "floatValue()", "decimalValue()", "bigIntegerValue()", "numberValue()", "booleanValue()", + "isString()", "isTextual()", "isNumber()", "isInt()", "isLong()", "isDouble()", "isBigDecimal()", "isBigInteger()", "isBoolean()", + "isValueNode()", "isNull()", "isMissingNode()", "isContainer()", "isObject()", "isArray()" + ); + + @Test + @Deployment + public void parallelUserTasks() { + ArrayNode assignees = processEngineConfiguration.getObjectMapper().createArrayNode() + .add("kermit") + .add("gonzo"); + + ProcessInstance processInstance = runtimeService.createProcessInstanceBuilder() + .processDefinitionKey("miJsonCollection") + .variable("assignees", assignees) + .start(); + + List tasks = taskService.createTaskQuery().processInstanceId(processInstance.getId()).list(); + assertThat(tasks) + .extracting(Task::getAssignee, Task::getName) + .containsExactlyInAnyOrder( + tuple("kermit", "Task kermit"), + tuple("gonzo", "Task gonzo") + ); + + for (Task task : tasks) { + assertThat(runtimeService.getVariableLocal(task.getExecutionId(), "assignee")) + .isEqualTo(task.getAssignee()); + } + + tasks.forEach(task -> taskService.complete(task.getId())); + assertProcessEnded(processInstance.getId()); + } + + @Test + @Deployment + public void elementVariableTypes() { + ArrayNode items = processEngineConfiguration.getObjectMapper().createArrayNode(); + items.add(42); + items.add((short) 7); + items.add(5000000000L); + items.add(2.5); + items.add(1.5f); + items.add(new BigDecimal("1.25")); + items.add(new BigInteger("12345678901234567890123")); + items.add(true); + items.add("text"); + items.addNull(); + items.addObject().put("name", "gonzo"); + items.addArray().add(1).add(2); + + ProcessInstance processInstance = runtimeService.createProcessInstanceBuilder() + .processDefinitionKey("miJsonCollection") + .variable("items", items) + .start(); + + Map itemsByLoopCounter = new HashMap<>(); + List tasks = taskService.createTaskQuery().processInstanceId(processInstance.getId()).list(); + assertThat(tasks).hasSize(12); + for (Task task : tasks) { + Map localVariables = runtimeService.getVariablesLocal(task.getExecutionId()); + assertThat(localVariables).containsKey("item"); + itemsByLoopCounter.put((Integer) localVariables.get("loopCounter"), localVariables.get("item")); + } + + assertThat(itemsByLoopCounter.get(0)).isEqualTo(42); + assertThat(itemsByLoopCounter.get(1)).isEqualTo(7); + assertThat(itemsByLoopCounter.get(2)).isEqualTo(5000000000L); + assertThat(itemsByLoopCounter.get(3)).isEqualTo(2.5); + assertThat(itemsByLoopCounter.get(4)).isEqualTo(1.5); + assertThat(itemsByLoopCounter.get(5)).isEqualTo(new BigDecimal("1.25")); + assertThat(itemsByLoopCounter.get(6)).isEqualTo(new BigInteger("12345678901234567890123")); + assertThat(itemsByLoopCounter.get(7)).isEqualTo(true); + assertThat(itemsByLoopCounter.get(8)).isEqualTo("text"); + assertThat(itemsByLoopCounter.get(9)).isNull(); + assertThat(itemsByLoopCounter.get(10)).isInstanceOf(ObjectNode.class); + assertThatJson(itemsByLoopCounter.get(10)).isEqualTo("{ name: 'gonzo' }"); + assertThat(itemsByLoopCounter.get(11)).isInstanceOf(ArrayNode.class); + assertThatJson(itemsByLoopCounter.get(11)).isEqualTo("[1, 2]"); + } + + @Test + @Deployment + public void jacksonMethodsOnElement() { + ArrayNode assignees = processEngineConfiguration.getObjectMapper().createArrayNode() + .add("kermit") + .add("gonzo"); + + ProcessInstance processInstance = runtimeService.createProcessInstanceBuilder() + .processDefinitionKey("miJsonCollection") + .variable("assignees", assignees) + .start(); + + List tasks = taskService.createTaskQuery().processInstanceId(processInstance.getId()).list(); + assertThat(tasks) + .extracting(Task::getAssignee, Task::getName) + .containsExactlyInAnyOrder( + tuple("kermit", "kermit kermit true false"), + tuple("gonzo", "gonzo gonzo true false") + ); + } + + @Test + @Deployment(resources = "org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.elementVariableTypes.bpmn20.xml") + public void jacksonMethodsOnElementBehaveLikeJackson3ValueNode() { + ObjectMapper objectMapper = processEngineConfiguration.getObjectMapper(); + ArrayNode items = objectMapper.createArrayNode(); + VALUES.forEach(value -> items.add(objectMapper.valueToTree(value))); + + ProcessInstance processInstance = runtimeService.createProcessInstanceBuilder() + .processDefinitionKey("miJsonCollection") + .variable("items", items) + .start(); + + SoftAssertions softly = new SoftAssertions(); + getExecutionIdsByLoopCounter(processInstance.getId()).forEach((loopCounter, executionId) -> { + Object node = items.get(loopCounter); + for (String method : JACKSON_3_METHODS) { + Object expected; + try { + expected = evaluate("${node." + method + "}", executionId, node); + } catch (RuntimeException e) { + // Jackson 3 is stricter than Jackson 2 and throws for some conversions, the element is lenient in that case + continue; + } + + softly.assertThat(evaluate("${item." + method + "}", executionId)) + .as("%s.%s", VALUES.get(loopCounter), method) + .isEqualTo(expected); + } + }); + softly.assertAll(); + } + + @Test + @Deployment(resources = "org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.elementVariableTypes.bpmn20.xml") + public void nonJacksonMethodsOnElement() { + ArrayNode items = processEngineConfiguration.getObjectMapper().createArrayNode() + .add("kermit") + .add(42) + .add(true); + + ProcessInstance processInstance = runtimeService.createProcessInstanceBuilder() + .processDefinitionKey("miJsonCollection") + .variable("items", items) + .start(); + + Map executionIds = getExecutionIdsByLoopCounter(processInstance.getId()); + String textExecutionId = executionIds.get(0); + String numberExecutionId = executionIds.get(1); + String booleanExecutionId = executionIds.get(2); + + assertThat(evaluate("${item.length()}", textExecutionId)).isEqualTo(6); + assertThat(evaluate("${item.toUpperCase()}", textExecutionId)).isEqualTo("KERMIT"); + assertThat(evaluate("${item.substring(1)}", textExecutionId)).isEqualTo("ermit"); + assertThat(evaluate("${item.isEmpty()}", textExecutionId)).isEqualTo(false); + assertThat(evaluate("${item.startsWith('ker')}", textExecutionId)).isEqualTo(true); + assertThat(evaluate("${item.toString()}", numberExecutionId)).isEqualTo("42"); + assertThat(evaluate("${item.byteValue()}", numberExecutionId)).isEqualTo((byte) 42); + assertThat(evaluate("${item.toString()}", booleanExecutionId)).isEqualTo("true"); + + assertThatThrownBy(() -> evaluate("${item.unknownMethod()}", textExecutionId)) + .hasMessageContaining("unknownMethod"); + assertThatThrownBy(() -> evaluate("${item.asInt('notANumber')}", textExecutionId)) + .hasMessageContaining("asInt"); + assertThatThrownBy(() -> evaluate("${item.asText(1, 2)}", textExecutionId)) + .hasMessageContaining("asText"); + } + + protected Map getExecutionIdsByLoopCounter(String processInstanceId) { + Map executionIds = new HashMap<>(); + for (Task task : taskService.createTaskQuery().processInstanceId(processInstanceId).list()) { + executionIds.put((Integer) runtimeService.getVariableLocal(task.getExecutionId(), "loopCounter"), task.getExecutionId()); + } + return executionIds; + } + + protected Object evaluate(String expression, String executionId) { + return evaluate(expression, executionId, null); + } + + protected Object evaluate(String expression, String executionId, Object node) { + return managementService.executeCommand(commandContext -> { + ExecutionEntity execution = (ExecutionEntity) runtimeService.createExecutionQuery().executionId(executionId).singleResult(); + if (node != null) { + execution.setTransientVariableLocal("node", node); + } + return processEngineConfiguration.getExpressionManager().createExpression(expression).getValue(execution); + }); + } +} diff --git a/modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.elementVariableTypes.bpmn20.xml b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.elementVariableTypes.bpmn20.xml new file mode 100644 index 00000000000..03097013563 --- /dev/null +++ b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.elementVariableTypes.bpmn20.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.jacksonMethodsOnElement.bpmn20.xml b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.jacksonMethodsOnElement.bpmn20.xml new file mode 100644 index 00000000000..361b53eb81b --- /dev/null +++ b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.jacksonMethodsOnElement.bpmn20.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + diff --git a/modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.parallelUserTasks.bpmn20.xml b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.parallelUserTasks.bpmn20.xml new file mode 100644 index 00000000000..62045cd22cf --- /dev/null +++ b/modules/flowable-engine/src/test/resources/org/flowable/engine/test/bpmn/multiinstance/MultiInstanceJsonCollectionTest.parallelUserTasks.bpmn20.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + +