Files
2025.2/tmp/VariableTrackerComponent01.java
Andreas Isler 2f9c0a5c9e Initial commit
2026-05-22 14:59:58 +02:00

229 lines
8.7 KiB
Java

package com.customer.work.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.flowable.engine.delegate.DelegateExecution;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Flowable Spring Boot component for tracking process variable changes across calls.
*
* Usage in Flowable expression:
* ${variableTracker.track(execution, 'order.customer.name,order.total,status')}
*
* Returns a JSON string with an array of changed variables, each entry containing:
* { "path": "order.customer.name", "oldValue": "Alice", "newValue": "Bob" }
*
* State (the snapshot of previous values) is stored as a process variable keyed by
* SNAPSHOT_VAR_PREFIX + the provided paths string, so independent track() calls
* with different path sets do not interfere with each other.
*/
@Component("variableTracker")
public class VariableTrackerComponent {
private static final Logger LOGGER = LoggerFactory.getLogger(VariableTrackerComponent.class);
private static final String SNAPSHOT_VAR_PREFIX = "__vartracker__";
private final ObjectMapper objectMapper;
public VariableTrackerComponent() {
this.objectMapper = new ObjectMapper();
this.objectMapper.registerModule(new JavaTimeModule());
this.objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
}
/**
* Checks which of the given variable paths changed since the last call and returns
* a JSON array describing each change.
*
* @param execution the current Flowable execution context
* @param pathsCsv comma-separated variable paths, e.g. "order.total,status,user.address.city"
* @return JSON string — an array of { path, oldValue, newValue } objects for every changed path
*/
public String track(DelegateExecution execution, String pathsCsv) {
if (pathsCsv == null || pathsCsv.isBlank()) {
return "[]";
}
List<String> paths = Arrays.stream(pathsCsv.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
String snapshotKey = SNAPSHOT_VAR_PREFIX + pathsCsv.replaceAll("\\s+", "");
Map<String, JsonNode> previousSnapshot = loadSnapshot(execution, snapshotKey);
Map<String, JsonNode> currentSnapshot = new HashMap<>();
ArrayNode changes = objectMapper.createArrayNode();
for (String path : paths) {
JsonNode currentValue = resolvePathToJson(execution, path);
currentSnapshot.put(path, currentValue);
if (previousSnapshot.isEmpty()) {
// First call — record baseline, no changes reported
continue;
}
JsonNode previousValue = previousSnapshot.getOrDefault(path, objectMapper.nullNode());
if (!jsonEquals(previousValue, currentValue)) {
ObjectNode change = objectMapper.createObjectNode();
change.put("path", path);
change.set("oldValue", previousValue);
change.set("newValue", currentValue);
changes.add(change);
}
}
saveSnapshot(execution, snapshotKey, currentSnapshot);
try {
return objectMapper.writeValueAsString(changes);
} catch (Exception e) {
LOGGER.error("Failed to serialize changes to JSON", e);
return "[]";
}
}
// -------------------------------------------------------------------------
// Variable path resolution
// -------------------------------------------------------------------------
/**
* Resolves a dot-separated path against the execution's variables and returns
* the result as a JsonNode. The first segment is the top-level process variable
* name; subsequent segments navigate into the object graph.
*/
private JsonNode resolvePathToJson(DelegateExecution execution, String path) {
String[] segments = path.split("\\.", -1);
Object current = execution.getVariable(segments[0]);
for (int i = 1; i < segments.length; i++) {
if (current == null) {
return objectMapper.nullNode();
}
current = getProperty(current, segments[i]);
}
return toJsonNode(current);
}
/**
* Reads a named property from an object. Supports Maps, Jackson ObjectNodes,
* and plain Java objects (via public getter or public field).
*/
@SuppressWarnings("unchecked")
private Object getProperty(Object obj, String property) {
if (obj instanceof Map<?, ?> map) {
return map.get(property);
}
if (obj instanceof ObjectNode on) {
JsonNode node = on.get(property);
return node != null ? node : null;
}
if (obj instanceof JsonNode jn) {
JsonNode node = jn.get(property);
return node != null ? node : null;
}
// Reflection: try getter first, then field
try {
String getter = "get" + Character.toUpperCase(property.charAt(0)) + property.substring(1);
Method method = obj.getClass().getMethod(getter);
return method.invoke(obj);
} catch (Exception ignored) {
}
try {
String getter = "is" + Character.toUpperCase(property.charAt(0)) + property.substring(1);
Method method = obj.getClass().getMethod(getter);
return method.invoke(obj);
} catch (Exception ignored) {
}
try {
java.lang.reflect.Field field = findField(obj.getClass(), property);
if (field != null) {
field.setAccessible(true);
return field.get(obj);
}
} catch (Exception ignored) {
}
LOGGER.warn("Could not resolve property '{}' on {}", property, obj.getClass().getName());
return null;
}
private java.lang.reflect.Field findField(Class<?> clazz, String name) {
while (clazz != null && clazz != Object.class) {
try {
return clazz.getDeclaredField(name);
} catch (NoSuchFieldException e) {
clazz = clazz.getSuperclass();
}
}
return null;
}
// -------------------------------------------------------------------------
// Snapshot persistence
// -------------------------------------------------------------------------
@SuppressWarnings("unchecked")
private Map<String, JsonNode> loadSnapshot(DelegateExecution execution, String key) {
Object raw = execution.getVariable(key);
if (raw == null) {
return new HashMap<>();
}
try {
String json = raw instanceof String s ? s : objectMapper.writeValueAsString(raw);
Map<String, Object> flat = objectMapper.readValue(json,
objectMapper.getTypeFactory().constructMapType(HashMap.class, String.class, Object.class));
Map<String, JsonNode> result = new HashMap<>();
for (Map.Entry<String, Object> entry : flat.entrySet()) {
result.put(entry.getKey(), toJsonNode(entry.getValue()));
}
return result;
} catch (Exception e) {
LOGGER.warn("Could not load variable tracker snapshot for key '{}': {}", key, e.getMessage());
return new HashMap<>();
}
}
private void saveSnapshot(DelegateExecution execution, String key, Map<String, JsonNode> snapshot) {
try {
execution.setVariable(key, objectMapper.writeValueAsString(snapshot));
} catch (Exception e) {
LOGGER.error("Could not save variable tracker snapshot for key '{}'", key, e);
}
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private JsonNode toJsonNode(Object value) {
if (value == null) {
return objectMapper.nullNode();
}
if (value instanceof JsonNode jn) {
return jn;
}
return objectMapper.valueToTree(value);
}
private boolean jsonEquals(JsonNode a, JsonNode b) {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
return a.equals(b);
}
}