seenTypes) throws XPathExpressionException{
if (dataTypeName == null || dataTypeName.isEmpty()) {
return null;
}
if (isPrim(dataTypeName)) {
return dataTypeName;
}
+ if (!seenTypes.add(dataTypeName)) {
+ return null;
+ }
String simpleExp = String.format(checkSimpleDataTypeExp, dataTypeName);
String simpleType = (String) xPath.compile(simpleExp).evaluate(doc, XPathConstants.STRING);
if (!simpleType.isEmpty())
- return simpleType;
+ return resolvePrimitiveType(simpleType, seenTypes);
String enumExp = String.format(checkEnumDataTypeExp, dataTypeName);
String enumType = (String) xPath.compile(enumExp).evaluate(doc, XPathConstants.STRING);
if (!enumType.isEmpty())
- return enumType;
+ return resolvePrimitiveType(enumType, seenTypes);
return null;
}
- public Document getDoc() {
- return doc;
+ public HLADecoderRegistry getDecoderRegistry() {
+ return decoderRegistry;
}
- public void setDoc(Document doc) {
- this.doc = doc;
+ public void setDecoderRegistry(HLADecoderRegistry decoderRegistry) {
+ this.decoderRegistry = decoderRegistry;
}
- public XPath getxPath() {
- return xPath;
+ /**
+ * Return the object-class hierarchy as immutable, XML-free definitions.
+ *
+ * Only attributes declared directly on a class are included. Consumers that
+ * need inherited attributes can apply inheritance using {@link
+ * ObjectClassDefinition#parentName()} without accessing the raw FOM document.
+ */
+ public List objectClassDefinitions() {
+ if (doc == null || doc.getDocumentElement() == null) {
+ return List.of();
+ }
+ Element objects = firstChildElement(doc.getDocumentElement(), "objects");
+ if (objects == null) {
+ return List.of();
+ }
+
+ List definitions = new ArrayList<>();
+ for (Element objectClass : childElements(objects, "objectClass")) {
+ collectObjectClassDefinitions(objectClass, null, definitions);
+ }
+ return List.copyOf(definitions);
+ }
+
+ private void collectObjectClassDefinitions(
+ Element objectClass,
+ String parentName,
+ List definitions) {
+ String className = childText(objectClass, "name");
+ if (className == null) {
+ return;
+ }
+
+ List attributes = new ArrayList<>();
+ for (Element attribute : childElements(objectClass, "attribute")) {
+ String attributeName = childText(attribute, "name");
+ String dataType = childText(attribute, "dataType");
+ if (attributeName != null && dataType != null) {
+ attributes.add(new ObjectAttributeDefinition(attributeName, dataType));
+ }
+ }
+ definitions.add(new ObjectClassDefinition(className, parentName, attributes));
+
+ for (Element childClass : childElements(objectClass, "objectClass")) {
+ collectObjectClassDefinitions(childClass, className, definitions);
+ }
}
- public void setxPath(XPath xPath) {
- this.xPath = xPath;
+ private static String childText(Element parent, String tagName) {
+ Element element = firstChildElement(parent, tagName);
+ if (element == null) {
+ return null;
+ }
+ String text = element.getTextContent();
+ return text == null || text.isBlank() ? null : text.trim();
}
- public HLADecoderRegistry getDecoderRegistry() {
- return decoderRegistry;
+ private static Element firstChildElement(Element parent, String tagName) {
+ for (Element element : childElements(parent, tagName)) {
+ return element;
+ }
+ return null;
}
- public void setDecoderRegistry(HLADecoderRegistry decoderRegistry) {
- this.decoderRegistry = decoderRegistry;
+ private static List childElements(Element parent, String tagName) {
+ if (parent == null) {
+ return List.of();
+ }
+ List elements = new ArrayList<>();
+ NodeList children = parent.getChildNodes();
+ for (int i = 0; i < children.getLength(); i++) {
+ Node child = children.item(i);
+ if (child instanceof Element element && element.getTagName().equals(tagName)) {
+ elements.add(element);
+ }
+ }
+ return elements;
}
public String getParameterType(String entityName, String parameterName, boolean isInteraction)
@@ -340,4 +415,17 @@ public FixedRecordField(String name, String dataType) {
this.dataType = dataType;
}
}
+
+ public record ObjectClassDefinition(
+ String name,
+ String parentName,
+ List attributes) {
+
+ public ObjectClassDefinition {
+ attributes = List.copyOf(attributes);
+ }
+ }
+
+ public record ObjectAttributeDefinition(String name, String dataType) {
+ }
}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java
index 0a19780..be1c346 100755
--- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java
+++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java
@@ -5,6 +5,7 @@
import java.net.URL;
import java.util.HashMap;
import java.util.Map;
+import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
@@ -12,17 +13,23 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
+import com.yetanalytics.hlaxapi.cache.FomCatalog;
+import com.yetanalytics.hlaxapi.cache.ObjectCache;
import com.yetanalytics.hlaxapi.config.XapiConfig;
import com.yetanalytics.hlaxapi.config.model.StatementTrigger;
import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext;
+import hla.rti1516e.AttributeHandle;
+import hla.rti1516e.AttributeHandleSet;
+import hla.rti1516e.AttributeHandleValueMap;
import hla.rti1516e.CallbackModel;
import hla.rti1516e.InteractionClassHandle;
import hla.rti1516e.LogicalTime;
import hla.rti1516e.MessageRetractionHandle;
import hla.rti1516e.NullFederateAmbassador;
+import hla.rti1516e.ObjectClassHandle;
+import hla.rti1516e.ObjectInstanceHandle;
import hla.rti1516e.OrderType;
-import hla.rti1516e.ParameterHandle;
import hla.rti1516e.ParameterHandleValueMap;
import hla.rti1516e.RTIambassador;
import hla.rti1516e.ResignAction;
@@ -30,6 +37,7 @@
import hla.rti1516e.RtiFactoryFactory;
import hla.rti1516e.TransportationTypeHandle;
import hla.rti1516e.exceptions.AlreadyConnected;
+import hla.rti1516e.exceptions.AttributeNotDefined;
import hla.rti1516e.exceptions.CallNotAllowedFromWithinCallback;
import hla.rti1516e.exceptions.ConnectionFailed;
import hla.rti1516e.exceptions.CouldNotCreateLogicalTimeFactory;
@@ -46,13 +54,17 @@
import hla.rti1516e.exceptions.FederationExecutionDoesNotExist;
import hla.rti1516e.exceptions.InconsistentFDD;
import hla.rti1516e.exceptions.InteractionClassNotDefined;
+import hla.rti1516e.exceptions.InvalidAttributeHandle;
import hla.rti1516e.exceptions.InteractionParameterNotDefined;
import hla.rti1516e.exceptions.InvalidInteractionClassHandle;
import hla.rti1516e.exceptions.InvalidLocalSettingsDesignator;
+import hla.rti1516e.exceptions.InvalidObjectClassHandle;
import hla.rti1516e.exceptions.InvalidParameterHandle;
import hla.rti1516e.exceptions.InvalidResignAction;
import hla.rti1516e.exceptions.NameNotFound;
import hla.rti1516e.exceptions.NotConnected;
+import hla.rti1516e.exceptions.ObjectClassNotDefined;
+import hla.rti1516e.exceptions.ObjectInstanceNotKnown;
import hla.rti1516e.exceptions.OwnershipAcquisitionPending;
import hla.rti1516e.exceptions.RTIinternalError;
import hla.rti1516e.exceptions.RestoreInProgress;
@@ -66,8 +78,6 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter
private RTIambassador ambassador;
- private ParameterHandle timeScaleFactorParameterHandle;
-
@Autowired
private XapiConfig xapiConfig;
@@ -77,6 +87,9 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter
@Autowired
private TriggerProcessor triggerProcessor;
+ @Autowired
+ private ObjectCache objectCache;
+
@Autowired
private XapiClient xapiClient;
@@ -87,8 +100,9 @@ public void start()
RtiFactory rtiFactory = RtiFactoryFactory.getRtiFactory();
ambassador = rtiFactory.getRtiAmbassador();
- // Use injected HLADecoderRegistry when available; fall back to creating one
- // decoderRegistry.registerAlias("ScaleFactorFloat32", "HLAfloat32BE");
+ if (!objectCache.isEnabled()) {
+ logger.info("No query injections or tracked objects configured; object cache is disabled");
+ }
try {
if (simulationConfig.getLocalSettingsDesignator() == null
@@ -138,6 +152,7 @@ public void start()
// Get relevant interactions to subscribe to from the xapiConfig
try {
+ subscribeObjectClasses();
subscribeInteractions();
logger.info("Started Subscription");
@@ -170,6 +185,164 @@ public void connectionLost(String faultDescription) throws FederateInternalError
System.out.println("Lost Connection because: " + faultDescription);
}
+ /*
+ * Objects
+ */
+
+ private void subscribeObjectClasses()
+ throws FederateNotExecutionMember, RestoreInProgress, SaveInProgress, NotConnected, RTIinternalError {
+ if (!objectCache.isEnabled()) {
+ return;
+ }
+ for (Map.Entry> subscription : objectCache.subscriptions().entrySet()) {
+ try {
+ FomCatalog.ObjectClassDef clazz = objectCache.catalog().objectClass(subscription.getKey()).orElseThrow(
+ () -> new IllegalArgumentException("No FOM object class " + subscription.getKey()));
+ ObjectClassHandle classHandle = ambassador.getObjectClassHandle(clazz.localName());
+ AttributeHandleSet attributeHandles = ambassador.getAttributeHandleSetFactory().create();
+ for (String attributeName : subscription.getValue()) {
+ attributeHandles.add(ambassador.getAttributeHandle(classHandle, attributeName));
+ }
+ if (attributeHandles.isEmpty()) {
+ continue;
+ }
+ ambassador.subscribeObjectClassAttributes(classHandle, attributeHandles);
+ } catch (AttributeNotDefined | InvalidObjectClassHandle | NameNotFound | ObjectClassNotDefined
+ | IllegalArgumentException e) {
+ logger.error("Could not subscribe object class {}!", subscription.getKey(), e);
+ }
+ }
+ }
+
+ @Override
+ public void discoverObjectInstance(
+ ObjectInstanceHandle theObject,
+ ObjectClassHandle theObjectClass,
+ String objectName) throws FederateInternalError {
+ discoverObjectInstance(theObject, theObjectClass, objectName, null);
+ }
+
+ @Override
+ public void discoverObjectInstance(
+ ObjectInstanceHandle theObject,
+ ObjectClassHandle theObjectClass,
+ String objectName,
+ hla.rti1516e.FederateHandle producingFederate) throws FederateInternalError {
+ if (!objectCache.isEnabled()) {
+ return;
+ }
+ try {
+ String className = StringUtils.substringAfterLast(ambassador.getObjectClassName(theObjectClass), ".");
+ objectCache.discoverObject(theObject.toString(), objectName, className);
+ logger.info("Discovered object {} as {}", objectName, className);
+ } catch (InvalidObjectClassHandle | FederateNotExecutionMember | NotConnected | RTIinternalError
+ | RuntimeException e) {
+ logger.error("Error caching discovered object {}", objectName, e);
+ }
+ }
+
+ @Override
+ public void reflectAttributeValues(
+ ObjectInstanceHandle theObject,
+ AttributeHandleValueMap theAttributes,
+ byte[] userSuppliedTag,
+ OrderType sentOrdering,
+ TransportationTypeHandle theTransport,
+ SupplementalReflectInfo reflectInfo) throws FederateInternalError {
+ reflectAttributeValues(theObject, theAttributes);
+ }
+
+ @Override
+ public void reflectAttributeValues(
+ ObjectInstanceHandle theObject,
+ AttributeHandleValueMap theAttributes,
+ byte[] userSuppliedTag,
+ OrderType sentOrdering,
+ TransportationTypeHandle theTransport,
+ LogicalTime theTime,
+ OrderType receivedOrdering,
+ SupplementalReflectInfo reflectInfo) throws FederateInternalError {
+ reflectAttributeValues(theObject, theAttributes);
+ }
+
+ @Override
+ public void reflectAttributeValues(
+ ObjectInstanceHandle theObject,
+ AttributeHandleValueMap theAttributes,
+ byte[] userSuppliedTag,
+ OrderType sentOrdering,
+ TransportationTypeHandle theTransport,
+ LogicalTime theTime,
+ OrderType receivedOrdering,
+ MessageRetractionHandle retractionHandle,
+ SupplementalReflectInfo reflectInfo) throws FederateInternalError {
+ reflectAttributeValues(theObject, theAttributes);
+ }
+
+ private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHandleValueMap theAttributes) {
+ if (!objectCache.isEnabled()) {
+ return;
+ }
+ try {
+ ObjectClassHandle classHandle = ambassador.getKnownObjectClassHandle(theObject);
+ String className = StringUtils.substringAfterLast(ambassador.getObjectClassName(classHandle), ".");
+ for (AttributeHandle attributeHandle : theAttributes.keySet()) {
+ String attributeName = ambassador.getAttributeName(classHandle, attributeHandle);
+ objectCache.reflectAttributeValue(
+ theObject.toString(),
+ className,
+ attributeName,
+ theAttributes.get(attributeHandle));
+ }
+ } catch (AttributeNotDefined | InvalidAttributeHandle | InvalidObjectClassHandle | ObjectInstanceNotKnown
+ | FederateNotExecutionMember | NotConnected | RTIinternalError | RuntimeException e) {
+ logger.error("Error caching reflected object attributes", e);
+ }
+ }
+
+ @Override
+ public void removeObjectInstance(
+ ObjectInstanceHandle theObject,
+ byte[] userSuppliedTag,
+ OrderType sentOrdering,
+ SupplementalRemoveInfo removeInfo) throws FederateInternalError {
+ removeCachedObject(theObject);
+ }
+
+ @Override
+ public void removeObjectInstance(
+ ObjectInstanceHandle theObject,
+ byte[] userSuppliedTag,
+ OrderType sentOrdering,
+ LogicalTime theTime,
+ OrderType receivedOrdering,
+ SupplementalRemoveInfo removeInfo) throws FederateInternalError {
+ removeCachedObject(theObject);
+ }
+
+ @Override
+ public void removeObjectInstance(
+ ObjectInstanceHandle theObject,
+ byte[] userSuppliedTag,
+ OrderType sentOrdering,
+ LogicalTime theTime,
+ OrderType receivedOrdering,
+ MessageRetractionHandle retractionHandle,
+ SupplementalRemoveInfo removeInfo) throws FederateInternalError {
+ removeCachedObject(theObject);
+ }
+
+ private void removeCachedObject(ObjectInstanceHandle theObject) {
+ if (!objectCache.isEnabled()) {
+ return;
+ }
+ try {
+ objectCache.removeObject(theObject.toString());
+ } catch (RuntimeException e) {
+ logger.error("Error removing cached object {}", theObject, e);
+ }
+ }
+
/*
* Interactions
*/
@@ -177,7 +350,12 @@ public void connectionLost(String faultDescription) throws FederateInternalError
private void subscribeInteractions()
throws FederateNotExecutionMember, RestoreInProgress, SaveInProgress, NotConnected,
RTIinternalError, FederateServiceInvocationsAreBeingReportedViaMOM {
- xapiConfig.statementTriggers.forEach(trigger -> {
+ if (xapiConfig.statementTriggers == null) {
+ return;
+ }
+ xapiConfig.statementTriggers.stream()
+ .filter(trigger -> trigger.type == StatementTrigger.Type.INTERACTION)
+ .forEach(trigger -> {
try {
InteractionClassHandle handle = ambassador.getInteractionClassHandle(trigger.clazz);
ambassador.subscribeInteractionClass(handle);
@@ -228,6 +406,7 @@ private void receiveInteraction(InteractionClassHandle interactionClass, Paramet
&& trigger.type.equals(StatementTrigger.Type.INTERACTION))
.forEach(trigger -> {
logger.trace("Processing trigger for interaction {}", trigger.clazz);
+ // TODO: this is nullable, implement DLQ
String xapi = triggerProcessor.processTrigger(trigger, context);
try {
xapiClient.sendStatementFromString(xapi);
diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java
index c8ff244..851cc0a 100644
--- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java
+++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java
@@ -2,6 +2,7 @@
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import javax.xml.xpath.XPathExpressionException;
@@ -11,8 +12,16 @@
import org.springframework.stereotype.Component;
import com.yetanalytics.hlaxapi.FOMXML.PathCheckResult;
+import com.yetanalytics.hlaxapi.cache.CachedObject;
+import com.yetanalytics.hlaxapi.cache.ObjectCache;
+import com.yetanalytics.hlaxapi.cache.ValueResolution;
+import com.yetanalytics.hlaxapi.config.model.Criterion;
import com.yetanalytics.hlaxapi.config.model.Expression;
+import com.yetanalytics.hlaxapi.config.model.LogicalExpression;
+import com.yetanalytics.hlaxapi.config.model.ObjectLookup;
import com.yetanalytics.hlaxapi.config.model.Target;
+import com.yetanalytics.hlaxapi.config.model.ThisExpression;
+import com.yetanalytics.hlaxapi.config.model.ValueExpression;
import com.yetanalytics.hlaxapi.injection.InjectionContext;
import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext;
import com.yetanalytics.hlaxapi.injection.ObjectInjectionContext;
@@ -30,12 +39,18 @@ public class InjectionHandler {
private static final Logger logger = LogManager.getLogger(InjectionHandler.class);
+ @Autowired
+ private ObjectCache objectCache;
+
@Autowired
private FOMXML fomXml;
@Autowired
private HLADecoderRegistry hlaDecoderRegistry;
+ public InjectionHandler() {
+ }
+
public Object handleThis(Target t, InjectionContext context) {
if (context instanceof InteractionInjectionContext) {
return handleThis(t, (InteractionInjectionContext) context);
@@ -208,13 +223,77 @@ public Object handleThis(Target t, ObjectInjectionContext context) {
return "[THIS(object):" + t.toString() + ":CONTEXT:" + context.getHlaClass() + "]";
}
+ public ValueResolution handleThisResolution(Target t, InjectionContext context) {
+ Object value = handleThis(t, context);
+ if (value == null) {
+ return ValueResolution.missingValue();
+ }
+ return ValueResolution.present(value);
+ }
+
+ public Object handleQuery(String clazz, Target attrTarget, Expression criteria) {
+ return handleQuery(clazz, attrTarget, criteria, null);
+ }
+
public Object handleQuery(String clazz, Target attrTarget, Expression criteria, InjectionContext context) {
- // placeholder: return a demo string showing the criteria expression
- // TODO: Query methods for both types like interation has
- return "[QUERY:" + clazz
- + ":" + (attrTarget == null ? "null" : attrTarget.toString())
- + ":" + (criteria == null ? "null" : criteria.toString())
- + "]";
+ ValueResolution resolution = handleQueryResolution(clazz, attrTarget, criteria, context);
+ return resolution.present() ? resolution.value() : null;
+ }
+
+ public ValueResolution handleQueryResolution(
+ String clazz,
+ Target attrTarget,
+ Expression criteria,
+ InjectionContext context) {
+ if (objectCache == null) {
+ return ValueResolution.missingObject();
+ }
+
+ Expression resolvedCriteria = resolveThisExpressions(criteria, context);
+ return objectCache.findFirstResolution(clazz, attrTarget, resolvedCriteria);
+ }
+
+ public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) {
+ if (objectCache == null || lookup == null || lookup.clazz == null || lookup.clazz.isBlank()) {
+ return Optional.empty();
+ }
+ Expression resolvedCriteria = resolveThisExpressions(lookup.criteria, context);
+ return objectCache.findFirstObject(lookup.clazz, resolvedCriteria);
+ }
+
+ public Object handleLookup(CachedObject object, Target attrTarget) {
+ ValueResolution resolution = handleLookupResolution(object, attrTarget);
+ return resolution.present() ? resolution.value() : null;
+ }
+
+ public ValueResolution handleLookupResolution(CachedObject object, Target attrTarget) {
+ if (objectCache == null || object == null) {
+ return ValueResolution.missingObject();
+ }
+ return objectCache.findValueResolution(object, attrTarget);
+ }
+
+ private Expression resolveThisExpressions(Expression expression, InjectionContext context) {
+ if (expression == null || context == null) {
+ return expression;
+ }
+ if (expression instanceof ThisExpression thisExpression) {
+ return new ValueExpression(handleThis(thisExpression.target, context));
+ }
+ if (expression instanceof Criterion criterion) {
+ return new Criterion(
+ resolveThisExpressions(criterion.left, context),
+ criterion.operator,
+ resolveThisExpressions(criterion.right, context));
+ }
+ if (expression instanceof LogicalExpression logicalExpression) {
+ return new LogicalExpression(
+ logicalExpression.operator,
+ logicalExpression.operands.stream()
+ .map(operand -> resolveThisExpressions(operand, context))
+ .toList());
+ }
+ return expression;
}
// for test
@@ -225,4 +304,8 @@ public void setFomXml(FOMXML fomXml) {
public void setHLADecoderRegistry(HLADecoderRegistry hdr) {
this.hlaDecoderRegistry = hdr;
}
+
+ ObjectCache objectCache() {
+ return objectCache;
+ }
}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java
index c99afb1..3845313 100644
--- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java
+++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java
@@ -1,7 +1,8 @@
package com.yetanalytics.hlaxapi;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -14,12 +15,20 @@
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
-import com.yetanalytics.hlaxapi.config.ConfigConverter;
-import com.yetanalytics.hlaxapi.config.model.Expression;
-import com.yetanalytics.hlaxapi.config.model.InjectionType;
+import com.yetanalytics.hlaxapi.cache.CachedObject;
+import com.yetanalytics.hlaxapi.cache.ValueResolution;
+import com.yetanalytics.hlaxapi.config.model.ObjectLookup;
import com.yetanalytics.hlaxapi.config.model.StatementTrigger;
import com.yetanalytics.hlaxapi.config.model.Target;
import com.yetanalytics.hlaxapi.injection.InjectionContext;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.InjectionOptions;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.InlineInjection;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.LookupInjection;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.ParseResult;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.QueryInjection;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.StatementInjection;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.ThisInjection;
@Component
public class TriggerProcessor {
@@ -44,21 +53,39 @@ public String processTrigger(StatementTrigger trigger, InjectionContext context)
ObjectMapper mapper = new ObjectMapper();
try {
JsonNode stmtNode = mapper.readTree(trigger.statement);
+ Map lookupObjects = resolveLookups(trigger, context);
- JsonNode processed = processNode(stmtNode, context, mapper);
+ JsonNode processed = processNode(stmtNode, context, mapper, lookupObjects);
String output = mapper.writeValueAsString(processed);
logger.trace("Processed statement output: {}", output);
return output;
+ } catch (RequiredInjectionException e) {
+ logger.error("Could not process trigger {}.{}: {}", trigger.type, trigger.clazz, e.getMessage());
+ return null;
} catch (Exception e) {
logger.debug("Could not process statement for trigger", e);
return null;
}
}
- private static final Pattern INLINE_PLACEHOLDER = Pattern.compile("<<(.+?)>>", Pattern.DOTALL);
+ private Map resolveLookups(StatementTrigger trigger, InjectionContext context) {
+ Map lookupObjects = new LinkedHashMap<>();
+ if (trigger.lookups == null || trigger.lookups.isEmpty()) {
+ return lookupObjects;
+ }
+ for (Map.Entry entry : trigger.lookups.entrySet()) {
+ injectionHandler.resolveLookup(entry.getValue(), context)
+ .ifPresent(object -> lookupObjects.put(entry.getKey(), object));
+ }
+ return lookupObjects;
+ }
- private JsonNode processNode(JsonNode node, InjectionContext context, ObjectMapper mapper) {
+ private JsonNode processNode(
+ JsonNode node,
+ InjectionContext context,
+ ObjectMapper mapper,
+ Map lookupObjects) {
if (node == null || node.isNull())
return node;
@@ -68,122 +95,173 @@ private JsonNode processNode(JsonNode node, InjectionContext context, ObjectMapp
ObjectNode out = mapper.createObjectNode();
node.fieldNames().forEachRemaining(field -> {
JsonNode child = node.get(field);
- JsonNode processedChild = processNode(child, context, mapper);
+ JsonNode processedChild = processNode(child, context, mapper, lookupObjects);
out.set(field, processedChild);
});
return out;
}
if (node.isArray()) {
- // check if this array itself is an injection array
- if (node.size() > 0 && node.get(0).isTextual()) {
- String k = node.get(0).asText();
- if (InjectionType.fromString(k) != null) {
- // handle injection array
- return handleInjectionArray(node, context, mapper, false);
- }
+ ParseResult parsed = StatementInjectionParser.parse(node);
+ if (parsed.recognized()) {
+ return parsed.valid()
+ ? handleInjection(parsed.injection(), context, mapper, false, lookupObjects)
+ : NullNode.instance;
}
- // if not, process each element instead
ArrayNode out = mapper.createArrayNode();
for (JsonNode el : node) {
- out.add(processNode(el, context, mapper));
+ out.add(processNode(el, context, mapper, lookupObjects));
}
return out;
}
if (node.isTextual()) {
- // if it's stringy, we need to check for escaped/encoded injection array(s) and route them through the
- // handlers, then replace that text in the string with the result(s)
String txt = node.asText();
- Matcher m = INLINE_PLACEHOLDER.matcher(txt);
- if (!m.find()) {
+ List injections = StatementInjectionParser.findInline(txt);
+ if (injections.isEmpty()) {
return node;
}
- m.reset();
- StringBuffer sb = new StringBuffer();
-
- while (m.find()) {
- String inner = m.group(1);
+ StringBuilder rendered = new StringBuilder();
+ int cursor = 0;
+ for (InlineInjection inline : injections) {
+ rendered.append(txt, cursor, inline.start());
try {
- JsonNode injNode = mapper.readTree(inner);
JsonNode repNode = null;
- if (injNode.isArray() && injNode.size() > 0 && injNode.get(0).isTextual()
- && InjectionType.fromString(injNode.get(0).asText()) != null) {
- repNode = handleInjectionArray(injNode, context, mapper, true);
+ if (inline.result().valid()) {
+ repNode = handleInjection(
+ inline.result().injection(),
+ context,
+ mapper,
+ true,
+ lookupObjects);
+ } else if (inline.result().recognized()) {
+ repNode = NullNode.instance;
}
if (repNode == null) {
- m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0)));
+ rendered.append(inline.source());
} else {
String replacementText = repNode.isValueNode()
? repNode.asText()
: mapper.writeValueAsString(repNode);
- m.appendReplacement(sb, Matcher.quoteReplacement(replacementText));
+ rendered.append(replacementText);
}
+ } catch (RequiredInjectionException e) {
+ throw e;
} catch (Exception e) {
- m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0)));
+ rendered.append(inline.source());
}
+ cursor = inline.end();
}
- m.appendTail(sb);
- return TextNode.valueOf(sb.toString());
+ rendered.append(txt, cursor, txt.length());
+ return TextNode.valueOf(rendered.toString());
}
return node;
+ } catch (RequiredInjectionException e) {
+ throw e;
} catch (Exception e) {
logger.debug("Error processing node", e);
return node;
}
}
- private JsonNode handleInjectionArray(JsonNode injArray, InjectionContext context, ObjectMapper mapper,
- Boolean embedded) {
+ private JsonNode handleInjection(
+ StatementInjection injection,
+ InjectionContext context,
+ ObjectMapper mapper,
+ Boolean embedded,
+ Map lookupObjects) {
try {
- String keyword = injArray.get(0).asText();
- InjectionType iType = InjectionType.fromString(keyword);
- if (iType == InjectionType.THIS && injArray.size() >= 2) {
- Object rawTarget = mapper.convertValue(injArray.get(1), Object.class);
- Target t = ConfigConverter.toTarget(rawTarget);
- Object replacement = injectionHandler.handleThis(t, context);
- if (replacement == null)
- return NullNode.instance;
- String replacementString = render(replacement, embedded);
- try {
- return mapper.readTree(replacementString);
- } catch (Exception e) {
- return TextNode.valueOf(replacementString);
- }
- } else if (iType == InjectionType.QUERY && injArray.size() >= 4) {
- String clazz = injArray.get(1).asText();
- Object rawTarget = mapper.convertValue(injArray.get(2), Object.class);
- Target attr = ConfigConverter.toTarget(rawTarget);
- Object criteriaRaw = mapper.convertValue(injArray.get(3), Object.class);
- Expression criteriaExpr = ConfigConverter.toExpression(criteriaRaw);
- Object replacement = injectionHandler.handleQuery(clazz, attr, criteriaExpr, context);
- if (replacement == null)
- return NullNode.instance;
- String replacementString = render(replacement, embedded);
- try {
- return mapper.readTree(replacementString);
- } catch (Exception e) {
- return TextNode.valueOf(replacementString);
- }
+ if (injection instanceof ThisInjection thisInjection) {
+ return renderResolution(
+ injectionHandler.handleThisResolution(thisInjection.target(), context),
+ thisInjection.options(),
+ injectionDescription(thisInjection, null),
+ embedded,
+ mapper);
+ } else if (injection instanceof QueryInjection queryInjection) {
+ return renderResolution(
+ injectionHandler.handleQueryResolution(
+ queryInjection.className(),
+ queryInjection.target(),
+ queryInjection.criteria(),
+ context),
+ queryInjection.options(),
+ injectionDescription(queryInjection, queryInjection.className()),
+ embedded,
+ mapper);
+ } else if (injection instanceof LookupInjection lookupInjection) {
+ return renderResolution(
+ injectionHandler.handleLookupResolution(
+ lookupObjects.get(lookupInjection.alias()),
+ lookupInjection.target()),
+ lookupInjection.options(),
+ injectionDescription(lookupInjection, lookupInjection.alias()),
+ embedded,
+ mapper);
}
+ } catch (RequiredInjectionException e) {
+ throw e;
} catch (Exception e) {
- logger.debug("Error handling injection array", e);
+ logger.debug("Error handling statement injection", e);
}
return NullNode.instance;
}
/**
- * If the value is a String, and this is not embedded inside another string, add quotes.
- * TODO: Expand if needed for other datatype renders
+ * Embedded injections are always rendered as text within the containing string.
+ * Whole-node injections preserve the replacement's JSON shape.
+ *
* @param replacement
* @param embedded
+ * @param mapper
* @return actual string to put in result
*/
- private String render(Object replacement, Boolean embedded) {
- String formatString = (replacement instanceof String && embedded) ? "\"%s\"" : "%s";
- return String.format(formatString, replacement.toString());
+ private JsonNode render(Object replacement, Boolean embedded, ObjectMapper mapper) {
+ if (Boolean.TRUE.equals(embedded)) {
+ return TextNode.valueOf(replacement.toString());
+ }
+ return mapper.valueToTree(replacement);
+ }
+
+ private JsonNode renderResolution(
+ ValueResolution resolution,
+ InjectionOptions options,
+ String description,
+ Boolean embedded,
+ ObjectMapper mapper) {
+ if (!resolution.present()) {
+ if (!options.required()) {
+ return NullNode.instance;
+ }
+ throw new RequiredInjectionException(description + " failed: " + resolution.status());
+ }
+ Object replacement = resolution.value();
+ if (replacement == null) {
+ if (options.required() && !options.nullable()) {
+ throw new RequiredInjectionException(description + " failed: unexpected null value");
+ }
+ return NullNode.instance;
+ }
+ return render(replacement, embedded, mapper);
+ }
+
+ private String injectionDescription(StatementInjection injection, String scope) {
+ StringBuilder description = new StringBuilder(injection.type().toString());
+ if (scope != null && !scope.isBlank()) {
+ description.append("(").append(scope).append(")");
+ }
+ description.append(" target ");
+ Target target = injection.target();
+ description.append(target == null ? "" : target.parts);
+ return description.toString();
+ }
+
+ private static class RequiredInjectionException extends RuntimeException {
+ RequiredInjectionException(String message) {
+ super(message);
+ }
}
}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java b/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java
new file mode 100644
index 0000000..6631f26
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java
@@ -0,0 +1,96 @@
+package com.yetanalytics.hlaxapi.cache;
+
+import com.yetanalytics.hlaxapi.config.model.Expression;
+import com.yetanalytics.hlaxapi.config.model.Target;
+import com.yetanalytics.hlaxapi.criteria.CriteriaEvaluator;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+public class CacheQueryService {
+
+ private final ObjectCache cache;
+ private final CriteriaEvaluator criteriaEvaluator;
+
+ public CacheQueryService(ObjectCache cache) {
+ this.cache = Objects.requireNonNull(cache, "cache");
+ this.criteriaEvaluator = new CriteriaEvaluator();
+ }
+
+ public Optional findFirstValue(String className, Target target, Expression criteria) {
+ return findValues(className, target, criteria).stream().findFirst();
+ }
+
+ public Optional findFirstObject(String className, Expression criteria) {
+ for (CachedObject object : cache.currentObjects(className)) {
+ if (matches(object, criteria)) {
+ return Optional.of(object);
+ }
+ }
+ return Optional.empty();
+ }
+
+ public ValueResolution findFirstResolution(String className, Target target, Expression criteria) {
+ Optional object = findFirstObject(className, criteria);
+ if (object.isEmpty()) {
+ return ValueResolution.missingObject();
+ }
+ return findValueResolution(object.orElseThrow(), target);
+ }
+
+ public Optional findValue(CachedObject object, Target target) {
+ ValueResolution resolution = findValueResolution(object, target);
+ if (!resolution.present() || resolution.value() == null) {
+ return Optional.empty();
+ }
+ return Optional.of(resolution.value());
+ }
+
+ public ValueResolution findValueResolution(CachedObject object, Target target) {
+ if (object == null) {
+ return ValueResolution.missingObject();
+ }
+ return resolveTarget(object, target);
+ }
+
+ public List findValues(String className, Target target, Expression criteria) {
+ String pathKey = FomCatalog.targetPath(target == null ? null : target.parts);
+ if (pathKey == null) {
+ return List.of();
+ }
+
+ List values = new ArrayList<>();
+ for (CachedObject object : cache.currentObjects(className)) {
+ if (matches(object, criteria)) {
+ cache.findCurrentValue(object.id(), pathKey)
+ .map(CachedValue::value)
+ .ifPresent(values::add);
+ }
+ }
+ return values;
+ }
+
+ public boolean matches(CachedObject object, Expression expression) {
+ return criteriaEvaluator.matches(expression, leaf -> resolveCacheExpression(object, leaf));
+ }
+
+ private Object resolveCacheExpression(CachedObject object, Expression expression) {
+ if (expression instanceof Target target) {
+ ValueResolution resolution = resolveTarget(object, target);
+ return resolution.present() ? resolution.value() : null;
+ }
+ return null;
+ }
+
+ private ValueResolution resolveTarget(CachedObject object, Target target) {
+ String pathKey = FomCatalog.targetPath(target.parts);
+ if (pathKey == null) {
+ return ValueResolution.missingValue();
+ }
+ return cache.findCurrentValue(object.id(), pathKey)
+ .map(value -> ValueResolution.present(value.value()))
+ .orElseGet(ValueResolution::missingValue);
+ }
+
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/CachedObject.java b/src/main/java/com/yetanalytics/hlaxapi/cache/CachedObject.java
new file mode 100644
index 0000000..49a0e64
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/cache/CachedObject.java
@@ -0,0 +1,4 @@
+package com.yetanalytics.hlaxapi.cache;
+
+public record CachedObject(long id, String objectHandle, String objectName, String className) {
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/CachedValue.java b/src/main/java/com/yetanalytics/hlaxapi/cache/CachedValue.java
new file mode 100644
index 0000000..7037161
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/cache/CachedValue.java
@@ -0,0 +1,48 @@
+package com.yetanalytics.hlaxapi.cache;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+public final class CachedValue {
+
+ private final String valueType;
+ private final Object value;
+ private final byte[] rawBytes;
+
+ public CachedValue(String valueType, Object value, byte[] rawBytes) {
+ this.valueType = Objects.requireNonNull(valueType, "valueType");
+ this.value = value;
+ this.rawBytes = rawBytes == null ? null : Arrays.copyOf(rawBytes, rawBytes.length);
+ }
+
+ public String valueType() {
+ return valueType;
+ }
+
+ public Object value() {
+ return value;
+ }
+
+ public byte[] rawBytes() {
+ return rawBytes == null ? null : Arrays.copyOf(rawBytes, rawBytes.length);
+ }
+
+ public static String valueType(Object value) {
+ if (value == null) {
+ return "null";
+ }
+ if (value instanceof Boolean) {
+ return "bool";
+ }
+ if (value instanceof Number) {
+ return "num";
+ }
+ if (value instanceof String || value instanceof Character) {
+ return "text";
+ }
+ if (value instanceof byte[]) {
+ return "blob";
+ }
+ return "json";
+ }
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/DecodedAttributeValue.java b/src/main/java/com/yetanalytics/hlaxapi/cache/DecodedAttributeValue.java
new file mode 100644
index 0000000..641778c
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/cache/DecodedAttributeValue.java
@@ -0,0 +1,10 @@
+package com.yetanalytics.hlaxapi.cache;
+
+public record DecodedAttributeValue(
+ String pathKey,
+ String dataType,
+ String primitiveType,
+ Object value,
+ byte[] rawBytes,
+ boolean leaf) {
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java
new file mode 100644
index 0000000..abf6543
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java
@@ -0,0 +1,266 @@
+package com.yetanalytics.hlaxapi.cache;
+
+import com.yetanalytics.hlaxapi.FOMXML;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import javax.xml.xpath.XPathExpressionException;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * FOM-derived object metadata used by the SQLite cache.
+ */
+@Component
+public final class FomCatalog {
+
+ private final Map classesByName;
+ private final Map classesById;
+ private final Map attributesById;
+
+ public FomCatalog(FOMXML fomXml) {
+ CatalogBuilder builder = new CatalogBuilder(fomXml);
+ for (FOMXML.ObjectClassDefinition definition : fomXml.objectClassDefinitions()) {
+ builder.addObjectClass(definition);
+ }
+ this.classesByName = builder.classesByName;
+
+ Map byId = new LinkedHashMap<>();
+ Map attrsById = new LinkedHashMap<>();
+ for (ObjectClassDef clazz : classesByName.values()) {
+ byId.put(clazz.id(), clazz);
+ for (FomAttribute attribute : clazz.attributes()) {
+ attrsById.put(attribute.id(), attribute);
+ }
+ }
+ this.classesById = Collections.unmodifiableMap(byId);
+ this.attributesById = Collections.unmodifiableMap(attrsById);
+ }
+
+ public Collection objectClasses() {
+ return classesByName.values();
+ }
+
+ public Optional objectClass(String name) {
+ return Optional.ofNullable(classesByName.get(localName(name)));
+ }
+
+ public Optional objectClass(int id) {
+ return Optional.ofNullable(classesById.get(id));
+ }
+
+ public Optional attribute(int id) {
+ return Optional.ofNullable(attributesById.get(id));
+ }
+
+ public Optional attribute(String className, String attributeName, String pathKey) {
+ return objectClass(className).flatMap(clazz -> clazz.attribute(attributeName, pathKey));
+ }
+
+ public Optional attribute(String className, String pathKey) {
+ return objectClass(className).flatMap(clazz -> clazz.attribute(pathKey));
+ }
+
+ public static String targetPath(List targetParts) {
+ if (targetParts == null || targetParts.isEmpty()) {
+ return null;
+ }
+ StringBuilder path = new StringBuilder();
+ for (Object part : targetParts) {
+ if (part instanceof Number number) {
+ path.append('[').append(number.intValue()).append(']');
+ } else if (part != null) {
+ if (path.length() > 0) {
+ path.append('.');
+ }
+ path.append(part);
+ }
+ }
+ return path.toString();
+ }
+
+ public static String topLevelTargetPart(List targetParts) {
+ if (targetParts == null || targetParts.isEmpty() || !(targetParts.get(0) instanceof String part)) {
+ return null;
+ }
+ return part;
+ }
+
+ public static String wildcardArrayIndexes(String pathKey) {
+ if (pathKey == null) {
+ return null;
+ }
+ return pathKey.replaceAll("\\[[0-9]+\\]", "[]");
+ }
+
+ static String localName(String hlaName) {
+ if (hlaName == null) {
+ return null;
+ }
+ String trimmed = hlaName.trim();
+ int index = trimmed.lastIndexOf('.');
+ return index >= 0 ? trimmed.substring(index + 1) : trimmed;
+ }
+
+ public record ObjectClassDef(
+ int id,
+ String hlaName,
+ String localName,
+ String parentName,
+ List attributes) {
+
+ public ObjectClassDef {
+ attributes = List.copyOf(attributes);
+ }
+
+ public List leafAttributes() {
+ return attributes.stream().filter(FomAttribute::leaf).toList();
+ }
+
+ public List topLevelAttributeNames() {
+ Set names = new LinkedHashSet<>();
+ for (FomAttribute attribute : attributes) {
+ names.add(attribute.attributeName());
+ }
+ return List.copyOf(names);
+ }
+
+ public Optional attribute(String pathKey) {
+ String localPath = pathKey == null ? null : pathKey.trim();
+ String wildcardPath = wildcardArrayIndexes(localPath);
+ for (FomAttribute attribute : attributes) {
+ if (attribute.pathKey().equals(localPath) || attribute.pathKey().equals(wildcardPath)) {
+ return Optional.of(attribute);
+ }
+ }
+ return Optional.empty();
+ }
+
+ public Optional attribute(String attributeName, String pathKey) {
+ return attribute(pathKey).filter(attribute -> attribute.attributeName().equals(attributeName));
+ }
+ }
+
+ public record FomAttribute(
+ int id,
+ int classId,
+ String attributeName,
+ String pathKey,
+ String dataType,
+ String primitiveType,
+ boolean leaf) {
+ }
+
+ private static final class CatalogBuilder {
+
+ private final FOMXML fomXml;
+ private final Map classesByName = new LinkedHashMap<>();
+ private final Map> attributesByClassName = new LinkedHashMap<>();
+ private int nextClassId = 1;
+ private int nextAttributeId = 1;
+
+ private CatalogBuilder(FOMXML fomXml) {
+ this.fomXml = fomXml;
+ }
+
+ private void addObjectClass(FOMXML.ObjectClassDefinition definition) {
+ List allAttributes = new ArrayList<>();
+ if (definition.parentName() != null) {
+ allAttributes.addAll(attributesByClassName.getOrDefault(definition.parentName(), List.of()));
+ }
+ for (FOMXML.ObjectAttributeDefinition attribute : definition.attributes()) {
+ allAttributes.add(new AttributeSource(attribute.name(), attribute.dataType()));
+ }
+ attributesByClassName.put(definition.name(), List.copyOf(allAttributes));
+
+ int classId = nextClassId++;
+ List flattened = new ArrayList<>();
+ for (AttributeSource attribute : allAttributes) {
+ flattenAttribute(classId, attribute.name(), attribute.name(), attribute.dataType(), flattened);
+ }
+
+ ObjectClassDef classDef =
+ new ObjectClassDef(
+ classId,
+ definition.name(),
+ localName(definition.name()),
+ localName(definition.parentName()),
+ flattened);
+ classesByName.put(classDef.localName(), classDef);
+ }
+
+ private void flattenAttribute(
+ int classId,
+ String attributeName,
+ String pathKey,
+ String dataType,
+ List attributes) {
+ String primitive = primitiveType(dataType);
+ List fields = fixedRecordFields(dataType);
+ String arrayElementType = arrayElementType(dataType);
+ boolean leaf = primitive != null || fields.isEmpty() && arrayElementType == null;
+
+ attributes.add(new FomAttribute(
+ nextAttributeId++,
+ classId,
+ attributeName,
+ pathKey,
+ dataType,
+ primitive,
+ leaf));
+
+ if (!fields.isEmpty()) {
+ for (FOMXML.FixedRecordField field : fields) {
+ flattenAttribute(
+ classId,
+ attributeName,
+ pathKey + "." + field.name,
+ field.dataType,
+ attributes);
+ }
+ } else if (arrayElementType != null) {
+ flattenAttribute(classId, attributeName, pathKey + "[]", arrayElementType, attributes);
+ }
+ }
+
+ private String primitiveType(String dataType) {
+ try {
+ return fomXml.resolvePrimitiveType(dataType);
+ } catch (XPathExpressionException e) {
+ throw new IllegalArgumentException("Could not resolve primitive type for " + dataType, e);
+ }
+ }
+
+ private List fixedRecordFields(String dataType) {
+ try {
+ if (!fomXml.isFixedRecordType(dataType)) {
+ return List.of();
+ }
+ return fomXml.getFixedRecordFields(dataType);
+ } catch (XPathExpressionException e) {
+ throw new IllegalArgumentException("Could not resolve fixed record fields for " + dataType, e);
+ }
+ }
+
+ private String arrayElementType(String dataType) {
+ try {
+ if (!fomXml.isArrayType(dataType)) {
+ return null;
+ }
+ String elementType = fomXml.getArrayElementType(dataType);
+ return elementType == null || elementType.isBlank() ? null : elementType;
+ } catch (XPathExpressionException e) {
+ throw new IllegalArgumentException("Could not resolve array element type for " + dataType, e);
+ }
+ }
+
+ private record AttributeSource(String name, String dataType) {
+ }
+ }
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/HlaValueFlattener.java b/src/main/java/com/yetanalytics/hlaxapi/cache/HlaValueFlattener.java
new file mode 100644
index 0000000..12061f2
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/cache/HlaValueFlattener.java
@@ -0,0 +1,128 @@
+package com.yetanalytics.hlaxapi.cache;
+
+import com.yetanalytics.hlaxapi.FOMXML;
+import com.yetanalytics.hlaxapi.HLADecoderRegistry;
+import hla.rti1516e.encoding.DataElement;
+import hla.rti1516e.encoding.DecoderException;
+import hla.rti1516e.encoding.EncoderException;
+import hla.rti1516e.encoding.HLAfixedRecord;
+import hla.rti1516e.encoding.HLAvariableArray;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import javax.xml.xpath.XPathExpressionException;
+
+public class HlaValueFlattener {
+
+ private final FOMXML fomXml;
+ private final HLADecoderRegistry decoderRegistry;
+
+ public HlaValueFlattener(FOMXML fomXml, HLADecoderRegistry decoderRegistry) {
+ this.fomXml = Objects.requireNonNull(fomXml, "fomXml");
+ this.decoderRegistry = Objects.requireNonNull(decoderRegistry, "decoderRegistry");
+ }
+
+ public List flatten(String attributeName, String dataType, byte[] bytes) {
+ Objects.requireNonNull(attributeName, "attributeName");
+ Objects.requireNonNull(dataType, "dataType");
+ Objects.requireNonNull(bytes, "bytes");
+ try {
+ DataElement element = fomXml.createDataElementForType(dataType);
+ element.decode(bytes);
+ List values = new ArrayList<>();
+ Object value = extractValue(attributeName, dataType, element, values);
+ String primitiveType = primitiveType(dataType);
+ if (primitiveType != null) {
+ return values;
+ }
+ values.add(0, new DecodedAttributeValue(
+ attributeName,
+ dataType,
+ null,
+ value,
+ bytes,
+ false));
+ return values;
+ } catch (DecoderException | EncoderException | IllegalArgumentException e) {
+ return List.of(new DecodedAttributeValue(attributeName, dataType, null, null, bytes, false));
+ }
+ }
+
+ private Object extractValue(
+ String pathKey,
+ String dataType,
+ DataElement element,
+ List values) throws DecoderException, EncoderException {
+ String primitiveType = primitiveType(dataType);
+ if (primitiveType != null) {
+ byte[] elementBytes = element.toByteArray();
+ Object value = decoderRegistry.decode(primitiveType, elementBytes);
+ values.add(new DecodedAttributeValue(
+ pathKey,
+ dataType,
+ primitiveType,
+ value,
+ elementBytes,
+ true));
+ return value;
+ }
+
+ List fields = fixedRecordFields(dataType);
+ if (!fields.isEmpty() && element instanceof HLAfixedRecord record) {
+ Map fieldValues = new LinkedHashMap<>();
+ for (int i = 0; i < fields.size(); i++) {
+ FOMXML.FixedRecordField field = fields.get(i);
+ String fieldPath = pathKey + "." + field.name;
+ fieldValues.put(field.name, extractValue(fieldPath, field.dataType, record.get(i), values));
+ }
+ return fieldValues;
+ }
+
+ String elementType = arrayElementType(dataType);
+ if (elementType != null && element instanceof HLAvariableArray> variableArray) {
+ List arrayValues = new ArrayList<>();
+ int index = 0;
+ for (DataElement child : variableArray) {
+ String childPath = pathKey + "[" + index + "]";
+ arrayValues.add(extractValue(childPath, elementType, child, values));
+ index++;
+ }
+ return arrayValues;
+ }
+
+ return null;
+ }
+
+ private String primitiveType(String dataType) {
+ try {
+ return fomXml.resolvePrimitiveType(dataType);
+ } catch (XPathExpressionException e) {
+ throw new IllegalArgumentException("Could not resolve primitive type for " + dataType, e);
+ }
+ }
+
+ private List fixedRecordFields(String dataType) {
+ try {
+ if (!fomXml.isFixedRecordType(dataType)) {
+ return List.of();
+ }
+ return fomXml.getFixedRecordFields(dataType);
+ } catch (XPathExpressionException e) {
+ throw new IllegalArgumentException("Could not resolve fixed record fields for " + dataType, e);
+ }
+ }
+
+ private String arrayElementType(String dataType) {
+ try {
+ if (!fomXml.isArrayType(dataType)) {
+ return null;
+ }
+ String elementType = fomXml.getArrayElementType(dataType);
+ return elementType == null || elementType.isBlank() ? null : elementType;
+ } catch (XPathExpressionException e) {
+ throw new IllegalArgumentException("Could not resolve array element type for " + dataType, e);
+ }
+ }
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java
new file mode 100644
index 0000000..8746437
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java
@@ -0,0 +1,598 @@
+package com.yetanalytics.hlaxapi.cache;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yetanalytics.hlaxapi.FOMXML;
+import com.yetanalytics.hlaxapi.HLADecoderRegistry;
+import com.yetanalytics.hlaxapi.config.XapiConfig;
+import com.yetanalytics.hlaxapi.config.model.Expression;
+import com.yetanalytics.hlaxapi.config.model.Target;
+import com.yetanalytics.hlaxapi.config.model.TrackedObject;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class ObjectCache implements AutoCloseable {
+
+ private static final int SCHEMA_VERSION = 1;
+
+ private final FomCatalog catalog;
+ private final Map> subscriptions;
+ private final HlaValueFlattener valueFlattener;
+ private final CacheQueryService queryService;
+ private final ObjectMapper mapper = new ObjectMapper();
+ private final AtomicLong sequence = new AtomicLong();
+ private Connection connection;
+
+ public ObjectCache(XapiConfig xapiConfig, FomCatalog catalog, FOMXML fomXml, HLADecoderRegistry decoderRegistry) {
+ this(xapiConfig, catalog, fomXml, decoderRegistry, defaultJdbcUrl());
+ }
+
+ ObjectCache(
+ XapiConfig xapiConfig,
+ FomCatalog catalog,
+ FOMXML fomXml,
+ HLADecoderRegistry decoderRegistry,
+ String jdbcUrl) {
+ this.catalog = Objects.requireNonNull(catalog, "catalog");
+ this.subscriptions = collectSubscriptions(xapiConfig);
+ this.valueFlattener = new HlaValueFlattener(fomXml, decoderRegistry);
+ this.queryService = new CacheQueryService(this);
+ if (!subscriptions.isEmpty()) {
+ try {
+ this.connection = DriverManager.getConnection(Objects.requireNonNull(jdbcUrl, "jdbcUrl"));
+ initializeSchema();
+ seedFomMetadata();
+ } catch (SQLException e) {
+ throw new IllegalStateException("Could not initialize HLA object cache", e);
+ }
+ }
+ }
+
+ public boolean isEnabled() {
+ return connection != null;
+ }
+
+ public Map> subscriptions() {
+ return subscriptions;
+ }
+
+ public FomCatalog catalog() {
+ return catalog;
+ }
+
+ public CacheQueryService queryService() {
+ return queryService;
+ }
+
+ public Optional findFirstValue(String clazz, Target attrTarget, Expression criteria) {
+ if (!isEnabled()) {
+ return Optional.empty();
+ }
+ return queryService.findFirstValue(clazz, attrTarget, criteria);
+ }
+
+ public ValueResolution findFirstResolution(String clazz, Target attrTarget, Expression criteria) {
+ if (!isEnabled()) {
+ return ValueResolution.missingObject();
+ }
+ return queryService.findFirstResolution(clazz, attrTarget, criteria);
+ }
+
+ public Optional findFirstObject(String clazz, Expression criteria) {
+ if (!isEnabled()) {
+ return Optional.empty();
+ }
+ return queryService.findFirstObject(clazz, criteria);
+ }
+
+ public Optional findValue(CachedObject object, Target attrTarget) {
+ if (!isEnabled()) {
+ return Optional.empty();
+ }
+ return queryService.findValue(object, attrTarget);
+ }
+
+ public ValueResolution findValueResolution(CachedObject object, Target attrTarget) {
+ if (!isEnabled()) {
+ return ValueResolution.missingObject();
+ }
+ return queryService.findValueResolution(object, attrTarget);
+ }
+
+ public synchronized void discoverObject(String objectHandle, String objectName, String className) {
+ if (isEnabled()) {
+ FomCatalog.ObjectClassDef clazz = requireClass(className);
+ ensureObject(objectHandle, objectName, clazz.localName());
+ }
+ }
+
+ public synchronized void reflectAttributeValue(
+ String objectHandle,
+ String className,
+ String attributeName,
+ byte[] bytes) {
+ if (!isEnabled()) {
+ return;
+ }
+ FomCatalog.ObjectClassDef clazz = requireClass(className);
+ CachedObject object = ensureObject(objectHandle, null, clazz.localName());
+ FomCatalog.FomAttribute topAttribute = clazz.attribute(attributeName)
+ .orElseThrow(() -> new IllegalArgumentException(
+ "No FOM attribute " + attributeName + " on object class " + className));
+ List values = valueFlattener.flatten(attributeName, topAttribute.dataType(), bytes);
+ String observedAt = Instant.now().toString();
+ long observedSequence = sequence.incrementAndGet();
+ for (DecodedAttributeValue value : values) {
+ attributeIdForPath(clazz, value.pathKey()).ifPresent(attributeId -> upsertCurrentValue(
+ object.id(),
+ attributeId,
+ value,
+ observedAt,
+ observedSequence));
+ }
+ }
+
+ public synchronized void removeObject(String objectHandle) {
+ if (!isEnabled()) {
+ return;
+ }
+ try (PreparedStatement statement = connection.prepareStatement(
+ "UPDATE object_instance SET removed_at = ? WHERE object_handle = ?")) {
+ statement.setString(1, Instant.now().toString());
+ statement.setString(2, objectHandle);
+ statement.executeUpdate();
+ } catch (SQLException e) {
+ throw new IllegalStateException("Could not mark object removed: " + objectHandle, e);
+ }
+ }
+
+ public synchronized Optional findCurrentValue(long instanceId, String pathKey) {
+ if (!isEnabled()) {
+ return Optional.empty();
+ }
+ String normalizedPath = FomCatalog.wildcardArrayIndexes(pathKey);
+ String sql = """
+ SELECT c.value_type, c.value_json, c.value_blob, c.raw_bytes
+ FROM object_attribute_current c
+ JOIN fom_attribute a ON a.id = c.attribute_id
+ WHERE c.instance_id = ? AND (a.path_key = ? OR a.path_key = ?)
+ ORDER BY CASE WHEN a.path_key = ? THEN 0 ELSE 1 END
+ LIMIT 1
+ """;
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setLong(1, instanceId);
+ statement.setString(2, pathKey);
+ statement.setString(3, normalizedPath);
+ statement.setString(4, pathKey);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (!resultSet.next()) {
+ return Optional.empty();
+ }
+ return Optional.of(readCachedValue(resultSet));
+ }
+ } catch (SQLException e) {
+ throw new IllegalStateException("Could not read current cached value", e);
+ }
+ }
+
+ public synchronized Optional findCurrentValue(String objectHandle, String pathKey) {
+ if (!isEnabled()) {
+ return Optional.empty();
+ }
+ String sql = """
+ SELECT id
+ FROM object_instance
+ WHERE object_handle = ?
+ """;
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, objectHandle);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (!resultSet.next()) {
+ return Optional.empty();
+ }
+ return findCurrentValue(resultSet.getLong("id"), pathKey);
+ }
+ } catch (SQLException e) {
+ throw new IllegalStateException("Could not read object handle: " + objectHandle, e);
+ }
+ }
+
+ public synchronized List currentObjects(String className) {
+ if (!isEnabled()) {
+ return List.of();
+ }
+ FomCatalog.ObjectClassDef clazz = requireClass(className);
+ String sql = """
+ SELECT id, object_handle, object_name
+ FROM object_instance
+ WHERE class_id = ? AND removed_at IS NULL
+ ORDER BY id
+ """;
+ List objects = new ArrayList<>();
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setInt(1, clazz.id());
+ try (ResultSet resultSet = statement.executeQuery()) {
+ while (resultSet.next()) {
+ objects.add(new CachedObject(
+ resultSet.getLong("id"),
+ resultSet.getString("object_handle"),
+ resultSet.getString("object_name"),
+ clazz.localName()));
+ }
+ }
+ } catch (SQLException e) {
+ throw new IllegalStateException("Could not list cached objects for class " + className, e);
+ }
+ return objects;
+ }
+
+ Connection connection() {
+ return connection;
+ }
+
+ @Override
+ public synchronized void close() {
+ if (connection == null) {
+ return;
+ }
+ try {
+ connection.close();
+ connection = null;
+ } catch (SQLException e) {
+ throw new IllegalStateException("Could not close HLA object cache", e);
+ }
+ }
+
+ public static String defaultJdbcUrl() {
+ String configured = System.getenv("HLA_OBJECT_CACHE_JDBC_URL");
+ if (configured != null && !configured.isBlank()) {
+ return configured;
+ }
+ String path = System.getenv().getOrDefault("HLA_OBJECT_CACHE_DB", "hla-object-cache.sqlite");
+ return "jdbc:sqlite:" + path;
+ }
+
+ private FomCatalog.ObjectClassDef requireClass(String className) {
+ return catalog.objectClass(className)
+ .orElseThrow(() -> new IllegalArgumentException("No FOM object class " + className));
+ }
+
+ private CachedObject ensureObject(String objectHandle, String objectName, String className) {
+ FomCatalog.ObjectClassDef clazz = requireClass(className);
+ String sql = """
+ INSERT INTO object_instance (object_handle, object_name, class_id, discovered_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(object_handle) DO UPDATE SET
+ object_name = COALESCE(excluded.object_name, object_instance.object_name),
+ class_id = excluded.class_id,
+ removed_at = NULL
+ """;
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setString(1, Objects.requireNonNull(objectHandle, "objectHandle"));
+ statement.setString(2, objectName);
+ statement.setInt(3, clazz.id());
+ statement.setString(4, Instant.now().toString());
+ statement.executeUpdate();
+ return loadObject(objectHandle, clazz.localName());
+ } catch (SQLException e) {
+ throw new IllegalStateException("Could not upsert object instance " + objectHandle, e);
+ }
+ }
+
+ private CachedObject loadObject(String objectHandle, String className) throws SQLException {
+ try (PreparedStatement statement = connection.prepareStatement(
+ "SELECT id, object_name FROM object_instance WHERE object_handle = ?")) {
+ statement.setString(1, objectHandle);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (!resultSet.next()) {
+ throw new SQLException("Object instance was not inserted: " + objectHandle);
+ }
+ return new CachedObject(
+ resultSet.getLong("id"),
+ objectHandle,
+ resultSet.getString("object_name"),
+ className);
+ }
+ }
+ }
+
+ private void initializeSchema() throws SQLException {
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("PRAGMA foreign_keys = OFF");
+ statement.execute("DROP TABLE IF EXISTS object_attribute_current");
+ statement.execute("DROP TABLE IF EXISTS object_instance");
+ statement.execute("DROP TABLE IF EXISTS fom_attribute");
+ statement.execute("DROP TABLE IF EXISTS fom_object_class");
+ statement.execute("PRAGMA foreign_keys = ON");
+ statement.execute("""
+ CREATE TABLE IF NOT EXISTS fom_object_class (
+ id INTEGER PRIMARY KEY,
+ hla_name TEXT NOT NULL,
+ local_name TEXT NOT NULL UNIQUE,
+ parent_name TEXT
+ )
+ """);
+ statement.execute("""
+ CREATE TABLE IF NOT EXISTS fom_attribute (
+ id INTEGER PRIMARY KEY,
+ class_id INTEGER NOT NULL REFERENCES fom_object_class(id),
+ attribute_name TEXT NOT NULL,
+ path_key TEXT NOT NULL,
+ data_type TEXT NOT NULL,
+ primitive_type TEXT,
+ is_leaf INTEGER NOT NULL,
+ UNIQUE(class_id, path_key)
+ )
+ """);
+ statement.execute("""
+ CREATE TABLE IF NOT EXISTS object_instance (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ object_handle TEXT NOT NULL UNIQUE,
+ object_name TEXT,
+ class_id INTEGER NOT NULL REFERENCES fom_object_class(id),
+ discovered_at TEXT NOT NULL,
+ removed_at TEXT
+ )
+ """);
+ statement.execute("""
+ CREATE TABLE IF NOT EXISTS object_attribute_current (
+ instance_id INTEGER NOT NULL REFERENCES object_instance(id),
+ attribute_id INTEGER NOT NULL REFERENCES fom_attribute(id),
+ value_type TEXT NOT NULL,
+ value_blob BLOB,
+ value_json TEXT,
+ raw_bytes BLOB,
+ observed_at TEXT NOT NULL,
+ sequence INTEGER NOT NULL,
+ PRIMARY KEY(instance_id, attribute_id)
+ )
+ """);
+ statement.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_object_handle ON object_instance(object_handle)");
+ statement.execute("CREATE INDEX IF NOT EXISTS idx_fom_attribute_class_path "
+ + "ON fom_attribute(class_id, path_key)");
+ statement.execute("PRAGMA user_version = " + SCHEMA_VERSION);
+ }
+ }
+
+ private void seedFomMetadata() throws SQLException {
+ boolean autoCommit = connection.getAutoCommit();
+ connection.setAutoCommit(false);
+ try {
+ insertClasses();
+ insertAttributes();
+ connection.commit();
+ } catch (SQLException e) {
+ connection.rollback();
+ throw e;
+ } finally {
+ connection.setAutoCommit(autoCommit);
+ }
+ }
+
+ private void insertClasses() throws SQLException {
+ String sql = """
+ INSERT OR IGNORE INTO fom_object_class (id, hla_name, local_name, parent_name)
+ VALUES (?, ?, ?, ?)
+ """;
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ for (FomCatalog.ObjectClassDef clazz : catalog.objectClasses()) {
+ statement.setInt(1, clazz.id());
+ statement.setString(2, clazz.hlaName());
+ statement.setString(3, clazz.localName());
+ statement.setString(4, clazz.parentName());
+ statement.addBatch();
+ }
+ statement.executeBatch();
+ }
+ }
+
+ private void insertAttributes() throws SQLException {
+ String sql = """
+ INSERT OR IGNORE INTO fom_attribute
+ (id, class_id, attribute_name, path_key, data_type, primitive_type, is_leaf)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ """;
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ for (FomCatalog.ObjectClassDef clazz : catalog.objectClasses()) {
+ for (FomCatalog.FomAttribute attribute : clazz.attributes()) {
+ statement.setInt(1, attribute.id());
+ statement.setInt(2, attribute.classId());
+ statement.setString(3, attribute.attributeName());
+ statement.setString(4, attribute.pathKey());
+ statement.setString(5, attribute.dataType());
+ statement.setString(6, attribute.primitiveType());
+ statement.setInt(7, attribute.leaf() ? 1 : 0);
+ statement.addBatch();
+ }
+ }
+ statement.executeBatch();
+ }
+ }
+
+ private void upsertCurrentValue(
+ long instanceId,
+ int attributeId,
+ DecodedAttributeValue value,
+ String observedAt,
+ long observedSequence) {
+ String sql = """
+ INSERT INTO object_attribute_current
+ (instance_id, attribute_id, value_type, value_blob, value_json, raw_bytes, observed_at, sequence)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(instance_id, attribute_id) DO UPDATE SET
+ value_type = excluded.value_type,
+ value_blob = excluded.value_blob,
+ value_json = excluded.value_json,
+ raw_bytes = excluded.raw_bytes,
+ observed_at = excluded.observed_at,
+ sequence = excluded.sequence
+ """;
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ Object objectValue = value.value();
+ String valueType = CachedValue.valueType(objectValue);
+ statement.setLong(1, instanceId);
+ statement.setInt(2, attributeId);
+ statement.setString(3, valueType);
+ if (objectValue instanceof byte[] bytes) {
+ statement.setBytes(4, bytes);
+ statement.setNull(5, java.sql.Types.VARCHAR);
+ } else {
+ statement.setNull(4, java.sql.Types.BLOB);
+ statement.setString(5, serializeJson(objectValue));
+ }
+ statement.setBytes(6, value.rawBytes());
+ statement.setString(7, observedAt);
+ statement.setLong(8, observedSequence);
+ statement.executeUpdate();
+ } catch (SQLException e) {
+ throw new IllegalStateException("Could not upsert current object attribute", e);
+ }
+ }
+
+ private Optional attributeIdForPath(FomCatalog.ObjectClassDef clazz, String pathKey) {
+ Optional existingId = attributeIdFromDatabase(clazz.id(), pathKey);
+ if (existingId.isPresent()) {
+ return existingId;
+ }
+
+ String wildcardPath = FomCatalog.wildcardArrayIndexes(pathKey);
+ FomCatalog.FomAttribute template = clazz.attribute(wildcardPath).orElse(null);
+ if (template == null || wildcardPath.equals(pathKey)) {
+ return Optional.empty();
+ }
+
+ String sql = """
+ INSERT OR IGNORE INTO fom_attribute
+ (class_id, attribute_name, path_key, data_type, primitive_type, is_leaf)
+ VALUES (?, ?, ?, ?, ?, ?)
+ """;
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setInt(1, clazz.id());
+ statement.setString(2, template.attributeName());
+ statement.setString(3, pathKey);
+ statement.setString(4, template.dataType());
+ statement.setString(5, template.primitiveType());
+ statement.setInt(6, template.leaf() ? 1 : 0);
+ statement.executeUpdate();
+ } catch (SQLException e) {
+ throw new IllegalStateException("Could not insert dynamic FOM attribute path " + pathKey, e);
+ }
+ return attributeIdFromDatabase(clazz.id(), pathKey);
+ }
+
+ private Optional attributeIdFromDatabase(int classId, String pathKey) {
+ String sql = "SELECT id FROM fom_attribute WHERE class_id = ? AND path_key = ?";
+ try (PreparedStatement statement = connection.prepareStatement(sql)) {
+ statement.setInt(1, classId);
+ statement.setString(2, pathKey);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ if (resultSet.next()) {
+ return Optional.of(resultSet.getInt("id"));
+ }
+ }
+ } catch (SQLException e) {
+ throw new IllegalStateException("Could not read FOM attribute path " + pathKey, e);
+ }
+ return Optional.empty();
+ }
+
+ private String serializeJson(Object value) throws SQLException {
+ try {
+ return mapper.writeValueAsString(value);
+ } catch (JsonProcessingException e) {
+ throw new SQLException("Could not serialize cached value as JSON", e);
+ }
+ }
+
+ private CachedValue readCachedValue(ResultSet resultSet) throws SQLException {
+ String valueType = resultSet.getString("value_type");
+ byte[] rawBytes = resultSet.getBytes("raw_bytes");
+ if ("blob".equals(valueType)) {
+ return new CachedValue(valueType, resultSet.getBytes("value_blob"), rawBytes);
+ }
+ String valueJson = resultSet.getString("value_json");
+ if (valueJson == null) {
+ return new CachedValue(valueType, null, rawBytes);
+ }
+ try {
+ return new CachedValue(valueType, mapper.readValue(valueJson, Object.class), rawBytes);
+ } catch (JsonProcessingException e) {
+ throw new SQLException("Could not deserialize cached value JSON", e);
+ }
+ }
+
+ private Map> collectSubscriptions(XapiConfig xapiConfig) {
+ Map> merged = new LinkedHashMap<>();
+ QueryReferenceCollector.collect(xapiConfig.statementTriggers)
+ .forEach((className, attributes) -> addAttributes(merged, className, attributes));
+ addTrackedObjects(merged, xapiConfig);
+ return copySubscriptions(merged);
+ }
+
+ private void addTrackedObjects(Map> merged, XapiConfig xapiConfig) {
+ if (xapiConfig.objectCacheConfig == null || xapiConfig.objectCacheConfig.trackedObjects == null) {
+ return;
+ }
+ for (TrackedObject trackedObject : xapiConfig.objectCacheConfig.trackedObjects) {
+ if (trackedObject == null || trackedObject.clazz == null || trackedObject.clazz.isBlank()) {
+ continue;
+ }
+ if ("*".equals(trackedObject.clazz.trim())) {
+ if (trackedObject.allAttributes) {
+ catalog.objectClasses().forEach(clazz ->
+ addAttributes(merged, clazz.localName(), clazz.topLevelAttributeNames()));
+ }
+ continue;
+ }
+ if (trackedObject.allAttributes) {
+ Optional clazz = catalog.objectClass(trackedObject.clazz);
+ if (clazz.isPresent()) {
+ FomCatalog.ObjectClassDef objectClass = clazz.orElseThrow();
+ addAttributes(merged, objectClass.localName(), objectClass.topLevelAttributeNames());
+ } else {
+ addAttributes(merged, trackedObject.clazz, Set.of("*"));
+ }
+ } else {
+ String className = catalog.objectClass(trackedObject.clazz)
+ .map(FomCatalog.ObjectClassDef::localName)
+ .orElse(trackedObject.clazz);
+ addAttributes(merged, className, trackedObject.attributes);
+ }
+ }
+ }
+
+ private void addAttributes(Map> subscriptions, String className, Iterable attributes) {
+ if (className == null || className.isBlank() || attributes == null) {
+ return;
+ }
+ Set targetAttributes = subscriptions.computeIfAbsent(className, ignored -> new LinkedHashSet<>());
+ for (String attribute : attributes) {
+ if (attribute != null && !attribute.isBlank()) {
+ targetAttributes.add(attribute);
+ }
+ }
+ if (targetAttributes.isEmpty()) {
+ subscriptions.remove(className);
+ }
+ }
+
+ private Map> copySubscriptions(Map> subscriptions) {
+ Map> copy = new LinkedHashMap<>();
+ subscriptions.forEach((className, attributes) -> copy.put(className, Set.copyOf(attributes)));
+ return Map.copyOf(copy);
+ }
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java
new file mode 100644
index 0000000..2c92d06
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java
@@ -0,0 +1,162 @@
+package com.yetanalytics.hlaxapi.cache;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yetanalytics.hlaxapi.config.model.Criterion;
+import com.yetanalytics.hlaxapi.config.model.Expression;
+import com.yetanalytics.hlaxapi.config.model.LogicalExpression;
+import com.yetanalytics.hlaxapi.config.model.StatementTrigger;
+import com.yetanalytics.hlaxapi.config.model.Target;
+import com.yetanalytics.hlaxapi.config.model.ValueExpression;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.InlineInjection;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.LookupInjection;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.ParseResult;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.QueryInjection;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.StatementInjection;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public final class QueryReferenceCollector {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private QueryReferenceCollector() {
+ }
+
+ public static Map> collect(List triggers) {
+ Map> references = new LinkedHashMap<>();
+ if (triggers == null) {
+ return references;
+ }
+ for (StatementTrigger trigger : triggers) {
+ if (trigger == null || trigger.statement == null) {
+ continue;
+ }
+ Map lookupClasses = collectLookupDefinitions(trigger, references);
+ try {
+ collectFromNode(MAPPER.readTree(trigger.statement), references, lookupClasses);
+ } catch (IOException ignored) {
+ // Bad statement JSON is handled by TriggerProcessor at runtime.
+ }
+ }
+ return copyReferences(references);
+ }
+
+ private static Map collectLookupDefinitions(
+ StatementTrigger trigger,
+ Map> references) {
+ Map lookupClasses = new LinkedHashMap<>();
+ if (trigger.lookups == null) {
+ return lookupClasses;
+ }
+ trigger.lookups.forEach((alias, lookup) -> {
+ if (lookup == null || lookup.clazz == null || lookup.clazz.isBlank()) {
+ return;
+ }
+ lookupClasses.put(alias, lookup.clazz);
+ collectCriteriaTargets(references, lookup.clazz, lookup.criteria);
+ });
+ return lookupClasses;
+ }
+
+ private static void collectFromNode(
+ JsonNode node,
+ Map> references,
+ Map lookupClasses) {
+ if (node == null || node.isNull()) {
+ return;
+ }
+ if (node.isObject()) {
+ for (JsonNode child : node) {
+ collectFromNode(child, references, lookupClasses);
+ }
+ return;
+ }
+ if (node.isArray()) {
+ ParseResult parsed = StatementInjectionParser.parse(node);
+ if (parsed.valid()) {
+ collectInjection(parsed.injection(), references, lookupClasses);
+ return;
+ }
+ for (JsonNode child : node) {
+ collectFromNode(child, references, lookupClasses);
+ }
+ return;
+ }
+ if (node.isTextual()) {
+ for (InlineInjection inline : StatementInjectionParser.findInline(node.asText())) {
+ if (inline.result().valid()) {
+ collectInjection(inline.result().injection(), references, lookupClasses);
+ }
+ }
+ }
+ }
+
+ private static void collectInjection(
+ StatementInjection injection,
+ Map> references,
+ Map lookupClasses) {
+ if (injection instanceof QueryInjection queryInjection) {
+ collectQuery(queryInjection, references);
+ } else if (injection instanceof LookupInjection lookupInjection) {
+ collectLookup(lookupInjection, references, lookupClasses);
+ }
+ }
+
+ private static void collectQuery(QueryInjection query, Map> references) {
+ String className = query.className();
+ if (className == null || className.isBlank()) {
+ return;
+ }
+
+ addTarget(references, className, query.target());
+ collectCriteriaTargets(references, className, query.criteria());
+ }
+
+ private static void collectLookup(
+ LookupInjection lookup,
+ Map> references,
+ Map lookupClasses) {
+ String className = lookupClasses.get(lookup.alias());
+ if (className == null || className.isBlank()) {
+ return;
+ }
+
+ addTarget(references, className, lookup.target());
+ }
+
+ private static void collectCriteriaTargets(
+ Map> references,
+ String className,
+ Expression expression) {
+ if (expression instanceof Target target) {
+ addTarget(references, className, target);
+ } else if (expression instanceof Criterion criterion) {
+ collectCriteriaTargets(references, className, criterion.left);
+ collectCriteriaTargets(references, className, criterion.right);
+ } else if (expression instanceof LogicalExpression logicalExpression) {
+ logicalExpression.operands.forEach(operand -> collectCriteriaTargets(references, className, operand));
+ } else if (expression instanceof ValueExpression) {
+ return;
+ }
+ }
+
+ private static void addTarget(Map> references, String className, Target target) {
+ String topLevelAttribute = target == null ? null : FomCatalog.topLevelTargetPart(target.parts);
+ if (topLevelAttribute == null || topLevelAttribute.isBlank()) {
+ return;
+ }
+ references.computeIfAbsent(className, ignored -> new LinkedHashSet<>()).add(topLevelAttribute);
+ }
+
+ private static Map> copyReferences(Map> references) {
+ Map> copy = new LinkedHashMap<>();
+ references.forEach((className, attributes) -> copy.put(className, Set.copyOf(attributes)));
+ return Map.copyOf(copy);
+ }
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ValueResolution.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ValueResolution.java
new file mode 100644
index 0000000..af0432e
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ValueResolution.java
@@ -0,0 +1,42 @@
+package com.yetanalytics.hlaxapi.cache;
+
+public final class ValueResolution {
+
+ public enum Status {
+ PRESENT,
+ MISSING_OBJECT,
+ MISSING_VALUE
+ }
+
+ private final Status status;
+ private final Object value;
+
+ private ValueResolution(Status status, Object value) {
+ this.status = status;
+ this.value = value;
+ }
+
+ public static ValueResolution present(Object value) {
+ return new ValueResolution(Status.PRESENT, value);
+ }
+
+ public static ValueResolution missingObject() {
+ return new ValueResolution(Status.MISSING_OBJECT, null);
+ }
+
+ public static ValueResolution missingValue() {
+ return new ValueResolution(Status.MISSING_VALUE, null);
+ }
+
+ public Status status() {
+ return status;
+ }
+
+ public Object value() {
+ return value;
+ }
+
+ public boolean present() {
+ return status == Status.PRESENT;
+ }
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigConverter.java b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigConverter.java
index f7aabaf..6fced49 100644
--- a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigConverter.java
+++ b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigConverter.java
@@ -6,6 +6,7 @@
import com.yetanalytics.hlaxapi.config.model.Expression;
import com.yetanalytics.hlaxapi.config.model.LogicalExpression;
import com.yetanalytics.hlaxapi.config.model.LogicalOperator;
+import com.yetanalytics.hlaxapi.config.model.InjectionType;
import com.yetanalytics.hlaxapi.config.model.ValueExpression;
import com.yetanalytics.hlaxapi.config.model.ThisExpression;
@@ -33,7 +34,9 @@ public static Object toCriterionOrValue(Object raw) {
if (raw instanceof List) {
List> l = (List>) raw;
// If this is an injection-style array starting with "this", treat as ThisExpression
- if (l.size() > 0 && l.get(0) instanceof String && ((String) l.get(0)).equalsIgnoreCase("this")) {
+ if (!l.isEmpty()
+ && l.get(0) instanceof String token
+ && InjectionType.fromString(token) == InjectionType.THIS) {
// expected form: ["this", targetArray]
Target t = null;
if (l.size() >= 2) t = toTarget(l.get(1));
diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java
index 2e6ede5..861174a 100644
--- a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java
+++ b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java
@@ -7,13 +7,19 @@
import com.yetanalytics.hlaxapi.config.model.InjectionType;
import com.yetanalytics.hlaxapi.config.model.LogicalOperator;
import com.yetanalytics.hlaxapi.config.model.LrsConfig;
+import com.yetanalytics.hlaxapi.config.model.ObjectLookup;
+import com.yetanalytics.hlaxapi.config.model.ObjectCacheConfig;
import com.yetanalytics.hlaxapi.config.model.StatementTrigger;
+import com.yetanalytics.hlaxapi.config.model.TrackedObject;
import java.io.File;
import java.io.IOException;
import java.nio.file.Paths;
import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
/**
* Lightweight parser for the config format described in config-ideas.md.
@@ -52,6 +58,7 @@ public XapiConfig parse() {
stt.clazz = tnode.path("class").asText(null);
Object rawCrit = parseCriteriaNode(tnode.get("criteria"));
stt.criteria = ConfigConverter.toCriterion(rawCrit);
+ stt.lookups = parseLookups(tnode.get("lookups"));
if (tnode.has("statement")) {
try {
stt.statement = mapper.writeValueAsString(tnode.get("statement"));
@@ -69,9 +76,57 @@ public XapiConfig parse() {
cfg.lrsConfig = mapper.convertValue(root.get("lrs"), LrsConfig.class);
}
+ // object cache
+ JsonNode oc = root.get("objectCache");
+ if (oc != null && oc.isObject()) {
+ ObjectCacheConfig objectCacheConfig = new ObjectCacheConfig();
+ JsonNode trackedObjects = oc.get("trackedObjects");
+ if (trackedObjects != null && trackedObjects.isArray()) {
+ List tracked = new ArrayList<>();
+ for (JsonNode trackedNode : trackedObjects) {
+ TrackedObject trackedObject = new TrackedObject();
+ trackedObject.clazz = trackedNode.path("class").asText(null);
+ trackedObject.allAttributes = trackedNode.path("allAttributes").asBoolean(false);
+ JsonNode attributes = trackedNode.get("attributes");
+ if (attributes != null && attributes.isArray()) {
+ trackedObject.attributes = new ArrayList<>();
+ for (JsonNode attribute : attributes) {
+ if (attribute.isTextual()) {
+ trackedObject.attributes.add(attribute.asText());
+ }
+ }
+ }
+ tracked.add(trackedObject);
+ }
+ objectCacheConfig.trackedObjects = tracked;
+ }
+ cfg.objectCacheConfig = objectCacheConfig;
+ }
+
return cfg;
}
+ private Map parseLookups(JsonNode node) {
+ if (node == null || !node.isObject()) {
+ return null;
+ }
+ Map lookups = new LinkedHashMap<>();
+ Iterator> fields = node.fields();
+ while (fields.hasNext()) {
+ Map.Entry field = fields.next();
+ JsonNode lookupNode = field.getValue();
+ if (lookupNode == null || !lookupNode.isObject()) {
+ continue;
+ }
+ ObjectLookup lookup = new ObjectLookup();
+ lookup.clazz = lookupNode.path("class").asText(null);
+ Object rawCriteria = parseCriteriaNode(lookupNode.get("criteria"));
+ lookup.criteria = rawCriteria == null ? null : ConfigConverter.toExpression(rawCriteria);
+ lookups.put(field.getKey(), lookup);
+ }
+ return lookups.isEmpty() ? null : lookups;
+ }
+
private Object parseCriteriaNode(JsonNode node) {
// criteria syntax: [targetSyntax, operator, value]
if (node == null || node.isNull()) return null;
diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/XapiConfig.java b/src/main/java/com/yetanalytics/hlaxapi/config/XapiConfig.java
index b8adef1..05ec7ed 100644
--- a/src/main/java/com/yetanalytics/hlaxapi/config/XapiConfig.java
+++ b/src/main/java/com/yetanalytics/hlaxapi/config/XapiConfig.java
@@ -3,9 +3,11 @@
import java.util.List;
import com.yetanalytics.hlaxapi.config.model.LrsConfig;
+import com.yetanalytics.hlaxapi.config.model.ObjectCacheConfig;
import com.yetanalytics.hlaxapi.config.model.StatementTrigger;
public class XapiConfig {
public List statementTriggers;
public LrsConfig lrsConfig;
+ public ObjectCacheConfig objectCacheConfig;
}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/InjectionType.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/InjectionType.java
index b2189da..d054ad9 100644
--- a/src/main/java/com/yetanalytics/hlaxapi/config/model/InjectionType.java
+++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/InjectionType.java
@@ -1,7 +1,7 @@
package com.yetanalytics.hlaxapi.config.model;
public enum InjectionType {
- THIS("this"), QUERY("query");
+ THIS("this"), QUERY("query"), LOOKUP("lookup");
public final String token;
@@ -15,6 +15,7 @@ public static InjectionType fromString(String s) {
switch (s.trim().toLowerCase()) {
case "this": return THIS;
case "query": return QUERY;
+ case "lookup": return LOOKUP;
default: return null;
}
}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/ObjectCacheConfig.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/ObjectCacheConfig.java
new file mode 100644
index 0000000..f26797a
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/ObjectCacheConfig.java
@@ -0,0 +1,7 @@
+package com.yetanalytics.hlaxapi.config.model;
+
+import java.util.List;
+
+public class ObjectCacheConfig {
+ public List trackedObjects;
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/ObjectLookup.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/ObjectLookup.java
new file mode 100644
index 0000000..38da06e
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/ObjectLookup.java
@@ -0,0 +1,6 @@
+package com.yetanalytics.hlaxapi.config.model;
+
+public class ObjectLookup {
+ public String clazz;
+ public Expression criteria;
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/StatementTrigger.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/StatementTrigger.java
index c0a056e..6c45d8f 100644
--- a/src/main/java/com/yetanalytics/hlaxapi/config/model/StatementTrigger.java
+++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/StatementTrigger.java
@@ -1,10 +1,13 @@
package com.yetanalytics.hlaxapi.config.model;
+import java.util.Map;
+
public class StatementTrigger {
public Type type;
public String clazz; // "class" is a Java keyword, map json "class" to this field via parser
public Criterion criteria;
+ public Map lookups;
// keep the original statement as a JSON string (we'll process injections at
// runtime)
public String statement;
diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/TrackedObject.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/TrackedObject.java
new file mode 100644
index 0000000..0ea8939
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/TrackedObject.java
@@ -0,0 +1,9 @@
+package com.yetanalytics.hlaxapi.config.model;
+
+import java.util.List;
+
+public class TrackedObject {
+ public String clazz;
+ public List attributes;
+ public boolean allAttributes;
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/criteria/CriteriaEvaluator.java b/src/main/java/com/yetanalytics/hlaxapi/criteria/CriteriaEvaluator.java
new file mode 100644
index 0000000..43f2546
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/criteria/CriteriaEvaluator.java
@@ -0,0 +1,95 @@
+package com.yetanalytics.hlaxapi.criteria;
+
+import com.yetanalytics.hlaxapi.config.model.ComparisonOperator;
+import com.yetanalytics.hlaxapi.config.model.Criterion;
+import com.yetanalytics.hlaxapi.config.model.Expression;
+import com.yetanalytics.hlaxapi.config.model.LogicalExpression;
+import com.yetanalytics.hlaxapi.config.model.LogicalOperator;
+import com.yetanalytics.hlaxapi.config.model.ValueExpression;
+import java.util.Objects;
+
+/** Evaluates criteria expressions independently of their runtime value source. */
+public final class CriteriaEvaluator {
+
+ public boolean matches(Expression expression, ExpressionResolver resolver) {
+ Objects.requireNonNull(resolver, "resolver");
+ if (expression == null) {
+ return true;
+ }
+ if (expression instanceof Criterion criterion) {
+ return evaluateCriterion(criterion, resolver);
+ }
+ if (expression instanceof LogicalExpression logical) {
+ if (logical.operator == LogicalOperator.OR) {
+ return logical.operands.stream().anyMatch(operand -> matches(operand, resolver));
+ }
+ return logical.operands.stream().allMatch(operand -> matches(operand, resolver));
+ }
+ Object value = resolveExpression(expression, resolver);
+ return Boolean.TRUE.equals(value);
+ }
+
+ private boolean evaluateCriterion(Criterion criterion, ExpressionResolver resolver) {
+ Object left = resolveExpression(criterion.left, resolver);
+ Object right = resolveExpression(criterion.right, resolver);
+ ComparisonOperator operator = criterion.operator;
+ if (operator == null) {
+ return false;
+ }
+ return compare(left, right, operator);
+ }
+
+ private Object resolveExpression(Expression expression, ExpressionResolver resolver) {
+ if (expression == null) {
+ return null;
+ }
+ if (expression instanceof ValueExpression valueExpression) {
+ return valueExpression.value;
+ }
+ if (expression instanceof Criterion criterion) {
+ return evaluateCriterion(criterion, resolver);
+ }
+ if (expression instanceof LogicalExpression logical) {
+ return matches(logical, resolver);
+ }
+ return resolver.resolve(expression);
+ }
+
+ private boolean compare(Object left, Object right, ComparisonOperator operator) {
+ if (operator == ComparisonOperator.EQ) {
+ return valuesEqual(left, right);
+ }
+ if (operator == ComparisonOperator.NEQ) {
+ return !valuesEqual(left, right);
+ }
+ if (left == null || right == null) {
+ return false;
+ }
+ int result = compareOrder(left, right);
+ return switch (operator) {
+ case LT -> result < 0;
+ case GT -> result > 0;
+ case LTE -> result <= 0;
+ case GTE -> result >= 0;
+ default -> false;
+ };
+ }
+
+ private boolean valuesEqual(Object left, Object right) {
+ if (left instanceof Number leftNumber && right instanceof Number rightNumber) {
+ return Double.compare(leftNumber.doubleValue(), rightNumber.doubleValue()) == 0;
+ }
+ return Objects.equals(left, right);
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private int compareOrder(Object left, Object right) {
+ if (left instanceof Number leftNumber && right instanceof Number rightNumber) {
+ return Double.compare(leftNumber.doubleValue(), rightNumber.doubleValue());
+ }
+ if (left instanceof Comparable comparable && left.getClass().isInstance(right)) {
+ return comparable.compareTo(right);
+ }
+ return String.valueOf(left).compareTo(String.valueOf(right));
+ }
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/criteria/ExpressionResolver.java b/src/main/java/com/yetanalytics/hlaxapi/criteria/ExpressionResolver.java
new file mode 100644
index 0000000..fa0fbbb
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/criteria/ExpressionResolver.java
@@ -0,0 +1,10 @@
+package com.yetanalytics.hlaxapi.criteria;
+
+import com.yetanalytics.hlaxapi.config.model.Expression;
+
+/** Resolves context-dependent leaf expressions to runtime values. */
+@FunctionalInterface
+public interface ExpressionResolver {
+
+ Object resolve(Expression expression);
+}
diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java
new file mode 100644
index 0000000..236e5e0
--- /dev/null
+++ b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java
@@ -0,0 +1,188 @@
+package com.yetanalytics.hlaxapi.injection;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yetanalytics.hlaxapi.config.ConfigConverter;
+import com.yetanalytics.hlaxapi.config.model.Expression;
+import com.yetanalytics.hlaxapi.config.model.InjectionType;
+import com.yetanalytics.hlaxapi.config.model.Target;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Canonical parser for whole-node and inline statement injection syntax.
+ */
+public final class StatementInjectionParser {
+
+ private static final Pattern INLINE_PLACEHOLDER = Pattern.compile("<<(.+?)>>", Pattern.DOTALL);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private StatementInjectionParser() {
+ }
+
+ /**
+ * Inspect a JSON node and, when it starts with a known injection token, parse
+ * it into a typed injection.
+ */
+ public static ParseResult parse(JsonNode node) {
+ if (node == null || !node.isArray() || node.isEmpty() || !node.get(0).isTextual()) {
+ return ParseResult.notInjection();
+ }
+
+ InjectionType type = InjectionType.fromString(node.get(0).asText());
+ if (type == null) {
+ return ParseResult.notInjection();
+ }
+
+ try {
+ return switch (type) {
+ case THIS -> node.size() < 2
+ ? ParseResult.malformed(type)
+ : ParseResult.valid(new ThisInjection(
+ target(node.get(1)),
+ options(node, 2)));
+ case QUERY -> node.size() < 4
+ ? ParseResult.malformed(type)
+ : ParseResult.valid(new QueryInjection(
+ node.get(1).asText(),
+ target(node.get(2)),
+ expression(node.get(3)),
+ options(node, 4)));
+ case LOOKUP -> node.size() < 3
+ ? ParseResult.malformed(type)
+ : ParseResult.valid(new LookupInjection(
+ node.get(1).asText(),
+ target(node.get(2)),
+ options(node, 3)));
+ };
+ } catch (RuntimeException e) {
+ return ParseResult.malformed(type);
+ }
+ }
+
+ /**
+ * Find all inline {@code <<...>>} placeholders in encounter order. Invalid
+ * JSON and non-injection placeholders are returned as non-injections so a
+ * rendering caller can preserve their original text.
+ */
+ public static List findInline(String text) {
+ if (text == null || text.isEmpty()) {
+ return List.of();
+ }
+
+ List injections = new ArrayList<>();
+ Matcher matcher = INLINE_PLACEHOLDER.matcher(text);
+ while (matcher.find()) {
+ ParseResult result;
+ try {
+ result = parse(MAPPER.readTree(matcher.group(1)));
+ } catch (Exception e) {
+ result = ParseResult.notInjection();
+ }
+ injections.add(new InlineInjection(
+ matcher.start(),
+ matcher.end(),
+ matcher.group(0),
+ result));
+ }
+ return List.copyOf(injections);
+ }
+
+ private static Target target(JsonNode node) {
+ return ConfigConverter.toTarget(MAPPER.convertValue(node, Object.class));
+ }
+
+ private static Expression expression(JsonNode node) {
+ Object raw = MAPPER.convertValue(node, Object.class);
+ return ConfigConverter.toExpression(raw);
+ }
+
+ private static InjectionOptions options(JsonNode node, int index) {
+ if (node.size() <= index || !node.get(index).isObject()) {
+ return InjectionOptions.DEFAULT;
+ }
+ JsonNode options = node.get(index);
+ return new InjectionOptions(
+ options.path(InjectionOptions.NULLABLE_KEY).asBoolean(false),
+ options.path(InjectionOptions.REQUIRED_KEY).asBoolean(true));
+ }
+
+ public sealed interface StatementInjection
+ permits ThisInjection, QueryInjection, LookupInjection {
+
+ InjectionType type();
+
+ Target target();
+
+ InjectionOptions options();
+ }
+
+ public record ThisInjection(Target target, InjectionOptions options) implements StatementInjection {
+
+ @Override
+ public InjectionType type() {
+ return InjectionType.THIS;
+ }
+ }
+
+ public record QueryInjection(
+ String className,
+ Target target,
+ Expression criteria,
+ InjectionOptions options) implements StatementInjection {
+
+ @Override
+ public InjectionType type() {
+ return InjectionType.QUERY;
+ }
+ }
+
+ public record LookupInjection(
+ String alias,
+ Target target,
+ InjectionOptions options) implements StatementInjection {
+
+ @Override
+ public InjectionType type() {
+ return InjectionType.LOOKUP;
+ }
+ }
+
+ public record InjectionOptions(boolean nullable, boolean required) {
+
+ public static final String NULLABLE_KEY = "nullable";
+ public static final String REQUIRED_KEY = "required";
+ public static final InjectionOptions DEFAULT = new InjectionOptions(false, true);
+ }
+
+ public record ParseResult(
+ boolean recognized,
+ InjectionType type,
+ StatementInjection injection) {
+
+ public static ParseResult notInjection() {
+ return new ParseResult(false, null, null);
+ }
+
+ public static ParseResult malformed(InjectionType type) {
+ return new ParseResult(true, type, null);
+ }
+
+ public static ParseResult valid(StatementInjection injection) {
+ return new ParseResult(true, injection.type(), injection);
+ }
+
+ public boolean valid() {
+ return injection != null;
+ }
+ }
+
+ public record InlineInjection(
+ int start,
+ int end,
+ String source,
+ ParseResult result) {
+ }
+}
diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java
index 4a36b1b..da649f9 100644
--- a/src/test/java/com/yetanalytics/ConfigParserTest.java
+++ b/src/test/java/com/yetanalytics/ConfigParserTest.java
@@ -3,21 +3,25 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
-import java.lang.reflect.Parameter;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
-import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory;
import com.yetanalytics.hlaxapi.FOMXML;
@@ -26,6 +30,8 @@
import com.yetanalytics.hlaxapi.InjectionHandler;
import com.yetanalytics.hlaxapi.SimulationConfig;
import com.yetanalytics.hlaxapi.TriggerProcessor;
+import com.yetanalytics.hlaxapi.cache.CachedObject;
+import com.yetanalytics.hlaxapi.cache.ValueResolution;
import com.yetanalytics.hlaxapi.config.ConfigConverter;
import com.yetanalytics.hlaxapi.config.ConfigParser;
import com.yetanalytics.hlaxapi.config.XapiConfig;
@@ -34,6 +40,8 @@
import com.yetanalytics.hlaxapi.config.model.Expression;
import com.yetanalytics.hlaxapi.config.model.LogicalExpression;
import com.yetanalytics.hlaxapi.config.model.LogicalOperator;
+import com.yetanalytics.hlaxapi.config.model.ObjectLookup;
+import com.yetanalytics.hlaxapi.config.model.StatementTrigger;
import com.yetanalytics.hlaxapi.config.model.Target;
import com.yetanalytics.hlaxapi.config.model.ThisExpression;
import com.yetanalytics.hlaxapi.injection.InjectionContext;
@@ -53,7 +61,7 @@ public class ConfigParserTest {
public void parsesConfigFile() throws IOException {
XapiConfig config = ConfigParser.fromFile("src/test/resources/config-test.json").parse();
- SimulationConfig simConfig = new SimulationConfig(null, null, null, null,
+ SimulationConfig simConfig = new SimulationConfig(null, null, null, null,
"config/HlaFedereplFOM.xml");
HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory());
InjectionHandler ih = new InjectionHandler();
@@ -65,7 +73,7 @@ public void parsesConfigFile() throws IOException {
Map paramMap = new HashMap();
paramMap.put("EntityId", HLAEncodingTestSupport.asciiString("c5988e0e-c521-4ff7-ba83-df1a63eb72bf"));
-
+
byte[] fromX = HLAEncodingTestSupport.int32(4, ByteOrder.BIG_ENDIAN);
byte[] fromY = HLAEncodingTestSupport.int32(12, ByteOrder.BIG_ENDIAN);
paramMap.put("FromPosition", HLAEncodingTestSupport.fixedRecord(fromX, fromY));
@@ -73,7 +81,7 @@ public void parsesConfigFile() throws IOException {
byte[] toX = HLAEncodingTestSupport.int32(5, ByteOrder.BIG_ENDIAN);
byte[] toY = HLAEncodingTestSupport.int32(13, ByteOrder.BIG_ENDIAN);
paramMap.put("ToPosition", HLAEncodingTestSupport.fixedRecord(toX, toY));
-
+
InteractionInjectionContext injectionContext = new InteractionInjectionContext("EntityMoved", paramMap);
@@ -100,6 +108,66 @@ public void parsesConfigFile() throws IOException {
assertNotNull(config.lrsConfig);
}
+ @Test
+ public void parsesTriggerLookups(@TempDir Path tempDir) throws IOException {
+ Path configPath = tempDir.resolve("xapi-config.json");
+ Files.writeString(configPath, """
+ {
+ "statementTriggers": [
+ {
+ "type": "Interaction",
+ "class": "EntityAte",
+ "lookups": {
+ "predator": {
+ "class": "SimEntity",
+ "criteria": [["EntityId"], "=", ["this", ["PredatorId"]]]
+ }
+ },
+ "statement": {"actor": {"name": ["lookup", "predator", ["EntityType"]]}}
+ }
+ ]
+ }
+ """);
+
+ XapiConfig config = ConfigParser.fromFile(configPath.toString()).parse();
+
+ assertEquals(1, config.statementTriggers.size());
+ ObjectLookup lookup = config.statementTriggers.get(0).lookups.get("predator");
+ assertNotNull(lookup);
+ assertEquals("SimEntity", lookup.clazz);
+ assertTrue(lookup.criteria instanceof Criterion);
+ Criterion criterion = (Criterion) lookup.criteria;
+ assertTrue(criterion.left instanceof Target);
+ assertTrue(criterion.right instanceof ThisExpression);
+ }
+
+ @Test
+ public void parsesObjectCacheTrackedObjects(@TempDir Path tempDir) throws IOException {
+ Path configPath = tempDir.resolve("xapi-config.json");
+ Files.writeString(configPath, """
+ {
+ "objectCache": {
+ "trackedObjects": [
+ {"class": "Rabbit", "attributes": ["EntityId", "Hunger"]},
+ {"class": "World", "allAttributes": true},
+ {"class": "*", "allAttributes": true}
+ ]
+ }
+ }
+ """);
+
+ XapiConfig config = ConfigParser.fromFile(configPath.toString()).parse();
+
+ assertNotNull(config.objectCacheConfig);
+ assertNotNull(config.objectCacheConfig.trackedObjects);
+ assertEquals(3, config.objectCacheConfig.trackedObjects.size());
+ assertEquals("Rabbit", config.objectCacheConfig.trackedObjects.get(0).clazz);
+ assertEquals(List.of("EntityId", "Hunger"), config.objectCacheConfig.trackedObjects.get(0).attributes);
+ assertTrue(config.objectCacheConfig.trackedObjects.get(1).allAttributes);
+ assertEquals("*", config.objectCacheConfig.trackedObjects.get(2).clazz);
+ assertTrue(config.objectCacheConfig.trackedObjects.get(2).allAttributes);
+ }
+
@Test
public void fixedRecordHelperConcatenatesEncodedFieldsInOrder() {
byte[] first = HLAEncodingTestSupport.int32(12, ByteOrder.BIG_ENDIAN);
@@ -321,6 +389,214 @@ public Object handleThis(Target t, InjectionContext context) {
assertTrue(out.contains("\"name\":\"[alpha, beta]\""));
}
+ @Test
+ public void lookupInjectionResolvesAliasOnceAndReusesObject() {
+ AtomicInteger resolveCount = new AtomicInteger();
+ CachedObject matchedObject = new CachedObject(7, "object-7", "Predator", "SimEntity");
+ InjectionHandler ih = new InjectionHandler() {
+ @Override
+ public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) {
+ resolveCount.incrementAndGet();
+ return Optional.of(matchedObject);
+ }
+
+ @Override
+ public ValueResolution handleLookupResolution(CachedObject object, Target attrTarget) {
+ assertEquals(matchedObject, object);
+ if (attrTarget.parts.equals(List.of("EntityId"))) {
+ return ValueResolution.present("predator-1");
+ }
+ if (attrTarget.parts.equals(List.of("EntityType"))) {
+ return ValueResolution.present("Wolf");
+ }
+ return ValueResolution.missingValue();
+ }
+ };
+
+ TriggerProcessor triggerProcessor = new TriggerProcessor(ih);
+ StatementTrigger trigger = new StatementTrigger();
+ ObjectLookup lookup = new ObjectLookup();
+ lookup.clazz = "SimEntity";
+ lookup.criteria = new Criterion(
+ new Target(List.of("EntityId")),
+ ComparisonOperator.EQ,
+ new ThisExpression(new Target(List.of("PredatorId"))));
+ trigger.lookups = Map.of("predator", lookup);
+ trigger.statement = """
+ {
+ "actor": {
+ "account": {"name": ["lookup", "predator", ["EntityId"]]},
+ "name": "the <<[\\"lookup\\", \\"predator\\", [\\"EntityType\\"]]>>"
+ }
+ }
+ """;
+
+ String out = triggerProcessor.processTrigger(
+ trigger,
+ new InteractionInjectionContext("EntityAte", new HashMap<>()));
+
+ assertNotNull(out);
+ assertEquals(1, resolveCount.get());
+ assertTrue(out.contains("\"name\":\"predator-1\""));
+ assertTrue(out.contains("\"name\":\"the Wolf\""));
+ }
+
+ @Test
+ public void lookupInjectionMissingObjectHonorsRequiredOption() {
+ InjectionHandler ih = new InjectionHandler() {
+ @Override
+ public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) {
+ return Optional.empty();
+ }
+ };
+
+ StatementTrigger trigger = lookupTrigger("[\"lookup\", \"predator\", [\"EntityId\"]]");
+
+ String out = new TriggerProcessor(ih).processTrigger(
+ trigger,
+ new InteractionInjectionContext("EntityAte", new HashMap<>()));
+
+ assertNull(out);
+
+ String optionalOut = new TriggerProcessor(ih).processTrigger(
+ lookupTrigger("[\"lookup\", \"predator\", [\"EntityId\"], {\"required\": false}]"),
+ new InteractionInjectionContext("EntityAte", new HashMap<>()));
+
+ assertNotNull(optionalOut);
+ assertTrue(optionalOut.contains("\"name\":null"));
+ }
+
+ @Test
+ public void lookupInjectionMissingValueHonorsRequiredButNotNullable() {
+ CachedObject matchedObject = new CachedObject(7, "object-7", "Predator", "SimEntity");
+ InjectionHandler ih = new InjectionHandler() {
+ @Override
+ public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) {
+ return Optional.of(matchedObject);
+ }
+
+ @Override
+ public ValueResolution handleLookupResolution(CachedObject object, Target attrTarget) {
+ return ValueResolution.missingValue();
+ }
+ };
+
+ StatementTrigger trigger = lookupTrigger("[\"lookup\", \"predator\", [\"Nickname\"], {\"nullable\": true}]");
+
+ String out = new TriggerProcessor(ih).processTrigger(
+ trigger,
+ new InteractionInjectionContext("EntityAte", new HashMap<>()));
+
+ assertNull(out);
+
+ StatementTrigger optionalTrigger = lookupTrigger(
+ "[\"lookup\", \"predator\", [\"Nickname\"], {\"required\": false}]");
+ String optionalOut = new TriggerProcessor(ih).processTrigger(
+ optionalTrigger,
+ new InteractionInjectionContext("EntityAte", new HashMap<>()));
+
+ assertNotNull(optionalOut);
+ assertTrue(optionalOut.contains("\"name\":null"));
+ }
+
+ @Test
+ public void lookupInjectionPresentNullAbortsByDefaultAndRendersWhenNullable() {
+ CachedObject matchedObject = new CachedObject(7, "object-7", "Predator", "SimEntity");
+ InjectionHandler ih = new InjectionHandler() {
+ @Override
+ public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) {
+ return Optional.of(matchedObject);
+ }
+
+ @Override
+ public ValueResolution handleLookupResolution(CachedObject object, Target attrTarget) {
+ return ValueResolution.present(null);
+ }
+ };
+ TriggerProcessor triggerProcessor = new TriggerProcessor(ih);
+
+ assertNull(triggerProcessor.processTrigger(
+ lookupTrigger("[\"lookup\", \"predator\", [\"Nickname\"]]"),
+ new InteractionInjectionContext("EntityAte", new HashMap<>())));
+
+ StatementTrigger nullableTrigger = lookupTrigger(
+ "\"the <<[\\\"lookup\\\", \\\"predator\\\", [\\\"Nickname\\\"], {\\\"nullable\\\": true}]>>\"");
+ String out = triggerProcessor.processTrigger(
+ nullableTrigger,
+ new InteractionInjectionContext("EntityAte", new HashMap<>()));
+
+ assertNotNull(out);
+ assertTrue(out.contains("\"name\":\"the null\""));
+
+ StatementTrigger optionalTrigger = lookupTrigger(
+ "\"optional <<[\\\"lookup\\\", \\\"predator\\\", [\\\"Nickname\\\"], {\\\"required\\\": false}]>>\"");
+ String optionalOut = triggerProcessor.processTrigger(
+ optionalTrigger,
+ new InteractionInjectionContext("EntityAte", new HashMap<>()));
+
+ assertNotNull(optionalOut);
+ assertTrue(optionalOut.contains("\"name\":\"optional null\""));
+ }
+
+ @Test
+ public void queryAndThisMissingValuesHonorRequiredOption() {
+ InjectionHandler ih = new InjectionHandler() {
+ @Override
+ public ValueResolution handleQueryResolution(
+ String clazz,
+ Target attrTarget,
+ Expression criteria,
+ InjectionContext context) {
+ return ValueResolution.missingObject();
+ }
+
+ @Override
+ public ValueResolution handleThisResolution(Target t, InjectionContext context) {
+ return ValueResolution.missingValue();
+ }
+ };
+ TriggerProcessor triggerProcessor = new TriggerProcessor(ih);
+
+ assertNull(triggerProcessor.processTrigger(
+ statementTrigger("{\"actor\":{\"name\":[\"query\",\"Rabbit\",[\"EntityId\"],[[\"Hunger\"],\">\",50]]}}"),
+ new InteractionInjectionContext("EntityAte", new HashMap<>())));
+ assertNull(triggerProcessor.processTrigger(
+ statementTrigger("{\"actor\":{\"name\":[\"this\",[\"MissingParam\"]]}}"),
+ new InteractionInjectionContext("EntityAte", new HashMap<>())));
+
+ String optionalQueryOut = triggerProcessor.processTrigger(
+ statementTrigger(
+ "{\"actor\":{\"name\":[\"query\",\"Rabbit\",[\"EntityId\"],[[\"Hunger\"],\">\",50],{\"required\":false}]}}"),
+ new InteractionInjectionContext("EntityAte", new HashMap<>()));
+ String optionalThisOut = triggerProcessor.processTrigger(
+ statementTrigger(
+ "{\"actor\":{\"name\":\"value=<<[\\\"this\\\",[\\\"MissingParam\\\"],{\\\"required\\\":false}]>>\"}}"),
+ new InteractionInjectionContext("EntityAte", new HashMap<>()));
+
+ assertNotNull(optionalQueryOut);
+ assertTrue(optionalQueryOut.contains("\"name\":null"));
+ assertNotNull(optionalThisOut);
+ assertTrue(optionalThisOut.contains("\"name\":\"value=null\""));
+ }
+
+ private StatementTrigger lookupTrigger(String nameExpression) {
+ StatementTrigger trigger = statementTrigger("{\"actor\":{\"name\":" + nameExpression + "}}");
+ ObjectLookup lookup = new ObjectLookup();
+ lookup.clazz = "SimEntity";
+ lookup.criteria = new Criterion(
+ new Target(List.of("EntityId")),
+ ComparisonOperator.EQ,
+ new ThisExpression(new Target(List.of("PredatorId"))));
+ trigger.lookups = Map.of("predator", lookup);
+ return trigger;
+ }
+
+ private StatementTrigger statementTrigger(String statement) {
+ StatementTrigger trigger = new StatementTrigger();
+ trigger.statement = statement;
+ return trigger;
+ }
+
// Stub implementations of HLA ParameterHandle and ParameterHandleValueMap for
// testing
class TestParameterHandle implements ParameterHandle {
diff --git a/src/test/java/com/yetanalytics/FOMTest.java b/src/test/java/com/yetanalytics/FOMTest.java
index a441b8a..642988e 100644
--- a/src/test/java/com/yetanalytics/FOMTest.java
+++ b/src/test/java/com/yetanalytics/FOMTest.java
@@ -25,14 +25,14 @@ public class FOMTest {
@BeforeEach
public void setUp() {
- SimulationConfig simConfig = new SimulationConfig(null, null, null, null,
+ SimulationConfig simConfig = new SimulationConfig(null, null, null, null,
"config/HlaFedereplFOM.xml");
HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory());
fomXml = new FOMXML(simConfig, decoderRegistry);
}
@Test
- public void InteractionsXML() {
+ public void InteractionsXML() {
// simple data type - Probability is HLAfloat32BE
PathCheckResult carrotGrowthResult = fomXml.checkInteractionParameterPath("SimulationParametersChanged", "CarrotGrowthRate");
@@ -51,7 +51,7 @@ public void InteractionsXML() {
assertTrue(entityMovedFromXResult.primitiveType.equals("HLAinteger32BE"));
// fixedRecord nested field access - Y component
- PathCheckResult entityMovedToYResult = fomXml.checkInteractionParameterPath("EntityMoved",
+ PathCheckResult entityMovedToYResult = fomXml.checkInteractionParameterPath("EntityMoved",
List.of("ToPosition", "Y"));
logger.info("EntityMoved, ToPosition, Y Type: {}", entityMovedToYResult);
assertTrue(entityMovedToYResult.primitiveType.equals("HLAinteger32BE"));
diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java
new file mode 100644
index 0000000..4d31b4f
--- /dev/null
+++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java
@@ -0,0 +1,84 @@
+package com.yetanalytics.hlaxapi.cache;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.yetanalytics.hlaxapi.FOMXML;
+import com.yetanalytics.hlaxapi.HLADecoderRegistry;
+import com.yetanalytics.hlaxapi.SimulationConfig;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory;
+
+class FomCatalogTest {
+
+ @Test
+ void flattensPrimitiveAliasesEnumsAndFixedRecords() {
+ FomCatalog catalog = catalog("config/HlaFedereplFOM.xml");
+ FomCatalog.ObjectClassDef rabbit = catalog.objectClass("Rabbit").orElseThrow();
+
+ assertTrue(rabbit.attribute("Hunger").orElseThrow().leaf());
+ assertEquals("HLAinteger32BE", rabbit.attribute("Hunger").orElseThrow().primitiveType());
+ assertEquals("HLAinteger32BE", rabbit.attribute("EntityType").orElseThrow().primitiveType());
+
+ FomCatalog.FomAttribute position = rabbit.attribute("Position").orElseThrow();
+ assertFalse(position.leaf());
+ assertEquals("GridPosition", position.dataType());
+ assertEquals("HLAinteger32BE", rabbit.attribute("Position.X").orElseThrow().primitiveType());
+ assertEquals("HLAinteger32BE", rabbit.attribute("Position.Y").orElseThrow().primitiveType());
+ }
+
+ @Test
+ void includesInheritedObjectAttributes() {
+ FomCatalog catalog = catalog("config/HlaFedereplFOM.xml");
+
+ FomCatalog.ObjectClassDef simEntity = catalog.objectClass("SimEntity").orElseThrow();
+ assertEquals("HLAASCIIstring", simEntity.attribute("EntityId").orElseThrow().primitiveType());
+ assertEquals("HLAinteger32BE", simEntity.attribute("Position.X").orElseThrow().primitiveType());
+
+ FomCatalog.ObjectClassDef rabbit = catalog.objectClass("Rabbit").orElseThrow();
+ assertEquals("SimEntity", rabbit.parentName());
+ assertEquals("HLAASCIIstring", rabbit.attribute("EntityId").orElseThrow().primitiveType());
+ assertEquals("HLAinteger32BE", rabbit.attribute("Hunger").orElseThrow().primitiveType());
+ }
+
+ @Test
+ void fomXmlReturnsHierarchyWithDeclaredAttributes() {
+ FOMXML fomXml = fomXml("config/HlaFedereplFOM.xml");
+
+ FOMXML.ObjectClassDefinition simEntity = fomXml.objectClassDefinitions().stream()
+ .filter(definition -> definition.name().equals("SimEntity"))
+ .findFirst()
+ .orElseThrow();
+ FOMXML.ObjectClassDefinition rabbit = fomXml.objectClassDefinitions().stream()
+ .filter(definition -> definition.name().equals("Rabbit"))
+ .findFirst()
+ .orElseThrow();
+
+ assertEquals("HLAobjectRoot", simEntity.parentName());
+ assertTrue(simEntity.attributes().stream()
+ .anyMatch(attribute -> attribute.name().equals("EntityId")));
+ assertEquals("SimEntity", rabbit.parentName());
+ assertEquals(List.of("Hunger"), rabbit.attributes().stream()
+ .map(FOMXML.ObjectAttributeDefinition::name)
+ .toList());
+ }
+
+ @Test
+ void convertsTargetsToPathKeys() {
+ assertEquals("Position.X", FomCatalog.targetPath(List.of("Position", "X")));
+ assertEquals("TargetModifiers[0].Key", FomCatalog.targetPath(List.of("TargetModifiers", 0, "Key")));
+ assertEquals("TargetModifiers[].Key", FomCatalog.wildcardArrayIndexes("TargetModifiers[12].Key"));
+ }
+
+ private FomCatalog catalog(String fomPath) {
+ return new FomCatalog(fomXml(fomPath));
+ }
+
+ private FOMXML fomXml(String fomPath) {
+ SimulationConfig simConfig = new SimulationConfig(null, null, null, null, fomPath);
+ HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory());
+ return new FOMXML(simConfig, decoderRegistry);
+ }
+}
diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java
new file mode 100644
index 0000000..04ff66b
--- /dev/null
+++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java
@@ -0,0 +1,230 @@
+package com.yetanalytics.hlaxapi.cache;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.yetanalytics.hlaxapi.FOMXML;
+import com.yetanalytics.hlaxapi.HLADecoderRegistry;
+import com.yetanalytics.hlaxapi.SimulationConfig;
+import com.yetanalytics.hlaxapi.config.XapiConfig;
+import com.yetanalytics.hlaxapi.config.model.ComparisonOperator;
+import com.yetanalytics.hlaxapi.config.model.Criterion;
+import com.yetanalytics.hlaxapi.config.model.LogicalExpression;
+import com.yetanalytics.hlaxapi.config.model.LogicalOperator;
+import com.yetanalytics.hlaxapi.config.model.ObjectCacheConfig;
+import com.yetanalytics.hlaxapi.config.model.Target;
+import com.yetanalytics.hlaxapi.config.model.TrackedObject;
+import com.yetanalytics.hlaxapi.config.model.ValueExpression;
+import hla.rti1516e.encoding.DataElement;
+import hla.rti1516e.encoding.EncoderException;
+import hla.rti1516e.encoding.EncoderFactory;
+import hla.rti1516e.encoding.HLAfixedRecord;
+import java.nio.file.Path;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory;
+
+final class ObjectCachePersistenceTest {
+
+ private final EncoderFactory encoderFactory = new HLA1516eEncoderFactory();
+ private final HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(encoderFactory);
+ private final FOMXML fomXml = new FOMXML(
+ new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"),
+ decoderRegistry);
+ private final FomCatalog catalog = new FomCatalog(fomXml);
+
+ @Test
+ void initializesSchemaAndSeedsFomMetadata() throws SQLException {
+ try (ObjectCache cache = newCache()) {
+ assertEquals(1, scalarLong(cache, "PRAGMA user_version"));
+ assertTrue(count(cache, "SELECT COUNT(*) FROM fom_object_class") > 0);
+ assertTrue(count(cache, "SELECT COUNT(*) FROM fom_attribute WHERE path_key = 'Position.X'") > 0);
+ }
+ }
+
+ @Test
+ void upsertsLatestScalarObjectStateAndKeepsRawBytes() throws SQLException {
+ byte[] firstHunger = encoded(encoderFactory.createHLAinteger32BE(75));
+ byte[] secondHunger = encoded(encoderFactory.createHLAinteger32BE(40));
+
+ try (ObjectCache cache = newCache()) {
+ cache.discoverObject("object-1", "Rabbit One", "Rabbit");
+ cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", firstHunger);
+ cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", secondHunger);
+
+ assertEquals(40, cache.findCurrentValue("object-1", "Hunger").orElseThrow().value());
+ assertEquals(1, count(cache, """
+ SELECT COUNT(*)
+ FROM object_attribute_current c
+ JOIN fom_attribute a ON a.id = c.attribute_id
+ WHERE a.path_key = 'Hunger'
+ """));
+ assertArrayEquals(secondHunger, rawBytes(cache, "Hunger"));
+ }
+ }
+
+ @Test
+ void flattensFixedRecordValuesToNestedCurrentRows() {
+ byte[] position = position(12, 8);
+
+ try (ObjectCache cache = newCache()) {
+ cache.discoverObject("object-1", "Rabbit One", "Rabbit");
+ cache.reflectAttributeValue("object-1", "Rabbit", "Position", position);
+
+ assertEquals(12, cache.findCurrentValue("object-1", "Position.X").orElseThrow().value());
+ assertEquals(8, cache.findCurrentValue("object-1", "Position.Y").orElseThrow().value());
+ assertTrue(cache.findCurrentValue("object-1", "Position").orElseThrow().value() instanceof java.util.Map);
+ }
+ }
+
+ @Test
+ void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() {
+ try (ObjectCache cache = newCache()) {
+ cache.discoverObject("object-1", "Rabbit One", "Rabbit");
+ cache.reflectAttributeValue("object-1", "Rabbit", "EntityId", encoded(encoderFactory.createHLAASCIIstring(
+ "rabbit-one")));
+ cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE(75)));
+ cache.reflectAttributeValue("object-1", "Rabbit", "Position", position(12, 8));
+
+ cache.discoverObject("object-2", "Rabbit Two", "Rabbit");
+ cache.reflectAttributeValue("object-2", "Rabbit", "EntityId", encoded(encoderFactory.createHLAASCIIstring(
+ "rabbit-two")));
+ cache.reflectAttributeValue("object-2", "Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE(20)));
+ cache.reflectAttributeValue("object-2", "Rabbit", "Position", position(20, 5));
+
+ Criterion hungerCriteria = new Criterion(
+ new Target(List.of("Hunger")),
+ ComparisonOperator.GT,
+ new ValueExpression(50));
+ Criterion xCriteria = new Criterion(
+ new Target(List.of("Position", "X")),
+ ComparisonOperator.LT,
+ new ValueExpression(15));
+ LogicalExpression criteria = new LogicalExpression(LogicalOperator.AND, List.of(hungerCriteria, xCriteria));
+
+ assertEquals(
+ List.of("rabbit-one"),
+ cache.queryService().findValues("Rabbit", new Target(List.of("EntityId")), hungerCriteria));
+ assertEquals(
+ List.of(8),
+ cache.queryService().findValues("Rabbit", new Target(List.of("Position", "Y")), criteria));
+ CachedObject matched = cache.queryService().findFirstObject("Rabbit", criteria).orElseThrow();
+ assertEquals("object-1", matched.objectHandle());
+ assertEquals(
+ 8,
+ cache.queryService().findValue(matched, new Target(List.of("Position", "Y"))).orElseThrow());
+
+ cache.removeObject("object-1");
+
+ assertFalse(cache.queryService().findFirstValue("Rabbit", new Target(List.of("Hunger")), criteria)
+ .isPresent());
+ }
+ }
+
+ @Test
+ void queryServiceDistinguishesPresentNullFromMissingValue() {
+ try (ObjectCache cache = newCache()) {
+ cache.discoverObject("object-1", "Rabbit One", "Rabbit");
+ cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", new byte[] { 1 });
+ CachedObject matched = cache.queryService().findFirstObject("Rabbit", null).orElseThrow();
+
+ ValueResolution presentNull = cache.queryService().findValueResolution(
+ matched,
+ new Target(List.of("Hunger")));
+ ValueResolution missingValue = cache.queryService().findValueResolution(
+ matched,
+ new Target(List.of("Position", "X")));
+
+ assertEquals(ValueResolution.Status.PRESENT, presentNull.status());
+ assertNull(presentNull.value());
+ assertEquals(ValueResolution.Status.MISSING_VALUE, missingValue.status());
+ }
+ }
+
+ @Test
+ void persistentCacheStartsFreshOnInitialization(@TempDir Path tempDir) throws SQLException {
+ String jdbcUrl = "jdbc:sqlite:" + tempDir.resolve("cache.sqlite");
+
+ try (ObjectCache cache = newCache(jdbcUrl)) {
+ cache.discoverObject("object-1", "Rabbit One", "Rabbit");
+ cache.reflectAttributeValue("object-1", "Rabbit", "Hunger",
+ encoded(encoderFactory.createHLAinteger32BE(75)));
+
+ assertEquals(1, count(cache, "SELECT COUNT(*) FROM object_instance"));
+ assertEquals(1, count(cache, "SELECT COUNT(*) FROM object_attribute_current"));
+ }
+
+ try (ObjectCache cache = newCache(jdbcUrl)) {
+ assertEquals(0, count(cache, "SELECT COUNT(*) FROM object_instance"));
+ assertEquals(0, count(cache, "SELECT COUNT(*) FROM object_attribute_current"));
+ assertTrue(count(cache, "SELECT COUNT(*) FROM fom_object_class") > 0);
+ }
+ }
+
+ private ObjectCache newCache() {
+ return newCache("jdbc:sqlite::memory:");
+ }
+
+ private ObjectCache newCache(String jdbcUrl) {
+ return new ObjectCache(enabledConfig(), catalog, fomXml, decoderRegistry, jdbcUrl);
+ }
+
+ private XapiConfig enabledConfig() {
+ TrackedObject trackedObject = new TrackedObject();
+ trackedObject.clazz = "Rabbit";
+ trackedObject.allAttributes = true;
+ ObjectCacheConfig objectCacheConfig = new ObjectCacheConfig();
+ objectCacheConfig.trackedObjects = List.of(trackedObject);
+ XapiConfig config = new XapiConfig();
+ config.objectCacheConfig = objectCacheConfig;
+ return config;
+ }
+
+ private byte[] position(int x, int y) {
+ HLAfixedRecord record = encoderFactory.createHLAfixedRecord();
+ record.add(encoderFactory.createHLAinteger32BE(x));
+ record.add(encoderFactory.createHLAinteger32BE(y));
+ return encoded(record);
+ }
+
+ private byte[] encoded(DataElement element) {
+ try {
+ return element.toByteArray();
+ } catch (EncoderException e) {
+ throw new IllegalStateException("Could not encode test value", e);
+ }
+ }
+
+ private long count(ObjectCache cache, String sql) throws SQLException {
+ return scalarLong(cache, sql);
+ }
+
+ private long scalarLong(ObjectCache cache, String sql) throws SQLException {
+ try (PreparedStatement statement = cache.connection().prepareStatement(sql);
+ ResultSet resultSet = statement.executeQuery()) {
+ return resultSet.next() ? resultSet.getLong(1) : 0L;
+ }
+ }
+
+ private byte[] rawBytes(ObjectCache cache, String pathKey) throws SQLException {
+ String sql = """
+ SELECT c.raw_bytes
+ FROM object_attribute_current c
+ JOIN fom_attribute a ON a.id = c.attribute_id
+ WHERE a.path_key = ?
+ """;
+ try (PreparedStatement statement = cache.connection().prepareStatement(sql)) {
+ statement.setString(1, pathKey);
+ try (ResultSet resultSet = statement.executeQuery()) {
+ return resultSet.next() ? resultSet.getBytes(1) : null;
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java
new file mode 100644
index 0000000..1f152d9
--- /dev/null
+++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java
@@ -0,0 +1,219 @@
+package com.yetanalytics.hlaxapi.cache;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.yetanalytics.hlaxapi.FOMXML;
+import com.yetanalytics.hlaxapi.HLADecoderRegistry;
+import com.yetanalytics.hlaxapi.SimulationConfig;
+import com.yetanalytics.hlaxapi.config.XapiConfig;
+import com.yetanalytics.hlaxapi.config.model.ObjectCacheConfig;
+import com.yetanalytics.hlaxapi.config.model.ComparisonOperator;
+import com.yetanalytics.hlaxapi.config.model.Criterion;
+import com.yetanalytics.hlaxapi.config.model.StatementTrigger;
+import com.yetanalytics.hlaxapi.config.model.Target;
+import com.yetanalytics.hlaxapi.config.model.TrackedObject;
+import com.yetanalytics.hlaxapi.config.model.ValueExpression;
+import hla.rti1516e.encoding.DataElement;
+import hla.rti1516e.encoding.EncoderException;
+import hla.rti1516e.encoding.EncoderFactory;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory;
+
+class ObjectCacheTest {
+
+ private final EncoderFactory encoderFactory = new HLA1516eEncoderFactory();
+ private final HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(encoderFactory);
+ private final FOMXML fomXml = new FOMXML(
+ new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"),
+ decoderRegistry);
+ private final FomCatalog catalog = new FomCatalog(fomXml);
+
+ @Test
+ void disabledWhenNoQueryInjectionsAndDoesNotOpenSqlite(@TempDir Path tempDir) {
+ Path databasePath = tempDir.resolve("disabled.sqlite");
+
+ try (ObjectCache cache = new ObjectCache(
+ new XapiConfig(),
+ catalog,
+ fomXml,
+ decoderRegistry,
+ "jdbc:sqlite:" + databasePath)) {
+ cache.discoverObject("object-1", "Rabbit One", "Rabbit");
+ cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE(
+ 75)));
+ cache.removeObject("object-1");
+
+ assertFalse(cache.isEnabled());
+ assertTrue(cache.subscriptions().isEmpty());
+ assertFalse(cache.findFirstValue("Rabbit", new Target(List.of("Hunger")), null).isPresent());
+ assertFalse(Files.exists(databasePath));
+ }
+ }
+
+ @Test
+ void enabledWhenQueryInjectionsExistAndCanQueryReflectedValues(@TempDir Path tempDir) {
+ Path databasePath = tempDir.resolve("enabled.sqlite");
+
+ try (ObjectCache cache = new ObjectCache(
+ configWithQuery(),
+ catalog,
+ fomXml,
+ decoderRegistry,
+ "jdbc:sqlite:" + databasePath)) {
+ cache.discoverObject("object-1", "Rabbit One", "Rabbit");
+ cache.reflectAttributeValue("object-1", "Rabbit", "EntityId", encoded(encoderFactory
+ .createHLAASCIIstring("rabbit-one")));
+ cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE(
+ 75)));
+
+ Criterion criteria = new Criterion(
+ new Target(List.of("Hunger")),
+ ComparisonOperator.GT,
+ new ValueExpression(50));
+
+ assertTrue(cache.isEnabled());
+ assertEquals(Set.of("EntityId", "Hunger"), cache.subscriptions().get("Rabbit"));
+ assertEquals(
+ "rabbit-one",
+ cache.findFirstValue("Rabbit", new Target(List.of("EntityId")), criteria).orElseThrow());
+ assertTrue(Files.exists(databasePath));
+ }
+ }
+
+ @Test
+ void enabledWhenTrackedObjectsExistWithoutQueryInjections(@TempDir Path tempDir) {
+ Path databasePath = tempDir.resolve("tracked.sqlite");
+
+ try (ObjectCache cache = new ObjectCache(
+ configWithTrackedObject("Rabbit", List.of("EntityId", "Hunger"), false),
+ catalog,
+ fomXml,
+ decoderRegistry,
+ "jdbc:sqlite:" + databasePath)) {
+ assertTrue(cache.isEnabled());
+ assertEquals(Set.of("EntityId", "Hunger"), cache.subscriptions().get("Rabbit"));
+ assertTrue(Files.exists(databasePath));
+ }
+ }
+
+ @Test
+ void closeIsIdempotentAndDisablesCache(@TempDir Path tempDir) {
+ ObjectCache cache = new ObjectCache(
+ configWithQuery(),
+ catalog,
+ fomXml,
+ decoderRegistry,
+ "jdbc:sqlite:" + tempDir.resolve("close.sqlite"));
+
+ assertTrue(cache.isEnabled());
+ cache.close();
+ cache.close();
+
+ assertFalse(cache.isEnabled());
+ assertTrue(cache.currentObjects("Rabbit").isEmpty());
+ }
+
+ @Test
+ void trackedObjectAllAttributesExpandsTopLevelFomAttributes(@TempDir Path tempDir) {
+ try (ObjectCache cache = new ObjectCache(
+ configWithTrackedObject("Rabbit", null, true),
+ catalog,
+ fomXml,
+ decoderRegistry,
+ "jdbc:sqlite:" + tempDir.resolve("all-attrs.sqlite"))) {
+ assertEquals(
+ Set.of("EntityId", "EntityType", "Position", "Hunger"),
+ stableAttributes(cache.subscriptions().get("Rabbit"), "EntityId", "EntityType", "Position",
+ "Hunger"));
+ }
+ }
+
+ @Test
+ void trackedObjectWildcardExpandsEveryObjectClassWithAllAttributes(@TempDir Path tempDir) {
+ try (ObjectCache cache = new ObjectCache(
+ configWithTrackedObject("*", null, true),
+ catalog,
+ fomXml,
+ decoderRegistry,
+ "jdbc:sqlite:" + tempDir.resolve("wildcard.sqlite"))) {
+ assertEquals(
+ Set.of("WorldId", "Size", "StepNumber", "CarrotCount", "RabbitCount", "WolfCount"),
+ cache.subscriptions().get("World"));
+ assertEquals(
+ Set.of("EntityId", "EntityType", "Position"),
+ stableAttributes(cache.subscriptions().get("SimEntity"), "EntityId", "EntityType", "Position"));
+ assertEquals(
+ Set.of("EntityId", "EntityType", "Position", "Hunger"),
+ stableAttributes(cache.subscriptions().get("Rabbit"), "EntityId", "EntityType", "Position",
+ "Hunger"));
+ assertFalse(cache.subscriptions().containsKey("HLAobjectRoot"));
+ }
+ }
+
+ @Test
+ void trackedObjectsMergeWithQueryInjections(@TempDir Path tempDir) {
+ XapiConfig config = configWithQuery();
+ config.objectCacheConfig = objectCacheConfig(trackedObject("Rabbit", List.of("Position"), false));
+
+ try (ObjectCache cache = new ObjectCache(
+ config,
+ catalog,
+ fomXml,
+ decoderRegistry,
+ "jdbc:sqlite:" + tempDir.resolve("merged.sqlite"))) {
+ assertEquals(Set.of("EntityId", "Hunger", "Position"), cache.subscriptions().get("Rabbit"));
+ }
+ }
+
+ private XapiConfig configWithQuery() {
+ StatementTrigger trigger = new StatementTrigger();
+ trigger.statement = """
+ {"actor":{"name":["query","Rabbit",["EntityId"],[["Hunger"],">",50]]}}
+ """;
+
+ XapiConfig config = new XapiConfig();
+ config.statementTriggers = List.of(trigger);
+ return config;
+ }
+
+ private XapiConfig configWithTrackedObject(String className, List attributes, boolean allAttributes) {
+ XapiConfig config = new XapiConfig();
+ config.objectCacheConfig = objectCacheConfig(trackedObject(className, attributes, allAttributes));
+ return config;
+ }
+
+ private ObjectCacheConfig objectCacheConfig(TrackedObject... trackedObjects) {
+ ObjectCacheConfig objectCacheConfig = new ObjectCacheConfig();
+ objectCacheConfig.trackedObjects = List.of(trackedObjects);
+ return objectCacheConfig;
+ }
+
+ private TrackedObject trackedObject(String className, List attributes, boolean allAttributes) {
+ TrackedObject trackedObject = new TrackedObject();
+ trackedObject.clazz = className;
+ trackedObject.attributes = attributes;
+ trackedObject.allAttributes = allAttributes;
+ return trackedObject;
+ }
+
+ private byte[] encoded(DataElement element) {
+ try {
+ return element.toByteArray();
+ } catch (EncoderException e) {
+ throw new IllegalStateException("Could not encode test value", e);
+ }
+ }
+
+ private Set stableAttributes(Set attributes, String... expected) {
+ Set stable = Set.of(expected);
+ assertTrue(attributes.containsAll(stable));
+ return stable;
+ }
+}
diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java
new file mode 100644
index 0000000..2c628c8
--- /dev/null
+++ b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java
@@ -0,0 +1,74 @@
+package com.yetanalytics.hlaxapi.cache;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import com.yetanalytics.hlaxapi.config.model.StatementTrigger;
+import com.yetanalytics.hlaxapi.config.model.ComparisonOperator;
+import com.yetanalytics.hlaxapi.config.model.Criterion;
+import com.yetanalytics.hlaxapi.config.model.ObjectLookup;
+import com.yetanalytics.hlaxapi.config.model.Target;
+import com.yetanalytics.hlaxapi.config.model.ThisExpression;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+class QueryReferenceCollectorTest {
+
+ @Test
+ void findsWholeNodeAndInlineQueryInjections() {
+ StatementTrigger wholeNode = trigger("""
+ {"actor":{"name":["query","Rabbit",["EntityId"],[["Hunger"],">",50]]}}
+ """);
+ StatementTrigger inline = trigger("""
+ {"result":{"response":"at=<<[\\"query\\",\\"Rabbit\\",[\\"Position\\",\\"Y\\"],[[\\"Position\\",\\"X\\"],\\"<\\",15]]>>"}}
+ """);
+
+ Map> references = QueryReferenceCollector.collect(List.of(wholeNode, inline));
+
+ assertEquals(Set.of("EntityId", "Hunger", "Position"), references.get("Rabbit"));
+ }
+
+ @Test
+ void ignoresThisExpressionTargetsInsideQueryCriteria() {
+ StatementTrigger trigger = trigger("""
+ {"actor":{"name":["query","Rabbit",["EntityId"],[["Hunger"],">",["this",["DesiredHunger"]]]]}}
+ """);
+
+ Map> references = QueryReferenceCollector.collect(List.of(trigger));
+
+ assertEquals(Set.of("EntityId", "Hunger"), references.get("Rabbit"));
+ assertFalse(references.get("Rabbit").contains("DesiredHunger"));
+ }
+
+ @Test
+ void findsLookupDefinitionCriteriaAndLookupInjections() {
+ StatementTrigger trigger = trigger("""
+ {
+ "actor": {
+ "account": {"name": ["lookup", "predator", ["EntityId"], {"nullable": true}]},
+ "name": "<<[\\"lookup\\",\\"predator\\",[\\"EntityType\\"],{\\"nullable\\":true}]>>"
+ }
+ }
+ """);
+ ObjectLookup lookup = new ObjectLookup();
+ lookup.clazz = "SimEntity";
+ lookup.criteria = new Criterion(
+ new Target(List.of("EntityId")),
+ ComparisonOperator.EQ,
+ new ThisExpression(new Target(List.of("PredatorId"))));
+ trigger.lookups = Map.of("predator", lookup);
+
+ Map> references = QueryReferenceCollector.collect(List.of(trigger));
+
+ assertEquals(Set.of("EntityId", "EntityType"), references.get("SimEntity"));
+ assertFalse(references.get("SimEntity").contains("PredatorId"));
+ }
+
+ private StatementTrigger trigger(String statement) {
+ StatementTrigger trigger = new StatementTrigger();
+ trigger.statement = statement;
+ return trigger;
+ }
+}
diff --git a/src/test/java/com/yetanalytics/hlaxapi/criteria/CriteriaEvaluatorTest.java b/src/test/java/com/yetanalytics/hlaxapi/criteria/CriteriaEvaluatorTest.java
new file mode 100644
index 0000000..47f103e
--- /dev/null
+++ b/src/test/java/com/yetanalytics/hlaxapi/criteria/CriteriaEvaluatorTest.java
@@ -0,0 +1,126 @@
+package com.yetanalytics.hlaxapi.criteria;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.yetanalytics.hlaxapi.config.model.ComparisonOperator;
+import com.yetanalytics.hlaxapi.config.model.Criterion;
+import com.yetanalytics.hlaxapi.config.model.Expression;
+import com.yetanalytics.hlaxapi.config.model.LogicalExpression;
+import com.yetanalytics.hlaxapi.config.model.LogicalOperator;
+import com.yetanalytics.hlaxapi.config.model.Target;
+import com.yetanalytics.hlaxapi.config.model.ThisExpression;
+import com.yetanalytics.hlaxapi.config.model.ValueExpression;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class CriteriaEvaluatorTest {
+
+ private final CriteriaEvaluator evaluator = new CriteriaEvaluator();
+
+ @Test
+ void matchesNullCriteriaAndBooleanLiterals() {
+ ExpressionResolver resolver = expression -> null;
+
+ assertTrue(evaluator.matches(null, resolver));
+ assertTrue(evaluator.matches(new ValueExpression(true), resolver));
+ assertFalse(evaluator.matches(new ValueExpression(false), resolver));
+ assertFalse(evaluator.matches(new ValueExpression("true"), resolver));
+ }
+
+ @ParameterizedTest
+ @MethodSource("comparisonCases")
+ void evaluatesComparisonOperators(Object left, ComparisonOperator operator, Object right, boolean expected) {
+ Criterion criterion = new Criterion(new ValueExpression(left), operator, new ValueExpression(right));
+
+ if (expected) {
+ assertTrue(evaluator.matches(criterion, expression -> null));
+ } else {
+ assertFalse(evaluator.matches(criterion, expression -> null));
+ }
+ }
+
+ static Stream comparisonCases() {
+ return Stream.of(
+ Arguments.of(5, ComparisonOperator.EQ, 5L, true),
+ Arguments.of(5, ComparisonOperator.EQ, 6L, false),
+ Arguments.of("rabbit", ComparisonOperator.NEQ, "fox", true),
+ Arguments.of("rabbit", ComparisonOperator.NEQ, "rabbit", false),
+ Arguments.of(1, ComparisonOperator.LT, 2L, true),
+ Arguments.of(2, ComparisonOperator.LT, 1L, false),
+ Arguments.of(2.5, ComparisonOperator.GT, 2, true),
+ Arguments.of(1, ComparisonOperator.GT, 2, false),
+ Arguments.of("a", ComparisonOperator.LTE, "b", true),
+ Arguments.of("b", ComparisonOperator.LTE, "a", false),
+ Arguments.of("b", ComparisonOperator.GTE, "b", true),
+ Arguments.of("a", ComparisonOperator.GTE, "b", false),
+ Arguments.of(10, ComparisonOperator.LT, "2", true),
+ Arguments.of(null, ComparisonOperator.EQ, null, true),
+ Arguments.of(null, ComparisonOperator.NEQ, "value", true),
+ Arguments.of(null, ComparisonOperator.LT, 1, false));
+ }
+
+ @Test
+ void resolvesContextDependentLeafExpressions() {
+ Target target = new Target(List.of("EntityId"));
+ ThisExpression thisExpression = new ThisExpression(new Target(List.of("PredatorId")));
+ Criterion criterion = new Criterion(target, ComparisonOperator.EQ, thisExpression);
+
+ assertTrue(evaluator.matches(criterion, expression -> {
+ if (expression == target || expression == thisExpression) {
+ return "rabbit-one";
+ }
+ return null;
+ }));
+ }
+
+ @Test
+ void evaluatesNestedLogicalExpressions() {
+ Target hunger = new Target(List.of("Hunger"));
+ Target xPosition = new Target(List.of("Position", "X"));
+ Map values = Map.of(hunger, 75, xPosition, 12);
+ Criterion hungry = new Criterion(hunger, ComparisonOperator.GT, new ValueExpression(50));
+ Criterion nearby = new Criterion(xPosition, ComparisonOperator.LT, new ValueExpression(15));
+ Criterion missing = new Criterion(xPosition, ComparisonOperator.GT, new ValueExpression(20));
+ LogicalExpression nested = new LogicalExpression(
+ LogicalOperator.AND,
+ List.of(hungry, new LogicalExpression(LogicalOperator.OR, List.of(nearby, missing))));
+
+ assertTrue(evaluator.matches(nested, values::get));
+ }
+
+ @Test
+ void shortCircuitsLogicalExpressions() {
+ Target unresolved = new Target(List.of("Unresolved"));
+ AtomicInteger resolutions = new AtomicInteger();
+ ExpressionResolver resolver = expression -> {
+ resolutions.incrementAndGet();
+ return true;
+ };
+
+ LogicalExpression orExpression = new LogicalExpression(
+ LogicalOperator.OR,
+ List.of(new ValueExpression(true), unresolved));
+ LogicalExpression andExpression = new LogicalExpression(
+ LogicalOperator.AND,
+ List.of(new ValueExpression(false), unresolved));
+
+ assertTrue(evaluator.matches(orExpression, resolver));
+ assertFalse(evaluator.matches(andExpression, resolver));
+ assertEquals(0, resolutions.get());
+ }
+
+ @Test
+ void rejectsCriterionWithoutComparisonOperator() {
+ Criterion criterion = new Criterion(new ValueExpression(1), null, new ValueExpression(1));
+
+ assertFalse(evaluator.matches(criterion, expression -> null));
+ }
+}
diff --git a/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java b/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java
new file mode 100644
index 0000000..38233c1
--- /dev/null
+++ b/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java
@@ -0,0 +1,91 @@
+package com.yetanalytics.hlaxapi.injection;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yetanalytics.hlaxapi.config.model.Criterion;
+import com.yetanalytics.hlaxapi.config.model.InjectionType;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.InlineInjection;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.LookupInjection;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.ParseResult;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.QueryInjection;
+import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.ThisInjection;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class StatementInjectionParserTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ @Test
+ void parsesTypedWholeNodeInjections() throws Exception {
+ ParseResult thisResult = StatementInjectionParser.parse(
+ MAPPER.readTree("[\"this\",[\"EntityId\"]]"));
+ ThisInjection thisInjection = assertInstanceOf(ThisInjection.class, thisResult.injection());
+ assertEquals(List.of("EntityId"), thisInjection.target().parts);
+ assertTrue(thisInjection.options().required());
+
+ ParseResult queryResult = StatementInjectionParser.parse(MAPPER.readTree(
+ "[\"QUERY\",\"Rabbit\",[\"Position\",\"X\"],[[\"Hunger\"],\">\",50]]"));
+ QueryInjection query = assertInstanceOf(QueryInjection.class, queryResult.injection());
+ assertEquals("Rabbit", query.className());
+ assertEquals(List.of("Position", "X"), query.target().parts);
+ assertInstanceOf(Criterion.class, query.criteria());
+ assertTrue(query.options().required());
+
+ ParseResult lookupResult = StatementInjectionParser.parse(MAPPER.readTree(
+ "[\"lookup\",\"predator\",[\"EntityType\"],{\"nullable\":true}]"));
+ LookupInjection lookup = assertInstanceOf(LookupInjection.class, lookupResult.injection());
+ assertEquals("predator", lookup.alias());
+ assertEquals(List.of("EntityType"), lookup.target().parts);
+ assertTrue(lookup.options().nullable());
+ assertTrue(lookup.options().required());
+ }
+
+ @Test
+ void parsesOptionalInjectionsForEveryType() throws Exception {
+ ThisInjection thisInjection = assertInstanceOf(ThisInjection.class, StatementInjectionParser.parse(
+ MAPPER.readTree("[\"this\",[\"EntityId\"],{\"required\":false}]")).injection());
+ QueryInjection queryInjection = assertInstanceOf(QueryInjection.class, StatementInjectionParser.parse(
+ MAPPER.readTree(
+ "[\"query\",\"Rabbit\",[\"EntityId\"],[[\"Hunger\"],\">\",50],{\"required\":false}]")).injection());
+ LookupInjection lookupInjection = assertInstanceOf(LookupInjection.class, StatementInjectionParser.parse(
+ MAPPER.readTree(
+ "[\"lookup\",\"predator\",[\"EntityType\"],{\"nullable\":true,\"required\":false}]")).injection());
+
+ assertFalse(thisInjection.options().required());
+ assertFalse(queryInjection.options().required());
+ assertFalse(lookupInjection.options().required());
+ assertTrue(lookupInjection.options().nullable());
+ }
+
+ @Test
+ void findsInlineInjectionsAndPreservesNonInjectionCandidates() {
+ String text = "before <<[\"this\",[\"EntityId\"]]>> and "
+ + "<<[\"lookup\",\"predator\",[\"EntityType\"]]>> after <>";
+
+ List inline = StatementInjectionParser.findInline(text);
+
+ assertEquals(3, inline.size());
+ assertInstanceOf(ThisInjection.class, inline.get(0).result().injection());
+ assertInstanceOf(LookupInjection.class, inline.get(1).result().injection());
+ assertFalse(inline.get(2).result().recognized());
+ assertEquals("<>", inline.get(2).source());
+ assertEquals("before ", text.substring(0, inline.get(0).start()));
+ }
+
+ @Test
+ void distinguishesMalformedKnownTagsFromUnknownArrays() throws Exception {
+ ParseResult malformed = StatementInjectionParser.parse(MAPPER.readTree("[\"query\",\"Rabbit\"]"));
+ assertTrue(malformed.recognized());
+ assertFalse(malformed.valid());
+ assertEquals(InjectionType.QUERY, malformed.type());
+
+ ParseResult unknown = StatementInjectionParser.parse(MAPPER.readTree("[\"custom\",1,2]"));
+ assertFalse(unknown.recognized());
+ assertFalse(unknown.valid());
+ }
+}
diff --git a/src/test/resources/config-test.json b/src/test/resources/config-test.json
index 2933b45..ae9b2b4 100644
--- a/src/test/resources/config-test.json
+++ b/src/test/resources/config-test.json
@@ -30,5 +30,8 @@
"key": "key string",
"secret": "secret string",
"batch": 4
- }
+ },
+ "objectCache": {
+ "trackedObjects": [{"class":"*", "allAttributes": true}]
+ }
}