|
|
@@ -9,12 +9,7 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
|
|
|
import org.flowable.cmmn.api.CmmnRuntimeService;
|
|
|
|
import org.flowable.cmmn.api.CmmnRuntimeService;
|
|
|
|
import org.flowable.cmmn.api.runtime.CaseInstance;
|
|
|
|
import org.flowable.cmmn.api.runtime.CaseInstance;
|
|
|
|
import org.flowable.cmmn.api.runtime.PlanItemInstance;
|
|
|
|
import org.flowable.cmmn.api.runtime.PlanItemInstance;
|
|
|
|
import org.flowable.cmmn.engine.CmmnEngineConfiguration;
|
|
|
|
|
|
|
|
import org.flowable.cmmn.engine.impl.persistence.entity.CaseInstanceEntity;
|
|
|
|
import org.flowable.cmmn.engine.impl.persistence.entity.CaseInstanceEntity;
|
|
|
|
import org.flowable.common.engine.api.delegate.event.FlowableEngineEntityEvent;
|
|
|
|
|
|
|
|
import org.flowable.common.engine.api.delegate.event.FlowableEvent;
|
|
|
|
|
|
|
|
import org.flowable.common.engine.api.delegate.event.FlowableEventListener;
|
|
|
|
|
|
|
|
import org.flowable.engine.ProcessEngineConfiguration;
|
|
|
|
|
|
|
|
import org.flowable.engine.RuntimeService;
|
|
|
|
import org.flowable.engine.RuntimeService;
|
|
|
|
import org.flowable.engine.delegate.DelegateExecution;
|
|
|
|
import org.flowable.engine.delegate.DelegateExecution;
|
|
|
|
import org.flowable.engine.impl.persistence.entity.ExecutionEntity;
|
|
|
|
import org.flowable.engine.impl.persistence.entity.ExecutionEntity;
|
|
|
@@ -23,7 +18,6 @@ import org.flowable.engine.runtime.ProcessInstance;
|
|
|
|
import org.flowable.variable.api.delegate.VariableScope;
|
|
|
|
import org.flowable.variable.api.delegate.VariableScope;
|
|
|
|
import org.slf4j.Logger;
|
|
|
|
import org.slf4j.Logger;
|
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import org.springframework.beans.factory.SmartInitializingSingleton;
|
|
|
|
|
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
import org.springframework.stereotype.Component;
|
|
|
|
import org.springframework.stereotype.Component;
|
|
|
|
|
|
|
|
|
|
|
@@ -33,29 +27,19 @@ import java.util.List;
|
|
|
|
import java.util.Map;
|
|
|
|
import java.util.Map;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* General-purpose Flowable variable utility bean.
|
|
|
|
* General-purpose Flowable variable utility bean for tracking variable changes.
|
|
|
|
* Usable in BPMN process and CMMN case backend expressions without any scope parameter:
|
|
|
|
* Usable in BPMN process and CMMN case backend expressions:
|
|
|
|
* ${varUtils.get('order.customer.name')}
|
|
|
|
* ${varUtils.trackVars('root.snapshot', 'order.customer.name,order.total,status')}
|
|
|
|
* ${varUtils.track('order.customer.name,order.total,status')}
|
|
|
|
* ${varUtils.trackVars(execution, 'root.snapshot', 'status')} — BPMN, explicit scope
|
|
|
|
* Root variables are supported.
|
|
|
|
* ${varUtils.trackVars(planItemInstance, 'root.snapshot', 'status')} — CMMN, explicit scope
|
|
|
|
* How the scope is resolved without a parameter
|
|
|
|
* Variable paths use dot notation; a leading "root." prefix addresses the root
|
|
|
|
* -----------------------------------------------
|
|
|
|
* instance of a nested case/process hierarchy.
|
|
|
|
* The bean registers itself as a Flowable event listener on both engines.
|
|
|
|
|
|
|
|
* Before a service task expression is evaluated, Flowable fires events on the
|
|
|
|
|
|
|
|
* same thread that allow us to capture the current variable scope:
|
|
|
|
|
|
|
|
* BPMN — PROCESS_STARTED fires a FlowableEngineEntityEvent.
|
|
|
|
|
|
|
|
* The execution is retrieved via event.getEntity().
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
* CMMN — CASE_STARTED fires a FlowableEngineEntityEvent.; Instead, when a plan
|
|
|
|
|
|
|
|
* The case instance is retrieved via event.getEntity().
|
|
|
|
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
@Component("varUtils")
|
|
|
|
@Component("varUtils")
|
|
|
|
public class VarUtils implements FlowableEventListener, SmartInitializingSingleton {
|
|
|
|
public class VarUtils {
|
|
|
|
|
|
|
|
|
|
|
|
private static final Logger LOGGER = LoggerFactory.getLogger(VarUtils.class);
|
|
|
|
private static final Logger LOGGER = LoggerFactory.getLogger(VarUtils.class);
|
|
|
|
|
|
|
|
|
|
|
|
private static final ThreadLocal<VariableScope> SCOPE = new ThreadLocal<>();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private static final ObjectMapper MAPPER;
|
|
|
|
private static final ObjectMapper MAPPER;
|
|
|
|
static {
|
|
|
|
static {
|
|
|
|
MAPPER = new ObjectMapper();
|
|
|
|
MAPPER = new ObjectMapper();
|
|
|
@@ -63,72 +47,12 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
|
|
|
MAPPER.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
|
|
|
private ProcessEngineConfiguration processEngineConfiguration;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
|
|
|
|
private CmmnEngineConfiguration cmmnEngineConfiguration;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
@Autowired
|
|
|
|
private RuntimeService runtimeService;
|
|
|
|
private RuntimeService runtimeService;
|
|
|
|
|
|
|
|
|
|
|
|
@Autowired
|
|
|
|
@Autowired
|
|
|
|
private CmmnRuntimeService cmmnRuntimeService;
|
|
|
|
private CmmnRuntimeService cmmnRuntimeService;
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
// Listener registration — runs after all beans are ready
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
|
|
|
public void afterSingletonsInstantiated() {
|
|
|
|
|
|
|
|
if (processEngineConfiguration != null) {
|
|
|
|
|
|
|
|
processEngineConfiguration.getEventDispatcher().addEventListener(this);
|
|
|
|
|
|
|
|
LOGGER.debug("varUtils registered on BPMN event dispatcher");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (cmmnEngineConfiguration != null) {
|
|
|
|
|
|
|
|
cmmnEngineConfiguration.getEventDispatcher().addEventListener(this);
|
|
|
|
|
|
|
|
LOGGER.debug("varUtils registered on CMMN event dispatcher");
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
// FlowableEventListener — bind / unbind the scope ThreadLocal
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
|
|
|
public void onEvent(FlowableEvent event) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (event instanceof FlowableEngineEntityEvent entityEvent) {
|
|
|
|
|
|
|
|
String typeName = event.getType().name();
|
|
|
|
|
|
|
|
Object entity = entityEvent.getEntity();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// BPMN execution
|
|
|
|
|
|
|
|
if (entity instanceof ExecutionEntity execution) {
|
|
|
|
|
|
|
|
switch (typeName) {
|
|
|
|
|
|
|
|
case "PROCESS_STARTED"
|
|
|
|
|
|
|
|
-> SCOPE.set(execution);
|
|
|
|
|
|
|
|
case "PROCESS_CANCELLED",
|
|
|
|
|
|
|
|
"PROCESS_COMPLETED",
|
|
|
|
|
|
|
|
"PROCESS_COMPLETED_WITH_ERROR_END_EVENT",
|
|
|
|
|
|
|
|
"PROCESS_COMPLETED_WITH_ESCALATION_END_EVENT",
|
|
|
|
|
|
|
|
"PROCESS_COMPLETED_WITH_TERMINATE_END_EVENT"
|
|
|
|
|
|
|
|
-> SCOPE.remove();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// CMMN case instance itself
|
|
|
|
|
|
|
|
if (entity instanceof CaseInstanceEntity caseInstance) {
|
|
|
|
|
|
|
|
if ("CASE_STARTED".equals(typeName))
|
|
|
|
|
|
|
|
SCOPE.set(caseInstance);
|
|
|
|
|
|
|
|
else if ("CASE_ENDED".equals(typeName))
|
|
|
|
|
|
|
|
SCOPE.remove();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@Override public boolean isFailOnException() { return false; }
|
|
|
|
|
|
|
|
@Override public boolean isFireOnTransactionLifecycleEvent() { return false; }
|
|
|
|
|
|
|
|
@Override public String getOnTransaction() { return null; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
// Public API — called from Flowable expressions
|
|
|
|
// Public API — called from Flowable expressions
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
@@ -136,39 +60,41 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* Checks which of the given comma-separated variable paths changed since the
|
|
|
|
* Checks which of the given comma-separated variable paths changed since the
|
|
|
|
* last call and returns a JSON array describing each change.
|
|
|
|
* last call and returns a JSON array describing each change.
|
|
|
|
* ${varUtils.trackVars('root.snapshot', 'root.myString, status')}
|
|
|
|
* Example call:
|
|
|
|
* {@code snapshotPath} is a dot-notation path (supports the {@code root.} prefix)
|
|
|
|
* ${varUtils.trackVars(self, 'root.snapshot', 'root.status, status')}
|
|
|
|
* pointing to where the previous-values snapshot is stored and updated.
|
|
|
|
|
|
|
|
* Each subsequent call compares against the previous snapshot and updates it.
|
|
|
|
|
|
|
|
* Example return value:
|
|
|
|
* Example return value:
|
|
|
|
* [{"path":"root.myString","oldValue":"a","newValue":"b"}]
|
|
|
|
* [{"path":"root.status","oldValue":"a","newValue":"b"}, {"path":"status","oldValue":"c","newValue":"d"}]
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
public ArrayNode trackVars(String snapshotPath, String variablePathsCsv) {
|
|
|
|
public ArrayNode trackVars(VariableScope currentScope, String snapshotPath, String variablePathsCsv) {
|
|
|
|
|
|
|
|
|
|
|
|
// Check parameters
|
|
|
|
// Reject blank parameters early — report "no changes" instead of failing,
|
|
|
|
|
|
|
|
// so a misconfigured expression cannot break the surrounding process/case.
|
|
|
|
if (snapshotPath == null || snapshotPath.isBlank() || variablePathsCsv == null || variablePathsCsv.isBlank()) {
|
|
|
|
if (snapshotPath == null || snapshotPath.isBlank() || variablePathsCsv == null || variablePathsCsv.isBlank()) {
|
|
|
|
LOGGER.debug("{}.trackVars: empty parameters", getClass().getName());
|
|
|
|
LOGGER.debug("{}.trackVars: empty parameters", getClass().getName());
|
|
|
|
return MAPPER.createArrayNode();
|
|
|
|
return MAPPER.createArrayNode();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get current scope
|
|
|
|
// Without a resolvable scope there is nothing to read from or write to.
|
|
|
|
VariableScope currentScope = getCurrentScope();
|
|
|
|
|
|
|
|
if (currentScope == null) {
|
|
|
|
if (currentScope == null) {
|
|
|
|
LOGGER.debug("{}.trackVars: currentScope not found", getClass().getName());
|
|
|
|
LOGGER.debug("{}.trackVars: currentScope not found", getClass().getName());
|
|
|
|
return MAPPER.createArrayNode();
|
|
|
|
return MAPPER.createArrayNode();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Create paths list
|
|
|
|
// Split the CSV into individual variable paths, dropping blanks and whitespace.
|
|
|
|
List<String> paths = Arrays.stream(variablePathsCsv.split(","))
|
|
|
|
List<String> paths = Arrays.stream(variablePathsCsv.split(","))
|
|
|
|
.map(String::trim).filter(s -> !s.isEmpty()).toList();
|
|
|
|
.map(String::trim).filter(s -> !s.isEmpty()).toList();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Load the previous values (persisted as a JSON string variable at
|
|
|
|
|
|
|
|
// snapshotPath) to compare the current values against.
|
|
|
|
Map<String, JsonNode> oldSnapshot = loadSnapshot(currentScope, snapshotPath);
|
|
|
|
Map<String, JsonNode> oldSnapshot = loadSnapshot(currentScope, snapshotPath);
|
|
|
|
Map<String, JsonNode> newSnapshot = new HashMap<>();
|
|
|
|
Map<String, JsonNode> newSnapshot = new HashMap<>();
|
|
|
|
ArrayNode changes = MAPPER.createArrayNode();
|
|
|
|
ArrayNode changes = MAPPER.createArrayNode();
|
|
|
|
|
|
|
|
|
|
|
|
// Loop paths list
|
|
|
|
// For every tracked path: read the current value, normalize it so it
|
|
|
|
|
|
|
|
// compares consistently with the reloaded snapshot, and record a change
|
|
|
|
|
|
|
|
// entry whenever old != new.
|
|
|
|
for (String path : paths) {
|
|
|
|
for (String path : paths) {
|
|
|
|
JsonNode newValue = toJson(readVariableFromPath(currentScope, path));
|
|
|
|
JsonNode newValue = normalize(toJson(readVariableFromPath(currentScope, path)));
|
|
|
|
newSnapshot.put(path, newValue);
|
|
|
|
newSnapshot.put(path, newValue);
|
|
|
|
|
|
|
|
|
|
|
|
JsonNode oldValue = oldSnapshot.getOrDefault(path, MAPPER.nullNode());
|
|
|
|
JsonNode oldValue = oldSnapshot.getOrDefault(path, MAPPER.nullNode());
|
|
|
@@ -181,6 +107,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Persist the current values as the reference snapshot for the next call.
|
|
|
|
saveSnapshot(currentScope, snapshotPath, newSnapshot);
|
|
|
|
saveSnapshot(currentScope, snapshotPath, newSnapshot);
|
|
|
|
|
|
|
|
|
|
|
|
return changes;
|
|
|
|
return changes;
|
|
|
@@ -190,10 +117,18 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
// Path resolution
|
|
|
|
// Path resolution
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Reads a value via a dot-notation path, e.g. "order.customer.name".
|
|
|
|
|
|
|
|
* The first segment names a Flowable variable; the remaining segments navigate
|
|
|
|
|
|
|
|
* into that value (maps, lists/arrays by index, JsonNodes, POJOs — see
|
|
|
|
|
|
|
|
* {@link #getNestedVariable}). Returns null when any segment cannot be resolved.
|
|
|
|
|
|
|
|
*/
|
|
|
|
private Object readVariableFromPath(VariableScope scope, String path) {
|
|
|
|
private Object readVariableFromPath(VariableScope scope, String path) {
|
|
|
|
String[] segments = path.split("\\.", -1);
|
|
|
|
String[] segments = path.split("\\.", -1);
|
|
|
|
if (segments.length < 1) return null;
|
|
|
|
if (segments.length < 1) return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// A leading "root." switches to the root instance of the surrounding
|
|
|
|
|
|
|
|
// case/process hierarchy before resolving the variable.
|
|
|
|
int startIndex = 0;
|
|
|
|
int startIndex = 0;
|
|
|
|
if ("root".equals(segments[0])) {
|
|
|
|
if ("root".equals(segments[0])) {
|
|
|
|
if (segments.length < 2) return null;
|
|
|
|
if (segments.length < 2) return null;
|
|
|
@@ -202,6 +137,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (scope == null) return null;
|
|
|
|
if (scope == null) return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Read the top-level variable, then walk the remaining segments into it.
|
|
|
|
Object currentValue = scope.getVariable(segments[startIndex]);
|
|
|
|
Object currentValue = scope.getVariable(segments[startIndex]);
|
|
|
|
for (int i = startIndex + 1; i < segments.length; i++) {
|
|
|
|
for (int i = startIndex + 1; i < segments.length; i++) {
|
|
|
|
if (currentValue == null) return null;
|
|
|
|
if (currentValue == null) return null;
|
|
|
@@ -210,10 +146,18 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
return currentValue;
|
|
|
|
return currentValue;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Writes a value via a dot-notation path (same syntax as
|
|
|
|
|
|
|
|
* {@link #readVariableFromPath}). A single-segment path sets a plain Flowable
|
|
|
|
|
|
|
|
* variable; a nested path mutates the container inside the top-level variable
|
|
|
|
|
|
|
|
* and writes that variable back so the engine persists the change.
|
|
|
|
|
|
|
|
*/
|
|
|
|
private void writeVariableToPath(VariableScope scope, String path, Object value) {
|
|
|
|
private void writeVariableToPath(VariableScope scope, String path, Object value) {
|
|
|
|
String[] segments = path.split("\\.", -1);
|
|
|
|
String[] segments = path.split("\\.", -1);
|
|
|
|
if (segments.length < 1) return;
|
|
|
|
if (segments.length < 1) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// A leading "root." redirects the write to the root instance of the
|
|
|
|
|
|
|
|
// surrounding case/process hierarchy.
|
|
|
|
VariableScope targetScope;
|
|
|
|
VariableScope targetScope;
|
|
|
|
String[] varSegments;
|
|
|
|
String[] varSegments;
|
|
|
|
if ("root".equals(segments[0])) {
|
|
|
|
if ("root".equals(segments[0])) {
|
|
|
@@ -249,6 +193,11 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
targetScope.setVariable(topVar, topValue);
|
|
|
|
targetScope.setVariable(topVar, topValue);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Sets one key on a mutable container (Map or ObjectNode). Anything else —
|
|
|
|
|
|
|
|
* including POJOs, which are read-only for this bean — is rejected with a
|
|
|
|
|
|
|
|
* warning so a bad path never breaks the calling expression.
|
|
|
|
|
|
|
|
*/
|
|
|
|
@SuppressWarnings("unchecked")
|
|
|
|
@SuppressWarnings("unchecked")
|
|
|
|
private boolean setNestedValue(Object parent, String key, Object value, String path) {
|
|
|
|
private boolean setNestedValue(Object parent, String key, Object value, String path) {
|
|
|
|
if (parent instanceof Map map) {
|
|
|
|
if (parent instanceof Map map) {
|
|
|
@@ -268,20 +217,27 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
// Scope resolution
|
|
|
|
// Scope resolution
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
private VariableScope getCurrentScope() {
|
|
|
|
/**
|
|
|
|
VariableScope scope = SCOPE.get();
|
|
|
|
* Climbs from the given scope to the root VariableScope of the surrounding
|
|
|
|
if (scope == null) {
|
|
|
|
* case/process hierarchy, following call-activity parents (BPMN) and
|
|
|
|
LOGGER.error("{}.getCurrentScope called without an active Flowable scope", getClass().getName());
|
|
|
|
* parent/callback links (CMMN) across engine boundaries.
|
|
|
|
}
|
|
|
|
*/
|
|
|
|
return scope;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private VariableScope getRootScope(VariableScope scope) {
|
|
|
|
private VariableScope getRootScope(VariableScope scope) {
|
|
|
|
if (scope instanceof DelegateExecution ex) return findBpmnRootScope(ex.getProcessInstanceId());
|
|
|
|
if (scope instanceof DelegateExecution ex) return findBpmnRootScope(ex.getProcessInstanceId());
|
|
|
|
|
|
|
|
if (scope instanceof PlanItemInstance pii) return findCmmnRootScope(pii.getCaseInstanceId());
|
|
|
|
if (scope instanceof CaseInstance ci) return findCmmnRootScope(ci.getId());
|
|
|
|
if (scope instanceof CaseInstance ci) return findCmmnRootScope(ci.getId());
|
|
|
|
return null;
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Finds the root scope starting from a BPMN process instance:
|
|
|
|
|
|
|
|
* 1. started by a call activity → recurse into the calling process instance,
|
|
|
|
|
|
|
|
* 2. started from a CMMN plan item (callback) → continue climbing in the case,
|
|
|
|
|
|
|
|
* 3. otherwise this process instance is itself the root.
|
|
|
|
|
|
|
|
* NOTE: the returned object is a detached query result used as VariableScope;
|
|
|
|
|
|
|
|
* its variable access only works because expression evaluation runs inside an
|
|
|
|
|
|
|
|
* active Flowable command context.
|
|
|
|
|
|
|
|
*/
|
|
|
|
private VariableScope findBpmnRootScope(String processInstanceId) {
|
|
|
|
private VariableScope findBpmnRootScope(String processInstanceId) {
|
|
|
|
if (processInstanceId == null || runtimeService == null) return null;
|
|
|
|
if (processInstanceId == null || runtimeService == null) return null;
|
|
|
|
try {
|
|
|
|
try {
|
|
|
@@ -309,6 +265,13 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Finds the root scope starting from a CMMN case instance:
|
|
|
|
|
|
|
|
* 1. sub-case → recurse into the parent case,
|
|
|
|
|
|
|
|
* 2. started from a BPMN call/task (callback) → continue climbing in the process,
|
|
|
|
|
|
|
|
* 3. otherwise this case instance is itself the root.
|
|
|
|
|
|
|
|
* Same detached-query-result caveat as {@link #findBpmnRootScope}.
|
|
|
|
|
|
|
|
*/
|
|
|
|
private VariableScope findCmmnRootScope(String caseInstanceId) {
|
|
|
|
private VariableScope findCmmnRootScope(String caseInstanceId) {
|
|
|
|
if (caseInstanceId == null || cmmnRuntimeService == null) return null;
|
|
|
|
if (caseInstanceId == null || cmmnRuntimeService == null) return null;
|
|
|
|
try {
|
|
|
|
try {
|
|
|
@@ -331,7 +294,16 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Resolves one path segment against a value: list/array index, map key,
|
|
|
|
|
|
|
|
* JsonNode field (scalars are unwrapped to plain Java values), and finally
|
|
|
|
|
|
|
|
* POJO access via getX()/isX() getters or a declared field.
|
|
|
|
|
|
|
|
*/
|
|
|
|
private static Object getNestedVariable(Object obj, String segment) {
|
|
|
|
private static Object getNestedVariable(Object obj, String segment) {
|
|
|
|
|
|
|
|
// Empty segments (e.g. from "a..b" or a trailing dot) cannot address anything.
|
|
|
|
|
|
|
|
if (segment == null || segment.isEmpty()) return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Lists and arrays are addressed by numeric index, e.g. "items.0.name".
|
|
|
|
if (obj instanceof List<?> list) {
|
|
|
|
if (obj instanceof List<?> list) {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
int i = Integer.parseInt(segment);
|
|
|
|
int i = Integer.parseInt(segment);
|
|
|
@@ -345,6 +317,9 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
} catch (NumberFormatException ignored) {}
|
|
|
|
} catch (NumberFormatException ignored) {}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (obj instanceof Map<?, ?> map) return map.get(segment);
|
|
|
|
if (obj instanceof Map<?, ?> map) return map.get(segment);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// JsonNode: unwrap scalar fields to plain Java values so they compare and
|
|
|
|
|
|
|
|
// serialize the same way as values read from Map/POJO variables.
|
|
|
|
if (obj instanceof JsonNode jn) {
|
|
|
|
if (obj instanceof JsonNode jn) {
|
|
|
|
JsonNode node = jn.get(segment);
|
|
|
|
JsonNode node = jn.get(segment);
|
|
|
|
if (node == null || node.isNull()) return null;
|
|
|
|
if (node == null || node.isNull()) return null;
|
|
|
@@ -355,6 +330,9 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
if (node.isDouble()) return node.asDouble();
|
|
|
|
if (node.isDouble()) return node.asDouble();
|
|
|
|
return node;
|
|
|
|
return node;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback for POJOs: try bean getters first, then a declared field
|
|
|
|
|
|
|
|
// (climbing the class hierarchy). Read-only — writes reject POJO parents.
|
|
|
|
String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1);
|
|
|
|
String cap = Character.toUpperCase(segment.charAt(0)) + segment.substring(1);
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
return obj.getClass().getMethod("get" + cap).invoke(obj);
|
|
|
|
return obj.getClass().getMethod("get" + cap).invoke(obj);
|
|
|
@@ -370,6 +348,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
return null;
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Looks up a declared field by name, walking up the class hierarchy. */
|
|
|
|
private static java.lang.reflect.Field findField(Class<?> c, String name) {
|
|
|
|
private static java.lang.reflect.Field findField(Class<?> c, String name) {
|
|
|
|
while (c != null && c != Object.class) {
|
|
|
|
while (c != null && c != Object.class) {
|
|
|
|
try { return c.getDeclaredField(name); }
|
|
|
|
try { return c.getDeclaredField(name); }
|
|
|
@@ -382,6 +361,12 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
// Snapshot persistence
|
|
|
|
// Snapshot persistence
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Loads the previous-values snapshot: reads the variable at {@code path}
|
|
|
|
|
|
|
|
* (persisted as a JSON string by {@link #saveSnapshot}) and parses it back
|
|
|
|
|
|
|
|
* into a path → value-node map. Returns an empty map when there is no
|
|
|
|
|
|
|
|
* snapshot yet (first call) or it is unreadable.
|
|
|
|
|
|
|
|
*/
|
|
|
|
private Map<String, JsonNode> loadSnapshot(VariableScope scope, String path) {
|
|
|
|
private Map<String, JsonNode> loadSnapshot(VariableScope scope, String path) {
|
|
|
|
Object raw = readVariableFromPath(scope, path);
|
|
|
|
Object raw = readVariableFromPath(scope, path);
|
|
|
|
if (raw == null) return new HashMap<>();
|
|
|
|
if (raw == null) return new HashMap<>();
|
|
|
@@ -398,6 +383,7 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Persists the snapshot map as a JSON string variable at {@code path}. */
|
|
|
|
private void saveSnapshot(VariableScope scope, String path, Map<String, JsonNode> snapshot) {
|
|
|
|
private void saveSnapshot(VariableScope scope, String path, Map<String, JsonNode> snapshot) {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
writeVariableToPath(scope, path, MAPPER.writeValueAsString(snapshot));
|
|
|
|
writeVariableToPath(scope, path, MAPPER.writeValueAsString(snapshot));
|
|
|
@@ -410,9 +396,25 @@ public class VarUtils implements FlowableEventListener, SmartInitializingSinglet
|
|
|
|
// Helpers
|
|
|
|
// Helpers
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Wraps any Java value into a JsonNode (null-safe). */
|
|
|
|
private static JsonNode toJson(Object value) {
|
|
|
|
private static JsonNode toJson(Object value) {
|
|
|
|
if (value == null) return MAPPER.nullNode();
|
|
|
|
if (value == null) return MAPPER.nullNode();
|
|
|
|
if (value instanceof JsonNode jn) return jn;
|
|
|
|
if (value instanceof JsonNode jn) return jn;
|
|
|
|
return MAPPER.valueToTree(value);
|
|
|
|
return MAPPER.valueToTree(value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Serializes and re-parses a node so its value types match what
|
|
|
|
|
|
|
|
* {@link #loadSnapshot} produces when reading the persisted snapshot back
|
|
|
|
|
|
|
|
* (e.g. a Long 5 read from a variable and an Integer 5 parsed from the
|
|
|
|
|
|
|
|
* snapshot JSON both become the same numeric node). Without this, unchanged
|
|
|
|
|
|
|
|
* numeric values would be reported as changes on every call.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
private static JsonNode normalize(JsonNode node) {
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
return MAPPER.readTree(MAPPER.writeValueAsString(node));
|
|
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
|
|
|
return node;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|