From e04c1a0a43c2440300e2c89fa067a2eb9e957d40 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Thu, 18 Jun 2026 11:49:22 -0400 Subject: [PATCH 01/26] sqlite object cache first try --- pom.xml | 5 + .../hlaxapi/HlaInterfaceImpl.java | 167 ++++++ .../hlaxapi/cache/CacheQueryService.java | 139 +++++ .../hlaxapi/cache/CachedObject.java | 4 + .../hlaxapi/cache/CachedValue.java | 48 ++ .../hlaxapi/cache/DecodedAttributeValue.java | 10 + .../hlaxapi/cache/FomCatalog.java | 479 +++++++++++++++++ .../hlaxapi/cache/HlaObjectCache.java | 503 ++++++++++++++++++ .../hlaxapi/cache/HlaValueFlattener.java | 150 ++++++ .../hlaxapi/config/InjectionHandler.java | 36 +- .../com/yetanalytics/ConfigParserTest.java | 2 +- .../hlaxapi/cache/FomCatalogTest.java | 47 ++ .../hlaxapi/cache/HlaObjectCacheTest.java | 159 ++++++ 13 files changed, 1740 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/CachedObject.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/CachedValue.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/DecodedAttributeValue.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/HlaValueFlattener.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java diff --git a/pom.xml b/pom.xml index 2e3660d..0646462 100644 --- a/pom.xml +++ b/pom.xml @@ -85,6 +85,11 @@ 3.20.0 compile + + org.xerial + sqlite-jdbc + 3.42.0.0 + diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index d35e1a8..6b19f44 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -8,13 +8,21 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import com.yetanalytics.hlaxapi.cache.FomCatalog; +import com.yetanalytics.hlaxapi.cache.HlaObjectCache; +import com.yetanalytics.hlaxapi.config.InjectionHandler; import com.yetanalytics.hlaxapi.config.XapiConfig; +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; @@ -25,6 +33,7 @@ import hla.rti1516e.TransportationTypeHandle; import hla.rti1516e.encoding.EncoderFactory; 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; @@ -42,11 +51,15 @@ import hla.rti1516e.exceptions.FederationExecutionDoesNotExist; import hla.rti1516e.exceptions.InconsistentFDD; import hla.rti1516e.exceptions.InteractionClassNotDefined; +import hla.rti1516e.exceptions.InvalidAttributeHandle; import hla.rti1516e.exceptions.InvalidInteractionClassHandle; import hla.rti1516e.exceptions.InvalidLocalSettingsDesignator; +import hla.rti1516e.exceptions.InvalidObjectClassHandle; 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; @@ -67,6 +80,10 @@ class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInterface { private XapiConfig xapiConfig; + private FomCatalog _fomCatalog; + + private HlaObjectCache _objectCache; + public HlaInterfaceImpl() { } @@ -83,6 +100,15 @@ public void start(String localSettingsDesignator, String fomPath, String federat EncoderFactory encoderFactory = rtiFactory.getEncoderFactory(); _decoderRegistry = new HLADecoderRegistry(encoderFactory); _decoderRegistry.registerAlias("ScaleFactorFloat32", "HLAfloat32BE"); + _fomCatalog = FomCatalog.fromFile(fomPath); + _fomCatalog.aliasesToPrimitiveTypes().forEach((alias, primitiveType) -> { + if (!_decoderRegistry.supports(alias)) { + _decoderRegistry.registerAlias(alias, primitiveType); + } + }); + _objectCache = new HlaObjectCache(HlaObjectCache.defaultJdbcUrl(), _fomCatalog, _decoderRegistry, + encoderFactory); + InjectionHandler.setQueryService(_objectCache.queryService()); try { if (localSettingsDesignator == null || localSettingsDesignator.isBlank()) { @@ -136,6 +162,7 @@ public void start(String localSettingsDesignator, String fomPath, String federat // Get relevant interactions to subscribe to from the xapiConfig try { + subscribeObjectClasses(); subscribeInteractions(); logger.info("Started Subscription"); @@ -167,6 +194,9 @@ public void stop() throws RTIinternalError { } catch (FederateIsExecutionMember | CallNotAllowedFromWithinCallback e) { throw new RTIinternalError("HlaInterfaceFailure", e); } + if (_objectCache != null) { + _objectCache.close(); + } } catch (NotConnected ignored) { } } @@ -176,6 +206,140 @@ public void connectionLost(String faultDescription) throws FederateInternalError System.out.println("Lost Connection because: " + faultDescription); } + /* + * Objects + */ + + private void subscribeObjectClasses() + throws FederateNotExecutionMember, RestoreInProgress, SaveInProgress, NotConnected, RTIinternalError { + for (FomCatalog.ObjectClassDef clazz : _fomCatalog.objectClasses()) { + if (clazz.topLevelAttributeNames().isEmpty()) { + continue; + } + try { + ObjectClassHandle classHandle = _ambassador.getObjectClassHandle(clazz.localName()); + AttributeHandleSet attributeHandles = _ambassador.getAttributeHandleSetFactory().create(); + for (String attributeName : clazz.topLevelAttributeNames()) { + attributeHandles.add(_ambassador.getAttributeHandle(classHandle, attributeName)); + } + _ambassador.subscribeObjectClassAttributes(classHandle, attributeHandles); + } catch (AttributeNotDefined | InvalidObjectClassHandle | NameNotFound | ObjectClassNotDefined e) { + logger.error("Could not subscribe object class {}!", clazz.localName(), 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 { + 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 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) { + 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 e) { + logger.error("Error caching reflected object attributes", e); + } + } + + @Override + public void removeObjectInstance( + ObjectInstanceHandle theObject, + byte[] userSuppliedTag, + OrderType sentOrdering, + SupplementalRemoveInfo removeInfo) throws FederateInternalError { + _objectCache.removeObject(theObject.toString()); + } + + @Override + public void removeObjectInstance( + ObjectInstanceHandle theObject, + byte[] userSuppliedTag, + OrderType sentOrdering, + LogicalTime theTime, + OrderType receivedOrdering, + SupplementalRemoveInfo removeInfo) throws FederateInternalError { + _objectCache.removeObject(theObject.toString()); + } + + @Override + public void removeObjectInstance( + ObjectInstanceHandle theObject, + byte[] userSuppliedTag, + OrderType sentOrdering, + LogicalTime theTime, + OrderType receivedOrdering, + MessageRetractionHandle retractionHandle, + SupplementalRemoveInfo removeInfo) throws FederateInternalError { + _objectCache.removeObject(theObject.toString()); + } + /* * Interactions */ @@ -183,6 +347,9 @@ public void connectionLost(String faultDescription) throws FederateInternalError private void subscribeInteractions() throws FederateNotExecutionMember, RestoreInProgress, SaveInProgress, NotConnected, RTIinternalError, FederateServiceInvocationsAreBeingReportedViaMOM { + if (xapiConfig.statementTriggers == null) { + return; + } xapiConfig.statementTriggers.forEach(trigger -> { try { InteractionClassHandle handle = _ambassador.getInteractionClassHandle(trigger.clazz); 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..b4cbb5f --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java @@ -0,0 +1,139 @@ +package com.yetanalytics.hlaxapi.cache; + +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.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class CacheQueryService { + + private final HlaObjectCache cache; + + public CacheQueryService(HlaObjectCache cache) { + this.cache = Objects.requireNonNull(cache, "cache"); + } + + public Optional findFirstValue(String className, Target target, Expression criteria) { + return findValues(className, target, criteria).stream().findFirst(); + } + + 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) { + if (expression == null) { + return true; + } + if (expression instanceof Criterion criterion) { + return evaluateCriterion(object, criterion); + } + if (expression instanceof LogicalExpression logical) { + if (logical.operator == LogicalOperator.OR) { + return logical.operands.stream().anyMatch(operand -> matches(object, operand)); + } + return logical.operands.stream().allMatch(operand -> matches(object, operand)); + } + Object value = resolveExpression(object, expression); + return Boolean.TRUE.equals(value); + } + + private boolean evaluateCriterion(CachedObject object, Criterion criterion) { + Object left = resolveExpression(object, criterion.left); + Object right = resolveExpression(object, criterion.right); + ComparisonOperator operator = criterion.operator; + if (operator == null) { + return false; + } + return compare(left, right, operator); + } + + private Object resolveExpression(CachedObject object, Expression expression) { + if (expression == null) { + return null; + } + if (expression instanceof Target target) { + return resolveTarget(object, target).orElse(null); + } + if (expression instanceof ValueExpression valueExpression) { + return valueExpression.value; + } + if (expression instanceof ThisExpression) { + return null; + } + if (expression instanceof Criterion criterion) { + return evaluateCriterion(object, criterion); + } + if (expression instanceof LogicalExpression logical) { + return matches(object, logical); + } + return null; + } + + private Optional resolveTarget(CachedObject object, Target target) { + String pathKey = FomCatalog.targetPath(target.parts); + if (pathKey == null) { + return Optional.empty(); + } + return cache.findCurrentValue(object.id(), pathKey).map(CachedValue::value); + } + + 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/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..bbe42d1 --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -0,0 +1,479 @@ +package com.yetanalytics.hlaxapi.cache; + +import java.io.File; +import java.io.IOException; +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.Objects; +import java.util.Optional; +import java.util.Set; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +/** + * FOM-derived object metadata used by the SQLite cache. + */ +public final class FomCatalog { + + private static final Set PRIMITIVE_TYPES = Set.of( + "HLAASCIIchar", + "HLAASCIIstring", + "HLAboolean", + "HLAbyte", + "HLAfloat32BE", + "HLAfloat32LE", + "HLAfloat64BE", + "HLAfloat64LE", + "HLAinteger16BE", + "HLAinteger16LE", + "HLAinteger32BE", + "HLAinteger32LE", + "HLAinteger64BE", + "HLAinteger64LE", + "HLAoctet", + "HLAoctetPairBE", + "HLAoctetPairLE", + "HLAopaqueData", + "HLAunicodeChar", + "HLAunicodeString"); + + private final Map classesByName; + private final Map classesById; + private final Map simpleRepresentations; + private final Map enumeratedRepresentations; + private final Map fixedRecords; + private final Map arrays; + private final Map attributesById; + + private FomCatalog( + Map classesByName, + Map simpleRepresentations, + Map enumeratedRepresentations, + Map fixedRecords, + Map arrays) { + this.classesByName = Collections.unmodifiableMap(classesByName); + this.simpleRepresentations = Collections.unmodifiableMap(simpleRepresentations); + this.enumeratedRepresentations = Collections.unmodifiableMap(enumeratedRepresentations); + this.fixedRecords = Collections.unmodifiableMap(fixedRecords); + this.arrays = Collections.unmodifiableMap(arrays); + + 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 static FomCatalog fromFile(String fomPath) { + Objects.requireNonNull(fomPath, "fomPath"); + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + Document document = factory.newDocumentBuilder().parse(new File(fomPath)); + return fromDocument(document); + } catch (IOException | ParserConfigurationException | SAXException e) { + throw new IllegalArgumentException("Could not parse FOM XML: " + fomPath, e); + } + } + + static FomCatalog fromDocument(Document document) { + Map simpleTypes = parseRepresentations(document, "simpleData"); + Map enumTypes = parseRepresentations(document, "enumeratedData"); + Map fixedRecords = parseFixedRecords(document); + Map arrays = parseArrays(document); + + CatalogBuilder builder = new CatalogBuilder(simpleTypes, enumTypes, fixedRecords, arrays); + Element objects = firstElement(document.getDocumentElement(), "objects"); + if (objects != null) { + for (Element objectClass : childElements(objects, "objectClass")) { + builder.parseObjectClass(objectClass, null, new ArrayList<>()); + } + } + return new FomCatalog(builder.classesByName, simpleTypes, enumTypes, fixedRecords, arrays); + } + + 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 Map aliasesToPrimitiveTypes() { + Map aliases = new LinkedHashMap<>(); + aliases.putAll(simpleRepresentations); + aliases.putAll(enumeratedRepresentations); + return aliases; + } + + public TypeKind typeKind(String typeName) { + String localType = localName(typeName); + if (PRIMITIVE_TYPES.contains(localType)) { + return TypeKind.PRIMITIVE; + } + if (simpleRepresentations.containsKey(localType)) { + return TypeKind.SIMPLE; + } + if (enumeratedRepresentations.containsKey(localType)) { + return TypeKind.ENUMERATED; + } + if (fixedRecords.containsKey(localType)) { + return TypeKind.FIXED_RECORD; + } + if (arrays.containsKey(localType)) { + return TypeKind.ARRAY; + } + return TypeKind.UNKNOWN; + } + + public Optional fixedRecord(String typeName) { + return Optional.ofNullable(fixedRecords.get(localName(typeName))); + } + + public Optional array(String typeName) { + return Optional.ofNullable(arrays.get(localName(typeName))); + } + + public String primitiveType(String typeName) { + String localType = localName(typeName); + if (PRIMITIVE_TYPES.contains(localType)) { + return localType; + } + String simple = simpleRepresentations.get(localType); + if (simple != null) { + return primitiveType(simple); + } + String enumerated = enumeratedRepresentations.get(localType); + if (enumerated != null) { + return primitiveType(enumerated); + } + return null; + } + + 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 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; + } + + private static Map parseRepresentations(Document document, String elementName) { + Map representations = new LinkedHashMap<>(); + NodeList nodes = document.getElementsByTagName(elementName); + for (int i = 0; i < nodes.getLength(); i++) { + Element element = (Element) nodes.item(i); + String name = text(element, "name"); + String representation = text(element, "representation"); + if (name != null && representation != null) { + representations.put(name, representation); + } + } + return representations; + } + + private static Map parseFixedRecords(Document document) { + Map records = new LinkedHashMap<>(); + NodeList nodes = document.getElementsByTagName("fixedRecordData"); + for (int i = 0; i < nodes.getLength(); i++) { + Element element = (Element) nodes.item(i); + String name = text(element, "name"); + if (name == null) { + continue; + } + List fields = new ArrayList<>(); + for (Element field : childElements(element, "field")) { + String fieldName = text(field, "name"); + String fieldType = text(field, "dataType"); + if (fieldName != null && fieldType != null) { + fields.add(new FieldDef(fieldName, fieldType)); + } + } + records.put(name, new FixedRecordDef(name, fields)); + } + return records; + } + + private static Map parseArrays(Document document) { + Map arrayDefs = new LinkedHashMap<>(); + NodeList nodes = document.getElementsByTagName("arrayData"); + for (int i = 0; i < nodes.getLength(); i++) { + Element element = (Element) nodes.item(i); + String name = text(element, "name"); + String dataType = text(element, "dataType"); + if (name != null && dataType != null) { + arrayDefs.put(name, new ArrayDef(name, dataType)); + } + } + return arrayDefs; + } + + private static String text(Element parent, String tagName) { + Element element = firstElement(parent, tagName); + if (element == null) { + return null; + } + String text = element.getTextContent(); + return text == null || text.isBlank() ? null : text.trim(); + } + + private static Element firstElement(Element parent, String tagName) { + for (Element element : childElements(parent, tagName)) { + return element; + } + return null; + } + + 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 enum TypeKind { + PRIMITIVE, + SIMPLE, + ENUMERATED, + FIXED_RECORD, + ARRAY, + UNKNOWN + } + + public record FieldDef(String name, String dataType) { + } + + public record FixedRecordDef(String name, List fields) { + public FixedRecordDef { + fields = List.copyOf(fields); + } + } + + public record ArrayDef(String name, String elementType) { + } + + 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 Map simpleRepresentations; + private final Map enumeratedRepresentations; + private final Map fixedRecords; + private final Map arrays; + private final Map classesByName = new LinkedHashMap<>(); + private int nextClassId = 1; + private int nextAttributeId = 1; + + private CatalogBuilder( + Map simpleRepresentations, + Map enumeratedRepresentations, + Map fixedRecords, + Map arrays) { + this.simpleRepresentations = simpleRepresentations; + this.enumeratedRepresentations = enumeratedRepresentations; + this.fixedRecords = fixedRecords; + this.arrays = arrays; + } + + private void parseObjectClass(Element element, String parentName, List inheritedAttributes) { + String className = text(element, "name"); + if (className == null) { + return; + } + + List allAttributes = new ArrayList<>(inheritedAttributes); + for (Element attribute : childElements(element, "attribute")) { + String attributeName = text(attribute, "name"); + String dataType = text(attribute, "dataType"); + if (attributeName != null && dataType != null) { + allAttributes.add(new AttributeSource(attributeName, dataType)); + } + } + + 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, className, localName(className), parentName, flattened); + classesByName.put(classDef.localName(), classDef); + + for (Element childClass : childElements(element, "objectClass")) { + parseObjectClass(childClass, classDef.localName(), allAttributes); + } + } + + private void flattenAttribute( + int classId, + String attributeName, + String pathKey, + String dataType, + List attributes) { + String primitive = primitiveType(dataType); + FixedRecordDef fixedRecord = fixedRecords.get(localName(dataType)); + ArrayDef array = arrays.get(localName(dataType)); + boolean leaf = primitive != null || fixedRecord == null && array == null; + + attributes.add(new FomAttribute( + nextAttributeId++, + classId, + attributeName, + pathKey, + dataType, + primitive, + leaf)); + + if (fixedRecord != null) { + for (FieldDef field : fixedRecord.fields()) { + flattenAttribute( + classId, + attributeName, + pathKey + "." + field.name(), + field.dataType(), + attributes); + } + } else if (array != null) { + flattenAttribute( + classId, + attributeName, + pathKey + "[]", + array.elementType(), + attributes); + } + } + + private String primitiveType(String dataType) { + String localType = localName(dataType); + if (PRIMITIVE_TYPES.contains(localType)) { + return localType; + } + String simple = simpleRepresentations.get(localType); + if (simple != null) { + return primitiveType(simple); + } + String enumerated = enumeratedRepresentations.get(localType); + if (enumerated != null) { + return primitiveType(enumerated); + } + return null; + } + + private record AttributeSource(String name, String dataType) { + } + } +} diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java new file mode 100644 index 0000000..e4ddc5b --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java @@ -0,0 +1,503 @@ +package com.yetanalytics.hlaxapi.cache; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yetanalytics.hlaxapi.HLADecoderRegistry; +import hla.rti1516e.encoding.EncoderFactory; +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.sql.Types; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +public class HlaObjectCache implements AutoCloseable { + + private static final int SCHEMA_VERSION = 1; + + private final Connection connection; + private final FomCatalog catalog; + private final HlaValueFlattener valueFlattener; + private final CacheQueryService queryService; + private final ObjectMapper mapper = new ObjectMapper(); + private final AtomicLong sequence = new AtomicLong(); + + public HlaObjectCache( + String jdbcUrl, + FomCatalog catalog, + HLADecoderRegistry decoderRegistry, + EncoderFactory encoderFactory) { + try { + this.connection = DriverManager.getConnection(Objects.requireNonNull(jdbcUrl, "jdbcUrl")); + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.valueFlattener = new HlaValueFlattener(catalog, decoderRegistry, encoderFactory); + this.queryService = new CacheQueryService(this); + initializeSchema(); + seedFomMetadata(); + } catch (SQLException e) { + throw new IllegalStateException("Could not initialize 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; + } + + public FomCatalog catalog() { + return catalog; + } + + public CacheQueryService queryService() { + return queryService; + } + + public void discoverObject(String objectHandle, String objectName, String className) { + FomCatalog.ObjectClassDef clazz = requireClass(className); + ensureObject(objectHandle, objectName, clazz.localName()); + } + + public void removeObject(String objectHandle) { + 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 void reflectAttributeValue( + String objectHandle, + String className, + String attributeName, + byte[] bytes) { + 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 Optional findCurrentValue(long instanceId, String pathKey) { + 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 Optional findCurrentValue(String objectHandle, String pathKey) { + 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 List currentObjects(String className) { + 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 void close() { + try { + connection.close(); + } catch (SQLException e) { + throw new IllegalStateException("Could not close HLA object cache", e); + } + } + + 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 = 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_text TEXT, + value_num REAL, + value_bool INTEGER, + 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("CREATE INDEX IF NOT EXISTS idx_current_text " + + "ON object_attribute_current(attribute_id, value_text)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_current_num " + + "ON object_attribute_current(attribute_id, value_num)"); + statement.execute("CREATE INDEX IF NOT EXISTS idx_current_bool " + + "ON object_attribute_current(attribute_id, value_bool)"); + 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_text, value_num, value_bool, 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_text = excluded.value_text, + value_num = excluded.value_num, + value_bool = excluded.value_bool, + 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); + bindValueColumns(statement, 4, objectValue); + statement.setBytes(9, value.rawBytes()); + statement.setString(10, observedAt); + statement.setLong(11, 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 void bindValueColumns(PreparedStatement statement, int startIndex, Object value) throws SQLException { + setNullableString(statement, startIndex, value instanceof Character ? value.toString() : value); + setNullableNumber(statement, startIndex + 1, value); + setNullableBoolean(statement, startIndex + 2, value); + setNullableBlob(statement, startIndex + 3, value); + setNullableJson(statement, startIndex + 4, value); + } + + private void setNullableString(PreparedStatement statement, int index, Object value) throws SQLException { + if (value instanceof String stringValue) { + statement.setString(index, stringValue); + } else { + statement.setNull(index, Types.VARCHAR); + } + } + + private void setNullableNumber(PreparedStatement statement, int index, Object value) throws SQLException { + if (value instanceof Number number) { + statement.setDouble(index, number.doubleValue()); + } else { + statement.setNull(index, Types.DOUBLE); + } + } + + private void setNullableBoolean(PreparedStatement statement, int index, Object value) throws SQLException { + if (value instanceof Boolean booleanValue) { + statement.setInt(index, booleanValue ? 1 : 0); + } else { + statement.setNull(index, Types.INTEGER); + } + } + + private void setNullableBlob(PreparedStatement statement, int index, Object value) throws SQLException { + if (value instanceof byte[] bytes) { + statement.setBytes(index, bytes); + } else { + statement.setNull(index, Types.BLOB); + } + } + + private void setNullableJson(PreparedStatement statement, int index, Object value) throws SQLException { + if (value instanceof byte[]) { + statement.setNull(index, Types.VARCHAR); + return; + } + try { + statement.setString(index, 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); + } + } +} 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..342874d --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/HlaValueFlattener.java @@ -0,0 +1,150 @@ +package com.yetanalytics.hlaxapi.cache; + +import com.yetanalytics.hlaxapi.HLADecoderRegistry; +import hla.rti1516e.encoding.DataElement; +import hla.rti1516e.encoding.DecoderException; +import hla.rti1516e.encoding.EncoderException; +import hla.rti1516e.encoding.EncoderFactory; +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; + +public class HlaValueFlattener { + + private final FomCatalog catalog; + private final HLADecoderRegistry decoderRegistry; + private final EncoderFactory encoderFactory; + + public HlaValueFlattener( + FomCatalog catalog, + HLADecoderRegistry decoderRegistry, + EncoderFactory encoderFactory) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.decoderRegistry = Objects.requireNonNull(decoderRegistry, "decoderRegistry"); + this.encoderFactory = Objects.requireNonNull(encoderFactory, "encoderFactory"); + } + + public List flatten(String attributeName, String dataType, byte[] bytes) { + Objects.requireNonNull(attributeName, "attributeName"); + Objects.requireNonNull(dataType, "dataType"); + Objects.requireNonNull(bytes, "bytes"); + try { + DataElement element = createElement(dataType); + element.decode(bytes); + List values = new ArrayList<>(); + Object value = extractValue(attributeName, dataType, element, values); + String primitiveType = catalog.primitiveType(dataType); + if (primitiveType != null) { + return values; + } + values.add(0, new DecodedAttributeValue( + attributeName, + dataType, + primitiveType, + value, + bytes, + primitiveType != null)); + 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 = catalog.primitiveType(dataType); + if (primitiveType != null) { + Object value = decoderRegistry.decode(primitiveType, element.toByteArray()); + values.add(new DecodedAttributeValue( + pathKey, + dataType, + primitiveType, + value, + element.toByteArray(), + true)); + return value; + } + + FomCatalog.FixedRecordDef fixedRecord = catalog.fixedRecord(dataType).orElse(null); + if (fixedRecord != null) { + HLAfixedRecord record = (HLAfixedRecord) element; + Map fields = new LinkedHashMap<>(); + for (int i = 0; i < fixedRecord.fields().size(); i++) { + FomCatalog.FieldDef field = fixedRecord.fields().get(i); + String fieldPath = pathKey + "." + field.name(); + fields.put(field.name(), extractValue(fieldPath, field.dataType(), record.get(i), values)); + } + return fields; + } + + FomCatalog.ArrayDef array = catalog.array(dataType).orElse(null); + if (array != null && element instanceof HLAvariableArray variableArray) { + List arrayValues = new ArrayList<>(); + int index = 0; + for (DataElement child : variableArray) { + String childPath = pathKey + "[" + index + "]"; + arrayValues.add(extractValue(childPath, array.elementType(), child, values)); + index++; + } + return arrayValues; + } + + return null; + } + + private DataElement createElement(String dataType) { + String primitiveType = catalog.primitiveType(dataType); + if (primitiveType != null) { + return createPrimitiveElement(primitiveType); + } + + FomCatalog.FixedRecordDef fixedRecord = catalog.fixedRecord(dataType).orElse(null); + if (fixedRecord != null) { + HLAfixedRecord record = encoderFactory.createHLAfixedRecord(); + for (FomCatalog.FieldDef field : fixedRecord.fields()) { + record.add(createElement(field.dataType())); + } + return record; + } + + FomCatalog.ArrayDef array = catalog.array(dataType).orElse(null); + if (array != null) { + return encoderFactory.createHLAvariableArray(index -> createElement(array.elementType())); + } + + throw new IllegalArgumentException("Unsupported FOM data type " + dataType); + } + + private DataElement createPrimitiveElement(String primitiveType) { + return switch (primitiveType) { + case "HLAASCIIchar" -> encoderFactory.createHLAASCIIchar(); + case "HLAASCIIstring" -> encoderFactory.createHLAASCIIstring(); + case "HLAboolean" -> encoderFactory.createHLAboolean(); + case "HLAbyte" -> encoderFactory.createHLAbyte(); + case "HLAfloat32BE" -> encoderFactory.createHLAfloat32BE(); + case "HLAfloat32LE" -> encoderFactory.createHLAfloat32LE(); + case "HLAfloat64BE" -> encoderFactory.createHLAfloat64BE(); + case "HLAfloat64LE" -> encoderFactory.createHLAfloat64LE(); + case "HLAinteger16BE" -> encoderFactory.createHLAinteger16BE(); + case "HLAinteger16LE" -> encoderFactory.createHLAinteger16LE(); + case "HLAinteger32BE" -> encoderFactory.createHLAinteger32BE(); + case "HLAinteger32LE" -> encoderFactory.createHLAinteger32LE(); + case "HLAinteger64BE" -> encoderFactory.createHLAinteger64BE(); + case "HLAinteger64LE" -> encoderFactory.createHLAinteger64LE(); + case "HLAoctet" -> encoderFactory.createHLAoctet(); + case "HLAoctetPairBE" -> encoderFactory.createHLAoctetPairBE(); + case "HLAoctetPairLE" -> encoderFactory.createHLAoctetPairLE(); + case "HLAopaqueData" -> encoderFactory.createHLAopaqueData(); + case "HLAunicodeChar" -> encoderFactory.createHLAunicodeChar(); + case "HLAunicodeString" -> encoderFactory.createHLAunicodeString(); + default -> throw new IllegalArgumentException("Unsupported HLA primitive type " + primitiveType); + }; + } +} diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/config/InjectionHandler.java index ce34cc4..d1c44d0 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/InjectionHandler.java @@ -1,7 +1,11 @@ package com.yetanalytics.hlaxapi.config; -import com.yetanalytics.hlaxapi.config.model.Target; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yetanalytics.hlaxapi.cache.CacheQueryService; import com.yetanalytics.hlaxapi.config.model.Expression; +import com.yetanalytics.hlaxapi.config.model.Target; +import java.util.Optional; /** * Stubs for injection handlers. In the real app these will implement logic to @@ -9,16 +13,32 @@ */ public class InjectionHandler { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static volatile CacheQueryService queryService; + + public static void setQueryService(CacheQueryService cacheQueryService) { + queryService = cacheQueryService; + } + public static String handleThis(Target t) { - // placeholder: return a demo string showing the target - return "[THIS:" + t.toString() + "]"; + return null; } public static String handleQuery(String clazz, Target attrTarget, Expression criteria) { - // placeholder: return a demo string showing the criteria expression - return "[QUERY:" + clazz - + ":" + (attrTarget == null ? "null" : attrTarget.toString()) - + ":" + (criteria == null ? "null" : criteria.toString()) - + "]"; + CacheQueryService service = queryService; + if (service == null) { + return null; + } + Optional value = service.findFirstValue(clazz, attrTarget, criteria); + return value.map(InjectionHandler::toJson).orElse("null"); + } + + private static String toJson(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (JsonProcessingException e) { + return "null"; + } } } diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index c86669e..5ce6168 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -28,7 +28,7 @@ public class ConfigParserTest { private static final Logger logger = LogManager.getLogger(ConfigParserTest.class); - final static String CONFIG_STATEMENT_RESULT = "{\"actor\":{\"objectType\":\"Agent\",\"name\":[THIS:Target{[ScenarioName]}],\"account\":{\"homePage\":\"https://homepage.system.io\",\"name\":[\"Object\",\"Player\",[\"Name\"],[\"Number\",\"=\",0]]}},\"context\":{\"extensions\":{\"http://www.extensions.com/car-color\":[QUERY:Car:Target{[carColor]}:Criterion{Target{[carId]} = Value(4)}],\"http://www.extensions.com/nested-example\":[QUERY:Car:Target{[carColor]}:Criterion{Target{[carId]} = This(Target{[CarId]})}]}}}"; + final static String CONFIG_STATEMENT_RESULT = "{\"actor\":{\"objectType\":\"Agent\",\"name\":[\"this\",[\"ScenarioName\"]],\"account\":{\"homePage\":\"https://homepage.system.io\",\"name\":[\"Object\",\"Player\",[\"Name\"],[\"Number\",\"=\",0]]}},\"context\":{\"extensions\":{\"http://www.extensions.com/car-color\":[\"query\",\"Car\",[\"carColor\"],[[\"carId\"],\"=\",4]],\"http://www.extensions.com/nested-example\":[\"query\",\"Car\",[\"carColor\"],[[\"carId\"],\"=\",[\"this\",[\"CarId\"]]]]}}}"; @Test public void parsesConfigFile() throws IOException { 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..07b2400 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -0,0 +1,47 @@ +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 java.util.List; +import org.junit.jupiter.api.Test; + +class FomCatalogTest { + + @Test + void flattensPrimitiveAliasesEnumsAndFixedRecords() { + FomCatalog catalog = FomCatalog.fromFile("config/fom.xml"); + FomCatalog.ObjectClassDef car = catalog.objectClass("Car").orElseThrow(); + + assertTrue(car.attribute("FuelLevel").orElseThrow().leaf()); + assertEquals("HLAinteger32BE", car.attribute("FuelLevel").orElseThrow().primitiveType()); + assertEquals("HLAinteger32BE", car.attribute("FuelType").orElseThrow().primitiveType()); + + FomCatalog.FomAttribute position = car.attribute("Position").orElseThrow(); + assertFalse(position.leaf()); + assertEquals("PositionRec", position.dataType()); + assertEquals("HLAfloat64BE", car.attribute("Position.Lat").orElseThrow().primitiveType()); + assertEquals("HLAfloat64BE", car.attribute("Position.Long").orElseThrow().primitiveType()); + } + + @Test + void includesInheritedObjectAttributesAndArrayMetadata() { + FomCatalog catalog = FomCatalog.fromFile("src/test/resources/SISO-STD-025.3-2024.xml"); + + FomCatalog.ObjectClassDef network = catalog.objectClass("Network").orElseThrow(); + assertEquals("HLAASCIIstring", network.attribute("Name").orElseThrow().primitiveType()); + assertEquals("HLAASCIIstring", network.attribute("ObjectID.Value").orElseThrow().primitiveType()); + assertFalse(network.attribute("RelatedObjects").orElseThrow().leaf()); + + FomCatalog.ObjectClassDef networkLink = catalog.objectClass("NetworkLink").orElseThrow(); + assertEquals("HLAASCIIstring", networkLink.attribute("NetworkInterfaces[].Name").orElseThrow().primitiveType()); + } + + @Test + void convertsTargetsToPathKeys() { + assertEquals("Position.Lat", FomCatalog.targetPath(List.of("Position", "Lat"))); + assertEquals("TargetModifiers[0].Key", FomCatalog.targetPath(List.of("TargetModifiers", 0, "Key"))); + assertEquals("TargetModifiers[].Key", FomCatalog.wildcardArrayIndexes("TargetModifiers[12].Key")); + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java new file mode 100644 index 0000000..72ba3ac --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java @@ -0,0 +1,159 @@ +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.assertTrue; + +import com.yetanalytics.hlaxapi.HLADecoderRegistry; +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.Target; +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.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; + +class HlaObjectCacheTest { + + private final EncoderFactory encoderFactory = new HLA1516eEncoderFactory(); + private final FomCatalog catalog = FomCatalog.fromFile("config/fom.xml"); + private final HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(encoderFactory); + + @Test + void initializesSchemaAndSeedsFomMetadata() throws SQLException { + try (HlaObjectCache 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.Lat'") > 0); + } + } + + @Test + void upsertsLatestScalarObjectStateAndKeepsRawBytes() throws SQLException { + byte[] firstFuel = encoded(encoderFactory.createHLAinteger32BE(75)); + byte[] secondFuel = encoded(encoderFactory.createHLAinteger32BE(40)); + + try (HlaObjectCache cache = newCache()) { + cache.discoverObject("object-1", "Car One", "Car"); + cache.reflectAttributeValue("object-1", "Car", "FuelLevel", firstFuel); + cache.reflectAttributeValue("object-1", "Car", "FuelLevel", secondFuel); + + assertEquals(40, cache.findCurrentValue("object-1", "FuelLevel").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 = 'FuelLevel' + """)); + assertArrayEquals(secondFuel, rawBytes(cache, "FuelLevel")); + } + } + + @Test + void flattensFixedRecordValuesToNestedCurrentRows() { + byte[] position = position(39.75d, -104.99d); + + try (HlaObjectCache cache = newCache()) { + cache.discoverObject("object-1", "Car One", "Car"); + cache.reflectAttributeValue("object-1", "Car", "Position", position); + + assertEquals(39.75d, (Double) cache.findCurrentValue("object-1", "Position.Lat").orElseThrow().value()); + assertEquals(-104.99d, (Double) cache.findCurrentValue("object-1", "Position.Long").orElseThrow().value()); + assertTrue(cache.findCurrentValue("object-1", "Position").orElseThrow().value() instanceof java.util.Map); + } + } + + @Test + void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { + try (HlaObjectCache cache = newCache()) { + cache.discoverObject("object-1", "Car One", "Car"); + cache.reflectAttributeValue("object-1", "Car", "Name", encoded(encoderFactory.createHLAunicodeString( + "Car One"))); + cache.reflectAttributeValue("object-1", "Car", "FuelLevel", encoded(encoderFactory.createHLAinteger32BE(75))); + cache.reflectAttributeValue("object-1", "Car", "Position", position(39.75d, -104.99d)); + + cache.discoverObject("object-2", "Car Two", "Car"); + cache.reflectAttributeValue("object-2", "Car", "Name", encoded(encoderFactory.createHLAunicodeString( + "Car Two"))); + cache.reflectAttributeValue("object-2", "Car", "FuelLevel", encoded(encoderFactory.createHLAinteger32BE(20))); + cache.reflectAttributeValue("object-2", "Car", "Position", position(40.0d, -105.0d)); + + Criterion fuelCriteria = new Criterion( + new Target(List.of("FuelLevel")), + ComparisonOperator.GT, + new ValueExpression(50)); + Criterion latCriteria = new Criterion( + new Target(List.of("Position", "Lat")), + ComparisonOperator.LT, + new ValueExpression(40.0d)); + LogicalExpression criteria = new LogicalExpression(LogicalOperator.AND, List.of(fuelCriteria, latCriteria)); + + assertEquals( + List.of("Car One"), + cache.queryService().findValues("Car", new Target(List.of("Name")), fuelCriteria)); + assertEquals( + List.of(-104.99d), + cache.queryService().findValues("Car", new Target(List.of("Position", "Long")), criteria)); + + cache.removeObject("object-1"); + + assertFalse(cache.queryService().findFirstValue("Car", new Target(List.of("FuelLevel")), criteria) + .isPresent()); + } + } + + private HlaObjectCache newCache() { + return new HlaObjectCache("jdbc:sqlite::memory:", catalog, decoderRegistry, encoderFactory); + } + + private byte[] position(double lat, double lon) { + HLAfixedRecord record = encoderFactory.createHLAfixedRecord(); + record.add(encoderFactory.createHLAfloat64BE(lat)); + record.add(encoderFactory.createHLAfloat64BE(lon)); + 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(HlaObjectCache cache, String sql) throws SQLException { + return scalarLong(cache, sql); + } + + private long scalarLong(HlaObjectCache 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(HlaObjectCache 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; + } + } + } +} From 78b4838fd096138773e003409e5dfc145ed171c6 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Thu, 18 Jun 2026 12:20:00 -0400 Subject: [PATCH 02/26] ignore sqlite files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 09bf83e..40d9493 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .vscode/ target/ logs/ +*.sqlite From af3fab50eae39899d946c89fa0540d0f62481b2e Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 29 Jun 2026 17:03:50 -0400 Subject: [PATCH 03/26] use FOMXML --- .../hlaxapi/cache/FomCatalog.java | 272 ++++-------------- .../hlaxapi/cache/HlaObjectCache.java | 107 ++----- .../hlaxapi/cache/HlaValueFlattener.java | 116 +++----- .../cache/QueryReferenceCollector.java | 128 +++++++++ 4 files changed, 257 insertions(+), 366 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index bbe42d1..9e198ec 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -1,7 +1,6 @@ package com.yetanalytics.hlaxapi.cache; -import java.io.File; -import java.io.IOException; +import com.yetanalytics.hlaxapi.FOMXML; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -12,60 +11,23 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPathExpressionException; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; -import org.xml.sax.SAXException; /** * FOM-derived object metadata used by the SQLite cache. */ public final class FomCatalog { - private static final Set PRIMITIVE_TYPES = Set.of( - "HLAASCIIchar", - "HLAASCIIstring", - "HLAboolean", - "HLAbyte", - "HLAfloat32BE", - "HLAfloat32LE", - "HLAfloat64BE", - "HLAfloat64LE", - "HLAinteger16BE", - "HLAinteger16LE", - "HLAinteger32BE", - "HLAinteger32LE", - "HLAinteger64BE", - "HLAinteger64LE", - "HLAoctet", - "HLAoctetPairBE", - "HLAoctetPairLE", - "HLAopaqueData", - "HLAunicodeChar", - "HLAunicodeString"); - private final Map classesByName; private final Map classesById; - private final Map simpleRepresentations; - private final Map enumeratedRepresentations; - private final Map fixedRecords; - private final Map arrays; private final Map attributesById; - private FomCatalog( - Map classesByName, - Map simpleRepresentations, - Map enumeratedRepresentations, - Map fixedRecords, - Map arrays) { + private FomCatalog(Map classesByName) { this.classesByName = Collections.unmodifiableMap(classesByName); - this.simpleRepresentations = Collections.unmodifiableMap(simpleRepresentations); - this.enumeratedRepresentations = Collections.unmodifiableMap(enumeratedRepresentations); - this.fixedRecords = Collections.unmodifiableMap(fixedRecords); - this.arrays = Collections.unmodifiableMap(arrays); Map byId = new LinkedHashMap<>(); Map attrsById = new LinkedHashMap<>(); @@ -79,32 +41,17 @@ private FomCatalog( this.attributesById = Collections.unmodifiableMap(attrsById); } - public static FomCatalog fromFile(String fomPath) { - Objects.requireNonNull(fomPath, "fomPath"); - try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(false); - Document document = factory.newDocumentBuilder().parse(new File(fomPath)); - return fromDocument(document); - } catch (IOException | ParserConfigurationException | SAXException e) { - throw new IllegalArgumentException("Could not parse FOM XML: " + fomPath, e); - } - } - - static FomCatalog fromDocument(Document document) { - Map simpleTypes = parseRepresentations(document, "simpleData"); - Map enumTypes = parseRepresentations(document, "enumeratedData"); - Map fixedRecords = parseFixedRecords(document); - Map arrays = parseArrays(document); - - CatalogBuilder builder = new CatalogBuilder(simpleTypes, enumTypes, fixedRecords, arrays); + public static FomCatalog fromFomXml(FOMXML fomXml) { + Objects.requireNonNull(fomXml, "fomXml"); + CatalogBuilder builder = new CatalogBuilder(fomXml); + Document document = fomXml.getDoc(); Element objects = firstElement(document.getDocumentElement(), "objects"); if (objects != null) { for (Element objectClass : childElements(objects, "objectClass")) { builder.parseObjectClass(objectClass, null, new ArrayList<>()); } } - return new FomCatalog(builder.classesByName, simpleTypes, enumTypes, fixedRecords, arrays); + return new FomCatalog(builder.classesByName); } public Collection objectClasses() { @@ -131,57 +78,6 @@ public Optional attribute(String className, String pathKey) { return objectClass(className).flatMap(clazz -> clazz.attribute(pathKey)); } - public Map aliasesToPrimitiveTypes() { - Map aliases = new LinkedHashMap<>(); - aliases.putAll(simpleRepresentations); - aliases.putAll(enumeratedRepresentations); - return aliases; - } - - public TypeKind typeKind(String typeName) { - String localType = localName(typeName); - if (PRIMITIVE_TYPES.contains(localType)) { - return TypeKind.PRIMITIVE; - } - if (simpleRepresentations.containsKey(localType)) { - return TypeKind.SIMPLE; - } - if (enumeratedRepresentations.containsKey(localType)) { - return TypeKind.ENUMERATED; - } - if (fixedRecords.containsKey(localType)) { - return TypeKind.FIXED_RECORD; - } - if (arrays.containsKey(localType)) { - return TypeKind.ARRAY; - } - return TypeKind.UNKNOWN; - } - - public Optional fixedRecord(String typeName) { - return Optional.ofNullable(fixedRecords.get(localName(typeName))); - } - - public Optional array(String typeName) { - return Optional.ofNullable(arrays.get(localName(typeName))); - } - - public String primitiveType(String typeName) { - String localType = localName(typeName); - if (PRIMITIVE_TYPES.contains(localType)) { - return localType; - } - String simple = simpleRepresentations.get(localType); - if (simple != null) { - return primitiveType(simple); - } - String enumerated = enumeratedRepresentations.get(localType); - if (enumerated != null) { - return primitiveType(enumerated); - } - return null; - } - public static String targetPath(List targetParts) { if (targetParts == null || targetParts.isEmpty()) { return null; @@ -200,6 +96,13 @@ public static String targetPath(List targetParts) { 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; @@ -216,56 +119,6 @@ static String localName(String hlaName) { return index >= 0 ? trimmed.substring(index + 1) : trimmed; } - private static Map parseRepresentations(Document document, String elementName) { - Map representations = new LinkedHashMap<>(); - NodeList nodes = document.getElementsByTagName(elementName); - for (int i = 0; i < nodes.getLength(); i++) { - Element element = (Element) nodes.item(i); - String name = text(element, "name"); - String representation = text(element, "representation"); - if (name != null && representation != null) { - representations.put(name, representation); - } - } - return representations; - } - - private static Map parseFixedRecords(Document document) { - Map records = new LinkedHashMap<>(); - NodeList nodes = document.getElementsByTagName("fixedRecordData"); - for (int i = 0; i < nodes.getLength(); i++) { - Element element = (Element) nodes.item(i); - String name = text(element, "name"); - if (name == null) { - continue; - } - List fields = new ArrayList<>(); - for (Element field : childElements(element, "field")) { - String fieldName = text(field, "name"); - String fieldType = text(field, "dataType"); - if (fieldName != null && fieldType != null) { - fields.add(new FieldDef(fieldName, fieldType)); - } - } - records.put(name, new FixedRecordDef(name, fields)); - } - return records; - } - - private static Map parseArrays(Document document) { - Map arrayDefs = new LinkedHashMap<>(); - NodeList nodes = document.getElementsByTagName("arrayData"); - for (int i = 0; i < nodes.getLength(); i++) { - Element element = (Element) nodes.item(i); - String name = text(element, "name"); - String dataType = text(element, "dataType"); - if (name != null && dataType != null) { - arrayDefs.put(name, new ArrayDef(name, dataType)); - } - } - return arrayDefs; - } - private static String text(Element parent, String tagName) { Element element = firstElement(parent, tagName); if (element == null) { @@ -297,27 +150,6 @@ private static List childElements(Element parent, String tagName) { return elements; } - public enum TypeKind { - PRIMITIVE, - SIMPLE, - ENUMERATED, - FIXED_RECORD, - ARRAY, - UNKNOWN - } - - public record FieldDef(String name, String dataType) { - } - - public record FixedRecordDef(String name, List fields) { - public FixedRecordDef { - fields = List.copyOf(fields); - } - } - - public record ArrayDef(String name, String elementType) { - } - public record ObjectClassDef( int id, String hlaName, @@ -369,23 +201,13 @@ public record FomAttribute( private static final class CatalogBuilder { - private final Map simpleRepresentations; - private final Map enumeratedRepresentations; - private final Map fixedRecords; - private final Map arrays; + private final FOMXML fomXml; private final Map classesByName = new LinkedHashMap<>(); private int nextClassId = 1; private int nextAttributeId = 1; - private CatalogBuilder( - Map simpleRepresentations, - Map enumeratedRepresentations, - Map fixedRecords, - Map arrays) { - this.simpleRepresentations = simpleRepresentations; - this.enumeratedRepresentations = enumeratedRepresentations; - this.fixedRecords = fixedRecords; - this.arrays = arrays; + private CatalogBuilder(FOMXML fomXml) { + this.fomXml = fomXml; } private void parseObjectClass(Element element, String parentName, List inheritedAttributes) { @@ -425,9 +247,9 @@ private void flattenAttribute( String dataType, List attributes) { String primitive = primitiveType(dataType); - FixedRecordDef fixedRecord = fixedRecords.get(localName(dataType)); - ArrayDef array = arrays.get(localName(dataType)); - boolean leaf = primitive != null || fixedRecord == null && array == null; + List fields = fixedRecordFields(dataType); + String arrayElementType = arrayElementType(dataType); + boolean leaf = primitive != null || fields.isEmpty() && arrayElementType == null; attributes.add(new FomAttribute( nextAttributeId++, @@ -438,39 +260,49 @@ private void flattenAttribute( primitive, leaf)); - if (fixedRecord != null) { - for (FieldDef field : fixedRecord.fields()) { + if (!fields.isEmpty()) { + for (FOMXML.FixedRecordField field : fields) { flattenAttribute( classId, attributeName, - pathKey + "." + field.name(), - field.dataType(), + pathKey + "." + field.name, + field.dataType, attributes); } - } else if (array != null) { - flattenAttribute( - classId, - attributeName, - pathKey + "[]", - array.elementType(), - attributes); + } else if (arrayElementType != null) { + flattenAttribute(classId, attributeName, pathKey + "[]", arrayElementType, attributes); } } private String primitiveType(String dataType) { - String localType = localName(dataType); - if (PRIMITIVE_TYPES.contains(localType)) { - return localType; + try { + return fomXml.resolvePrimitiveType(dataType); + } catch (XPathExpressionException e) { + throw new IllegalArgumentException("Could not resolve primitive type for " + dataType, e); } - String simple = simpleRepresentations.get(localType); - if (simple != null) { - return primitiveType(simple); + } + + 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); } - String enumerated = enumeratedRepresentations.get(localType); - if (enumerated != null) { - return primitiveType(enumerated); + } + + 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); } - return null; } private record AttributeSource(String name, String dataType) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java index e4ddc5b..14c554f 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java @@ -2,15 +2,14 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.yetanalytics.hlaxapi.FOMXML; import com.yetanalytics.hlaxapi.HLADecoderRegistry; -import hla.rti1516e.encoding.EncoderFactory; 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.sql.Types; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -32,12 +31,12 @@ public class HlaObjectCache implements AutoCloseable { public HlaObjectCache( String jdbcUrl, FomCatalog catalog, - HLADecoderRegistry decoderRegistry, - EncoderFactory encoderFactory) { + FOMXML fomXml, + HLADecoderRegistry decoderRegistry) { try { this.connection = DriverManager.getConnection(Objects.requireNonNull(jdbcUrl, "jdbcUrl")); this.catalog = Objects.requireNonNull(catalog, "catalog"); - this.valueFlattener = new HlaValueFlattener(catalog, decoderRegistry, encoderFactory); + this.valueFlattener = new HlaValueFlattener(fomXml, decoderRegistry); this.queryService = new CacheQueryService(this); initializeSchema(); seedFomMetadata(); @@ -63,12 +62,12 @@ public CacheQueryService queryService() { return queryService; } - public void discoverObject(String objectHandle, String objectName, String className) { + public synchronized void discoverObject(String objectHandle, String objectName, String className) { FomCatalog.ObjectClassDef clazz = requireClass(className); ensureObject(objectHandle, objectName, clazz.localName()); } - public void removeObject(String objectHandle) { + public synchronized void removeObject(String objectHandle) { try (PreparedStatement statement = connection.prepareStatement( "UPDATE object_instance SET removed_at = ? WHERE object_handle = ?")) { statement.setString(1, Instant.now().toString()); @@ -79,7 +78,7 @@ public void removeObject(String objectHandle) { } } - public void reflectAttributeValue( + public synchronized void reflectAttributeValue( String objectHandle, String className, String attributeName, @@ -102,7 +101,7 @@ public void reflectAttributeValue( } } - public Optional findCurrentValue(long instanceId, String pathKey) { + public synchronized Optional findCurrentValue(long instanceId, String pathKey) { String normalizedPath = FomCatalog.wildcardArrayIndexes(pathKey); String sql = """ SELECT c.value_type, c.value_json, c.value_blob, c.raw_bytes @@ -128,7 +127,7 @@ public Optional findCurrentValue(long instanceId, String pathKey) { } } - public Optional findCurrentValue(String objectHandle, String pathKey) { + public synchronized Optional findCurrentValue(String objectHandle, String pathKey) { String sql = """ SELECT id FROM object_instance @@ -147,7 +146,7 @@ public Optional findCurrentValue(String objectHandle, String pathKe } } - public List currentObjects(String className) { + public synchronized List currentObjects(String className) { FomCatalog.ObjectClassDef clazz = requireClass(className); String sql = """ SELECT id, object_handle, object_name @@ -178,7 +177,7 @@ Connection connection() { } @Override - public void close() { + public synchronized void close() { try { connection.close(); } catch (SQLException e) { @@ -232,6 +231,11 @@ private CachedObject loadObject(String objectHandle, String className) throws SQ 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 ( @@ -268,9 +272,6 @@ 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_text TEXT, - value_num REAL, - value_bool INTEGER, value_blob BLOB, value_json TEXT, raw_bytes BLOB, @@ -282,12 +283,6 @@ 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("CREATE INDEX IF NOT EXISTS idx_current_text " - + "ON object_attribute_current(attribute_id, value_text)"); - statement.execute("CREATE INDEX IF NOT EXISTS idx_current_num " - + "ON object_attribute_current(attribute_id, value_num)"); - statement.execute("CREATE INDEX IF NOT EXISTS idx_current_bool " - + "ON object_attribute_current(attribute_id, value_bool)"); statement.execute("PRAGMA user_version = " + SCHEMA_VERSION); } } @@ -355,14 +350,10 @@ private void upsertCurrentValue( long observedSequence) { String sql = """ INSERT INTO object_attribute_current - (instance_id, attribute_id, value_type, value_text, value_num, value_bool, value_blob, - value_json, raw_bytes, observed_at, sequence) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (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_text = excluded.value_text, - value_num = excluded.value_num, - value_bool = excluded.value_bool, value_blob = excluded.value_blob, value_json = excluded.value_json, raw_bytes = excluded.raw_bytes, @@ -375,10 +366,16 @@ ON CONFLICT(instance_id, attribute_id) DO UPDATE SET statement.setLong(1, instanceId); statement.setInt(2, attributeId); statement.setString(3, valueType); - bindValueColumns(statement, 4, objectValue); - statement.setBytes(9, value.rawBytes()); - statement.setString(10, observedAt); - statement.setLong(11, observedSequence); + 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); @@ -432,53 +429,9 @@ private Optional attributeIdFromDatabase(int classId, String pathKey) { return Optional.empty(); } - private void bindValueColumns(PreparedStatement statement, int startIndex, Object value) throws SQLException { - setNullableString(statement, startIndex, value instanceof Character ? value.toString() : value); - setNullableNumber(statement, startIndex + 1, value); - setNullableBoolean(statement, startIndex + 2, value); - setNullableBlob(statement, startIndex + 3, value); - setNullableJson(statement, startIndex + 4, value); - } - - private void setNullableString(PreparedStatement statement, int index, Object value) throws SQLException { - if (value instanceof String stringValue) { - statement.setString(index, stringValue); - } else { - statement.setNull(index, Types.VARCHAR); - } - } - - private void setNullableNumber(PreparedStatement statement, int index, Object value) throws SQLException { - if (value instanceof Number number) { - statement.setDouble(index, number.doubleValue()); - } else { - statement.setNull(index, Types.DOUBLE); - } - } - - private void setNullableBoolean(PreparedStatement statement, int index, Object value) throws SQLException { - if (value instanceof Boolean booleanValue) { - statement.setInt(index, booleanValue ? 1 : 0); - } else { - statement.setNull(index, Types.INTEGER); - } - } - - private void setNullableBlob(PreparedStatement statement, int index, Object value) throws SQLException { - if (value instanceof byte[] bytes) { - statement.setBytes(index, bytes); - } else { - statement.setNull(index, Types.BLOB); - } - } - - private void setNullableJson(PreparedStatement statement, int index, Object value) throws SQLException { - if (value instanceof byte[]) { - statement.setNull(index, Types.VARCHAR); - return; - } + private String serializeJson(Object value) throws SQLException { try { - statement.setString(index, mapper.writeValueAsString(value)); + return mapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw new SQLException("Could not serialize cached value as JSON", e); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/HlaValueFlattener.java b/src/main/java/com/yetanalytics/hlaxapi/cache/HlaValueFlattener.java index 342874d..12061f2 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/HlaValueFlattener.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/HlaValueFlattener.java @@ -1,10 +1,10 @@ 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.EncoderFactory; import hla.rti1516e.encoding.HLAfixedRecord; import hla.rti1516e.encoding.HLAvariableArray; import java.util.ArrayList; @@ -12,20 +12,16 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import javax.xml.xpath.XPathExpressionException; public class HlaValueFlattener { - private final FomCatalog catalog; + private final FOMXML fomXml; private final HLADecoderRegistry decoderRegistry; - private final EncoderFactory encoderFactory; - public HlaValueFlattener( - FomCatalog catalog, - HLADecoderRegistry decoderRegistry, - EncoderFactory encoderFactory) { - this.catalog = Objects.requireNonNull(catalog, "catalog"); + public HlaValueFlattener(FOMXML fomXml, HLADecoderRegistry decoderRegistry) { + this.fomXml = Objects.requireNonNull(fomXml, "fomXml"); this.decoderRegistry = Objects.requireNonNull(decoderRegistry, "decoderRegistry"); - this.encoderFactory = Objects.requireNonNull(encoderFactory, "encoderFactory"); } public List flatten(String attributeName, String dataType, byte[] bytes) { @@ -33,21 +29,21 @@ public List flatten(String attributeName, String dataType Objects.requireNonNull(dataType, "dataType"); Objects.requireNonNull(bytes, "bytes"); try { - DataElement element = createElement(dataType); + DataElement element = fomXml.createDataElementForType(dataType); element.decode(bytes); List values = new ArrayList<>(); Object value = extractValue(attributeName, dataType, element, values); - String primitiveType = catalog.primitiveType(dataType); + String primitiveType = primitiveType(dataType); if (primitiveType != null) { return values; } values.add(0, new DecodedAttributeValue( attributeName, dataType, - primitiveType, + null, value, bytes, - primitiveType != null)); + false)); return values; } catch (DecoderException | EncoderException | IllegalArgumentException e) { return List.of(new DecodedAttributeValue(attributeName, dataType, null, null, bytes, false)); @@ -59,38 +55,38 @@ private Object extractValue( String dataType, DataElement element, List values) throws DecoderException, EncoderException { - String primitiveType = catalog.primitiveType(dataType); + String primitiveType = primitiveType(dataType); if (primitiveType != null) { - Object value = decoderRegistry.decode(primitiveType, element.toByteArray()); + byte[] elementBytes = element.toByteArray(); + Object value = decoderRegistry.decode(primitiveType, elementBytes); values.add(new DecodedAttributeValue( pathKey, dataType, primitiveType, value, - element.toByteArray(), + elementBytes, true)); return value; } - FomCatalog.FixedRecordDef fixedRecord = catalog.fixedRecord(dataType).orElse(null); - if (fixedRecord != null) { - HLAfixedRecord record = (HLAfixedRecord) element; - Map fields = new LinkedHashMap<>(); - for (int i = 0; i < fixedRecord.fields().size(); i++) { - FomCatalog.FieldDef field = fixedRecord.fields().get(i); - String fieldPath = pathKey + "." + field.name(); - fields.put(field.name(), extractValue(fieldPath, field.dataType(), record.get(i), values)); + 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 fields; + return fieldValues; } - FomCatalog.ArrayDef array = catalog.array(dataType).orElse(null); - if (array != null && element instanceof HLAvariableArray variableArray) { + 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, array.elementType(), child, values)); + arrayValues.add(extractValue(childPath, elementType, child, values)); index++; } return arrayValues; @@ -99,52 +95,34 @@ private Object extractValue( return null; } - private DataElement createElement(String dataType) { - String primitiveType = catalog.primitiveType(dataType); - if (primitiveType != null) { - return createPrimitiveElement(primitiveType); + private String primitiveType(String dataType) { + try { + return fomXml.resolvePrimitiveType(dataType); + } catch (XPathExpressionException e) { + throw new IllegalArgumentException("Could not resolve primitive type for " + dataType, e); } + } - FomCatalog.FixedRecordDef fixedRecord = catalog.fixedRecord(dataType).orElse(null); - if (fixedRecord != null) { - HLAfixedRecord record = encoderFactory.createHLAfixedRecord(); - for (FomCatalog.FieldDef field : fixedRecord.fields()) { - record.add(createElement(field.dataType())); + private List fixedRecordFields(String dataType) { + try { + if (!fomXml.isFixedRecordType(dataType)) { + return List.of(); } - return record; + return fomXml.getFixedRecordFields(dataType); + } catch (XPathExpressionException e) { + throw new IllegalArgumentException("Could not resolve fixed record fields for " + dataType, e); } - - FomCatalog.ArrayDef array = catalog.array(dataType).orElse(null); - if (array != null) { - return encoderFactory.createHLAvariableArray(index -> createElement(array.elementType())); - } - - throw new IllegalArgumentException("Unsupported FOM data type " + dataType); } - private DataElement createPrimitiveElement(String primitiveType) { - return switch (primitiveType) { - case "HLAASCIIchar" -> encoderFactory.createHLAASCIIchar(); - case "HLAASCIIstring" -> encoderFactory.createHLAASCIIstring(); - case "HLAboolean" -> encoderFactory.createHLAboolean(); - case "HLAbyte" -> encoderFactory.createHLAbyte(); - case "HLAfloat32BE" -> encoderFactory.createHLAfloat32BE(); - case "HLAfloat32LE" -> encoderFactory.createHLAfloat32LE(); - case "HLAfloat64BE" -> encoderFactory.createHLAfloat64BE(); - case "HLAfloat64LE" -> encoderFactory.createHLAfloat64LE(); - case "HLAinteger16BE" -> encoderFactory.createHLAinteger16BE(); - case "HLAinteger16LE" -> encoderFactory.createHLAinteger16LE(); - case "HLAinteger32BE" -> encoderFactory.createHLAinteger32BE(); - case "HLAinteger32LE" -> encoderFactory.createHLAinteger32LE(); - case "HLAinteger64BE" -> encoderFactory.createHLAinteger64BE(); - case "HLAinteger64LE" -> encoderFactory.createHLAinteger64LE(); - case "HLAoctet" -> encoderFactory.createHLAoctet(); - case "HLAoctetPairBE" -> encoderFactory.createHLAoctetPairBE(); - case "HLAoctetPairLE" -> encoderFactory.createHLAoctetPairLE(); - case "HLAopaqueData" -> encoderFactory.createHLAopaqueData(); - case "HLAunicodeChar" -> encoderFactory.createHLAunicodeChar(); - case "HLAunicodeString" -> encoderFactory.createHLAunicodeString(); - default -> throw new IllegalArgumentException("Unsupported HLA primitive type " + primitiveType); - }; + 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/QueryReferenceCollector.java b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java new file mode 100644 index 0000000..5acf39b --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java @@ -0,0 +1,128 @@ +package com.yetanalytics.hlaxapi.cache; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yetanalytics.hlaxapi.config.ConfigConverter; +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 java.io.IOException; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class QueryReferenceCollector { + + private static final Pattern INLINE_PLACEHOLDER = Pattern.compile("<<(.+?)>>", Pattern.DOTALL); + 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; + } + try { + collectFromNode(MAPPER.readTree(trigger.statement), references); + } catch (IOException ignored) { + // Bad statement JSON is handled by TriggerProcessor at runtime. + } + } + return copyReferences(references); + } + + private static void collectFromNode(JsonNode node, Map> references) throws IOException { + if (node == null || node.isNull()) { + return; + } + if (node.isObject()) { + for (JsonNode child : node) { + collectFromNode(child, references); + } + return; + } + if (node.isArray()) { + if (isQueryInjection(node)) { + collectQuery(node, references); + return; + } + for (JsonNode child : node) { + collectFromNode(child, references); + } + return; + } + if (node.isTextual()) { + Matcher matcher = INLINE_PLACEHOLDER.matcher(node.asText()); + while (matcher.find()) { + JsonNode inner = MAPPER.readTree(matcher.group(1)); + if (isQueryInjection(inner)) { + collectQuery(inner, references); + } + } + } + } + + private static boolean isQueryInjection(JsonNode node) { + return node != null + && node.isArray() + && node.size() >= 4 + && node.get(0).isTextual() + && "query".equalsIgnoreCase(node.get(0).asText()); + } + + private static void collectQuery(JsonNode queryNode, Map> references) { + String className = queryNode.get(1).asText(null); + if (className == null || className.isBlank()) { + return; + } + + Target target = ConfigConverter.toTarget(MAPPER.convertValue(queryNode.get(2), Object.class)); + addTarget(references, className, target); + + Object criteriaRaw = MAPPER.convertValue(queryNode.get(3), Object.class); + collectCriteriaTargets(references, className, ConfigConverter.toExpression(criteriaRaw)); + } + + 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); + } +} From 00ee4df8876ea425bac815cfb07d6f82ecd8555d Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 29 Jun 2026 17:04:46 -0400 Subject: [PATCH 04/26] whoops also the tests --- .../hlaxapi/cache/FomCatalogTest.java | 14 +++++- .../hlaxapi/cache/HlaObjectCacheTest.java | 35 ++++++++++++++- .../cache/QueryReferenceCollectorTest.java | 45 +++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index 07b2400..30bf7ac 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -4,14 +4,18 @@ 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 = FomCatalog.fromFile("config/fom.xml"); + FomCatalog catalog = catalog("config/fom.xml"); FomCatalog.ObjectClassDef car = catalog.objectClass("Car").orElseThrow(); assertTrue(car.attribute("FuelLevel").orElseThrow().leaf()); @@ -27,7 +31,7 @@ void flattensPrimitiveAliasesEnumsAndFixedRecords() { @Test void includesInheritedObjectAttributesAndArrayMetadata() { - FomCatalog catalog = FomCatalog.fromFile("src/test/resources/SISO-STD-025.3-2024.xml"); + FomCatalog catalog = catalog("src/test/resources/SISO-STD-025.3-2024.xml"); FomCatalog.ObjectClassDef network = catalog.objectClass("Network").orElseThrow(); assertEquals("HLAASCIIstring", network.attribute("Name").orElseThrow().primitiveType()); @@ -44,4 +48,10 @@ void convertsTargetsToPathKeys() { assertEquals("TargetModifiers[0].Key", FomCatalog.targetPath(List.of("TargetModifiers", 0, "Key"))); assertEquals("TargetModifiers[].Key", FomCatalog.wildcardArrayIndexes("TargetModifiers[12].Key")); } + + private FomCatalog catalog(String fomPath) { + SimulationConfig simConfig = new SimulationConfig(null, null, null, null, fomPath); + HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); + return FomCatalog.fromFomXml(new FOMXML(simConfig, decoderRegistry)); + } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java index 72ba3ac..7e0a07a 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java @@ -5,7 +5,9 @@ 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.model.ComparisonOperator; import com.yetanalytics.hlaxapi.config.model.Criterion; import com.yetanalytics.hlaxapi.config.model.LogicalExpression; @@ -16,18 +18,23 @@ 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; class HlaObjectCacheTest { private final EncoderFactory encoderFactory = new HLA1516eEncoderFactory(); - private final FomCatalog catalog = FomCatalog.fromFile("config/fom.xml"); private final HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(encoderFactory); + private final FOMXML fomXml = new FOMXML( + new SimulationConfig(null, null, null, null, "config/fom.xml"), + decoderRegistry); + private final FomCatalog catalog = FomCatalog.fromFomXml(fomXml); @Test void initializesSchemaAndSeedsFomMetadata() throws SQLException { @@ -112,8 +119,32 @@ void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { } } + @Test + void persistentCacheStartsFreshOnInitialization(@TempDir Path tempDir) throws SQLException { + String jdbcUrl = "jdbc:sqlite:" + tempDir.resolve("cache.sqlite"); + + try (HlaObjectCache cache = newCache(jdbcUrl)) { + cache.discoverObject("object-1", "Car One", "Car"); + cache.reflectAttributeValue("object-1", "Car", "FuelLevel", + encoded(encoderFactory.createHLAinteger32BE(75))); + + assertEquals(1, count(cache, "SELECT COUNT(*) FROM object_instance")); + assertEquals(1, count(cache, "SELECT COUNT(*) FROM object_attribute_current")); + } + + try (HlaObjectCache 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 HlaObjectCache newCache() { - return new HlaObjectCache("jdbc:sqlite::memory:", catalog, decoderRegistry, encoderFactory); + return newCache("jdbc:sqlite::memory:"); + } + + private HlaObjectCache newCache(String jdbcUrl) { + return new HlaObjectCache(jdbcUrl, catalog, fomXml, decoderRegistry); } private byte[] position(double lat, double lon) { 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..9febfc6 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java @@ -0,0 +1,45 @@ +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 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","Car",["Name"],[["FuelLevel"],">",50]]}} + """); + StatementTrigger inline = trigger(""" + {"result":{"response":"at=<<[\\"query\\",\\"Car\\",[\\"Position\\",\\"Long\\"],[[\\"Position\\",\\"Lat\\"],\\"<\\",40.0]]>>"}} + """); + + Map> references = QueryReferenceCollector.collect(List.of(wholeNode, inline)); + + assertEquals(Set.of("Name", "FuelLevel", "Position"), references.get("Car")); + } + + @Test + void ignoresThisExpressionTargetsInsideQueryCriteria() { + StatementTrigger trigger = trigger(""" + {"actor":{"name":["query","Car",["Name"],[["FuelLevel"],">",["this",["DesiredFuel"]]]]}} + """); + + Map> references = QueryReferenceCollector.collect(List.of(trigger)); + + assertEquals(Set.of("Name", "FuelLevel"), references.get("Car")); + assertFalse(references.get("Car").contains("DesiredFuel")); + } + + private StatementTrigger trigger(String statement) { + StatementTrigger trigger = new StatementTrigger(); + trigger.statement = statement; + return trigger; + } +} From 8f94b74d248a1b646a1fea3672a2df6f8b6c7481 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 29 Jun 2026 17:05:19 -0400 Subject: [PATCH 05/26] wire up --- .../hlaxapi/HlaInterfaceImpl.java | 80 +++++++++++++------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index 2bfe972..9505aee 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -3,8 +3,10 @@ import java.io.File; import java.net.MalformedURLException; import java.net.URL; +import java.util.Collections; 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,6 +14,7 @@ import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.HlaObjectCache; +import com.yetanalytics.hlaxapi.cache.QueryReferenceCollector; import com.yetanalytics.hlaxapi.config.XapiConfig; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; @@ -33,7 +36,6 @@ import hla.rti1516e.RtiFactory; import hla.rti1516e.RtiFactoryFactory; import hla.rti1516e.TransportationTypeHandle; -import hla.rti1516e.encoding.EncoderFactory; import hla.rti1516e.exceptions.AlreadyConnected; import hla.rti1516e.exceptions.AttributeNotDefined; import hla.rti1516e.exceptions.CallNotAllowedFromWithinCallback; @@ -86,7 +88,7 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter private SimulationConfig simulationConfig; @Autowired - private EncoderFactory encoderFactory; + private FOMXML fomXml; @Autowired private HLADecoderRegistry decoderRegistry; @@ -101,6 +103,8 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter private HlaObjectCache objectCache; + private Map> objectCacheSubscriptions = Collections.emptyMap(); + public void start() throws ConnectionFailed, InvalidLocalSettingsDesignator, RTIinternalError, NotConnected, ErrorReadingFDD, CouldNotOpenFDD, InconsistentFDD, RestoreInProgress, SaveInProgress, @@ -108,17 +112,15 @@ public void start() RtiFactory rtiFactory = RtiFactoryFactory.getRtiFactory(); ambassador = rtiFactory.getRtiAmbassador(); - if (!decoderRegistry.supports("ScaleFactorFloat32")) { - decoderRegistry.registerAlias("ScaleFactorFloat32", "HLAfloat32BE"); + fomCatalog = FomCatalog.fromFomXml(fomXml); + objectCacheSubscriptions = QueryReferenceCollector.collect(xapiConfig.statementTriggers); + if (objectCacheSubscriptions.isEmpty()) { + injectionHandler.setQueryService(null); + logger.info("No query injections configured; object cache is disabled"); + } else { + objectCache = new HlaObjectCache(HlaObjectCache.defaultJdbcUrl(), fomCatalog, fomXml, decoderRegistry); + injectionHandler.setQueryService(objectCache.queryService()); } - fomCatalog = FomCatalog.fromFile(simulationConfig.getFom()); - fomCatalog.aliasesToPrimitiveTypes().forEach((alias, primitiveType) -> { - if (!decoderRegistry.supports(alias)) { - decoderRegistry.registerAlias(alias, primitiveType); - } - }); - objectCache = new HlaObjectCache(HlaObjectCache.defaultJdbcUrl(), fomCatalog, decoderRegistry, encoderFactory); - injectionHandler.setQueryService(objectCache.queryService()); try { if (simulationConfig.getLocalSettingsDesignator() == null @@ -192,8 +194,10 @@ public void stop() throws RTIinternalError { } catch (FederateIsExecutionMember | CallNotAllowedFromWithinCallback e) { throw new RTIinternalError("HlaInterfaceFailure", e); } + injectionHandler.setQueryService(null); if (objectCache != null) { objectCache.close(); + objectCache = null; } } catch (NotConnected ignored) { } @@ -210,19 +214,25 @@ public void connectionLost(String faultDescription) throws FederateInternalError private void subscribeObjectClasses() throws FederateNotExecutionMember, RestoreInProgress, SaveInProgress, NotConnected, RTIinternalError { - for (FomCatalog.ObjectClassDef clazz : fomCatalog.objectClasses()) { - if (clazz.topLevelAttributeNames().isEmpty()) { - continue; - } + if (objectCacheSubscriptions.isEmpty()) { + return; + } + for (Map.Entry> subscription : objectCacheSubscriptions.entrySet()) { try { + FomCatalog.ObjectClassDef clazz = fomCatalog.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 : clazz.topLevelAttributeNames()) { + for (String attributeName : subscription.getValue()) { attributeHandles.add(ambassador.getAttributeHandle(classHandle, attributeName)); } + if (attributeHandles.isEmpty()) { + continue; + } ambassador.subscribeObjectClassAttributes(classHandle, attributeHandles); - } catch (AttributeNotDefined | InvalidObjectClassHandle | NameNotFound | ObjectClassNotDefined e) { - logger.error("Could not subscribe object class {}!", clazz.localName(), e); + } catch (AttributeNotDefined | InvalidObjectClassHandle | NameNotFound | ObjectClassNotDefined + | IllegalArgumentException e) { + logger.error("Could not subscribe object class {}!", subscription.getKey(), e); } } } @@ -241,11 +251,15 @@ public void discoverObjectInstance( ObjectClassHandle theObjectClass, String objectName, hla.rti1516e.FederateHandle producingFederate) throws FederateInternalError { + if (objectCache == null) { + 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 e) { + } catch (InvalidObjectClassHandle | FederateNotExecutionMember | NotConnected | RTIinternalError + | RuntimeException e) { logger.error("Error caching discovered object {}", objectName, e); } } @@ -289,6 +303,9 @@ public void reflectAttributeValues( } private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHandleValueMap theAttributes) { + if (objectCache == null) { + return; + } try { ObjectClassHandle classHandle = ambassador.getKnownObjectClassHandle(theObject); String className = StringUtils.substringAfterLast(ambassador.getObjectClassName(classHandle), "."); @@ -301,7 +318,7 @@ private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHan theAttributes.get(attributeHandle)); } } catch (AttributeNotDefined | InvalidAttributeHandle | InvalidObjectClassHandle | ObjectInstanceNotKnown - | FederateNotExecutionMember | NotConnected | RTIinternalError e) { + | FederateNotExecutionMember | NotConnected | RTIinternalError | RuntimeException e) { logger.error("Error caching reflected object attributes", e); } } @@ -312,7 +329,7 @@ public void removeObjectInstance( byte[] userSuppliedTag, OrderType sentOrdering, SupplementalRemoveInfo removeInfo) throws FederateInternalError { - objectCache.removeObject(theObject.toString()); + removeCachedObject(theObject); } @Override @@ -323,7 +340,7 @@ public void removeObjectInstance( LogicalTime theTime, OrderType receivedOrdering, SupplementalRemoveInfo removeInfo) throws FederateInternalError { - objectCache.removeObject(theObject.toString()); + removeCachedObject(theObject); } @Override @@ -335,7 +352,18 @@ public void removeObjectInstance( OrderType receivedOrdering, MessageRetractionHandle retractionHandle, SupplementalRemoveInfo removeInfo) throws FederateInternalError { - objectCache.removeObject(theObject.toString()); + removeCachedObject(theObject); + } + + private void removeCachedObject(ObjectInstanceHandle theObject) { + if (objectCache == null) { + return; + } + try { + objectCache.removeObject(theObject.toString()); + } catch (RuntimeException e) { + logger.error("Error removing cached object {}", theObject, e); + } } /* @@ -348,7 +376,9 @@ private void subscribeInteractions() if (xapiConfig.statementTriggers == null) { return; } - xapiConfig.statementTriggers.forEach(trigger -> { + xapiConfig.statementTriggers.stream() + .filter(trigger -> trigger.type == StatementTrigger.Type.INTERACTION) + .forEach(trigger -> { try { InteractionClassHandle handle = ambassador.getInteractionClassHandle(trigger.clazz); ambassador.subscribeInteractionClass(handle); From 8840f0baeecd8b71a8dd617b0227d46380f0d934 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Mon, 29 Jun 2026 17:07:11 -0400 Subject: [PATCH 06/26] prevent typey loopey --- .../java/com/yetanalytics/hlaxapi/FOMXML.java | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java b/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java index e048784..f79c8cd 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java +++ b/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java @@ -25,7 +25,9 @@ import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; @Component public class FOMXML { @@ -207,23 +209,33 @@ private PathCheckResult checkParameterPath(String entityName, boolean isInteract * */ public String getRawType(String dataTypeName) throws XPathExpressionException{ + return resolvePrimitiveType(dataTypeName); + } + + public String resolvePrimitiveType(String dataTypeName) throws XPathExpressionException { + return resolvePrimitiveType(dataTypeName, new HashSet<>()); + } + private String resolvePrimitiveType(String dataTypeName, Set 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; } From a21f97974ae38893f929dfac0f8abea8881df49e Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 30 Jun 2026 13:06:16 -0400 Subject: [PATCH 07/26] add a cache-backed query to the xapi config --- config/xapi-config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/xapi-config.json b/config/xapi-config.json index 609a55e..b3f2e85 100644 --- a/config/xapi-config.json +++ b/config/xapi-config.json @@ -9,8 +9,8 @@ "objectType": "Agent", "name": ["this", ["EntityId"]], "account": { - "homePage": "https://homepage.system.io", - "name": ["this", ["EntityId"]] + "homePage": "https://hla-federepl.example/entities", + "name": ["query", "SimEntity", ["EntityId"], [["EntityId"], "=", ["this", ["EntityId"]]]] } }, "context": { From a3d3c1da497efbe44c623543ac240f1587de93e1 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 1 Jul 2026 15:11:39 -0400 Subject: [PATCH 08/26] DI wrapping ObjectCache for SQL object cache --- .../com/yetanalytics/hlaxapi/AppConfig.java | 9 ++ .../hlaxapi/HlaInterfaceImpl.java | 43 ++------ .../hlaxapi/InjectionHandler.java | 21 ++-- .../hlaxapi/cache/ObjectCache.java | 74 +++++++++++++ .../hlaxapi/ObjectCacheSpringContextTest.java | 58 ++++++++++ .../hlaxapi/cache/ObjectCacheTest.java | 103 ++++++++++++++++++ 6 files changed, 267 insertions(+), 41 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java b/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java index 891ddc5..dceb6d6 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java +++ b/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java @@ -11,6 +11,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import com.yetanalytics.hlaxapi.cache.ObjectCache; import com.yetanalytics.hlaxapi.config.ConfigParser; import com.yetanalytics.hlaxapi.config.XapiConfig; @@ -40,6 +41,14 @@ public HLADecoderRegistry hlaDecoderRegistry(EncoderFactory encoderFactory) { return new HLADecoderRegistry(encoderFactory); } + @Bean(destroyMethod = "close") + public ObjectCache objectCache( + XapiConfig xapiConfig, + FOMXML fomXml, + HLADecoderRegistry decoderRegistry) { + return new ObjectCache(xapiConfig, fomXml, decoderRegistry); + } + @Bean public XapiConfig xapiConfig() { XapiConfig xapiConfig; diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index 9505aee..6da5fce 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -3,7 +3,6 @@ import java.io.File; import java.net.MalformedURLException; import java.net.URL; -import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -13,8 +12,7 @@ import org.apache.logging.log4j.Logger; import com.yetanalytics.hlaxapi.cache.FomCatalog; -import com.yetanalytics.hlaxapi.cache.HlaObjectCache; -import com.yetanalytics.hlaxapi.cache.QueryReferenceCollector; +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; @@ -87,23 +85,11 @@ public class HlaInterfaceImpl extends NullFederateAmbassador implements HlaInter @Autowired private SimulationConfig simulationConfig; - @Autowired - private FOMXML fomXml; - - @Autowired - private HLADecoderRegistry decoderRegistry; - @Autowired private TriggerProcessor triggerProcessor; @Autowired - private InjectionHandler injectionHandler; - - private FomCatalog fomCatalog; - - private HlaObjectCache objectCache; - - private Map> objectCacheSubscriptions = Collections.emptyMap(); + private ObjectCache objectCache; public void start() throws ConnectionFailed, InvalidLocalSettingsDesignator, RTIinternalError, NotConnected, ErrorReadingFDD, @@ -112,14 +98,8 @@ public void start() RtiFactory rtiFactory = RtiFactoryFactory.getRtiFactory(); ambassador = rtiFactory.getRtiAmbassador(); - fomCatalog = FomCatalog.fromFomXml(fomXml); - objectCacheSubscriptions = QueryReferenceCollector.collect(xapiConfig.statementTriggers); - if (objectCacheSubscriptions.isEmpty()) { - injectionHandler.setQueryService(null); + if (!objectCache.isEnabled()) { logger.info("No query injections configured; object cache is disabled"); - } else { - objectCache = new HlaObjectCache(HlaObjectCache.defaultJdbcUrl(), fomCatalog, fomXml, decoderRegistry); - injectionHandler.setQueryService(objectCache.queryService()); } try { @@ -194,11 +174,6 @@ public void stop() throws RTIinternalError { } catch (FederateIsExecutionMember | CallNotAllowedFromWithinCallback e) { throw new RTIinternalError("HlaInterfaceFailure", e); } - injectionHandler.setQueryService(null); - if (objectCache != null) { - objectCache.close(); - objectCache = null; - } } catch (NotConnected ignored) { } } @@ -214,12 +189,12 @@ public void connectionLost(String faultDescription) throws FederateInternalError private void subscribeObjectClasses() throws FederateNotExecutionMember, RestoreInProgress, SaveInProgress, NotConnected, RTIinternalError { - if (objectCacheSubscriptions.isEmpty()) { + if (!objectCache.isEnabled()) { return; } - for (Map.Entry> subscription : objectCacheSubscriptions.entrySet()) { + for (Map.Entry> subscription : objectCache.subscriptions().entrySet()) { try { - FomCatalog.ObjectClassDef clazz = fomCatalog.objectClass(subscription.getKey()).orElseThrow( + 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(); @@ -251,7 +226,7 @@ public void discoverObjectInstance( ObjectClassHandle theObjectClass, String objectName, hla.rti1516e.FederateHandle producingFederate) throws FederateInternalError { - if (objectCache == null) { + if (!objectCache.isEnabled()) { return; } try { @@ -303,7 +278,7 @@ public void reflectAttributeValues( } private void reflectAttributeValues(ObjectInstanceHandle theObject, AttributeHandleValueMap theAttributes) { - if (objectCache == null) { + if (!objectCache.isEnabled()) { return; } try { @@ -356,7 +331,7 @@ public void removeObjectInstance( } private void removeCachedObject(ObjectInstanceHandle theObject) { - if (objectCache == null) { + if (!objectCache.isEnabled()) { return; } try { diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 53fbd16..9963233 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -12,7 +12,7 @@ import org.springframework.stereotype.Component; import com.yetanalytics.hlaxapi.FOMXML.PathCheckResult; -import com.yetanalytics.hlaxapi.cache.CacheQueryService; +import com.yetanalytics.hlaxapi.cache.ObjectCache; import com.yetanalytics.hlaxapi.config.model.Criterion; import com.yetanalytics.hlaxapi.config.model.Expression; import com.yetanalytics.hlaxapi.config.model.LogicalExpression; @@ -36,7 +36,7 @@ public class InjectionHandler { private static final Logger logger = LogManager.getLogger(InjectionHandler.class); - private volatile CacheQueryService queryService; + private ObjectCache objectCache; @Autowired private FOMXML fomXml; @@ -44,8 +44,12 @@ public class InjectionHandler { @Autowired private HLADecoderRegistry hlaDecoderRegistry; - public void setQueryService(CacheQueryService cacheQueryService) { - this.queryService = cacheQueryService; + public InjectionHandler() { + } + + @Autowired + public InjectionHandler(ObjectCache objectCache) { + this.objectCache = objectCache; } public Object handleThis(Target t, InjectionContext context) { @@ -225,13 +229,12 @@ public Object handleQuery(String clazz, Target attrTarget, Expression criteria) } public Object handleQuery(String clazz, Target attrTarget, Expression criteria, InjectionContext context) { - CacheQueryService service = queryService; - if (service == null) { + if (objectCache == null) { return null; } Expression resolvedCriteria = resolveThisExpressions(criteria, context); - Optional value = service.findFirstValue(clazz, attrTarget, resolvedCriteria); + Optional value = objectCache.findFirstValue(clazz, attrTarget, resolvedCriteria); return value.orElse(null); } @@ -266,4 +269,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/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java new file mode 100644 index 0000000..654cf52 --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -0,0 +1,74 @@ +package com.yetanalytics.hlaxapi.cache; + +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 java.util.Map; +import java.util.Optional; +import java.util.Set; + +public class ObjectCache implements AutoCloseable { + + private final FomCatalog catalog; + private final Map> subscriptions; + private HlaObjectCache delegate; + + public ObjectCache(XapiConfig xapiConfig, FOMXML fomXml, HLADecoderRegistry decoderRegistry) { + this(xapiConfig, fomXml, decoderRegistry, HlaObjectCache.defaultJdbcUrl()); + } + + ObjectCache(XapiConfig xapiConfig, FOMXML fomXml, HLADecoderRegistry decoderRegistry, String jdbcUrl) { + this.catalog = FomCatalog.fromFomXml(fomXml); + this.subscriptions = QueryReferenceCollector.collect(xapiConfig.statementTriggers); + if (!subscriptions.isEmpty()) { + this.delegate = new HlaObjectCache(jdbcUrl, catalog, fomXml, decoderRegistry); + } + } + + public boolean isEnabled() { + return delegate != null; + } + + public Map> subscriptions() { + return subscriptions; + } + + public FomCatalog catalog() { + return catalog; + } + + public Optional findFirstValue(String clazz, Target attrTarget, Expression criteria) { + if (delegate == null) { + return Optional.empty(); + } + return delegate.queryService().findFirstValue(clazz, attrTarget, criteria); + } + + public void discoverObject(String objectHandle, String objectName, String className) { + if (delegate != null) { + delegate.discoverObject(objectHandle, objectName, className); + } + } + + public void reflectAttributeValue(String objectHandle, String className, String attributeName, byte[] bytes) { + if (delegate != null) { + delegate.reflectAttributeValue(objectHandle, className, attributeName, bytes); + } + } + + public void removeObject(String objectHandle) { + if (delegate != null) { + delegate.removeObject(objectHandle); + } + } + + @Override + public synchronized void close() { + if (delegate != null) { + delegate.close(); + delegate = null; + } + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java new file mode 100644 index 0000000..0681a7e --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java @@ -0,0 +1,58 @@ +package com.yetanalytics.hlaxapi; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import com.yetanalytics.hlaxapi.cache.ObjectCache; +import com.yetanalytics.hlaxapi.config.XapiConfig; +import hla.rti1516e.encoding.EncoderFactory; +import org.junit.jupiter.api.Test; +import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +class ObjectCacheSpringContextTest { + + @Test + void springWiresObjectCacheIntoConsumers() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.register( + TestConfig.class, + FOMXML.class, + HlaInterfaceImpl.class, + InjectionHandler.class, + TriggerProcessor.class); + context.refresh(); + + ObjectCache objectCache = context.getBean(ObjectCache.class); + InjectionHandler injectionHandler = context.getBean(InjectionHandler.class); + HlaInterfaceImpl hlaInterface = context.getBean(HlaInterfaceImpl.class); + + assertNotNull(hlaInterface); + assertSame(objectCache, injectionHandler.objectCache()); + } + } + + @Configuration + static class TestConfig extends AppConfig { + + @Override + @Bean + public EncoderFactory encoderFactory() { + return new HLA1516eEncoderFactory(); + } + + @Override + @Bean + public XapiConfig xapiConfig() { + return new XapiConfig(); + } + + @Override + @Bean + public SimulationConfig simulationConfig() { + return new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"); + } + } +} 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..551b5aa --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -0,0 +1,103 @@ +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.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.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); + + @Test + void disabledWhenNoQueryInjectionsAndDoesNotOpenSqlite(@TempDir Path tempDir) { + Path databasePath = tempDir.resolve("disabled.sqlite"); + + try (ObjectCache cache = new ObjectCache( + new XapiConfig(), + 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(), + 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)); + } + } + + 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 byte[] encoded(DataElement element) { + try { + return element.toByteArray(); + } catch (EncoderException e) { + throw new IllegalStateException("Could not encode test value", e); + } + } +} From 84cb59d9342afedc28ace91cab00f9ee7622e9a5 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 1 Jul 2026 16:09:27 -0400 Subject: [PATCH 09/26] remove one-arg constructor --- src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 9963233..f30bc4e 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -47,11 +47,6 @@ public class InjectionHandler { public InjectionHandler() { } - @Autowired - public InjectionHandler(ObjectCache objectCache) { - this.objectCache = objectCache; - } - public Object handleThis(Target t, InjectionContext context) { if (context instanceof InteractionInjectionContext) { return handleThis(t, (InteractionInjectionContext) context); From dea23036c9019c45073b0cb441d26eb6752bd214 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 1 Jul 2026 16:10:11 -0400 Subject: [PATCH 10/26] FomCatalog spring bean --- .../java/com/yetanalytics/hlaxapi/AppConfig.java | 9 ++++++++- .../com/yetanalytics/hlaxapi/InjectionHandler.java | 1 + .../com/yetanalytics/hlaxapi/cache/ObjectCache.java | 13 +++++++++---- .../hlaxapi/ObjectCacheSpringContextTest.java | 3 +++ .../yetanalytics/hlaxapi/cache/ObjectCacheTest.java | 3 +++ 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java b/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java index dceb6d6..42ef24d 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java +++ b/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java @@ -11,6 +11,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.ObjectCache; import com.yetanalytics.hlaxapi.config.ConfigParser; import com.yetanalytics.hlaxapi.config.XapiConfig; @@ -41,12 +42,18 @@ public HLADecoderRegistry hlaDecoderRegistry(EncoderFactory encoderFactory) { return new HLADecoderRegistry(encoderFactory); } + @Bean + public FomCatalog fomCatalog(FOMXML fomXml) { + return FomCatalog.fromFomXml(fomXml); + } + @Bean(destroyMethod = "close") public ObjectCache objectCache( XapiConfig xapiConfig, + FomCatalog fomCatalog, FOMXML fomXml, HLADecoderRegistry decoderRegistry) { - return new ObjectCache(xapiConfig, fomXml, decoderRegistry); + return new ObjectCache(xapiConfig, fomCatalog, fomXml, decoderRegistry); } @Bean diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index f30bc4e..4273a7e 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -36,6 +36,7 @@ public class InjectionHandler { private static final Logger logger = LogManager.getLogger(InjectionHandler.class); + @Autowired private ObjectCache objectCache; @Autowired diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index 654cf52..008255b 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -15,12 +15,17 @@ public class ObjectCache implements AutoCloseable { private final Map> subscriptions; private HlaObjectCache delegate; - public ObjectCache(XapiConfig xapiConfig, FOMXML fomXml, HLADecoderRegistry decoderRegistry) { - this(xapiConfig, fomXml, decoderRegistry, HlaObjectCache.defaultJdbcUrl()); + public ObjectCache(XapiConfig xapiConfig, FomCatalog catalog, FOMXML fomXml, HLADecoderRegistry decoderRegistry) { + this(xapiConfig, catalog, fomXml, decoderRegistry, HlaObjectCache.defaultJdbcUrl()); } - ObjectCache(XapiConfig xapiConfig, FOMXML fomXml, HLADecoderRegistry decoderRegistry, String jdbcUrl) { - this.catalog = FomCatalog.fromFomXml(fomXml); + ObjectCache( + XapiConfig xapiConfig, + FomCatalog catalog, + FOMXML fomXml, + HLADecoderRegistry decoderRegistry, + String jdbcUrl) { + this.catalog = catalog; this.subscriptions = QueryReferenceCollector.collect(xapiConfig.statementTriggers); if (!subscriptions.isEmpty()) { this.delegate = new HlaObjectCache(jdbcUrl, catalog, fomXml, decoderRegistry); diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java index 0681a7e..09c6fd5 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; +import com.yetanalytics.hlaxapi.cache.FomCatalog; import com.yetanalytics.hlaxapi.cache.ObjectCache; import com.yetanalytics.hlaxapi.config.XapiConfig; import hla.rti1516e.encoding.EncoderFactory; @@ -25,11 +26,13 @@ void springWiresObjectCacheIntoConsumers() { TriggerProcessor.class); context.refresh(); + FomCatalog fomCatalog = context.getBean(FomCatalog.class); ObjectCache objectCache = context.getBean(ObjectCache.class); InjectionHandler injectionHandler = context.getBean(InjectionHandler.class); HlaInterfaceImpl hlaInterface = context.getBean(HlaInterfaceImpl.class); assertNotNull(hlaInterface); + assertSame(fomCatalog, objectCache.catalog()); assertSame(objectCache, injectionHandler.objectCache()); } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index 551b5aa..07592b0 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -31,6 +31,7 @@ class ObjectCacheTest { private final FOMXML fomXml = new FOMXML( new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"), decoderRegistry); + private final FomCatalog catalog = FomCatalog.fromFomXml(fomXml); @Test void disabledWhenNoQueryInjectionsAndDoesNotOpenSqlite(@TempDir Path tempDir) { @@ -38,6 +39,7 @@ void disabledWhenNoQueryInjectionsAndDoesNotOpenSqlite(@TempDir Path tempDir) { try (ObjectCache cache = new ObjectCache( new XapiConfig(), + catalog, fomXml, decoderRegistry, "jdbc:sqlite:" + databasePath)) { @@ -59,6 +61,7 @@ void enabledWhenQueryInjectionsExistAndCanQueryReflectedValues(@TempDir Path tem try (ObjectCache cache = new ObjectCache( configWithQuery(), + catalog, fomXml, decoderRegistry, "jdbc:sqlite:" + databasePath)) { From fef0683c44f56151355f6981de69bd7d13af605b Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 1 Jul 2026 16:51:48 -0400 Subject: [PATCH 11/26] one-arg constructor for fomcatalog and make it a component --- .../com/yetanalytics/hlaxapi/AppConfig.java | 5 ---- .../hlaxapi/cache/FomCatalog.java | 29 +++++++++---------- .../hlaxapi/cache/FomCatalogTest.java | 2 +- .../hlaxapi/cache/HlaObjectCacheTest.java | 2 +- .../hlaxapi/cache/ObjectCacheTest.java | 2 +- 5 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java b/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java index 42ef24d..634002f 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java +++ b/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java @@ -42,11 +42,6 @@ public HLADecoderRegistry hlaDecoderRegistry(EncoderFactory encoderFactory) { return new HLADecoderRegistry(encoderFactory); } - @Bean - public FomCatalog fomCatalog(FOMXML fomXml) { - return FomCatalog.fromFomXml(fomXml); - } - @Bean(destroyMethod = "close") public ObjectCache objectCache( XapiConfig xapiConfig, diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index 9e198ec..829c26e 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -12,6 +12,8 @@ import java.util.Optional; import java.util.Set; import javax.xml.xpath.XPathExpressionException; + +import org.springframework.stereotype.Component; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; @@ -20,14 +22,24 @@ /** * 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; - private FomCatalog(Map classesByName) { - this.classesByName = Collections.unmodifiableMap(classesByName); + public FomCatalog(FOMXML fomXml) { + Objects.requireNonNull(fomXml, "fomXml"); + CatalogBuilder builder = new CatalogBuilder(fomXml); + Document document = fomXml.getDoc(); + Element objects = firstElement(document.getDocumentElement(), "objects"); + if (objects != null) { + for (Element objectClass : childElements(objects, "objectClass")) { + builder.parseObjectClass(objectClass, null, new ArrayList<>()); + } + } + this.classesByName = builder.classesByName; Map byId = new LinkedHashMap<>(); Map attrsById = new LinkedHashMap<>(); @@ -41,19 +53,6 @@ private FomCatalog(Map classesByName) { this.attributesById = Collections.unmodifiableMap(attrsById); } - public static FomCatalog fromFomXml(FOMXML fomXml) { - Objects.requireNonNull(fomXml, "fomXml"); - CatalogBuilder builder = new CatalogBuilder(fomXml); - Document document = fomXml.getDoc(); - Element objects = firstElement(document.getDocumentElement(), "objects"); - if (objects != null) { - for (Element objectClass : childElements(objects, "objectClass")) { - builder.parseObjectClass(objectClass, null, new ArrayList<>()); - } - } - return new FomCatalog(builder.classesByName); - } - public Collection objectClasses() { return classesByName.values(); } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java index 18da6e7..b70a837 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/FomCatalogTest.java @@ -53,6 +53,6 @@ void convertsTargetsToPathKeys() { private FomCatalog catalog(String fomPath) { SimulationConfig simConfig = new SimulationConfig(null, null, null, null, fomPath); HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(new HLA1516eEncoderFactory()); - return FomCatalog.fromFomXml(new FOMXML(simConfig, decoderRegistry)); + return new FomCatalog(new FOMXML(simConfig, decoderRegistry)); } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java index 55a7d38..13c4309 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java @@ -34,7 +34,7 @@ class HlaObjectCacheTest { private final FOMXML fomXml = new FOMXML( new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"), decoderRegistry); - private final FomCatalog catalog = FomCatalog.fromFomXml(fomXml); + private final FomCatalog catalog = new FomCatalog(fomXml); @Test void initializesSchemaAndSeedsFomMetadata() throws SQLException { diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index 07592b0..db00da2 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -31,7 +31,7 @@ class ObjectCacheTest { private final FOMXML fomXml = new FOMXML( new SimulationConfig(null, null, null, null, "config/HlaFedereplFOM.xml"), decoderRegistry); - private final FomCatalog catalog = FomCatalog.fromFomXml(fomXml); + private final FomCatalog catalog = new FomCatalog(fomXml); @Test void disabledWhenNoQueryInjectionsAndDoesNotOpenSqlite(@TempDir Path tempDir) { From 01198f0f2b25d41f25cb59343065871bf793b9af Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 1 Jul 2026 16:58:20 -0400 Subject: [PATCH 12/26] update silly test --- .../com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java b/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java index 09c6fd5..a5762bb 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/ObjectCacheSpringContextTest.java @@ -21,6 +21,7 @@ void springWiresObjectCacheIntoConsumers() { context.register( TestConfig.class, FOMXML.class, + FomCatalog.class, HlaInterfaceImpl.class, InjectionHandler.class, TriggerProcessor.class); From 2b59635da913658bcc28fe8e77556d0a6a3ba0b4 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Thu, 2 Jul 2026 12:25:39 -0400 Subject: [PATCH 13/26] allow user to specify explicit list of tracked sim objects --- .../hlaxapi/HlaInterfaceImpl.java | 2 +- .../hlaxapi/cache/ObjectCache.java | 66 +++++++++++++- .../hlaxapi/config/ConfigParser.java | 29 ++++++ .../hlaxapi/config/XapiConfig.java | 2 + .../config/model/ObjectCacheConfig.java | 7 ++ .../hlaxapi/config/model/TrackedObject.java | 9 ++ .../com/yetanalytics/ConfigParserTest.java | 30 +++++++ .../hlaxapi/cache/ObjectCacheTest.java | 88 +++++++++++++++++++ src/test/resources/config-test.json | 5 +- 9 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/config/model/ObjectCacheConfig.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/config/model/TrackedObject.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index 6da5fce..f494632 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -99,7 +99,7 @@ public void start() ambassador = rtiFactory.getRtiAmbassador(); if (!objectCache.isEnabled()) { - logger.info("No query injections configured; object cache is disabled"); + logger.info("No query injections or tracked objects configured; object cache is disabled"); } try { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index 008255b..bcca425 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -5,6 +5,9 @@ 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.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -26,7 +29,7 @@ public ObjectCache(XapiConfig xapiConfig, FomCatalog catalog, FOMXML fomXml, HLA HLADecoderRegistry decoderRegistry, String jdbcUrl) { this.catalog = catalog; - this.subscriptions = QueryReferenceCollector.collect(xapiConfig.statementTriggers); + this.subscriptions = collectSubscriptions(xapiConfig); if (!subscriptions.isEmpty()) { this.delegate = new HlaObjectCache(jdbcUrl, catalog, fomXml, decoderRegistry); } @@ -76,4 +79,65 @@ public synchronized void close() { delegate = null; } } + + 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/config/ConfigParser.java b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java index 2e6ede5..d974c23 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java @@ -7,7 +7,9 @@ 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.ObjectCacheConfig; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.config.model.TrackedObject; import java.io.File; import java.io.IOException; @@ -69,6 +71,33 @@ 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; } 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/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/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/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index 69fe602..65f1978 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -8,6 +8,8 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -16,6 +18,7 @@ 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; @@ -98,6 +101,33 @@ public void parsesConfigFile() throws IOException { assertNotNull(config.lrsConfig); } + @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); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index db00da2..eae4f20 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -8,10 +8,12 @@ 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; @@ -85,6 +87,72 @@ void enabledWhenQueryInjectionsExistAndCanQueryReflectedValues(@TempDir Path tem } } + @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 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"), + cache.subscriptions().get("Rabbit")); + } + } + + @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"), + cache.subscriptions().get("SimEntity")); + assertEquals( + Set.of("EntityId", "EntityType", "Position", "Hunger"), + cache.subscriptions().get("Rabbit")); + 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 = """ @@ -96,6 +164,26 @@ private XapiConfig configWithQuery() { 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(); 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}] + } } From d777618c86831d490987c5c0e9c47ce95a5f010b Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Fri, 3 Jul 2026 11:19:38 -0400 Subject: [PATCH 14/26] cache lookups scoped by trigger --- .../hlaxapi/InjectionHandler.java | 17 ++++ .../hlaxapi/TriggerProcessor.java | 43 +++++++-- .../hlaxapi/cache/CacheQueryService.java | 16 ++++ .../hlaxapi/cache/ObjectCache.java | 14 +++ .../cache/QueryReferenceCollector.java | 57 +++++++++++- .../hlaxapi/config/ConfigParser.java | 26 ++++++ .../hlaxapi/config/model/InjectionType.java | 3 +- .../hlaxapi/config/model/ObjectLookup.java | 6 ++ .../config/model/StatementTrigger.java | 3 + .../com/yetanalytics/ConfigParserTest.java | 90 +++++++++++++++++++ .../hlaxapi/cache/HlaObjectCacheTest.java | 5 ++ .../hlaxapi/cache/ObjectCacheTest.java | 14 ++- .../cache/QueryReferenceCollectorTest.java | 29 ++++++ 13 files changed, 308 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/config/model/ObjectLookup.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 4273a7e..415dfdb 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -12,10 +12,12 @@ 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.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; @@ -234,6 +236,21 @@ public Object handleQuery(String clazz, Target attrTarget, Expression criteria, return value.orElse(null); } + 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) { + if (objectCache == null || object == null) { + return null; + } + return objectCache.findValue(object, attrTarget).orElse(null); + } + private Expression resolveThisExpressions(Expression expression, InjectionContext context) { if (expression == null || context == null) { return expression; diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java index ca125ad..55a53dc 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java @@ -1,5 +1,7 @@ package com.yetanalytics.hlaxapi; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -14,9 +16,11 @@ 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.cache.CachedObject; 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.ObjectLookup; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.injection.InjectionContext; @@ -44,8 +48,9 @@ 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.info("Processed statement output: {}", output); @@ -56,9 +61,25 @@ public String processTrigger(StatementTrigger trigger, InjectionContext context) } } + 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 static final Pattern INLINE_PLACEHOLDER = Pattern.compile("<<(.+?)>>", Pattern.DOTALL); - 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,7 +89,7 @@ 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; @@ -80,13 +101,13 @@ private JsonNode processNode(JsonNode node, InjectionContext context, ObjectMapp String k = node.get(0).asText(); if (InjectionType.fromString(k) != null) { // handle injection array - return handleInjectionArray(node, context, mapper, false); + return handleInjectionArray(node, context, mapper, false, lookupObjects); } } // 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; } @@ -110,7 +131,7 @@ private JsonNode processNode(JsonNode node, InjectionContext context, ObjectMapp 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); + repNode = handleInjectionArray(injNode, context, mapper, true, lookupObjects); } if (repNode == null) { m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0))); @@ -136,7 +157,7 @@ private JsonNode processNode(JsonNode node, InjectionContext context, ObjectMapp } private JsonNode handleInjectionArray(JsonNode injArray, InjectionContext context, ObjectMapper mapper, - Boolean embedded) { + Boolean embedded, Map lookupObjects) { try { String keyword = injArray.get(0).asText(); InjectionType iType = InjectionType.fromString(keyword); @@ -157,6 +178,14 @@ private JsonNode handleInjectionArray(JsonNode injArray, InjectionContext contex if (replacement == null) return NullNode.instance; return render(replacement, embedded, mapper); + } else if (iType == InjectionType.LOOKUP && injArray.size() >= 3) { + String alias = injArray.get(1).asText(); + Object rawTarget = mapper.convertValue(injArray.get(2), Object.class); + Target attr = ConfigConverter.toTarget(rawTarget); + Object replacement = injectionHandler.handleLookup(lookupObjects.get(alias), attr); + if (replacement == null) + return NullNode.instance; + return render(replacement, embedded, mapper); } } catch (Exception e) { logger.debug("Error handling injection array", e); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java b/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java index b4cbb5f..143543c 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java @@ -25,6 +25,22 @@ public Optional findFirstValue(String className, Target target, Expressi 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 Optional findValue(CachedObject object, Target target) { + if (object == null) { + return Optional.empty(); + } + 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) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index bcca425..d5887e4 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -54,6 +54,20 @@ public Optional findFirstValue(String clazz, Target attrTarget, Expressi return delegate.queryService().findFirstValue(clazz, attrTarget, criteria); } + public Optional findFirstObject(String clazz, Expression criteria) { + if (delegate == null) { + return Optional.empty(); + } + return delegate.queryService().findFirstObject(clazz, criteria); + } + + public Optional findValue(CachedObject object, Target attrTarget) { + if (delegate == null) { + return Optional.empty(); + } + return delegate.queryService().findValue(object, attrTarget); + } + public void discoverObject(String objectHandle, String objectName, String className) { if (delegate != null) { delegate.discoverObject(objectHandle, objectName, className); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java index 5acf39b..8380762 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java @@ -35,8 +35,9 @@ public static Map> collect(List triggers) if (trigger == null || trigger.statement == null) { continue; } + Map lookupClasses = collectLookupDefinitions(trigger, references); try { - collectFromNode(MAPPER.readTree(trigger.statement), references); + collectFromNode(MAPPER.readTree(trigger.statement), references, lookupClasses); } catch (IOException ignored) { // Bad statement JSON is handled by TriggerProcessor at runtime. } @@ -44,13 +45,33 @@ public static Map> collect(List triggers) return copyReferences(references); } - private static void collectFromNode(JsonNode node, Map> references) throws IOException { + 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) throws IOException { if (node == null || node.isNull()) { return; } if (node.isObject()) { for (JsonNode child : node) { - collectFromNode(child, references); + collectFromNode(child, references, lookupClasses); } return; } @@ -59,8 +80,12 @@ private static void collectFromNode(JsonNode node, Map> refe collectQuery(node, references); return; } + if (isLookupInjection(node)) { + collectLookup(node, references, lookupClasses); + return; + } for (JsonNode child : node) { - collectFromNode(child, references); + collectFromNode(child, references, lookupClasses); } return; } @@ -70,6 +95,8 @@ private static void collectFromNode(JsonNode node, Map> refe JsonNode inner = MAPPER.readTree(matcher.group(1)); if (isQueryInjection(inner)) { collectQuery(inner, references); + } else if (isLookupInjection(inner)) { + collectLookup(inner, references, lookupClasses); } } } @@ -83,6 +110,14 @@ private static boolean isQueryInjection(JsonNode node) { && "query".equalsIgnoreCase(node.get(0).asText()); } + private static boolean isLookupInjection(JsonNode node) { + return node != null + && node.isArray() + && node.size() >= 3 + && node.get(0).isTextual() + && "lookup".equalsIgnoreCase(node.get(0).asText()); + } + private static void collectQuery(JsonNode queryNode, Map> references) { String className = queryNode.get(1).asText(null); if (className == null || className.isBlank()) { @@ -96,6 +131,20 @@ private static void collectQuery(JsonNode queryNode, Map> re collectCriteriaTargets(references, className, ConfigConverter.toExpression(criteriaRaw)); } + private static void collectLookup( + JsonNode lookupNode, + Map> references, + Map lookupClasses) { + String alias = lookupNode.get(1).asText(null); + String className = lookupClasses.get(alias); + if (className == null || className.isBlank()) { + return; + } + + Target target = ConfigConverter.toTarget(MAPPER.convertValue(lookupNode.get(2), Object.class)); + addTarget(references, className, target); + } + private static void collectCriteriaTargets( Map> references, String className, diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java index d974c23..861174a 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java @@ -7,6 +7,7 @@ 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; @@ -15,7 +16,10 @@ 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. @@ -54,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")); @@ -101,6 +106,27 @@ public XapiConfig parse() { 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/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/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/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index 65f1978..aff7ff2 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -14,6 +14,8 @@ 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; @@ -27,6 +29,7 @@ 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.config.ConfigConverter; import com.yetanalytics.hlaxapi.config.ConfigParser; import com.yetanalytics.hlaxapi.config.XapiConfig; @@ -35,6 +38,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; @@ -101,6 +106,39 @@ 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"); @@ -320,6 +358,58 @@ 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 Object handleLookup(CachedObject object, Target attrTarget) { + assertEquals(matchedObject, object); + if (attrTarget.parts.equals(List.of("EntityId"))) { + return "predator-1"; + } + if (attrTarget.parts.equals(List.of("EntityType"))) { + return "Wolf"; + } + return null; + } + }; + + 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\"")); + } + // Stub implementations of HLA ParameterHandle and ParameterHandleValueMap for // testing class TestParameterHandle implements ParameterHandle { diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java index 13c4309..83551bb 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java @@ -111,6 +111,11 @@ void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { 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"); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index eae4f20..b844d3b 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -113,7 +113,8 @@ void trackedObjectAllAttributesExpandsTopLevelFomAttributes(@TempDir Path tempDi "jdbc:sqlite:" + tempDir.resolve("all-attrs.sqlite"))) { assertEquals( Set.of("EntityId", "EntityType", "Position", "Hunger"), - cache.subscriptions().get("Rabbit")); + stableAttributes(cache.subscriptions().get("Rabbit"), "EntityId", "EntityType", "Position", + "Hunger")); } } @@ -130,10 +131,11 @@ void trackedObjectWildcardExpandsEveryObjectClassWithAllAttributes(@TempDir Path cache.subscriptions().get("World")); assertEquals( Set.of("EntityId", "EntityType", "Position"), - cache.subscriptions().get("SimEntity")); + stableAttributes(cache.subscriptions().get("SimEntity"), "EntityId", "EntityType", "Position")); assertEquals( Set.of("EntityId", "EntityType", "Position", "Hunger"), - cache.subscriptions().get("Rabbit")); + stableAttributes(cache.subscriptions().get("Rabbit"), "EntityId", "EntityType", "Position", + "Hunger")); assertFalse(cache.subscriptions().containsKey("HLAobjectRoot")); } } @@ -191,4 +193,10 @@ private byte[] encoded(DataElement element) { 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 index 4eed78f..b2992f3 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java @@ -4,6 +4,11 @@ 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; @@ -37,6 +42,30 @@ void ignoresThisExpressionTargetsInsideQueryCriteria() { assertFalse(references.get("Rabbit").contains("DesiredHunger")); } + @Test + void findsLookupDefinitionCriteriaAndLookupInjections() { + StatementTrigger trigger = trigger(""" + { + "actor": { + "account": {"name": ["lookup", "predator", ["EntityId"]]}, + "name": "<<[\\"lookup\\",\\"predator\\",[\\"EntityType\\"]]>>" + } + } + """); + 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; From f9d1d6f9c67e9b05928c51f202f2626a7168ae1d Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 7 Jul 2026 11:07:37 -0400 Subject: [PATCH 15/26] strict throw on nulls by default with explicit nullables --- .../hlaxapi/InjectionHandler.java | 32 ++++- .../hlaxapi/TriggerProcessor.java | 86 ++++++++++-- .../hlaxapi/cache/CacheQueryService.java | 29 +++- .../hlaxapi/cache/ObjectCache.java | 14 ++ .../hlaxapi/cache/ValueResolution.java | 42 ++++++ .../com/yetanalytics/ConfigParserTest.java | 127 +++++++++++++++++- .../hlaxapi/cache/HlaObjectCacheTest.java | 21 +++ .../cache/QueryReferenceCollectorTest.java | 4 +- 8 files changed, 327 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/ValueResolution.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index 415dfdb..851cc0a 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -14,6 +14,7 @@ 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; @@ -222,18 +223,34 @@ 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) { + 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 null; + return ValueResolution.missingObject(); } Expression resolvedCriteria = resolveThisExpressions(criteria, context); - Optional value = objectCache.findFirstValue(clazz, attrTarget, resolvedCriteria); - return value.orElse(null); + return objectCache.findFirstResolution(clazz, attrTarget, resolvedCriteria); } public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) { @@ -245,10 +262,15 @@ public Optional resolveLookup(ObjectLookup lookup, InjectionContex } 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 null; + return ValueResolution.missingObject(); } - return objectCache.findValue(object, attrTarget).orElse(null); + return objectCache.findValueResolution(object, attrTarget); } private Expression resolveThisExpressions(Expression expression, InjectionContext context) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java index 55a53dc..b2649cf 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java @@ -17,6 +17,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; import com.yetanalytics.hlaxapi.cache.CachedObject; +import com.yetanalytics.hlaxapi.cache.ValueResolution; import com.yetanalytics.hlaxapi.config.ConfigConverter; import com.yetanalytics.hlaxapi.config.model.Expression; import com.yetanalytics.hlaxapi.config.model.InjectionType; @@ -55,6 +56,9 @@ public String processTrigger(StatementTrigger trigger, InjectionContext context) String output = mapper.writeValueAsString(processed); logger.info("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; @@ -141,6 +145,8 @@ private JsonNode processNode( : mapper.writeValueAsString(repNode); m.appendReplacement(sb, Matcher.quoteReplacement(replacementText)); } + } catch (RequiredInjectionException e) { + throw e; } catch (Exception e) { m.appendReplacement(sb, Matcher.quoteReplacement(m.group(0))); } @@ -150,6 +156,8 @@ private JsonNode processNode( } return node; + } catch (RequiredInjectionException e) { + throw e; } catch (Exception e) { logger.debug("Error processing node", e); return node; @@ -164,29 +172,37 @@ private JsonNode handleInjectionArray(JsonNode injArray, InjectionContext contex 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; - return render(replacement, embedded, mapper); + return renderResolution( + injectionHandler.handleThisResolution(t, context), + optionsFor(injArray, 2), + injectionDescription(iType, null, t), + embedded, + mapper); } 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; - return render(replacement, embedded, mapper); + return renderResolution( + injectionHandler.handleQueryResolution(clazz, attr, criteriaExpr, context), + optionsFor(injArray, 4), + injectionDescription(iType, clazz, attr), + embedded, + mapper); } else if (iType == InjectionType.LOOKUP && injArray.size() >= 3) { String alias = injArray.get(1).asText(); Object rawTarget = mapper.convertValue(injArray.get(2), Object.class); Target attr = ConfigConverter.toTarget(rawTarget); - Object replacement = injectionHandler.handleLookup(lookupObjects.get(alias), attr); - if (replacement == null) - return NullNode.instance; - return render(replacement, embedded, mapper); + return renderResolution( + injectionHandler.handleLookupResolution(lookupObjects.get(alias), attr), + optionsFor(injArray, 3), + injectionDescription(iType, alias, attr), + embedded, + mapper); } + } catch (RequiredInjectionException e) { + throw e; } catch (Exception e) { logger.debug("Error handling injection array", e); } @@ -209,4 +225,50 @@ private JsonNode render(Object replacement, Boolean embedded, ObjectMapper mappe return mapper.valueToTree(replacement); } + private JsonNode renderResolution( + ValueResolution resolution, + InjectionOptions options, + String description, + Boolean embedded, + ObjectMapper mapper) { + if (!resolution.present()) { + throw new RequiredInjectionException(description + " failed: " + resolution.status()); + } + Object replacement = resolution.value(); + if (replacement == null) { + if (!options.nullable) { + throw new RequiredInjectionException(description + " failed: unexpected null value"); + } + return NullNode.instance; + } + return render(replacement, embedded, mapper); + } + + private InjectionOptions optionsFor(JsonNode injArray, int index) { + if (injArray.size() <= index || !injArray.get(index).isObject()) { + return InjectionOptions.DEFAULT; + } + return new InjectionOptions(injArray.get(index).path("nullable").asBoolean(false)); + } + + private String injectionDescription(InjectionType type, String scope, Target target) { + StringBuilder description = new StringBuilder(type.toString()); + if (scope != null && !scope.isBlank()) { + description.append("(").append(scope).append(")"); + } + description.append(" target "); + description.append(target == null ? "" : target.parts); + return description.toString(); + } + + private record InjectionOptions(boolean nullable) { + private static final InjectionOptions DEFAULT = new InjectionOptions(false); + } + + 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 index 143543c..62812fa 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java @@ -34,10 +34,26 @@ public Optional findFirstObject(String className, Expression crite 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) { - if (object == null) { + 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); } @@ -90,7 +106,8 @@ private Object resolveExpression(CachedObject object, Expression expression) { return null; } if (expression instanceof Target target) { - return resolveTarget(object, target).orElse(null); + ValueResolution resolution = resolveTarget(object, target); + return resolution.present() ? resolution.value() : null; } if (expression instanceof ValueExpression valueExpression) { return valueExpression.value; @@ -107,12 +124,14 @@ private Object resolveExpression(CachedObject object, Expression expression) { return null; } - private Optional resolveTarget(CachedObject object, Target target) { + private ValueResolution resolveTarget(CachedObject object, Target target) { String pathKey = FomCatalog.targetPath(target.parts); if (pathKey == null) { - return Optional.empty(); + return ValueResolution.missingValue(); } - return cache.findCurrentValue(object.id(), pathKey).map(CachedValue::value); + return cache.findCurrentValue(object.id(), pathKey) + .map(value -> ValueResolution.present(value.value())) + .orElseGet(ValueResolution::missingValue); } private boolean compare(Object left, Object right, ComparisonOperator operator) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index d5887e4..5f04ad7 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -54,6 +54,13 @@ public Optional findFirstValue(String clazz, Target attrTarget, Expressi return delegate.queryService().findFirstValue(clazz, attrTarget, criteria); } + public ValueResolution findFirstResolution(String clazz, Target attrTarget, Expression criteria) { + if (delegate == null) { + return ValueResolution.missingObject(); + } + return delegate.queryService().findFirstResolution(clazz, attrTarget, criteria); + } + public Optional findFirstObject(String clazz, Expression criteria) { if (delegate == null) { return Optional.empty(); @@ -68,6 +75,13 @@ public Optional findValue(CachedObject object, Target attrTarget) { return delegate.queryService().findValue(object, attrTarget); } + public ValueResolution findValueResolution(CachedObject object, Target attrTarget) { + if (delegate == null) { + return ValueResolution.missingObject(); + } + return delegate.queryService().findValueResolution(object, attrTarget); + } + public void discoverObject(String objectHandle, String objectName, String className) { if (delegate != null) { delegate.discoverObject(objectHandle, objectName, className); 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/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index aff7ff2..08963de 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -3,6 +3,7 @@ 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; @@ -30,6 +31,7 @@ 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; @@ -370,15 +372,15 @@ public Optional resolveLookup(ObjectLookup lookup, InjectionContex } @Override - public Object handleLookup(CachedObject object, Target attrTarget) { + public ValueResolution handleLookupResolution(CachedObject object, Target attrTarget) { assertEquals(matchedObject, object); if (attrTarget.parts.equals(List.of("EntityId"))) { - return "predator-1"; + return ValueResolution.present("predator-1"); } if (attrTarget.parts.equals(List.of("EntityType"))) { - return "Wolf"; + return ValueResolution.present("Wolf"); } - return null; + return ValueResolution.missingValue(); } }; @@ -410,6 +412,123 @@ public Object handleLookup(CachedObject object, Target attrTarget) { assertTrue(out.contains("\"name\":\"the Wolf\"")); } + @Test + public void lookupInjectionMissingObjectAbortsStatement() { + 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); + } + + @Test + public void lookupInjectionMissingValueAbortsStatementEvenWhenNullable() { + 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); + } + + @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\"")); + } + + @Test + public void queryAndThisUnexpectedNullsAbortStatement() { + 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<>()))); + } + + 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/hlaxapi/cache/HlaObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java index 83551bb..7a4dbd5 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java @@ -3,6 +3,7 @@ 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; @@ -124,6 +125,26 @@ void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { } } + @Test + void queryServiceDistinguishesPresentNullFromMissingValue() { + try (HlaObjectCache 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"); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java index b2992f3..2c628c8 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java @@ -47,8 +47,8 @@ void findsLookupDefinitionCriteriaAndLookupInjections() { StatementTrigger trigger = trigger(""" { "actor": { - "account": {"name": ["lookup", "predator", ["EntityId"]]}, - "name": "<<[\\"lookup\\",\\"predator\\",[\\"EntityType\\"]]>>" + "account": {"name": ["lookup", "predator", ["EntityId"], {"nullable": true}]}, + "name": "<<[\\"lookup\\",\\"predator\\",[\\"EntityType\\"],{\\"nullable\\":true}]>>" } } """); From 96cf107b6df57f87a72005f55c10cd6019c717ec Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Tue, 7 Jul 2026 12:53:41 -0400 Subject: [PATCH 16/26] use adorable names FOM and make more interesting demo --- config/HlaFedereplFOM.xml | 24 ++++++++++++++++++++++++ config/xapi-config.json | 36 ++++++++++++++++++++++-------------- 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/config/HlaFedereplFOM.xml b/config/HlaFedereplFOM.xml index 65eb7c0..07dcc8d 100644 --- a/config/HlaFedereplFOM.xml +++ b/config/HlaFedereplFOM.xml @@ -150,6 +150,30 @@ Receive Concrete simulation type: carrot, rabbit, or wolf. + + FirstName + HLAunicodeString + Static + Provided when the entity object is registered. + true + NoTransfer + PublishSubscribe + HLAreliable + Receive + Random given name assigned to the entity. + + + LastName + HLAunicodeString + Static + Provided when the entity object is registered. + true + NoTransfer + PublishSubscribe + HLAreliable + Receive + Family name assigned to the entity and inherited during reproduction. + Position GridPosition diff --git a/config/xapi-config.json b/config/xapi-config.json index a2b1494..5b965fc 100644 --- a/config/xapi-config.json +++ b/config/xapi-config.json @@ -2,30 +2,38 @@ "statementTriggers": [ { "type": "Interaction", - "class": "EntityMoved", + "class": "EntityAte", "criteria": [["this", "key1"], "=", "thing1"], + "lookups": { + "predator": { + "class": "SimEntity", + "criteria": [["EntityId"], "=", ["this", ["PredatorId"]]] + }, + "prey": { + "class": "SimEntity", + "criteria": [["EntityId"], "=", ["this", ["PreyId"]]] + } + }, "statement": { "actor": { "objectType": "Agent", - "name": ["this", ["EntityId"]], + "name": "<<[\"lookup\", \"predator\", [\"FirstName\"]]>> <<[\"lookup\", \"predator\", [\"LastName\"]]>>", "account": { "homePage": "https://hla-federepl.example/entities", - "name": ["query", "SimEntity", ["EntityId"], [["EntityId"], "=", ["this", ["EntityId"]]]] + "name": ["this", ["PredatorId"]] } }, - "context": { - "extensions":{ - "https://yetanalytics.com/extensions/from-position": "[<<[\"this\", [\"FromPosition\", \"X\"]]>>, <<[\"this\", [\"FromPosition\", \"Y\"]]>>]" - } - }, - "result": { - "response": "[<<[\"this\", [\"ToPosition\", \"X\"]]>>, <<[\"this\", [\"ToPosition\", \"Y\"]]>>]" - }, "verb": { - "id": "http://yetanalytics.com/verbs/moved" + "id": "http://example.com/verbs/ate", + "display": {"en-US": "Ate"} }, "object": { - "id": "http://yetanalytics.com/objects/7" + "objectType": "Agent", + "name": "<<[\"lookup\", \"prey\", [\"FirstName\"]]>> <<[\"lookup\", \"prey\", [\"LastName\"]]>>", + "account": { + "homePage": "https://hla-federepl.example/entities", + "name": ["this", ["PreyId"]] + } } } } @@ -36,5 +44,5 @@ "secret": "my_secret", "batch": 35, "maxRetries": 3 - } + } } From 9fa08d09426681010cf48a0464e4b60e8e3b06a1 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 8 Jul 2026 14:38:39 -0400 Subject: [PATCH 17/26] basic config doc --- doc/xapi-config.md | 341 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 doc/xapi-config.md diff --git a/doc/xapi-config.md b/doc/xapi-config.md new file mode 100644 index 0000000..f83ca98 --- /dev/null +++ b/doc/xapi-config.md @@ -0,0 +1,341 @@ +## HLA xAPI Adapter xAPI Configuration Reference + +The xAPI configuration file controls which HLA events become xAPI statements, how statement templates are filled, how cached HLA object state is queried, and where generated statements are posted. + +By default the adapter reads `config/xapi-config.json`. Set `XAPI_CONFIG` to load a different file. + +```shell +XAPI_CONFIG=/path/to/xapi-config.json make run-dev +``` + +At the top level the file supports: + +```json +{ + "statementTriggers": [], + "lrs": {}, + "objectCache": {} +} +``` + +## Statement Triggers + +`statementTriggers` is an array of templates that are processed when matching HLA events arrive. + +```json +{ + "type": "Interaction", + "class": "EntityAte", + "criteria": [["PredatorId"], "!=", ["PreyId"]], + "lookups": { + "predator": { + "class": "SimEntity", + "criteria": [["EntityId"], "=", ["this", ["PredatorId"]]] + } + }, + "statement": { + "actor": { + "objectType": "Agent", + "name": ["lookup", "predator", ["FirstName"]] + }, + "verb": { + "id": "http://example.com/verbs/ate", + "display": {"en-US": "Ate"} + }, + "object": { + "id": "http://example.com/activity/eating" + } + } +} +``` + +Fields: + +- `type`: Type of trigger. `Interaction` is currently wired into RTI subscriptions and statement processing. `ObjectUpdate` is parsed by the config model but object updates currently feed the object cache rather than firing statement triggers directly. +- `class`: The local HLA interaction class name. For interactions this is matched against the final segment of the RTI interaction class name. +- `criteria`: **NOTE: Not Yet Implemented!** Parsed as a criteria expression, but not currently applied when deciding whether an incoming interaction should fire the trigger. +- `lookups`: Optional named cache lookups resolved once before the statement template is processed. These can be used to reduce the size and complexity of queries in the body of the statement config. +- `statement`: An xAPI statement template. Any JSON object accepted by the xAPI spec can be used here, with injection expressions inserted where dynamic values are needed. + +If multiple interaction triggers match the same interaction class, each trigger is processed and each resulting statement is queued for the LRS. + +## Targets + +A target is a JSON array path into an HLA parameter or cached object attribute: + +```json +["EntityId"] +["Position", "X"] +["LocationHistory", 0, "Y"] +``` + +String parts name parameters, attributes, or fixed-record fields. Integer parts index into arrays. For cached object values, paths are stored as FOM-derived keys such as `Position.X` and `LocationHistory[0].Y`; array index lookups can also match wildcard array metadata in the cache. + +## Criteria Expressions + +Criteria are JSON arrays: + +```json +[leftExpression, "operator", rightExpression] +``` + +Supported comparison operators: + +- `=` +- `!=` +- `<` +- `>` +- `<=` +- `>=` + +The left or right side may be a target, primitive value, nested criterion, or a `this` expression. + +```json +[["Hunger"], ">", 50] +[["EntityId"], "=", ["this", ["PredatorId"]]] +[["Position", "X"], "<=", ["this", ["ToPosition", "X"]]] +``` + +Logical expressions use `and` or `or` between criteria: + +```json +[ + [["Hunger"], ">", 50], + "and", + [["Position", "X"], "<", 15] +] +``` + +Use a single logical operator at a given array level. If mixed `and`/`or` logic is needed, prefer nested expressions. + +In cache queries, `=` compares numbers numerically when both sides are numeric; otherwise it uses normal equality. Ordered comparisons compare numbers numerically, comparable values of the same class directly, and otherwise fall back to string comparison. + +## Statement Injections + +An injection can appear as a whole JSON value: + +```json +"name": ["this", ["EntityId"]] +``` + +or inside a JSON string using `<<...>>` with an escaped JSON injection array: + +```json +"name": "Rabbit <<[\"this\", [\"EntityId\"]]>>" +``` + +Whole-value injections preserve the replacement's JSON type. Inline injections are always rendered as text inside the containing string. + +All injection types accept an optional final options object: + +```json +["this", ["OptionalField"], {"nullable": true}] +``` + +`nullable` only allows a resolved value of `null` to render as JSON `null` (or as the text `null` inline). It does not allow missing objects or missing values. A missing required injection aborts the statement; the trigger returns no xAPI statement. + +### `this` + +`this` reads a value from the current event context. + +```json +["this", ["PredatorId"]] +["this", ["FromPosition", "X"]] +``` + +For interaction triggers, `this` reads the interaction parameter map and decodes the value using the FOM. It supports top-level parameters, fixed-record fields, and array elements. Object-update `this` contexts are present in the codebase but currently return a placeholder value rather than decoded object attributes. + +### `query` + +`query` searches the object cache for the first current object of a class that matches criteria, then returns one target value from that object. + +```json +["query", "Rabbit", ["EntityId"], [["Hunger"], ">", 50]] +``` + +Arguments: + +- Class name: HLA object class to search, using the local FOM class name. +- Target: cached value to return from the matched object. +- Criteria: expression evaluated against cached values for each current object. +- Options: optional `{"nullable": true}`. + +Queries use the adapter's current object cache, not arbitrary SQL provided in the config. Removed objects are excluded. If more than one object matches, the first cached object is used. + +`this` may be used inside query criteria. It is resolved from the triggering event before the cache query runs: + +```json +[ + "query", + "SimEntity", + ["EntityType"], + [["EntityId"], "=", ["this", ["PredatorId"]]] +] +``` + +### `lookup` + +`lookup` reads a value from a named object resolved by the trigger's `lookups` +section. + +```json +{ + "lookups": { + "predator": { + "class": "SimEntity", + "criteria": [["EntityId"], "=", ["this", ["PredatorId"]]] + }, + "prey": { + "class": "SimEntity", + "criteria": [["EntityId"], "=", ["this", ["PreyId"]]] + } + }, + "statement": { + "actor": { + "name": "<<[\"lookup\", \"predator\", [\"FirstName\"]]>> <<[\"lookup\", \"predator\", [\"LastName\"]]>>" + }, + "object": { + "name": "<<[\"lookup\", \"prey\", [\"FirstName\"]]>> <<[\"lookup\", \"prey\", [\"LastName\"]]>>" + } + } +} +``` + +A lookup definition contains: + +- `class`: HLA object class to search in the cache. +- `criteria`: cache criteria used to select the object. `this` expressions are allowed and are resolved against the triggering event. + +A lookup injection has this shape: + +```json +["lookup", "predator", ["EntityId"]] +``` + +The alias must exist in the trigger's `lookups` map. Each lookup alias is resolved once before processing the statement template, and all `lookup` injections for that alias reuse the same cached object. + +## Object Cache + +The object cache stores the latest reflected values for subscribed HLA object attributes in SQLite. It is enabled when either: + +- a statement template contains a `query` injection, +- a trigger defines `lookups` or uses `lookup` injections that reference cached object attributes, or +- `objectCache.trackedObjects` explicitly requests tracked attributes. + +When enabled, the adapter subscribes to the top-level object attributes required by query targets, query criteria, lookup targets, lookup criteria, and explicit tracked objects. + +```json +{ + "objectCache": { + "trackedObjects": [ + {"class": "Rabbit", "attributes": ["EntityId", "Hunger"]}, + {"class": "World", "allAttributes": true}, + {"class": "*", "allAttributes": true} + ] + } +} +``` + +Tracked object fields: + +- `class`: Local HLA object class name. Use `*` with `allAttributes: true` to subscribe to all top-level attributes for every FOM object class with attributes. +- `attributes`: Top-level attribute names to subscribe to. +- `allAttributes`: When `true`, expands to all top-level attributes for the class. + +The cache decodes reflected values using the FOM and stores both top-level values and flattened nested values for fixed records and arrays. For example, reflecting `Position` can make `Position`, `Position.X`, and `Position.Y` available to query and lookup targets. + +By default the SQLite database is `hla-object-cache.sqlite` in the working directory. It can be changed with: + +```shell +HLA_OBJECT_CACHE_DB=/path/to/cache.sqlite make run-dev +HLA_OBJECT_CACHE_JDBC_URL=jdbc:sqlite:/path/to/cache.sqlite make run-dev +``` + +The cache starts fresh on initialization: the current schema drops and recreates object and FOM metadata tables when the cache opens. + +## LRS Configuration + +The `lrs` section configures the xAPI client. + +```json +{ + "lrs": { + "host": "http://localhost:8080/xapi", + "key": "my_key", + "secret": "my_secret", + "batch": 35, + "maxRetries": 3 + } +} +``` + +Fields: + +- `host`: LRS xAPI endpoint. +- `key`: LRS basic auth username or key. +- `secret`: LRS basic auth password or secret. +- `batch`: Statement batch size passed to the xAPI client and used as the adapter buffer size. +- `maxRetries`: Number of scheduled retry attempts before the current in-memory buffer is cleared after repeated LRS post failures. + +The buffer flush interval is controlled by the Java/Spring property `xapi.buffer.clear-rate`, defaulting to `10000` milliseconds. + +```shell +java -Dxapi.buffer.clear-rate=5000 ... +``` + +## Complete Example + +```json +{ + "statementTriggers": [ + { + "type": "Interaction", + "class": "EntityAte", + "lookups": { + "predator": { + "class": "SimEntity", + "criteria": [["EntityId"], "=", ["this", ["PredatorId"]]] + }, + "prey": { + "class": "SimEntity", + "criteria": [["EntityId"], "=", ["this", ["PreyId"]]] + } + }, + "statement": { + "actor": { + "objectType": "Agent", + "name": "<<[\"lookup\", \"predator\", [\"FirstName\"]]>> <<[\"lookup\", \"predator\", [\"LastName\"]]>>", + "account": { + "homePage": "https://hla-federepl.example/entities", + "name": ["this", ["PredatorId"]] + } + }, + "verb": { + "id": "http://example.com/verbs/ate", + "display": {"en-US": "Ate"} + }, + "object": { + "objectType": "Agent", + "name": "<<[\"lookup\", \"prey\", [\"FirstName\"]]>> <<[\"lookup\", \"prey\", [\"LastName\"]]>>", + "account": { + "homePage": "https://hla-federepl.example/entities", + "name": ["this", ["PreyId"]] + } + } + } + } + ], + "objectCache": { + "trackedObjects": [ + {"class": "SimEntity", "allAttributes": true} + ] + }, + "lrs": { + "host": "http://localhost:8080/xapi", + "key": "my_key", + "secret": "my_secret", + "batch": 35, + "maxRetries": 3 + } +} +``` From 90b900bc2001fa23a73b8f96a89250ece9a05034 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 8 Jul 2026 14:44:05 -0400 Subject: [PATCH 18/26] remove dup stuff and link to xapi config doc in readme --- README.md | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index f1af085..e300fb6 100644 --- a/README.md +++ b/README.md @@ -20,36 +20,8 @@ fom=config/HlaFedereplFOM.xml #### xAPI Config -*Section in progress...* - -The sample `xapi-config.json` reflects the current state of compatible configuration options. `statementTriggers` is an array of "triggers" which fire on a specific interaction or object update from the RTI. The trigger contains criteria (not yet implemented) and also a statement template which allows for the generation and storage of a valid xAPI statement. The statement template contains "injection" syntax which allow the insertion of details from the tiggering RTI update or values from an object cache (in progress) to be used as values in the statement. - -##### Criteria -*Section in progress...* - -##### Injections -*Section in progress...* - -##### LRS Configuration -The `lrs` section contains the details for connecting to a valid Learning Record Store (LRS), and will be used to initialize a client which will write the resulting statements from the Federate to that LRS. - -Example: - -``` -... -"lrs": { - "host": "http://localhost:8080/xapi", - "key": "my_key", - "secret": "my_secret", - "batch": 50 -} -``` - -The frequency of the buffer being cleared and statement being actually posted can be changed with a java property when running the jar: - -``` -java -Dxapi.buffer.clear-rate=5000 ... -``` +The sample `xapi-config.json` reflects the current state of compatible configuration options. +See the [HLA xAPI Adapter xAPI Configuration Reference](doc/xapi-config.md) for a full configuration reference. ### Vendoring Portico From f3081c9bf592f06f0ffcb3b9f974d3b85476a631 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Thu, 9 Jul 2026 11:40:11 -0400 Subject: [PATCH 19/26] whoops, forgot some extra info on cache --- doc/xapi-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/xapi-config.md b/doc/xapi-config.md index f83ca98..56355e4 100644 --- a/doc/xapi-config.md +++ b/doc/xapi-config.md @@ -222,7 +222,7 @@ The object cache stores the latest reflected values for subscribed HLA object at - a trigger defines `lookups` or uses `lookup` injections that reference cached object attributes, or - `objectCache.trackedObjects` explicitly requests tracked attributes. -When enabled, the adapter subscribes to the top-level object attributes required by query targets, query criteria, lookup targets, lookup criteria, and explicit tracked objects. +When enabled, the adapter subscribes to the top-level object attributes required by query targets, query criteria, lookup targets, lookup criteria, and explicit tracked objects. Use the `trackedObjects` array to force cacheing of simulation objects: ```json { From 72d6acbf883696d320f3ac825e1df6bf88297915 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Fri, 10 Jul 2026 10:59:06 -0400 Subject: [PATCH 20/26] remove pre-di null object guard --- src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index 829c26e..1dd65d7 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -8,7 +8,6 @@ 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 javax.xml.xpath.XPathExpressionException; @@ -30,7 +29,6 @@ public final class FomCatalog { private final Map attributesById; public FomCatalog(FOMXML fomXml) { - Objects.requireNonNull(fomXml, "fomXml"); CatalogBuilder builder = new CatalogBuilder(fomXml); Document document = fomXml.getDoc(); Element objects = firstElement(document.getDocumentElement(), "objects"); From 74033ea92ef393b89a42c29137729b3f3c65ef1b Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Fri, 10 Jul 2026 11:46:59 -0400 Subject: [PATCH 21/26] push all dom into FOMXML --- .../java/com/yetanalytics/hlaxapi/FOMXML.java | 106 +++++++++++++++--- .../hlaxapi/cache/FomCatalog.java | 74 +++--------- .../hlaxapi/cache/FomCatalogTest.java | 28 ++++- 3 files changed, 134 insertions(+), 74 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java b/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java index f79c8cd..3037d6a 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java +++ b/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java @@ -12,6 +12,7 @@ import org.apache.logging.log4j.Logger; import org.springframework.stereotype.Component; import org.w3c.dom.Document; +import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; @@ -46,13 +47,13 @@ public FOMXML(SimulationConfig simConfig, HLADecoderRegistry decoderRegistry) { try { DocumentBuilder builder = factory.newDocumentBuilder(); - setDoc(builder.parse(xmlFile)); + doc = builder.parse(xmlFile); } catch (SAXException | IOException | ParserConfigurationException e) { logger.error("Could not parse FOM XML.", e); } - setxPath(XPathFactory.newInstance().newXPath()); - setDecoderRegistry(decoderRegistry); + xPath = XPathFactory.newInstance().newXPath(); + this.decoderRegistry = decoderRegistry; } private boolean isPrim(String type) { @@ -240,28 +241,90 @@ private String resolvePrimitiveType(String dataTypeName, Set 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); } - public void setxPath(XPath xPath) { - this.xPath = xPath; + 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 HLADecoderRegistry getDecoderRegistry() { - return decoderRegistry; + 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 void setDecoderRegistry(HLADecoderRegistry decoderRegistry) { - this.decoderRegistry = decoderRegistry; + private static Element firstChildElement(Element parent, String tagName) { + for (Element element : childElements(parent, tagName)) { + return element; + } + return null; + } + + 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) @@ -352,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/cache/FomCatalog.java b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java index 1dd65d7..abf6543 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/FomCatalog.java @@ -13,10 +13,6 @@ import javax.xml.xpath.XPathExpressionException; import org.springframework.stereotype.Component; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.w3c.dom.Node; -import org.w3c.dom.NodeList; /** * FOM-derived object metadata used by the SQLite cache. @@ -30,12 +26,8 @@ public final class FomCatalog { public FomCatalog(FOMXML fomXml) { CatalogBuilder builder = new CatalogBuilder(fomXml); - Document document = fomXml.getDoc(); - Element objects = firstElement(document.getDocumentElement(), "objects"); - if (objects != null) { - for (Element objectClass : childElements(objects, "objectClass")) { - builder.parseObjectClass(objectClass, null, new ArrayList<>()); - } + for (FOMXML.ObjectClassDefinition definition : fomXml.objectClassDefinitions()) { + builder.addObjectClass(definition); } this.classesByName = builder.classesByName; @@ -116,37 +108,6 @@ static String localName(String hlaName) { return index >= 0 ? trimmed.substring(index + 1) : trimmed; } - private static String text(Element parent, String tagName) { - Element element = firstElement(parent, tagName); - if (element == null) { - return null; - } - String text = element.getTextContent(); - return text == null || text.isBlank() ? null : text.trim(); - } - - private static Element firstElement(Element parent, String tagName) { - for (Element element : childElements(parent, tagName)) { - return element; - } - return null; - } - - 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 record ObjectClassDef( int id, String hlaName, @@ -200,6 +161,7 @@ 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; @@ -207,20 +169,15 @@ private CatalogBuilder(FOMXML fomXml) { this.fomXml = fomXml; } - private void parseObjectClass(Element element, String parentName, List inheritedAttributes) { - String className = text(element, "name"); - if (className == null) { - return; + private void addObjectClass(FOMXML.ObjectClassDefinition definition) { + List allAttributes = new ArrayList<>(); + if (definition.parentName() != null) { + allAttributes.addAll(attributesByClassName.getOrDefault(definition.parentName(), List.of())); } - - List allAttributes = new ArrayList<>(inheritedAttributes); - for (Element attribute : childElements(element, "attribute")) { - String attributeName = text(attribute, "name"); - String dataType = text(attribute, "dataType"); - if (attributeName != null && dataType != null) { - allAttributes.add(new AttributeSource(attributeName, dataType)); - } + 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<>(); @@ -229,12 +186,13 @@ private void parseObjectClass(Element element, String parentName, List 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"))); @@ -51,8 +73,12 @@ void convertsTargetsToPathKeys() { } 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 FomCatalog(new FOMXML(simConfig, decoderRegistry)); + return new FOMXML(simConfig, decoderRegistry); } } From 142d2e8b3191846c5d07356398afe12dcbd5a51f Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Fri, 10 Jul 2026 12:10:14 -0400 Subject: [PATCH 22/26] pull out StatementInjectionParser and update consumers --- .../hlaxapi/TriggerProcessor.java | 138 ++++++------- .../cache/QueryReferenceCollector.java | 73 +++---- .../hlaxapi/config/ConfigConverter.java | 5 +- .../injection/StatementInjectionParser.java | 184 ++++++++++++++++++ .../StatementInjectionParserTest.java | 71 +++++++ 5 files changed, 352 insertions(+), 119 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java index b5cd490..71a17dc 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java @@ -1,9 +1,8 @@ package com.yetanalytics.hlaxapi; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -18,13 +17,18 @@ import com.fasterxml.jackson.databind.node.TextNode; import com.yetanalytics.hlaxapi.cache.CachedObject; import com.yetanalytics.hlaxapi.cache.ValueResolution; -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.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 { @@ -77,8 +81,6 @@ private Map resolveLookups(StatementTrigger trigger, Injec return lookupObjects; } - private static final Pattern INLINE_PLACEHOLDER = Pattern.compile("<<(.+?)>>", Pattern.DOTALL); - private JsonNode processNode( JsonNode node, InjectionContext context, @@ -100,15 +102,12 @@ private JsonNode processNode( } 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, lookupObjects); - } + 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, lookupObjects)); @@ -117,42 +116,45 @@ private JsonNode processNode( } 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, lookupObjects); + 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; @@ -164,47 +166,45 @@ private JsonNode processNode( } } - private JsonNode handleInjectionArray(JsonNode injArray, InjectionContext context, ObjectMapper mapper, - Boolean embedded, Map lookupObjects) { + 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); + if (injection instanceof ThisInjection thisInjection) { return renderResolution( - injectionHandler.handleThisResolution(t, context), - optionsFor(injArray, 2), - injectionDescription(iType, null, t), + injectionHandler.handleThisResolution(thisInjection.target(), context), + thisInjection.options(), + injectionDescription(thisInjection, null), embedded, mapper); - } 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); + } else if (injection instanceof QueryInjection queryInjection) { return renderResolution( - injectionHandler.handleQueryResolution(clazz, attr, criteriaExpr, context), - optionsFor(injArray, 4), - injectionDescription(iType, clazz, attr), + injectionHandler.handleQueryResolution( + queryInjection.className(), + queryInjection.target(), + queryInjection.criteria(), + context), + queryInjection.options(), + injectionDescription(queryInjection, queryInjection.className()), embedded, mapper); - } else if (iType == InjectionType.LOOKUP && injArray.size() >= 3) { - String alias = injArray.get(1).asText(); - Object rawTarget = mapper.convertValue(injArray.get(2), Object.class); - Target attr = ConfigConverter.toTarget(rawTarget); + } else if (injection instanceof LookupInjection lookupInjection) { return renderResolution( - injectionHandler.handleLookupResolution(lookupObjects.get(alias), attr), - optionsFor(injArray, 3), - injectionDescription(iType, alias, attr), + 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; } @@ -236,7 +236,7 @@ private JsonNode renderResolution( } Object replacement = resolution.value(); if (replacement == null) { - if (!options.nullable) { + if (!options.nullable()) { throw new RequiredInjectionException(description + " failed: unexpected null value"); } return NullNode.instance; @@ -244,27 +244,17 @@ private JsonNode renderResolution( return render(replacement, embedded, mapper); } - private InjectionOptions optionsFor(JsonNode injArray, int index) { - if (injArray.size() <= index || !injArray.get(index).isObject()) { - return InjectionOptions.DEFAULT; - } - return new InjectionOptions(injArray.get(index).path("nullable").asBoolean(false)); - } - - private String injectionDescription(InjectionType type, String scope, Target target) { - StringBuilder description = new StringBuilder(type.toString()); + 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 record InjectionOptions(boolean nullable) { - private static final InjectionOptions DEFAULT = new InjectionOptions(false); - } - private static class RequiredInjectionException extends RuntimeException { RequiredInjectionException(String message) { super(message); diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java index 8380762..2c92d06 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java @@ -2,25 +2,27 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.yetanalytics.hlaxapi.config.ConfigConverter; 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; -import java.util.regex.Matcher; -import java.util.regex.Pattern; public final class QueryReferenceCollector { - private static final Pattern INLINE_PLACEHOLDER = Pattern.compile("<<(.+?)>>", Pattern.DOTALL); private static final ObjectMapper MAPPER = new ObjectMapper(); private QueryReferenceCollector() { @@ -65,7 +67,7 @@ private static Map collectLookupDefinitions( private static void collectFromNode( JsonNode node, Map> references, - Map lookupClasses) throws IOException { + Map lookupClasses) { if (node == null || node.isNull()) { return; } @@ -76,12 +78,9 @@ private static void collectFromNode( return; } if (node.isArray()) { - if (isQueryInjection(node)) { - collectQuery(node, references); - return; - } - if (isLookupInjection(node)) { - collectLookup(node, references, lookupClasses); + ParseResult parsed = StatementInjectionParser.parse(node); + if (parsed.valid()) { + collectInjection(parsed.injection(), references, lookupClasses); return; } for (JsonNode child : node) { @@ -90,59 +89,45 @@ private static void collectFromNode( return; } if (node.isTextual()) { - Matcher matcher = INLINE_PLACEHOLDER.matcher(node.asText()); - while (matcher.find()) { - JsonNode inner = MAPPER.readTree(matcher.group(1)); - if (isQueryInjection(inner)) { - collectQuery(inner, references); - } else if (isLookupInjection(inner)) { - collectLookup(inner, references, lookupClasses); + for (InlineInjection inline : StatementInjectionParser.findInline(node.asText())) { + if (inline.result().valid()) { + collectInjection(inline.result().injection(), references, lookupClasses); } } } } - private static boolean isQueryInjection(JsonNode node) { - return node != null - && node.isArray() - && node.size() >= 4 - && node.get(0).isTextual() - && "query".equalsIgnoreCase(node.get(0).asText()); - } - - private static boolean isLookupInjection(JsonNode node) { - return node != null - && node.isArray() - && node.size() >= 3 - && node.get(0).isTextual() - && "lookup".equalsIgnoreCase(node.get(0).asText()); + 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(JsonNode queryNode, Map> references) { - String className = queryNode.get(1).asText(null); + private static void collectQuery(QueryInjection query, Map> references) { + String className = query.className(); if (className == null || className.isBlank()) { return; } - Target target = ConfigConverter.toTarget(MAPPER.convertValue(queryNode.get(2), Object.class)); - addTarget(references, className, target); - - Object criteriaRaw = MAPPER.convertValue(queryNode.get(3), Object.class); - collectCriteriaTargets(references, className, ConfigConverter.toExpression(criteriaRaw)); + addTarget(references, className, query.target()); + collectCriteriaTargets(references, className, query.criteria()); } private static void collectLookup( - JsonNode lookupNode, + LookupInjection lookup, Map> references, Map lookupClasses) { - String alias = lookupNode.get(1).asText(null); - String className = lookupClasses.get(alias); + String className = lookupClasses.get(lookup.alias()); if (className == null || className.isBlank()) { return; } - Target target = ConfigConverter.toTarget(MAPPER.convertValue(lookupNode.get(2), Object.class)); - addTarget(references, className, target); + addTarget(references, className, lookup.target()); } private static void collectCriteriaTargets( 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/injection/StatementInjectionParser.java b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java new file mode 100644 index 0000000..5860e3f --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java @@ -0,0 +1,184 @@ +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; + } + return new InjectionOptions(node.get(index).path(InjectionOptions.NULLABLE_KEY).asBoolean(false)); + } + + 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) { + + public static final String NULLABLE_KEY = "nullable"; + public static final InjectionOptions DEFAULT = new InjectionOptions(false); + } + + 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/hlaxapi/injection/StatementInjectionParserTest.java b/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java new file mode 100644 index 0000000..1736029 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java @@ -0,0 +1,71 @@ +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); + + 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()); + + 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()); + } + + @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()); + } +} From 1acdc10f0a6d8d8a3c72bd60c24ba5a7de136636 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Fri, 10 Jul 2026 14:53:57 -0400 Subject: [PATCH 23/26] extract criteria evaluation for future usage --- .../hlaxapi/cache/CacheQueryService.java | 88 +----------- .../hlaxapi/criteria/CriteriaEvaluator.java | 95 +++++++++++++ .../hlaxapi/criteria/ExpressionResolver.java | 10 ++ .../criteria/CriteriaEvaluatorTest.java | 126 ++++++++++++++++++ 4 files changed, 236 insertions(+), 83 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/criteria/CriteriaEvaluator.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/criteria/ExpressionResolver.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/criteria/CriteriaEvaluatorTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java b/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java index 62812fa..e603ced 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java @@ -1,13 +1,8 @@ package com.yetanalytics.hlaxapi.cache; -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 com.yetanalytics.hlaxapi.criteria.CriteriaEvaluator; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -16,9 +11,11 @@ public class CacheQueryService { private final HlaObjectCache cache; + private final CriteriaEvaluator criteriaEvaluator; public CacheQueryService(HlaObjectCache cache) { this.cache = Objects.requireNonNull(cache, "cache"); + this.criteriaEvaluator = new CriteriaEvaluator(); } public Optional findFirstValue(String className, Target target, Expression criteria) { @@ -75,52 +72,14 @@ public List findValues(String className, Target target, Expression crite } public boolean matches(CachedObject object, Expression expression) { - if (expression == null) { - return true; - } - if (expression instanceof Criterion criterion) { - return evaluateCriterion(object, criterion); - } - if (expression instanceof LogicalExpression logical) { - if (logical.operator == LogicalOperator.OR) { - return logical.operands.stream().anyMatch(operand -> matches(object, operand)); - } - return logical.operands.stream().allMatch(operand -> matches(object, operand)); - } - Object value = resolveExpression(object, expression); - return Boolean.TRUE.equals(value); - } - - private boolean evaluateCriterion(CachedObject object, Criterion criterion) { - Object left = resolveExpression(object, criterion.left); - Object right = resolveExpression(object, criterion.right); - ComparisonOperator operator = criterion.operator; - if (operator == null) { - return false; - } - return compare(left, right, operator); + return criteriaEvaluator.matches(expression, leaf -> resolveCacheExpression(object, leaf)); } - private Object resolveExpression(CachedObject object, Expression expression) { - if (expression == null) { - return null; - } + private Object resolveCacheExpression(CachedObject object, Expression expression) { if (expression instanceof Target target) { ValueResolution resolution = resolveTarget(object, target); return resolution.present() ? resolution.value() : null; } - if (expression instanceof ValueExpression valueExpression) { - return valueExpression.value; - } - if (expression instanceof ThisExpression) { - return null; - } - if (expression instanceof Criterion criterion) { - return evaluateCriterion(object, criterion); - } - if (expression instanceof LogicalExpression logical) { - return matches(object, logical); - } return null; } @@ -134,41 +93,4 @@ private ValueResolution resolveTarget(CachedObject object, Target target) { .orElseGet(ValueResolution::missingValue); } - 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/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/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)); + } +} From 96553af7c955bcbc0b37a960ba7d36f2b28d35cf Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Fri, 10 Jul 2026 15:47:25 -0400 Subject: [PATCH 24/26] collapse HlaObjectCache with ObjectCache --- .../hlaxapi/cache/CacheQueryService.java | 4 +- .../hlaxapi/cache/HlaObjectCache.java | 456 ----------------- .../hlaxapi/cache/ObjectCache.java | 481 +++++++++++++++++- ...t.java => ObjectCachePersistenceTest.java} | 42 +- .../hlaxapi/cache/ObjectCacheTest.java | 17 + 5 files changed, 501 insertions(+), 499 deletions(-) delete mode 100644 src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java rename src/test/java/com/yetanalytics/hlaxapi/cache/{HlaObjectCacheTest.java => ObjectCachePersistenceTest.java} (86%) diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java b/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java index e603ced..6631f26 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/CacheQueryService.java @@ -10,10 +10,10 @@ public class CacheQueryService { - private final HlaObjectCache cache; + private final ObjectCache cache; private final CriteriaEvaluator criteriaEvaluator; - public CacheQueryService(HlaObjectCache cache) { + public CacheQueryService(ObjectCache cache) { this.cache = Objects.requireNonNull(cache, "cache"); this.criteriaEvaluator = new CriteriaEvaluator(); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java deleted file mode 100644 index 14c554f..0000000 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/HlaObjectCache.java +++ /dev/null @@ -1,456 +0,0 @@ -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 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.List; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicLong; - -public class HlaObjectCache implements AutoCloseable { - - private static final int SCHEMA_VERSION = 1; - - private final Connection connection; - private final FomCatalog catalog; - private final HlaValueFlattener valueFlattener; - private final CacheQueryService queryService; - private final ObjectMapper mapper = new ObjectMapper(); - private final AtomicLong sequence = new AtomicLong(); - - public HlaObjectCache( - String jdbcUrl, - FomCatalog catalog, - FOMXML fomXml, - HLADecoderRegistry decoderRegistry) { - try { - this.connection = DriverManager.getConnection(Objects.requireNonNull(jdbcUrl, "jdbcUrl")); - this.catalog = Objects.requireNonNull(catalog, "catalog"); - this.valueFlattener = new HlaValueFlattener(fomXml, decoderRegistry); - this.queryService = new CacheQueryService(this); - initializeSchema(); - seedFomMetadata(); - } catch (SQLException e) { - throw new IllegalStateException("Could not initialize 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; - } - - public FomCatalog catalog() { - return catalog; - } - - public CacheQueryService queryService() { - return queryService; - } - - public synchronized void discoverObject(String objectHandle, String objectName, String className) { - FomCatalog.ObjectClassDef clazz = requireClass(className); - ensureObject(objectHandle, objectName, clazz.localName()); - } - - public synchronized void removeObject(String objectHandle) { - 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 void reflectAttributeValue( - String objectHandle, - String className, - String attributeName, - byte[] bytes) { - 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 Optional findCurrentValue(long instanceId, String pathKey) { - 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) { - 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) { - 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() { - try { - connection.close(); - } catch (SQLException e) { - throw new IllegalStateException("Could not close HLA object cache", e); - } - } - - 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); - } - } -} diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java index 5f04ad7..8746437 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/ObjectCache.java @@ -1,25 +1,44 @@ 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 HlaObjectCache delegate; + 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, HlaObjectCache.defaultJdbcUrl()); + this(xapiConfig, catalog, fomXml, decoderRegistry, defaultJdbcUrl()); } ObjectCache( @@ -28,15 +47,23 @@ public ObjectCache(XapiConfig xapiConfig, FomCatalog catalog, FOMXML fomXml, HLA FOMXML fomXml, HLADecoderRegistry decoderRegistry, String jdbcUrl) { - this.catalog = catalog; + this.catalog = Objects.requireNonNull(catalog, "catalog"); this.subscriptions = collectSubscriptions(xapiConfig); + this.valueFlattener = new HlaValueFlattener(fomXml, decoderRegistry); + this.queryService = new CacheQueryService(this); if (!subscriptions.isEmpty()) { - this.delegate = new HlaObjectCache(jdbcUrl, catalog, fomXml, decoderRegistry); + 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 delegate != null; + return connection != null; } public Map> subscriptions() { @@ -47,64 +74,464 @@ public FomCatalog catalog() { return catalog; } + public CacheQueryService queryService() { + return queryService; + } + public Optional findFirstValue(String clazz, Target attrTarget, Expression criteria) { - if (delegate == null) { + if (!isEnabled()) { return Optional.empty(); } - return delegate.queryService().findFirstValue(clazz, attrTarget, criteria); + return queryService.findFirstValue(clazz, attrTarget, criteria); } public ValueResolution findFirstResolution(String clazz, Target attrTarget, Expression criteria) { - if (delegate == null) { + if (!isEnabled()) { return ValueResolution.missingObject(); } - return delegate.queryService().findFirstResolution(clazz, attrTarget, criteria); + return queryService.findFirstResolution(clazz, attrTarget, criteria); } public Optional findFirstObject(String clazz, Expression criteria) { - if (delegate == null) { + if (!isEnabled()) { return Optional.empty(); } - return delegate.queryService().findFirstObject(clazz, criteria); + return queryService.findFirstObject(clazz, criteria); } public Optional findValue(CachedObject object, Target attrTarget) { - if (delegate == null) { + if (!isEnabled()) { return Optional.empty(); } - return delegate.queryService().findValue(object, attrTarget); + return queryService.findValue(object, attrTarget); } public ValueResolution findValueResolution(CachedObject object, Target attrTarget) { - if (delegate == null) { + if (!isEnabled()) { return ValueResolution.missingObject(); } - return delegate.queryService().findValueResolution(object, attrTarget); + 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 void discoverObject(String objectHandle, String objectName, String className) { - if (delegate != null) { - delegate.discoverObject(objectHandle, objectName, className); + 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 void reflectAttributeValue(String objectHandle, String className, String attributeName, byte[] bytes) { - if (delegate != null) { - delegate.reflectAttributeValue(objectHandle, className, attributeName, bytes); + 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 void removeObject(String objectHandle) { - if (delegate != null) { - delegate.removeObject(objectHandle); + 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 (delegate != null) { - delegate.close(); - delegate = null; + 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); } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java similarity index 86% rename from src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java rename to src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java index 7a4dbd5..04ff66b 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/HlaObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCachePersistenceTest.java @@ -9,11 +9,14 @@ 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; @@ -28,7 +31,7 @@ import org.junit.jupiter.api.io.TempDir; import org.portico.impl.hla1516e.types.encoding.HLA1516eEncoderFactory; -class HlaObjectCacheTest { +final class ObjectCachePersistenceTest { private final EncoderFactory encoderFactory = new HLA1516eEncoderFactory(); private final HLADecoderRegistry decoderRegistry = new HLADecoderRegistry(encoderFactory); @@ -39,7 +42,7 @@ class HlaObjectCacheTest { @Test void initializesSchemaAndSeedsFomMetadata() throws SQLException { - try (HlaObjectCache cache = newCache()) { + 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); @@ -51,7 +54,7 @@ void upsertsLatestScalarObjectStateAndKeepsRawBytes() throws SQLException { byte[] firstHunger = encoded(encoderFactory.createHLAinteger32BE(75)); byte[] secondHunger = encoded(encoderFactory.createHLAinteger32BE(40)); - try (HlaObjectCache cache = newCache()) { + 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); @@ -71,7 +74,7 @@ SELECT COUNT(*) void flattensFixedRecordValuesToNestedCurrentRows() { byte[] position = position(12, 8); - try (HlaObjectCache cache = newCache()) { + try (ObjectCache cache = newCache()) { cache.discoverObject("object-1", "Rabbit One", "Rabbit"); cache.reflectAttributeValue("object-1", "Rabbit", "Position", position); @@ -83,7 +86,7 @@ void flattensFixedRecordValuesToNestedCurrentRows() { @Test void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { - try (HlaObjectCache cache = newCache()) { + try (ObjectCache cache = newCache()) { cache.discoverObject("object-1", "Rabbit One", "Rabbit"); cache.reflectAttributeValue("object-1", "Rabbit", "EntityId", encoded(encoderFactory.createHLAASCIIstring( "rabbit-one"))); @@ -127,7 +130,7 @@ void queryServiceEvaluatesCriteriaAndExcludesRemovedObjects() { @Test void queryServiceDistinguishesPresentNullFromMissingValue() { - try (HlaObjectCache cache = newCache()) { + 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(); @@ -149,7 +152,7 @@ void queryServiceDistinguishesPresentNullFromMissingValue() { void persistentCacheStartsFreshOnInitialization(@TempDir Path tempDir) throws SQLException { String jdbcUrl = "jdbc:sqlite:" + tempDir.resolve("cache.sqlite"); - try (HlaObjectCache cache = newCache(jdbcUrl)) { + try (ObjectCache cache = newCache(jdbcUrl)) { cache.discoverObject("object-1", "Rabbit One", "Rabbit"); cache.reflectAttributeValue("object-1", "Rabbit", "Hunger", encoded(encoderFactory.createHLAinteger32BE(75))); @@ -158,19 +161,30 @@ void persistentCacheStartsFreshOnInitialization(@TempDir Path tempDir) throws SQ assertEquals(1, count(cache, "SELECT COUNT(*) FROM object_attribute_current")); } - try (HlaObjectCache cache = newCache(jdbcUrl)) { + 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 HlaObjectCache newCache() { + private ObjectCache newCache() { return newCache("jdbc:sqlite::memory:"); } - private HlaObjectCache newCache(String jdbcUrl) { - return new HlaObjectCache(jdbcUrl, catalog, fomXml, decoderRegistry); + 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) { @@ -188,18 +202,18 @@ private byte[] encoded(DataElement element) { } } - private long count(HlaObjectCache cache, String sql) throws SQLException { + private long count(ObjectCache cache, String sql) throws SQLException { return scalarLong(cache, sql); } - private long scalarLong(HlaObjectCache cache, String sql) throws SQLException { + 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(HlaObjectCache cache, String pathKey) throws SQLException { + private byte[] rawBytes(ObjectCache cache, String pathKey) throws SQLException { String sql = """ SELECT c.raw_bytes FROM object_attribute_current c diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index b844d3b..1f152d9 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -103,6 +103,23 @@ void enabledWhenTrackedObjectsExistWithoutQueryInjections(@TempDir Path tempDir) } } + @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( From fb3a086ac9865e4d2ff646e785b579df8b8ece0c Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Fri, 10 Jul 2026 16:00:39 -0400 Subject: [PATCH 25/26] add required injection option --- doc/xapi-config.md | 15 +++++-- .../hlaxapi/TriggerProcessor.java | 5 ++- .../injection/StatementInjectionParser.java | 10 +++-- .../com/yetanalytics/ConfigParserTest.java | 45 +++++++++++++++++-- .../StatementInjectionParserTest.java | 20 +++++++++ 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/doc/xapi-config.md b/doc/xapi-config.md index 56355e4..ca08af7 100644 --- a/doc/xapi-config.md +++ b/doc/xapi-config.md @@ -129,10 +129,19 @@ Whole-value injections preserve the replacement's JSON type. Inline injections a All injection types accept an optional final options object: ```json -["this", ["OptionalField"], {"nullable": true}] +["this", ["OptionalField"], {"required": false}] ``` -`nullable` only allows a resolved value of `null` to render as JSON `null` (or as the text `null` inline). It does not allow missing objects or missing values. A missing required injection aborts the statement; the trigger returns no xAPI statement. +The available options are: + +- `required`: Defaults to `true`. When `false`, a missing object, missing value, or resolved `null` renders as JSON `null` (or as the text `null` inline) and statement processing continues. +- `nullable`: Defaults to `false`. When `required` is `true`, this allows an explicitly resolved `null` to render, but still treats a missing object or value as an error. + +A failed required injection aborts the statement, and the trigger returns no xAPI statement. `nullable` is redundant when `required` is `false`. For example, an optional inline injection renders `Rabbit null` when the target cannot be resolved: + +```json +"name": "Rabbit <<[\"query\", \"Rabbit\", [\"Nickname\"], null, {\"required\": false}]>>" +``` ### `this` @@ -158,7 +167,7 @@ Arguments: - Class name: HLA object class to search, using the local FOM class name. - Target: cached value to return from the matched object. - Criteria: expression evaluated against cached values for each current object. -- Options: optional `{"nullable": true}`. +- Options: optional `{"required": false}` and/or `{"nullable": true}`. Queries use the adapter's current object cache, not arbitrary SQL provided in the config. Removed objects are excluded. If more than one object matches, the first cached object is used. diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java index 71a17dc..3845313 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java @@ -232,11 +232,14 @@ private JsonNode renderResolution( 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.nullable()) { + if (options.required() && !options.nullable()) { throw new RequiredInjectionException(description + " failed: unexpected null value"); } return NullNode.instance; diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java index 5860e3f..236e5e0 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java @@ -103,7 +103,10 @@ private static InjectionOptions options(JsonNode node, int index) { if (node.size() <= index || !node.get(index).isObject()) { return InjectionOptions.DEFAULT; } - return new InjectionOptions(node.get(index).path(InjectionOptions.NULLABLE_KEY).asBoolean(false)); + 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 @@ -147,10 +150,11 @@ public InjectionType type() { } } - public record InjectionOptions(boolean nullable) { + public record InjectionOptions(boolean nullable, boolean required) { public static final String NULLABLE_KEY = "nullable"; - public static final InjectionOptions DEFAULT = new InjectionOptions(false); + public static final String REQUIRED_KEY = "required"; + public static final InjectionOptions DEFAULT = new InjectionOptions(false, true); } public record ParseResult( diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index a05a095..da649f9 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -442,7 +442,7 @@ public ValueResolution handleLookupResolution(CachedObject object, Target attrTa } @Test - public void lookupInjectionMissingObjectAbortsStatement() { + public void lookupInjectionMissingObjectHonorsRequiredOption() { InjectionHandler ih = new InjectionHandler() { @Override public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) { @@ -457,10 +457,17 @@ public Optional resolveLookup(ObjectLookup lookup, InjectionContex 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 lookupInjectionMissingValueAbortsStatementEvenWhenNullable() { + public void lookupInjectionMissingValueHonorsRequiredButNotNullable() { CachedObject matchedObject = new CachedObject(7, "object-7", "Predator", "SimEntity"); InjectionHandler ih = new InjectionHandler() { @Override @@ -481,6 +488,15 @@ public ValueResolution handleLookupResolution(CachedObject object, Target attrTa 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 @@ -511,10 +527,19 @@ public ValueResolution handleLookupResolution(CachedObject object, Target attrTa 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 queryAndThisUnexpectedNullsAbortStatement() { + public void queryAndThisMissingValuesHonorRequiredOption() { InjectionHandler ih = new InjectionHandler() { @Override public ValueResolution handleQueryResolution( @@ -538,6 +563,20 @@ public ValueResolution handleThisResolution(Target t, InjectionContext context) 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) { diff --git a/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java b/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java index 1736029..38233c1 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java @@ -26,6 +26,7 @@ void parsesTypedWholeNodeInjections() throws Exception { 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]]")); @@ -33,6 +34,7 @@ void parsesTypedWholeNodeInjections() throws Exception { 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}]")); @@ -40,6 +42,24 @@ void parsesTypedWholeNodeInjections() throws Exception { 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 From 7e7caef1f0603a5d8eed870b08c90b69661e2045 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Fri, 10 Jul 2026 16:03:57 -0400 Subject: [PATCH 26/26] add todo about nullable processTrigger return --- src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index 8dae677..be1c346 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -406,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);