diff --git a/.gitignore b/.gitignore index b09ee95..0789c14 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .vscode/ target/ logs/ +*.sqlite lib/ 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 diff --git a/config/xapi-config.json b/config/xapi-config.json index da1cf07..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://homepage.system.io", - "name": ["this", ["EntityId"]] - } - }, - "context": { - "extensions":{ - "https://yetanalytics.com/extensions/from-position": "[<<[\"this\", [\"FromPosition\", \"X\"]]>>, <<[\"this\", [\"FromPosition\", \"Y\"]]>>]" + "homePage": "https://hla-federepl.example/entities", + "name": ["this", ["PredatorId"]] } }, - "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 - } + } } diff --git a/doc/xapi-config.md b/doc/xapi-config.md new file mode 100644 index 0000000..ca08af7 --- /dev/null +++ b/doc/xapi-config.md @@ -0,0 +1,350 @@ +## 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"], {"required": false}] +``` + +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` + +`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 `{"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. + +`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. Use the `trackedObjects` array to force cacheing of simulation 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 + } +} +``` diff --git a/pom.xml b/pom.xml index 20effa0..4caf3cf 100644 --- a/pom.xml +++ b/pom.xml @@ -99,6 +99,11 @@ 3.20.0 compile + + org.xerial + sqlite-jdbc + 3.42.0.0 + org.springframework diff --git a/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java b/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java index 2945660..2a163e2 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java +++ b/src/main/java/com/yetanalytics/hlaxapi/AppConfig.java @@ -12,6 +12,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; +import com.yetanalytics.hlaxapi.cache.FomCatalog; +import com.yetanalytics.hlaxapi.cache.ObjectCache; import com.yetanalytics.hlaxapi.config.ConfigParser; import com.yetanalytics.hlaxapi.config.XapiConfig; @@ -42,6 +44,15 @@ public HLADecoderRegistry hlaDecoderRegistry(EncoderFactory encoderFactory) { return new HLADecoderRegistry(encoderFactory); } + @Bean(destroyMethod = "close") + public ObjectCache objectCache( + XapiConfig xapiConfig, + FomCatalog fomCatalog, + FOMXML fomXml, + HLADecoderRegistry decoderRegistry) { + return new ObjectCache(xapiConfig, fomCatalog, fomXml, decoderRegistry); + } + @Bean public XapiConfig xapiConfig() { XapiConfig xapiConfig; diff --git a/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java b/src/main/java/com/yetanalytics/hlaxapi/FOMXML.java index e048784..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; @@ -25,7 +26,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 { @@ -44,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) { @@ -207,49 +210,121 @@ 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; } - public Document getDoc() { - return doc; + public HLADecoderRegistry getDecoderRegistry() { + return decoderRegistry; } - public void setDoc(Document doc) { - this.doc = doc; + public void setDecoderRegistry(HLADecoderRegistry decoderRegistry) { + this.decoderRegistry = decoderRegistry; } - public XPath getxPath() { - return xPath; + /** + * Return the object-class hierarchy as immutable, XML-free definitions. + * + *

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