Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docusaurus/docs/bpmn/ch07b-BPMN-Constructs.md
Original file line number Diff line number Diff line change
Expand Up @@ -3940,6 +3940,8 @@ For example, assuming the myService.getCollectionVariableName() returns a String
</multiInstanceLoopCharacteristics>
</userTask>

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.

<userTask id="miTasks" name="My Task" flowable:assignee="${assignee}">
Expand Down
2 changes: 2 additions & 0 deletions docs/docusaurus/docs/cmmn/ch06-cmmn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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*.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -209,7 +210,8 @@ protected static PlanItemInstanceEntity createPlanItemInstanceDuplicateForCollec
// check, if we need to set local variables as the item or item index
Map<String, Object> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PlanItemInstance> 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")
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/CMMN/20151109/MODEL"
xmlns:flowable="http://flowable.org/cmmn"
targetNamespace="http://flowable.org/cmmn">
<case id="repetitionWithJsonArray" name="Repetition with JSON array">
<casePlanModel id="casePlanModel" name="Case plan model">
<planItem id="planItem1" name="Task (${vars:getOrDefault('item', 'na')})" definitionRef="humanTask1">
<itemControl>
<repetitionRule flowable:collectionVariable="myCollection" flowable:elementVariable="item" flowable:elementIndexVariable="itemIndex" />
</itemControl>
</planItem>
<humanTask id="humanTask1" name="Task (${vars:getOrDefault('item', 'na')})" flowable:assignee="${item}" />
</casePlanModel>
</case>
</definitions>
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,10 @@ protected List<ELResolver> createDefaultElResolvers() {
if (jsonNodeElResolver != null) {
elResolvers.add(jsonNodeElResolver);
}
ELResolver jsonValueNodeMethodsElResolver = createJsonValueNodeMethodsElResolver();
if (jsonValueNodeMethodsElResolver != null) {
elResolvers.add(jsonValueNodeMethodsElResolver);
}
if (preBeanResolvers != null) {
elResolvers.addAll(preBeanResolvers);
}
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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;
}
}
Loading
Loading