From d7dceac1b5e89292f2af5da6e0453f7f662b706a Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 12:25:24 -0400 Subject: [PATCH 01/14] add query and lookup expression models --- .../config/model/LookupExpression.java | 18 +++++++++++++++++ .../hlaxapi/config/model/QueryExpression.java | 20 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/config/model/LookupExpression.java create mode 100644 src/main/java/com/yetanalytics/hlaxapi/config/model/QueryExpression.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/LookupExpression.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/LookupExpression.java new file mode 100644 index 0000000..0c9ecbe --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/LookupExpression.java @@ -0,0 +1,18 @@ +package com.yetanalytics.hlaxapi.config.model; + +/** Reads a target value from a named trigger lookup. */ +public class LookupExpression implements Expression { + + public final String alias; + public final Target target; + + public LookupExpression(String alias, Target target) { + this.alias = alias; + this.target = target; + } + + @Override + public String toString() { + return String.format("Lookup(%s, %s)", alias, target); + } +} diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/QueryExpression.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/QueryExpression.java new file mode 100644 index 0000000..dc38156 --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/QueryExpression.java @@ -0,0 +1,20 @@ +package com.yetanalytics.hlaxapi.config.model; + +/** Reads a target value from the first cached object matching the query criteria. */ +public class QueryExpression implements Expression { + + public final String clazz; + public final Target target; + public final Expression criteria; + + public QueryExpression(String clazz, Target target, Expression criteria) { + this.clazz = clazz; + this.target = target; + this.criteria = criteria; + } + + @Override + public String toString() { + return String.format("Query(%s, %s, %s)", clazz, target, criteria); + } +} From c4696a9fe4be1bd4892311eaa2b4e3b450a7469d Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 12:43:05 -0400 Subject: [PATCH 02/14] criteria expression parser & test --- .../config/CriteriaExpressionParser.java | 164 ++++++++++++++++++ .../config/CriteriaExpressionParserTest.java | 95 ++++++++++ 2 files changed, 259 insertions(+) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java new file mode 100644 index 0000000..2c94eb2 --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java @@ -0,0 +1,164 @@ +package com.yetanalytics.hlaxapi.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +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.InjectionType; +import com.yetanalytics.hlaxapi.config.model.LogicalExpression; +import com.yetanalytics.hlaxapi.config.model.LogicalOperator; +import com.yetanalytics.hlaxapi.config.model.LookupExpression; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; +import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import java.util.ArrayList; +import java.util.List; + +/** Parses criteria JSON into the expression model used by every criteria context. */ +public final class CriteriaExpressionParser { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private CriteriaExpressionParser() { + } + + public static Expression parse(JsonNode node) { + if (node == null || node.isNull()) { + return new ValueExpression(null); + } + if (!node.isArray()) { + return new ValueExpression(MAPPER.convertValue(node, Object.class)); + } + if (node.isEmpty()) { + throw new IllegalArgumentException("expression arrays must not be empty"); + } + + InjectionType injectionType = injectionType(node); + if (injectionType != null) { + return parseInjection(node, injectionType); + } + + if (isComparison(node)) { + ComparisonOperator operator = ComparisonOperator.fromString(node.get(1).asText()); + return new Criterion(parse(node.get(0)), operator, parse(node.get(2))); + } + + if (containsLogicalOperator(node)) { + return parseLogical(node); + } + + return parseTarget(node); + } + + public static Expression parseNullable(JsonNode node) { + return node == null || node.isNull() ? null : parse(node); + } + + private static Expression parseInjection(JsonNode node, InjectionType type) { + return switch (type) { + case TRIGGER -> { + requireArity(node, 2, type); + yield new TriggerExpression(parseTarget(node.get(1))); + } + case QUERY -> { + requireArity(node, 4, type); + String className = requireText(node.get(1), "query class"); + Target target = parseTarget(node.get(2)); + Expression criteria = parseNullable(node.get(3)); + yield new QueryExpression(className, target, criteria); + } + case LOOKUP -> { + requireArity(node, 3, type); + String alias = requireText(node.get(1), "lookup alias"); + yield new LookupExpression(alias, parseTarget(node.get(2))); + } + }; + } + + private static LogicalExpression parseLogical(JsonNode node) { + if (node.size() < 3 || node.size() % 2 == 0) { + throw new IllegalArgumentException( + "logical expressions must alternate operands and operators"); + } + LogicalOperator operator = null; + List operands = new ArrayList<>(); + for (int index = 0; index < node.size(); index++) { + if (index % 2 == 0) { + operands.add(parse(node.get(index))); + continue; + } + JsonNode operatorNode = node.get(index); + LogicalOperator next = operatorNode.isTextual() + ? LogicalOperator.fromString(operatorNode.asText()) + : null; + if (next == null) { + throw new IllegalArgumentException("invalid logical operator at index " + index); + } + if (operator != null && operator != next) { + throw new IllegalArgumentException( + "mixed logical operators require an explicitly nested expression"); + } + operator = next; + } + return new LogicalExpression(operator, List.copyOf(operands)); + } + + private static Target parseTarget(JsonNode node) { + if (node == null || !node.isArray() || node.isEmpty()) { + throw new IllegalArgumentException("target must be a non-empty array"); + } + List parts = new ArrayList<>(); + for (JsonNode part : node) { + if (part.isTextual()) { + parts.add(part.asText()); + } else if (part.isIntegralNumber() && part.canConvertToInt()) { + int index = part.asInt(); + if (index < 0) { + throw new IllegalArgumentException("target indexes must be non-negative"); + } + parts.add(index); + } else { + throw new IllegalArgumentException("target parts must be strings or integer indexes"); + } + } + return new Target(List.copyOf(parts)); + } + + private static boolean isComparison(JsonNode node) { + return node.size() == 3 + && node.get(1).isTextual() + && ComparisonOperator.fromString(node.get(1).asText()) != null; + } + + private static boolean containsLogicalOperator(JsonNode node) { + for (int index = 1; index < node.size(); index += 2) { + if (node.get(index).isTextual() + && LogicalOperator.fromString(node.get(index).asText()) != null) { + return true; + } + } + return false; + } + + private static InjectionType injectionType(JsonNode node) { + return node.get(0).isTextual() + ? InjectionType.fromString(node.get(0).asText()) + : null; + } + + private static void requireArity(JsonNode node, int expected, InjectionType type) { + if (node.size() != expected) { + throw new IllegalArgumentException( + type + " criteria expression requires exactly " + expected + " elements"); + } + } + + private static String requireText(JsonNode node, String description) { + if (node == null || !node.isTextual() || node.asText().isBlank()) { + throw new IllegalArgumentException(description + " must be a non-empty string"); + } + return node.asText(); + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java b/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java new file mode 100644 index 0000000..4160fc1 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java @@ -0,0 +1,95 @@ +package com.yetanalytics.hlaxapi.config; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +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.Expression; +import com.yetanalytics.hlaxapi.config.model.LogicalExpression; +import com.yetanalytics.hlaxapi.config.model.LookupExpression; +import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class CriteriaExpressionParserTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void parsesEveryTriggerValueSourceInNestedExpressions() throws Exception { + Expression expression = CriteriaExpressionParser.parse(MAPPER.readTree(""" + [ + [["trigger", ["Score"]], ">", 10], + "and", + [ + ["query", "World", ["Size"], [["WorldId"], "=", ["trigger", ["WorldId"]]]], + ">=", + ["lookup", "subject", ["MinimumSize"]] + ] + ] + """)); + + LogicalExpression logical = assertInstanceOf(LogicalExpression.class, expression); + Criterion eventCriterion = assertInstanceOf(Criterion.class, logical.operands.get(0)); + Criterion cacheCriterion = assertInstanceOf(Criterion.class, logical.operands.get(1)); + assertInstanceOf(TriggerExpression.class, eventCriterion.left); + assertInstanceOf(QueryExpression.class, cacheCriterion.left); + assertInstanceOf(LookupExpression.class, cacheCriterion.right); + + ObjectLookup definition = new ObjectLookup(); + definition.clazz = "World"; + assertDoesNotThrow(() -> CriteriaExpressionValidator.validateTrigger(expression, Map.of("subject", definition))); + } + + @Test + void preservesNullComparisonOperands() throws Exception { + Criterion criterion = assertInstanceOf( + Criterion.class, + CriteriaExpressionParser.parse(MAPPER.readTree(""" + [["query", "Rabbit", ["Nickname"], null], "=", null] + """))); + + ValueExpression right = assertInstanceOf(ValueExpression.class, criterion.right); + assertTrue(right.value == null); + } + + @Test + void rejectsExpressionRenderingOptionsAndMixedLogicalOperators() throws Exception { + assertThrows( + IllegalArgumentException.class, + () -> CriteriaExpressionParser.parse(MAPPER.readTree(""" + ["query", "Rabbit", ["Hunger"], null, {"required": false}] + """))); + assertThrows( + IllegalArgumentException.class, + () -> CriteriaExpressionParser.parse(MAPPER.readTree(""" + [[true, "=", true], "and", [true, "=", true], "or", [true, "=", true]] + """))); + } + + @Test + void triggerValidationRejectsBareTargetsAndInvalidLookupDefinitions() throws Exception { + Expression bareTarget = CriteriaExpressionParser.parse(MAPPER.readTree("[[\"Score\"], \">\", 10]")); + IllegalArgumentException bareTargetError = assertThrows( + IllegalArgumentException.class, + () -> CriteriaExpressionValidator.validateTrigger(bareTarget, Map.of())); + assertTrue(bareTargetError.getMessage().contains("use [\"trigger\"")); + + Expression unknownLookup = CriteriaExpressionParser.parse(MAPPER.readTree( + "[[\"lookup\", \"missing\", [\"Score\"]], \">\", 10]")); + assertThrows( + IllegalArgumentException.class, + () -> CriteriaExpressionValidator.validateTrigger(unknownLookup, Map.of())); + + ObjectLookup missingClass = new ObjectLookup(); + assertThrows( + IllegalArgumentException.class, + () -> CriteriaExpressionValidator.validateTrigger(unknownLookup, Map.of("missing", missingClass))); + } +} From a7c4ebfb02c936f4f9dd915ebff445b66206bcff Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 12:46:24 -0400 Subject: [PATCH 03/14] add missing validator --- .../config/CriteriaExpressionValidator.java | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java new file mode 100644 index 0000000..a781d89 --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java @@ -0,0 +1,83 @@ +package com.yetanalytics.hlaxapi.config; + +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.LookupExpression; +import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; +import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import java.util.Map; + +/** Enforces the value sources permitted by each criteria evaluation context. */ +public final class CriteriaExpressionValidator { + + private enum Context { + TRIGGER, + CACHE_FILTER + } + + private CriteriaExpressionValidator() { + } + + public static void validateTrigger(Expression criteria, Map lookupDefinitions) { + Map definitions = lookupDefinitions == null ? Map.of() : lookupDefinitions; + visit(criteria, Context.TRIGGER, definitions, "criteria"); + } + + public static void validateCacheFilter(Expression criteria) { + visit(criteria, Context.CACHE_FILTER, Map.of(), "criteria"); + } + + private static void visit( + Expression expression, + Context context, + Map definitions, + String location) { + if (expression == null || expression instanceof ValueExpression) { + return; + } + if (expression instanceof Criterion criterion) { + visit(criterion.left, context, definitions, location + ".left"); + visit(criterion.right, context, definitions, location + ".right"); + return; + } + if (expression instanceof LogicalExpression logical) { + for (int index = 0; index < logical.operands.size(); index++) { + visit(logical.operands.get(index), context, definitions, location + "[" + index + "]"); + } + return; + } + if (expression instanceof TriggerExpression) { + return; + } + if (context == Context.CACHE_FILTER && expression instanceof Target) { + return; + } + if (context == Context.TRIGGER && expression instanceof QueryExpression query) { + visit(query.criteria, Context.CACHE_FILTER, definitions, location + ".queryFilter"); + return; + } + if (context == Context.TRIGGER && expression instanceof LookupExpression lookup) { + ObjectLookup definition = definitions.get(lookup.alias); + if (definition == null) { + throw new IllegalArgumentException( + location + " references unknown lookup alias '" + lookup.alias + "'"); + } + if (definition.clazz == null || definition.clazz.isBlank()) { + throw new IllegalArgumentException( + location + " references lookup alias '" + lookup.alias + "' without a class"); + } + return; + } + if (context == Context.TRIGGER && expression instanceof Target target) { + throw new IllegalArgumentException( + location + " contains bare target " + target.parts + + "; use [\"trigger\", [...]] for incoming event values"); + } + throw new IllegalArgumentException( + location + " contains unsupported " + expression.getClass().getSimpleName()); + } +} From a54fafbcc9be08d5cffe4af85277779057a3cdd7 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 15:08:25 -0400 Subject: [PATCH 04/14] lazy lookup caches lookup results as they are retrieved one at a time --- .../hlaxapi/LazyLookupContext.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/LazyLookupContext.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/LazyLookupContext.java b/src/main/java/com/yetanalytics/hlaxapi/LazyLookupContext.java new file mode 100644 index 0000000..b339d3b --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/LazyLookupContext.java @@ -0,0 +1,45 @@ +package com.yetanalytics.hlaxapi; + +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.Target; +import com.yetanalytics.hlaxapi.injection.InjectionContext; +import com.yetanalytics.hlaxapi.injection.TestInjectionContext; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** Holds lookup results that are scoped to one statement-trigger attempt. */ +final class LazyLookupContext { + + private final InjectionHandler handler; + private final InjectionContext injectionContext; + private final Map definitions; + private final Map> objects = new HashMap<>(); + + LazyLookupContext( + InjectionHandler handler, + InjectionContext injectionContext, + Map definitions) { + this.handler = handler; + this.injectionContext = injectionContext; + this.definitions = definitions == null ? Map.of() : definitions; + } + + ValueResolution value(String alias, Target target) { + if (injectionContext instanceof TestInjectionContext) { + return handler.handleLookup(null, target, injectionContext); + } + return handler.handleLookup(object(alias), target, injectionContext); + } + + private CachedObject object(String alias) { + return objects.computeIfAbsent(alias, this::load).orElse(null); + } + + private Optional load(String alias) { + Optional result = handler.resolveLookup(definitions.get(alias), injectionContext); + return result == null ? Optional.empty() : result; + } +} From 765a0b5a8787bcc1ce0c80e24f5dc2c68487dd04 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 15:15:22 -0400 Subject: [PATCH 05/14] wire in criteria parser to config parser --- .../hlaxapi/config/ConfigParser.java | 92 +++---------------- .../config/model/StatementTrigger.java | 2 +- .../com/yetanalytics/ConfigParserTest.java | 51 ++++++++++ src/test/resources/config-test.json | 6 +- 4 files changed, 69 insertions(+), 82 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java index 85413b5..03b24d3 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigParser.java @@ -3,9 +3,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -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; @@ -51,15 +48,22 @@ public XapiConfig parse() { JsonNode st = root.get("statementTriggers"); if (st != null && st.isArray()) { List triggers = new ArrayList<>(); - for (JsonNode tnode : st) { + for (int triggerIndex = 0; triggerIndex < st.size(); triggerIndex++) { + JsonNode tnode = st.get(triggerIndex); StatementTrigger stt = new StatementTrigger(); stt.type = StatementTrigger.Type.fromString(tnode.path("type").asText(null)); stt.skipValidation = tnode.path("skipValidation").asBoolean(false); // map "class" json prop to clazz stt.clazz = tnode.path("class").asText(null); - Object rawCrit = parseCriteriaNode(tnode.get("criteria")); - stt.criteria = ConfigConverter.toCriterion(rawCrit); stt.lookups = parseLookups(tnode.get("lookups")); + try { + stt.criteria = CriteriaExpressionParser.parseNullable(tnode.get("criteria")); + CriteriaExpressionValidator.validateTrigger(stt.criteria, stt.lookups); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "statementTriggers[" + triggerIndex + "].criteria: " + e.getMessage(), + e); + } if (tnode.has("statement")) { try { stt.statement = mapper.writeValueAsString(tnode.get("statement")); @@ -121,83 +125,11 @@ private Map parseLookups(JsonNode node) { } 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); + lookup.criteria = CriteriaExpressionParser.parseNullable(lookupNode.get("criteria")); + CriteriaExpressionValidator.validateCacheFilter(lookup.criteria); 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; - - if (node.isArray()) { - ArrayNode an = (ArrayNode) node; - // Could be nested criteria: e.g. [criteria, "or", criteria] - if (an.size() == 3 && !isLogicalOperator(an.get(1))) { - Object target = parseTargetSyntax(an.get(0)); - String op = an.get(1).asText(); - Object val = parseValueNode(an.get(2)); - return List.of(target, op, val); - } - // Otherwise treat as raw array/compound expression - List out = new ArrayList<>(); - for (JsonNode el : an) { - if (el.isTextual() - && (LogicalOperator.fromString(el.asText().toLowerCase()) != null)) { - out.add(el.asText().toLowerCase()); - } else { - out.add(parseCriteriaNode(el)); - } - } - return out; - } - - // fallback to primitive - if (node.isTextual()) return node.asText(); - if (node.isNumber()) return node.numberValue(); - if (node.isBoolean()) return node.booleanValue(); - return mapper.convertValue(node, Object.class); - } - - private boolean isLogicalOperator(JsonNode n) { - if (!n.isTextual()) return false; - String s = n.asText().toLowerCase(); - return LogicalOperator.fromString(s) != null; - } - - private Object parseTargetSyntax(JsonNode node) { - // target syntax is an array of strings and ints - if (node == null || !node.isArray()) return null; - List parts = new ArrayList<>(); - for (JsonNode el : node) { - if (el.isTextual()) parts.add(el.asText()); - else if (el.isInt()) parts.add(el.asInt()); - else if (el.isLong()) parts.add(el.longValue()); - else parts.add(mapper.convertValue(el, Object.class)); - } - return parts; - } - - private Object parseValueNode(JsonNode node) { - // value can be an injection syntax, a primitive, or another criteria - if (node == null || node.isNull()) return null; - if (node.isArray()) { - ArrayNode an = (ArrayNode) node; - if (an.size() > 0 && an.get(0).isTextual()) { - String keyword = an.get(0).asText(); - if (InjectionType.fromString(keyword) != null) { - // return the raw array as parsed JSON to be interpreted later - return mapper.convertValue(node, List.class); - } - } - // otherwise fallback - return mapper.convertValue(node, List.class); - } - if (node.isTextual()) return node.asText(); - if (node.isNumber()) return node.numberValue(); - if (node.isBoolean()) return node.booleanValue(); - return mapper.convertValue(node, Object.class); - } } 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 ee22573..d343c07 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/StatementTrigger.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/StatementTrigger.java @@ -6,7 +6,7 @@ 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 Expression criteria; public Map lookups; // keep the original statement as a JSON string (we'll process injections at // runtime) diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index 2b2a5df..3679016 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -5,6 +5,7 @@ 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 static org.junit.jupiter.api.Assertions.assertThrows; import java.io.IOException; import java.nio.ByteBuffer; @@ -41,7 +42,9 @@ 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.LookupExpression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; @@ -142,6 +145,54 @@ public void parsesTriggerLookups(@TempDir Path tempDir) throws IOException { assertTrue(criterion.right instanceof TriggerExpression); } + @Test + public void parsesQueriesAndLookupsInTriggerCriteria(@TempDir Path tempDir) throws IOException { + Path configPath = tempDir.resolve("xapi-config.json"); + Files.writeString(configPath, """ + { + "statementTriggers": [{ + "type": "Interaction", + "class": "EntityAte", + "lookups": {"subject": {"class": "SimEntity"}}, + "criteria": [ + ["query", "World", ["Size"], [["WorldId"], "=", ["trigger", ["WorldId"]]]], + ">", + ["lookup", "subject", ["MinimumWorldSize"]] + ], + "statement": {} + }] + } + """); + + Criterion criteria = (Criterion) ConfigParser.fromFile(configPath.toString()).parse() + .statementTriggers.get(0).criteria; + + assertTrue(criteria.left instanceof QueryExpression); + assertTrue(criteria.right instanceof LookupExpression); + } + + @Test + public void rejectsBareEventTargetsInTriggerCriteria(@TempDir Path tempDir) throws IOException { + Path configPath = tempDir.resolve("xapi-config.json"); + Files.writeString(configPath, """ + { + "statementTriggers": [{ + "type": "Interaction", + "class": "EntityAte", + "criteria": [["Score"], ">", 10], + "statement": {} + }] + } + """); + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> ConfigParser.fromFile(configPath.toString()).parse()); + + assertTrue(error.getMessage().contains("statementTriggers[0].criteria")); + assertTrue(error.getMessage().contains("use [\"trigger\"")); + } + @Test public void parsesObjectCacheTrackedObjects(@TempDir Path tempDir) throws IOException { Path configPath = tempDir.resolve("xapi-config.json"); diff --git a/src/test/resources/config-test.json b/src/test/resources/config-test.json index 3ba0f0d..ccb845f 100644 --- a/src/test/resources/config-test.json +++ b/src/test/resources/config-test.json @@ -3,7 +3,11 @@ { "type": "Interaction", "class": "EntityMoved", - "criteria": [["trigger", "key1"], "=", "thing1"], + "criteria": [ + ["trigger", ["FromPosition", "X"]], + "<", + ["trigger", ["ToPosition", "X"]] + ], "statement": { "actor": { "objectType": "Agent", From a69d339faca1f528b8ef2c4b14910e1e3bc4d060 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 15:24:48 -0400 Subject: [PATCH 06/14] enable criteria in trigger processor --- .../hlaxapi/HlaInterfaceImpl.java | 10 +- .../hlaxapi/TriggerCriteriaMatcher.java | 43 ++++ .../hlaxapi/TriggerProcessor.java | 72 +++--- .../cache/QueryReferenceCollector.java | 40 +++- .../injection/StatementInjectionParser.java | 7 +- .../hlaxapi/TriggerProcessorCriteriaTest.java | 211 ++++++++++++++++++ .../hlaxapi/cache/ObjectCacheTest.java | 29 +++ .../cache/QueryReferenceCollectorTest.java | 40 ++++ 8 files changed, 411 insertions(+), 41 deletions(-) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/TriggerCriteriaMatcher.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java index a324d56..5498505 100755 --- a/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java +++ b/src/main/java/com/yetanalytics/hlaxapi/HlaInterfaceImpl.java @@ -194,7 +194,9 @@ public void stop() throws RTIinternalError { public void validateConfig() throws XapiConfigurationException { for(StatementTrigger st : xapiConfig.statementTriggers){ if (st.skipValidation) continue; - TriggerProcessingResult tpr = triggerProcessor.processTrigger(st, new TestInjectionContext(st.clazz)); + TriggerProcessingResult tpr = triggerProcessor.renderTemplateForValidation( + st, + new TestInjectionContext(st.clazz)); if (tpr.success()) { StatementValidationResult svr = validator.validateStatement(tpr.statement()); if (!svr.isValid()){ @@ -434,15 +436,15 @@ 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 TriggerProcessingResult result = triggerProcessor.processTrigger(trigger, context); - if (result.success()){ + if (result.success() && result.matched()){ try { xapiClient.sendStatement(result.statement()); } catch (Exception e) { logger.error("Error parsing or posting statement {}", result.statement(), e); } - } else { + } else if (!result.success()) { + // TODO: DLQ logger.error("Error processing Interaction: {}", result.error().getMessage(), result.error()); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerCriteriaMatcher.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerCriteriaMatcher.java new file mode 100644 index 0000000..45eedbb --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerCriteriaMatcher.java @@ -0,0 +1,43 @@ +package com.yetanalytics.hlaxapi; + +import com.yetanalytics.hlaxapi.cache.ValueResolution; +import com.yetanalytics.hlaxapi.config.model.Expression; +import com.yetanalytics.hlaxapi.config.model.LookupExpression; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; +import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.criteria.CriteriaEvaluator; +import com.yetanalytics.hlaxapi.injection.InjectionContext; + +/** Evaluates a statement trigger's expression against its runtime value sources. */ +final class TriggerCriteriaMatcher { + + private final InjectionHandler handler; + private final CriteriaEvaluator evaluator = new CriteriaEvaluator(); + + TriggerCriteriaMatcher(InjectionHandler handler) { + this.handler = handler; + } + + boolean matches(Expression criteria, InjectionContext context, LazyLookupContext lookups) { + return evaluator.matches(criteria, expression -> resolve(expression, context, lookups)); + } + + private Object resolve(Expression expression, InjectionContext context, LazyLookupContext lookups) { + ValueResolution resolution; + if (expression instanceof TriggerExpression trigger) { + resolution = handler.handleTrigger(trigger.target, context); + } else if (expression instanceof QueryExpression query) { + resolution = handler.handleQuery(query.clazz, query.target, query.criteria, context); + } else if (expression instanceof LookupExpression lookup) { + resolution = lookups.value(lookup.alias, lookup.target); + } else if (expression instanceof Target target) { + throw new IllegalStateException( + "Bare target " + target.parts + " reached statement-trigger evaluation"); + } else { + throw new IllegalStateException( + "Unsupported statement-trigger expression " + expression.getClass().getSimpleName()); + } + return resolution != null && resolution.present() ? resolution.value() : null; + } +} diff --git a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java index 2ceb72a..1170159 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java +++ b/src/main/java/com/yetanalytics/hlaxapi/TriggerProcessor.java @@ -1,9 +1,7 @@ package com.yetanalytics.hlaxapi; import java.util.Iterator; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -17,9 +15,7 @@ import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; -import com.yetanalytics.hlaxapi.cache.CachedObject; import com.yetanalytics.hlaxapi.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; @@ -49,25 +45,54 @@ public TriggerProcessor(InjectionHandler injectionHandler) { this.injectionHandler = injectionHandler; } - public record TriggerProcessingResult(String statement, boolean success, Throwable error){} + public record TriggerProcessingResult(String statement, boolean matched, boolean success, Throwable error) { + + private static TriggerProcessingResult emitted(String statement) { + return new TriggerProcessingResult(statement, true, true, null); + } + + private static TriggerProcessingResult skipped() { + return new TriggerProcessingResult(null, false, true, null); + } + + private static TriggerProcessingResult failed(Throwable error) { + return new TriggerProcessingResult(null, false, false, error); + } + } public TriggerProcessingResult processTrigger(StatementTrigger trigger, InjectionContext context) { + return processTrigger(trigger, context, true); + } + + public TriggerProcessingResult renderTemplateForValidation(StatementTrigger trigger, InjectionContext context) { + return processTrigger(trigger, context, false); + } + + private TriggerProcessingResult processTrigger( + StatementTrigger trigger, + InjectionContext context, + boolean evaluateCriteria) { if (trigger == null || trigger.statement == null) { return null; } ObjectMapper mapper = new ObjectMapper(); try { + LazyLookupContext lookups = new LazyLookupContext(injectionHandler, context, trigger.lookups); + if (evaluateCriteria + && !new TriggerCriteriaMatcher(injectionHandler).matches(trigger.criteria, context, lookups)) { + logger.trace("Skipping trigger {}.{} because criteria did not match", trigger.type, trigger.clazz); + return TriggerProcessingResult.skipped(); + } JsonNode stmtNode = mapper.readTree(trigger.statement); - Map lookupObjects = resolveLookups(trigger, context); context.setObjectType(getObjectType(stmtNode)); - JsonNode processed = processNode(stmtNode, context, mapper, lookupObjects, List.of()); + JsonNode processed = processNode(stmtNode, context, mapper, lookups, List.of()); String output = mapper.writeValueAsString(processed); logger.trace("Processed statement output: {}", output); - return new TriggerProcessingResult(output, true, null); + return TriggerProcessingResult.emitted(output); } catch (Exception e) { logger.error("Could not process trigger {}.{}: {}", trigger.type, trigger.clazz, e.getMessage(), e); - return new TriggerProcessingResult(null, false, e); + return TriggerProcessingResult.failed(e); } } @@ -83,23 +108,11 @@ public String getObjectType(JsonNode stmtNode) { } - 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, - Map lookupObjects, + LazyLookupContext lookups, List statementPath) throws JsonProcessingException { if (node == null || node.isNull()) return node; @@ -115,7 +128,7 @@ private JsonNode processNode( child, context, mapper, - lookupObjects, + lookups, appendPath(statementPath, field)); out.set(field, processedChild); } @@ -126,7 +139,7 @@ private JsonNode processNode( ParseResult parsed = StatementInjectionParser.parse(node); if (parsed.recognized()) { return parsed.valid() - ? handleInjection(parsed.injection(), context, mapper, false, lookupObjects, statementPath) + ? handleInjection(parsed.injection(), context, mapper, false, lookups, statementPath) : NullNode.instance; } ArrayNode out = mapper.createArrayNode(); @@ -135,7 +148,7 @@ private JsonNode processNode( node.get(index), context, mapper, - lookupObjects, + lookups, appendPath(statementPath, index))); } return out; @@ -159,7 +172,7 @@ private JsonNode processNode( context, mapper, true, - lookupObjects, + lookups, statementPath); } else if (inline.result().recognized()) { repNode = NullNode.instance; @@ -192,7 +205,7 @@ private JsonNode handleInjection( InjectionContext context, ObjectMapper mapper, Boolean embedded, - Map lookupObjects, + LazyLookupContext lookups, List statementPath) { List previousPath = context.getStatementPath(); context.setStatementPath(statementPath); @@ -218,10 +231,7 @@ private JsonNode handleInjection( mapper); } else if (injection instanceof LookupInjection lookupInjection) { return renderResolution( - injectionHandler.handleLookup( - lookupObjects.get(lookupInjection.alias()), - lookupInjection.target(), - context), + lookups.value(lookupInjection.alias(), lookupInjection.target()), lookupInjection.options(), injectionDescription(lookupInjection, lookupInjection.alias()), embedded, diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java index 2c92d06..ddca04a 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java @@ -5,6 +5,8 @@ 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.LookupExpression; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.ValueExpression; @@ -34,10 +36,14 @@ public static Map> collect(List triggers) return references; } for (StatementTrigger trigger : triggers) { - if (trigger == null || trigger.statement == null) { + if (trigger == null) { continue; } Map lookupClasses = collectLookupDefinitions(trigger, references); + collectTriggerExpression(trigger.criteria, references, lookupClasses); + if (trigger.statement == null) { + continue; + } try { collectFromNode(MAPPER.readTree(trigger.statement), references, lookupClasses); } catch (IOException ignored) { @@ -64,6 +70,22 @@ private static Map collectLookupDefinitions( return lookupClasses; } + private static void collectTriggerExpression( + Expression expression, + Map> references, + Map lookupClasses) { + if (expression instanceof QueryExpression query) { + collectQueryReference(query.clazz, query.target, query.criteria, references); + } else if (expression instanceof LookupExpression lookup) { + addTarget(references, lookupClasses.get(lookup.alias), lookup.target); + } else if (expression instanceof Criterion criterion) { + collectTriggerExpression(criterion.left, references, lookupClasses); + collectTriggerExpression(criterion.right, references, lookupClasses); + } else if (expression instanceof LogicalExpression logical) { + logical.operands.forEach(operand -> collectTriggerExpression(operand, references, lookupClasses)); + } + } + private static void collectFromNode( JsonNode node, Map> references, @@ -109,13 +131,20 @@ private static void collectInjection( } private static void collectQuery(QueryInjection query, Map> references) { - String className = query.className(); + collectQueryReference(query.className(), query.target(), query.criteria(), references); + } + + private static void collectQueryReference( + String className, + Target target, + Expression criteria, + Map> references) { if (className == null || className.isBlank()) { return; } - addTarget(references, className, query.target()); - collectCriteriaTargets(references, className, query.criteria()); + addTarget(references, className, target); + collectCriteriaTargets(references, className, criteria); } private static void collectLookup( @@ -147,6 +176,9 @@ private static void collectCriteriaTargets( } private static void addTarget(Map> references, String className, Target target) { + if (className == null || className.isBlank()) { + return; + } String topLevelAttribute = target == null ? null : FomCatalog.topLevelTargetPart(target.parts); if (topLevelAttribute == null || topLevelAttribute.isBlank()) { return; diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java index b1bbde1..17a1a05 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.yetanalytics.hlaxapi.config.ConfigConverter; +import com.yetanalytics.hlaxapi.config.CriteriaExpressionParser; +import com.yetanalytics.hlaxapi.config.CriteriaExpressionValidator; import com.yetanalytics.hlaxapi.config.model.Expression; import com.yetanalytics.hlaxapi.config.model.InjectionType; import com.yetanalytics.hlaxapi.config.model.Target; @@ -95,8 +97,9 @@ private static Target target(JsonNode node) { } private static Expression expression(JsonNode node) { - Object raw = MAPPER.convertValue(node, Object.class); - return ConfigConverter.toExpression(raw); + Expression expression = CriteriaExpressionParser.parseNullable(node); + CriteriaExpressionValidator.validateCacheFilter(expression); + return expression; } private static InjectionOptions options(JsonNode node, int index) { diff --git a/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java new file mode 100644 index 0000000..3a400e6 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/TriggerProcessorCriteriaTest.java @@ -0,0 +1,211 @@ +package com.yetanalytics.hlaxapi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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 com.yetanalytics.extension.SuppressTestLogging; +import com.yetanalytics.hlaxapi.TriggerProcessor.TriggerProcessingResult; +import com.yetanalytics.hlaxapi.cache.CachedObject; +import com.yetanalytics.hlaxapi.cache.ValueResolution; +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.LookupExpression; +import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; +import com.yetanalytics.hlaxapi.config.model.StatementTrigger; +import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import com.yetanalytics.hlaxapi.injection.InjectionContext; +import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; +import com.yetanalytics.hlaxapi.injection.TestInjectionContext; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class TriggerProcessorCriteriaTest { + + @Test + void absentCriteriaMatchesAndFalseCriteriaSkipsCleanly() { + TriggerProcessingResult unconditional = new TriggerProcessor(new InjectionHandler()) + .processTrigger(trigger(null, "{}"), context()); + TriggerProcessingResult skipped = new TriggerProcessor(new InjectionHandler()) + .processTrigger(trigger(new ValueExpression(false), "{}"), context()); + + assertTrue(unconditional.success()); + assertTrue(unconditional.matched()); + assertTrue(skipped.success()); + assertFalse(skipped.matched()); + assertNull(skipped.statement()); + } + + @Test + void eventCriteriaShortCircuitBeforeUnusedLookup() { + AtomicInteger lookupLoads = new AtomicInteger(); + InjectionHandler handler = new InjectionHandler() { + @Override + public ValueResolution handleTrigger(Target target, InjectionContext context) { + return ValueResolution.present(4); + } + + @Override + public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) { + lookupLoads.incrementAndGet(); + return Optional.empty(); + } + }; + Expression criteria = new LogicalExpression( + LogicalOperator.AND, + List.of( + new Criterion( + new TriggerExpression(target("Score")), + ComparisonOperator.GT, + new ValueExpression(10)), + new Criterion( + new LookupExpression("subject", target("Score")), + ComparisonOperator.GT, + new ValueExpression(10)))); + StatementTrigger trigger = trigger(criteria, "{\"actor\":{\"name\":[\"lookup\",\"subject\",[\"Name\"]]}}"); + trigger.lookups = Map.of("subject", lookup("Rabbit")); + + TriggerProcessingResult result = new TriggerProcessor(handler).processTrigger(trigger, context()); + + assertTrue(result.success()); + assertFalse(result.matched()); + assertEquals(0, lookupLoads.get()); + } + + @Test + void oneLookupObjectIsSharedByCriteriaAndStatementRendering() { + AtomicInteger lookupLoads = new AtomicInteger(); + CachedObject rabbit = new CachedObject(7, "handle-7", "rabbit-7", "Rabbit"); + InjectionHandler handler = new InjectionHandler() { + @Override + public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) { + lookupLoads.incrementAndGet(); + return Optional.of(rabbit); + } + + @Override + public ValueResolution handleLookup(CachedObject object, Target target, InjectionContext context) { + assertEquals(rabbit, object); + return target.parts.equals(List.of("Hunger")) + ? ValueResolution.present(75) + : ValueResolution.present("rabbit-7"); + } + }; + StatementTrigger trigger = trigger( + new Criterion( + new LookupExpression("subject", target("Hunger")), + ComparisonOperator.GT, + new ValueExpression(50)), + "{\"actor\":{\"name\":[\"lookup\",\"subject\",[\"EntityId\"]]}}"); + trigger.lookups = Map.of("subject", lookup("Rabbit")); + + TriggerProcessingResult result = new TriggerProcessor(handler).processTrigger(trigger, context()); + + assertTrue(result.matched()); + assertEquals(1, lookupLoads.get()); + assertTrue(result.statement().contains("rabbit-7")); + } + + @Test + void missingLookupIsMemoizedAndMissingQueryComparesAsNull() { + AtomicInteger lookupLoads = new AtomicInteger(); + InjectionHandler handler = new InjectionHandler() { + @Override + public Optional resolveLookup(ObjectLookup lookup, InjectionContext context) { + lookupLoads.incrementAndGet(); + return Optional.empty(); + } + + @Override + public ValueResolution handleLookup(CachedObject object, Target target, InjectionContext context) { + return ValueResolution.missingObject(); + } + + @Override + public ValueResolution handleQuery( + String clazz, + Target target, + Expression criteria, + InjectionContext context) { + return ValueResolution.missingObject(); + } + }; + StatementTrigger trigger = trigger( + new LogicalExpression( + LogicalOperator.AND, + List.of( + equalsNull(new LookupExpression("subject", target("First"))), + equalsNull(new LookupExpression("subject", target("Second"))), + equalsNull(new QueryExpression("Rabbit", target("Nickname"), null)))), + "{}"); + trigger.lookups = Map.of("subject", lookup("Rabbit")); + + TriggerProcessingResult result = new TriggerProcessor(handler).processTrigger(trigger, context()); + + assertTrue(result.matched()); + assertEquals(1, lookupLoads.get()); + } + + @Test + @SuppressTestLogging({"com.yetanalytics.hlaxapi.TriggerProcessor"}) + void criteriaFailuresAreReportedAndValidationRenderingDoesNotEvaluateCriteria() { + InjectionHandler handler = new InjectionHandler() { + @Override + public ValueResolution handleTrigger(Target target, InjectionContext context) { + throw new IllegalStateException("cannot decode event value"); + } + }; + StatementTrigger trigger = trigger( + new TriggerExpression(target("Broken")), + "{\"result\":{\"success\":true}}"); + TriggerProcessor processor = new TriggerProcessor(handler); + + TriggerProcessingResult failed = processor.processTrigger(trigger, context()); + TriggerProcessingResult validation = processor.renderTemplateForValidation( + trigger, + new TestInjectionContext("TestInteraction")); + + assertFalse(failed.success()); + assertNotNull(failed.error()); + assertTrue(validation.success()); + assertTrue(validation.matched()); + } + + private static Criterion equalsNull(Expression expression) { + return new Criterion(expression, ComparisonOperator.EQ, new ValueExpression(null)); + } + + private static Target target(String name) { + return new Target(List.of(name)); + } + + private static ObjectLookup lookup(String className) { + ObjectLookup lookup = new ObjectLookup(); + lookup.clazz = className; + return lookup; + } + + private static StatementTrigger trigger(Expression criteria, String statement) { + StatementTrigger trigger = new StatementTrigger(); + trigger.type = StatementTrigger.Type.INTERACTION; + trigger.clazz = "TestInteraction"; + trigger.criteria = criteria; + trigger.statement = statement; + return trigger; + } + + private static InteractionInjectionContext context() { + return new InteractionInjectionContext("TestInteraction", Map.of()); + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java index 9e08020..78b3962 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/ObjectCacheTest.java @@ -11,6 +11,7 @@ import com.yetanalytics.hlaxapi.config.model.ComparisonOperator; import com.yetanalytics.hlaxapi.config.model.Criterion; import com.yetanalytics.hlaxapi.config.model.ObjectCacheConfig; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TrackedObject; @@ -94,6 +95,34 @@ void enabledWhenQueryInjectionsExistAndCanQueryReflectedValues(@TempDir Path tem } } + @Test + void enabledWhenQueryAppearsOnlyInTriggerCriteria(@TempDir Path tempDir) { + StatementTrigger trigger = new StatementTrigger(); + trigger.statement = "{}"; + trigger.criteria = new Criterion( + new QueryExpression( + "Rabbit", + new Target(List.of("EntityId")), + new Criterion( + new Target(List.of("Hunger")), + ComparisonOperator.GT, + new ValueExpression(50))), + ComparisonOperator.NEQ, + new ValueExpression(null)); + XapiConfig config = new XapiConfig(); + config.statementTriggers = List.of(trigger); + + try (ObjectCache cache = new ObjectCache( + config, + catalog, + fomXml, + decoderRegistry, + "jdbc:sqlite:" + tempDir.resolve("criteria-query.sqlite"))) { + assertTrue(cache.isEnabled()); + assertEquals(Set.of("EntityId", "Hunger"), cache.subscriptions().get("Rabbit")); + } + } + @Test void enabledWhenTrackedObjectsExistWithoutQueryInjections(@TempDir Path tempDir) { Path databasePath = tempDir.resolve("tracked.sqlite"); diff --git a/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java index f9e0f07..0d0baa0 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollectorTest.java @@ -6,9 +6,14 @@ 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.LogicalExpression; +import com.yetanalytics.hlaxapi.config.model.LogicalOperator; +import com.yetanalytics.hlaxapi.config.model.LookupExpression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; +import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.config.model.ValueExpression; import java.util.List; import java.util.Map; import java.util.Set; @@ -66,6 +71,41 @@ void findsLookupDefinitionCriteriaAndLookupInjections() { assertFalse(references.get("SimEntity").contains("PredatorId")); } + @Test + void findsCacheReferencesUsedOnlyByTriggerCriteria() { + StatementTrigger trigger = trigger("{}"); + ObjectLookup subject = new ObjectLookup(); + subject.clazz = "SimEntity"; + subject.criteria = new Criterion( + new Target(List.of("EntityId")), + ComparisonOperator.EQ, + new TriggerExpression(new Target(List.of("SubjectId")))); + trigger.lookups = Map.of("subject", subject); + trigger.criteria = new LogicalExpression( + LogicalOperator.AND, + List.of( + new Criterion( + new LookupExpression("subject", new Target(List.of("Hunger"))), + ComparisonOperator.GT, + new ValueExpression(50)), + new Criterion( + new QueryExpression( + "World", + new Target(List.of("Size")), + new Criterion( + new Target(List.of("WorldId")), + ComparisonOperator.EQ, + new TriggerExpression(new Target(List.of("DesiredWorldId"))))), + ComparisonOperator.GT, + new ValueExpression(0)))); + + Map> references = QueryReferenceCollector.collect(List.of(trigger)); + + assertEquals(Set.of("EntityId", "Hunger"), references.get("SimEntity")); + assertEquals(Set.of("WorldId", "Size"), references.get("World")); + assertFalse(references.get("World").contains("DesiredWorldId")); + } + private StatementTrigger trigger(String statement) { StatementTrigger trigger = new StatementTrigger(); trigger.statement = statement; From c9aaef2b2d48bc0a4880af97cd80b67d7061f363 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 15:25:16 -0400 Subject: [PATCH 07/14] add always-true example of criterion --- config/xapi-config.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/config/xapi-config.json b/config/xapi-config.json index 0e76b00..55fbf6b 100644 --- a/config/xapi-config.json +++ b/config/xapi-config.json @@ -3,7 +3,11 @@ { "type": "Interaction", "class": "EntityAte", - "criteria": [["trigger", "key1"], "=", "thing1"], + "criteria": [ + ["trigger", ["PredatorId"]], + "!=", + ["trigger", ["PreyId"]] + ], "lookups": { "predator": { "class": "SimEntity", From f0d0f69ff28fbe5e68c20a4bb3b13d037e69521b Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 15:25:53 -0400 Subject: [PATCH 08/14] docs --- doc/xapi-config.md | 46 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/doc/xapi-config.md b/doc/xapi-config.md index d56f0b7..a9fed0a 100644 --- a/doc/xapi-config.md +++ b/doc/xapi-config.md @@ -53,8 +53,8 @@ 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. +- `criteria`: Optional expression evaluated before the statement template is processed. A non-matching trigger is skipped without producing an xAPI statement. A trigger without criteria always matches. +- `lookups`: Optional named cache lookups loaded on first use. A lookup result, including a missing result, is reused for the rest of that trigger attempt. - `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. - `skipValidation`: Optional flag to skip boot validation for xAPI statement template and injections. **NOTE: This may result in invalid statements being sent to LRS!** Only use if startup is throwing unnecessary validation errors for your template. If you encounter validation issues that you believe to be in error, please report them in a Github Issue. @@ -89,7 +89,7 @@ Supported comparison operators: - `<=` - `>=` -The left or right side may be a target, primitive value, nested criterion, or a `trigger` expression. +The left or right side may be a target, primitive value, nested criterion, or an expression that reads from `trigger`, `query`, or `lookup`. ```json [["Hunger"], ">", 50] @@ -111,6 +111,40 @@ Use a single logical operator at a given array level. If mixed `and`/`or` logic 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. +### Trigger criteria + +Statement-trigger criteria require an explicit value source. Use `trigger` to read the incoming event, `query` to read the first matching cached object, or `lookup` to read a named lookup. Bare targets remain reserved for the cached object being tested inside query and lookup filters. + +```json +{ + "lookups": { + "predator": { + "class": "SimEntity", + "criteria": [["EntityId"], "=", ["trigger", ["PredatorId"]]] + } + }, + "criteria": [ + ["trigger", ["PredatorId"]], + "=", + ["lookup", "predator", ["EntityId"]] + ] +} +``` + +A query is also a value expression in trigger criteria: + +```json +[ + ["query", "World", ["Size"], [["WorldId"], "=", ["trigger", ["WorldId"]]]], + ">", + 0 +] +``` + +Query and lookup filters may contain cached targets, literals, nested comparisons/logical expressions, and `trigger` expressions. Nested queries and lookups are not allowed in cache filters. Injection rendering options such as `required` and `nullable` do not apply inside criteria. + +Logical expressions short-circuit. A missing query object, lookup object, or target value resolves to `null` for comparison purposes. Other resolution errors fail trigger processing rather than being treated as a non-match. + ## Statement Injections An injection can appear as a whole JSON value: @@ -222,14 +256,14 @@ A lookup injection has this shape: ["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. +The alias must exist in the trigger's `lookups` map. An alias is resolved only when criteria evaluation or statement rendering first reads it. All later reads during that trigger attempt reuse the same cached object; a missing result is memoized as well. ## Object Cache The object cache stores the latest reflected values for subscribed HLA object attributes in SQLite or PostgreSQL. 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 +- a statement template or trigger criterion contains a `query`, +- a trigger defines `lookups` or uses `lookup` expressions 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: From b56666b2659bc9dae307477770f986e508bb2125 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 15:42:16 -0400 Subject: [PATCH 09/14] remove ConfigConverter and use single parser --- .../hlaxapi/config/ConfigConverter.java | 98 ------------------- .../config/CriteriaExpressionParser.java | 6 +- .../injection/StatementInjectionParser.java | 11 +-- .../com/yetanalytics/ConfigParserTest.java | 51 ---------- .../config/CriteriaExpressionParserTest.java | 26 +++++ .../StatementInjectionParserTest.java | 14 +++ 6 files changed, 48 insertions(+), 158 deletions(-) delete mode 100644 src/main/java/com/yetanalytics/hlaxapi/config/ConfigConverter.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigConverter.java b/src/main/java/com/yetanalytics/hlaxapi/config/ConfigConverter.java deleted file mode 100644 index 4dc7045..0000000 --- a/src/main/java/com/yetanalytics/hlaxapi/config/ConfigConverter.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.yetanalytics.hlaxapi.config; - -import com.yetanalytics.hlaxapi.config.model.Target; -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.InjectionType; -import com.yetanalytics.hlaxapi.config.model.ValueExpression; -import com.yetanalytics.hlaxapi.config.model.TriggerExpression; - -import java.util.ArrayList; -import java.util.List; - -/** - * Convert raw parsed criteria/targets into typed model objects. - */ -public class ConfigConverter { - - public static Target toTarget(Object raw) { - if (raw == null) return null; - if (raw instanceof List) { - List l = (List) raw; - List parts = new ArrayList<>(); - for (Object o : l) parts.add(o); - return new Target(parts); - } - return null; - } - - public static Object toCriterionOrValue(Object raw) { - if (raw == null) return null; - if (raw instanceof List) { - List l = (List) raw; - // If this is an injection-style array starting with "trigger", treat as TriggerExpression - if (!l.isEmpty() - && l.get(0) instanceof String token - && InjectionType.fromString(token) == InjectionType.TRIGGER) { - // expected form: ["trigger", targetArray] - Target t = null; - if (l.size() >= 2) t = toTarget(l.get(1)); - return new TriggerExpression(t); - } - // binary comparison (3 elements, middle is a string, and not a logical operator) - if (l.size() == 3 && l.get(1) instanceof String && LogicalOperator.fromString((String) l.get(1)) == null) { - Expression left = toExpression(l.get(0)); - String op = (String) l.get(1); - Expression right = toExpression(l.get(2)); - ComparisonOperator cop = ComparisonOperator.fromString(op); - return new Criterion(left, cop, right); - } - - // logical expression like [expr, "or", expr, "and", expr] - // normalize into operator and operands - List operands = new ArrayList<>(); - LogicalOperator operator = null; - for (Object el : l) { - if (el instanceof String) { - String s = ((String) el).toLowerCase(); - LogicalOperator lo = LogicalOperator.fromString(s); - if (lo != null) { - operator = lo; - continue; - } - } - operands.add(toExpression(el)); - } - if (operator != null && !operands.isEmpty()) { - return new LogicalExpression(operator, operands); - } - - // try target - Target t = toTarget(raw); - if (t != null) return t; - // fallback: return raw - return raw; - } - // primitive -> ValueExpression - return new ValueExpression(raw); - } - - public static Expression toExpression(Object raw) { - Object o = toCriterionOrValue(raw); - if (o instanceof Expression) return (Expression) o; - // wrap primitives - return new ValueExpression(o); - } - - /** - * Try to convert raw parsed structure into a Criterion (or return null) - */ - public static Criterion toCriterion(Object raw) { - Object out = toCriterionOrValue(raw); - if (out instanceof Criterion) return (Criterion) out; - return null; - } -} diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java index 2c94eb2..592ea22 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParser.java @@ -105,7 +105,11 @@ private static LogicalExpression parseLogical(JsonNode node) { return new LogicalExpression(operator, List.copyOf(operands)); } - private static Target parseTarget(JsonNode node) { + /** + * Parse a target path represented by a non-empty JSON array. Path parts must + * be strings or non-negative integer indexes. + */ + public static Target parseTarget(JsonNode node) { if (node == null || !node.isArray() || node.isEmpty()) { throw new IllegalArgumentException("target must be a non-empty array"); } diff --git a/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java index 17a1a05..8d36949 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java +++ b/src/main/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParser.java @@ -2,7 +2,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.yetanalytics.hlaxapi.config.ConfigConverter; import com.yetanalytics.hlaxapi.config.CriteriaExpressionParser; import com.yetanalytics.hlaxapi.config.CriteriaExpressionValidator; import com.yetanalytics.hlaxapi.config.model.Expression; @@ -43,20 +42,20 @@ public static ParseResult parse(JsonNode node) { case TRIGGER -> node.size() < 2 ? ParseResult.malformed(type) : ParseResult.valid(new TriggerInjection( - target(node.get(1)), + CriteriaExpressionParser.parseTarget(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)), + CriteriaExpressionParser.parseTarget(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)), + CriteriaExpressionParser.parseTarget(node.get(2)), options(node, 3))); }; } catch (RuntimeException e) { @@ -92,10 +91,6 @@ public static List findInline(String text) { return List.copyOf(injections); } - private static Target target(JsonNode node) { - return ConfigConverter.toTarget(MAPPER.convertValue(node, Object.class)); - } - private static Expression expression(JsonNode node) { Expression expression = CriteriaExpressionParser.parseNullable(node); CriteriaExpressionValidator.validateCacheFilter(expression); diff --git a/src/test/java/com/yetanalytics/ConfigParserTest.java b/src/test/java/com/yetanalytics/ConfigParserTest.java index 3679016..5998679 100644 --- a/src/test/java/com/yetanalytics/ConfigParserTest.java +++ b/src/test/java/com/yetanalytics/ConfigParserTest.java @@ -34,14 +34,11 @@ 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; 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.LookupExpression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; import com.yetanalytics.hlaxapi.config.model.QueryExpression; @@ -318,54 +315,6 @@ public void handlesFixedRecordGridPositionFieldAccess() { assertEquals(7, yResult.value()); } - @Test - public void convertsBinaryCriterion() { - // raw form: [ ["Event"], "=", 5 ] - List target = List.of("Event"); - List raw = List.of(target, "=", 5); - - Expression e = ConfigConverter.toExpression(raw); - assertTrue(e instanceof Criterion); - Criterion c = (Criterion) e; - assertTrue(c.left instanceof Target); - assertEquals(ComparisonOperator.EQ, c.operator); - assertTrue(c.right instanceof com.yetanalytics.hlaxapi.config.model.ValueExpression); - } - - @Test - public void convertsLogicalExpression() { - // raw: [ [["A"],"=",1], "or", [["B"],">",2] ] - List left = List.of(List.of("A"), "=", 1); - List right = List.of(List.of("B"), ">", 2); - List raw = new ArrayList<>(); - raw.add(left); - raw.add("or"); - raw.add(right); - - Expression e = ConfigConverter.toExpression(raw); - assertTrue(e instanceof LogicalExpression || e instanceof Criterion); - if (e instanceof LogicalExpression) { - LogicalExpression le = (LogicalExpression) e; - assertEquals(LogicalOperator.OR, le.operator); - assertEquals(2, le.operands.size()); - } - } - - @Test - public void convertsTriggerExpressionInsideCriterion() { - // raw: [ ["X"], "=", ["trigger", ["attr"]] ] - List rawTrigger = List.of("trigger", List.of("attr")); - List raw = List.of(List.of("X"), "=", rawTrigger); - - Expression e = ConfigConverter.toExpression(raw); - assertTrue(e instanceof Criterion); - Criterion c = (Criterion) e; - assertTrue(c.right instanceof TriggerExpression); - TriggerExpression te = (TriggerExpression) c.right; - assertNotNull(te.target); - assertEquals(List.of("attr"), te.target.parts); - } - @Test public void inlinePlaceholderProcessing() throws IOException { // Setup using HlaFedereplFOM diff --git a/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java b/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java index 4160fc1..b270ac1 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java @@ -1,6 +1,7 @@ package com.yetanalytics.hlaxapi.config; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -12,8 +13,10 @@ import com.yetanalytics.hlaxapi.config.model.LookupExpression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; import com.yetanalytics.hlaxapi.config.model.QueryExpression; +import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -21,6 +24,29 @@ class CriteriaExpressionParserTest { private static final ObjectMapper MAPPER = new ObjectMapper(); + @Test + void parsesStrictTargetPaths() throws Exception { + Target target = CriteriaExpressionParser.parseTarget( + MAPPER.readTree("[\"PositionHistory\", 0, \"X\"]")); + + assertEquals(List.of("PositionHistory", 0, "X"), target.parts); + + for (String invalidTarget : List.of( + "null", + "\"EntityId\"", + "[]", + "[\"PositionHistory\", -1]", + "[\"PositionHistory\", 1.5]", + "[\"PositionHistory\", {}]", + "[\"PositionHistory\", []]", + "[\"PositionHistory\", 2147483648]")) { + assertThrows( + IllegalArgumentException.class, + () -> CriteriaExpressionParser.parseTarget(MAPPER.readTree(invalidTarget)), + invalidTarget); + } + } + @Test void parsesEveryTriggerValueSourceInNestedExpressions() throws Exception { Expression expression = CriteriaExpressionParser.parse(MAPPER.readTree(""" diff --git a/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java b/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java index af7a935..c015e59 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/injection/StatementInjectionParserTest.java @@ -88,4 +88,18 @@ void distinguishesMalformedKnownTagsFromUnknownArrays() throws Exception { assertFalse(unknown.recognized()); assertFalse(unknown.valid()); } + + @Test + void marksKnownInjectionsWithInvalidTargetsAsMalformed() throws Exception { + for (String source : List.of( + "[\"trigger\",[]]", + "[\"trigger\",[\"PositionHistory\",-1]]", + "[\"query\",\"Rabbit\",\"EntityId\",null]", + "[\"lookup\",\"predator\",[\"EntityId\",{}]]")) { + ParseResult malformed = StatementInjectionParser.parse(MAPPER.readTree(source)); + + assertTrue(malformed.recognized(), source); + assertFalse(malformed.valid(), source); + } + } } From e01775de12405afeee848b226971cb6977008e71 Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 15:59:31 -0400 Subject: [PATCH 10/14] expression walker --- .../config/model/ExpressionWalker.java | 133 ++++++++++++++ .../config/model/ExpressionWalkerTest.java | 173 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/main/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalker.java create mode 100644 src/test/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalkerTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalker.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalker.java new file mode 100644 index 0000000..a96f87f --- /dev/null +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalker.java @@ -0,0 +1,133 @@ +package com.yetanalytics.hlaxapi.config.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; + +/** Traverses and rewrites the evaluated children of an expression tree. */ +public final class ExpressionWalker { + + /** Identifies how a child expression is related to its parent. */ + public enum ChildRole { + LEFT, + RIGHT, + OPERAND, + QUERY_FILTER + } + + /** A child expression and its position within the parent. */ + public record Child(Expression expression, ChildRole role, int index) { + + public Child { + Objects.requireNonNull(role, "role"); + if (role == ChildRole.OPERAND && index < 0) { + throw new IllegalArgumentException("operand children require a non-negative index"); + } + if (role != ChildRole.OPERAND && index != -1) { + throw new IllegalArgumentException("only operand children have an index"); + } + } + } + + /** Receives each expression and optionally derives state for each child edge. */ + @FunctionalInterface + public interface Visitor { + + void visit(Expression expression, S state); + + default S stateForChild(Expression parent, Child child, S state) { + return state; + } + } + + private ExpressionWalker() { + } + + /** Walks an expression tree in pre-order. A null root produces no visits. */ + public static void walk(Expression root, Consumer visitor) { + Objects.requireNonNull(visitor, "visitor"); + walk(root, null, (expression, ignored) -> visitor.accept(expression)); + } + + /** Walks an expression tree in pre-order while propagating caller-defined state. */ + public static void walk(Expression root, S initialState, Visitor visitor) { + Objects.requireNonNull(visitor, "visitor"); + walkInternal(root, initialState, visitor); + } + + /** + * Rewrites an expression tree bottom-up without mutating the source tree. + * Unchanged branches retain their original object identity. + */ + public static Expression rewrite(Expression root, UnaryOperator rewriter) { + Objects.requireNonNull(rewriter, "rewriter"); + return rewriteInternal(root, rewriter); + } + + private static void walkInternal(Expression expression, S state, Visitor visitor) { + if (expression == null) { + return; + } + + visitor.visit(expression, state); + for (Child child : children(expression)) { + if (child.expression() == null) { + continue; + } + S childState = visitor.stateForChild(expression, child, state); + walkInternal(child.expression(), childState, visitor); + } + } + + private static Expression rewriteInternal(Expression expression, UnaryOperator rewriter) { + if (expression == null) { + return null; + } + + List children = children(expression); + List rewrittenChildren = new ArrayList<>(children.size()); + boolean changed = false; + for (Child child : children) { + Expression rewrittenChild = rewriteInternal(child.expression(), rewriter); + rewrittenChildren.add(rewrittenChild); + changed |= rewrittenChild != child.expression(); + } + + Expression rebuilt = changed ? rebuild(expression, rewrittenChildren) : expression; + return Objects.requireNonNull(rewriter.apply(rebuilt), "rewriter returned null"); + } + + private static List children(Expression expression) { + return switch (expression) { + case Criterion criterion -> List.of( + new Child(criterion.left, ChildRole.LEFT, -1), + new Child(criterion.right, ChildRole.RIGHT, -1)); + case LogicalExpression logical -> { + List children = new ArrayList<>(logical.operands.size()); + for (int index = 0; index < logical.operands.size(); index++) { + children.add(new Child(logical.operands.get(index), ChildRole.OPERAND, index)); + } + yield children; + } + case QueryExpression query -> List.of(new Child(query.criteria, ChildRole.QUERY_FILTER, -1)); + case LookupExpression ignored -> List.of(); + case Target ignored -> List.of(); + case TriggerExpression ignored -> List.of(); + case ValueExpression ignored -> List.of(); + }; + } + + private static Expression rebuild(Expression expression, List children) { + return switch (expression) { + case Criterion criterion -> new Criterion(children.get(0), criterion.operator, children.get(1)); + case LogicalExpression logical -> new LogicalExpression(logical.operator, List.copyOf(children)); + case QueryExpression query -> new QueryExpression(query.clazz, query.target, children.get(0)); + case LookupExpression lookup -> lookup; + case Target target -> target; + case TriggerExpression trigger -> trigger; + case ValueExpression value -> value; + }; + } +} diff --git a/src/test/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalkerTest.java b/src/test/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalkerTest.java new file mode 100644 index 0000000..2c16114 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/config/model/ExpressionWalkerTest.java @@ -0,0 +1,173 @@ +package com.yetanalytics.hlaxapi.config.model; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Test; + +class ExpressionWalkerTest { + + @Test + void walksEveryEvaluatedExpressionChildInPreOrder() { + Fixture fixture = fixture(); + List visited = new ArrayList<>(); + + ExpressionWalker.walk(fixture.root, visited::add); + + assertEquals( + List.of( + fixture.root, + fixture.outerCriterion, + fixture.trigger, + fixture.query, + fixture.filterCriterion, + fixture.filterTarget, + fixture.filterValue, + fixture.lookup), + visited); + assertFalse(visited.contains(fixture.triggerTarget)); + assertFalse(visited.contains(fixture.queryTarget)); + assertFalse(visited.contains(fixture.lookupTarget)); + } + + @Test + void propagatesCallerStateByChildRole() { + Fixture fixture = fixture(); + Map paths = new IdentityHashMap<>(); + + ExpressionWalker.walk(fixture.root, "criteria", new ExpressionWalker.Visitor<>() { + @Override + public void visit(Expression expression, String path) { + paths.put(expression, path); + } + + @Override + public String stateForChild(Expression parent, ExpressionWalker.Child child, String path) { + return switch (child.role()) { + case LEFT -> path + ".left"; + case RIGHT -> path + ".right"; + case OPERAND -> path + "[" + child.index() + "]"; + case QUERY_FILTER -> path + ".queryFilter"; + }; + } + }); + + assertEquals("criteria", paths.get(fixture.root)); + assertEquals("criteria[0]", paths.get(fixture.outerCriterion)); + assertEquals("criteria[0].left", paths.get(fixture.trigger)); + assertEquals("criteria[0].right", paths.get(fixture.query)); + assertEquals("criteria[0].right.queryFilter", paths.get(fixture.filterCriterion)); + assertEquals("criteria[0].right.queryFilter.left", paths.get(fixture.filterTarget)); + assertEquals("criteria[0].right.queryFilter.right", paths.get(fixture.filterValue)); + assertEquals("criteria[1]", paths.get(fixture.lookup)); + } + + @Test + void rewritesBottomUpWithoutMutatingTheSourceTree() { + Target firstTriggerTarget = target("FirstId"); + TriggerExpression firstTrigger = new TriggerExpression(firstTriggerTarget); + ValueExpression originalRight = new ValueExpression(10); + Criterion outerCriterion = new Criterion(firstTrigger, ComparisonOperator.EQ, originalRight); + + Target queryTarget = target("Result"); + Target filterTarget = target("EntityId"); + TriggerExpression filterTrigger = new TriggerExpression(target("DesiredId")); + Criterion queryFilter = new Criterion(filterTarget, ComparisonOperator.EQ, filterTrigger); + QueryExpression query = new QueryExpression("Entity", queryTarget, queryFilter); + LogicalExpression root = new LogicalExpression(LogicalOperator.AND, List.of(outerCriterion, query)); + + Expression rewritten = ExpressionWalker.rewrite(root, expression -> { + if (expression instanceof TriggerExpression trigger) { + return new ValueExpression("resolved:" + trigger.target.parts.get(0)); + } + return expression; + }); + + LogicalExpression rewrittenRoot = assertInstanceOf(LogicalExpression.class, rewritten); + Criterion rewrittenOuter = assertInstanceOf(Criterion.class, rewrittenRoot.operands.get(0)); + ValueExpression rewrittenFirst = assertInstanceOf(ValueExpression.class, rewrittenOuter.left); + QueryExpression rewrittenQuery = assertInstanceOf(QueryExpression.class, rewrittenRoot.operands.get(1)); + Criterion rewrittenFilter = assertInstanceOf(Criterion.class, rewrittenQuery.criteria); + ValueExpression rewrittenFilterValue = assertInstanceOf(ValueExpression.class, rewrittenFilter.right); + + assertEquals("resolved:FirstId", rewrittenFirst.value); + assertEquals("resolved:DesiredId", rewrittenFilterValue.value); + assertSame(originalRight, rewrittenOuter.right); + assertSame(queryTarget, rewrittenQuery.target); + assertSame(filterTarget, rewrittenFilter.left); + + assertSame(firstTrigger, outerCriterion.left); + assertSame(filterTrigger, queryFilter.right); + assertSame(root, ExpressionWalker.rewrite(root, UnaryOperator.identity())); + } + + @Test + void handlesNullRootsAndRejectsNullRewriteResults() { + AtomicInteger visits = new AtomicInteger(); + + ExpressionWalker.walk(null, ignored -> visits.incrementAndGet()); + + assertEquals(0, visits.get()); + assertNull(ExpressionWalker.rewrite(null, UnaryOperator.identity())); + assertThrows( + NullPointerException.class, + () -> ExpressionWalker.rewrite(new ValueExpression(true), ignored -> null)); + } + + private static Fixture fixture() { + Target triggerTarget = target("Score"); + TriggerExpression trigger = new TriggerExpression(triggerTarget); + + Target queryTarget = target("Size"); + Target filterTarget = target("WorldId"); + ValueExpression filterValue = new ValueExpression(7); + Criterion filterCriterion = new Criterion(filterTarget, ComparisonOperator.EQ, filterValue); + QueryExpression query = new QueryExpression("World", queryTarget, filterCriterion); + + Criterion outerCriterion = new Criterion(trigger, ComparisonOperator.GTE, query); + Target lookupTarget = target("MinimumSize"); + LookupExpression lookup = new LookupExpression("subject", lookupTarget); + LogicalExpression root = new LogicalExpression(LogicalOperator.AND, List.of(outerCriterion, lookup)); + + return new Fixture( + root, + outerCriterion, + trigger, + triggerTarget, + query, + queryTarget, + filterCriterion, + filterTarget, + filterValue, + lookup, + lookupTarget); + } + + private static Target target(String part) { + return new Target(List.of(part)); + } + + private record Fixture( + LogicalExpression root, + Criterion outerCriterion, + TriggerExpression trigger, + Target triggerTarget, + QueryExpression query, + Target queryTarget, + Criterion filterCriterion, + Target filterTarget, + ValueExpression filterValue, + LookupExpression lookup, + Target lookupTarget) { + } +} From 7ef52151ed4b87f89f64b7e14f4a49cf0f5762dd Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 16:00:12 -0400 Subject: [PATCH 11/14] class changes --- .../com/yetanalytics/hlaxapi/config/model/Criterion.java | 2 +- .../yetanalytics/hlaxapi/config/model/Expression.java | 9 ++++++++- .../hlaxapi/config/model/LogicalExpression.java | 2 +- .../hlaxapi/config/model/LookupExpression.java | 2 +- .../hlaxapi/config/model/QueryExpression.java | 2 +- .../com/yetanalytics/hlaxapi/config/model/Target.java | 2 +- .../hlaxapi/config/model/TriggerExpression.java | 2 +- .../hlaxapi/config/model/ValueExpression.java | 2 +- 8 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/Criterion.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/Criterion.java index f7a0daa..5ea575c 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/Criterion.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/Criterion.java @@ -3,7 +3,7 @@ /** * Binary comparison node (left op right). Left and right are Expressions. */ -public class Criterion implements Expression { +public final class Criterion implements Expression { public final Expression left; // Target or nested Expression public final ComparisonOperator operator; public final Expression right; // ValueExpression, Target, or nested Expression diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/Expression.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/Expression.java index c9ac314..98d3d6a 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/Expression.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/Expression.java @@ -1,4 +1,11 @@ package com.yetanalytics.hlaxapi.config.model; -public interface Expression { +public sealed interface Expression + permits Criterion, + LogicalExpression, + LookupExpression, + QueryExpression, + Target, + TriggerExpression, + ValueExpression { } diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/LogicalExpression.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/LogicalExpression.java index c7c5eca..4667d26 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/LogicalExpression.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/LogicalExpression.java @@ -2,7 +2,7 @@ import java.util.List; -public class LogicalExpression implements Expression { +public final class LogicalExpression implements Expression { public final LogicalOperator operator; public final List operands; diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/LookupExpression.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/LookupExpression.java index 0c9ecbe..621c054 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/LookupExpression.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/LookupExpression.java @@ -1,7 +1,7 @@ package com.yetanalytics.hlaxapi.config.model; /** Reads a target value from a named trigger lookup. */ -public class LookupExpression implements Expression { +public final class LookupExpression implements Expression { public final String alias; public final Target target; diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/QueryExpression.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/QueryExpression.java index dc38156..aa02f9b 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/QueryExpression.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/QueryExpression.java @@ -1,7 +1,7 @@ package com.yetanalytics.hlaxapi.config.model; /** Reads a target value from the first cached object matching the query criteria. */ -public class QueryExpression implements Expression { +public final class QueryExpression implements Expression { public final String clazz; public final Target target; diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/Target.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/Target.java index 0a63a1c..fd6f93b 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/Target.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/Target.java @@ -5,7 +5,7 @@ /** * Represents a parsed 'target' syntax: an ordered list of keys and array indexes */ -public class Target implements Expression { +public final class Target implements Expression { public final List parts; public Target(List parts) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/TriggerExpression.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/TriggerExpression.java index 97ca2dd..68d9503 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/TriggerExpression.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/TriggerExpression.java @@ -4,7 +4,7 @@ * Represents a trigger injection inside an expression tree. Holds a Target that * specifies the attribute path to extract from the current statement/context. */ -public class TriggerExpression implements Expression { +public final class TriggerExpression implements Expression { public final Target target; public TriggerExpression(Target target) { diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/model/ValueExpression.java b/src/main/java/com/yetanalytics/hlaxapi/config/model/ValueExpression.java index f0ed63a..b9f55de 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/model/ValueExpression.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/model/ValueExpression.java @@ -1,6 +1,6 @@ package com.yetanalytics.hlaxapi.config.model; -public class ValueExpression implements Expression { +public final class ValueExpression implements Expression { public final Object value; public ValueExpression(Object value) { From 2a22b61d5864a022fe3f9e4bc9bb893f4d2d457c Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 16:05:37 -0400 Subject: [PATCH 12/14] migrate validator to use walker --- .../config/CriteriaExpressionValidator.java | 123 +++++++++++------- .../config/CriteriaExpressionParserTest.java | 26 ++++ 2 files changed, 105 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java index a781d89..309478a 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java +++ b/src/main/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionValidator.java @@ -2,6 +2,7 @@ import com.yetanalytics.hlaxapi.config.model.Criterion; import com.yetanalytics.hlaxapi.config.model.Expression; +import com.yetanalytics.hlaxapi.config.model.ExpressionWalker; import com.yetanalytics.hlaxapi.config.model.LogicalExpression; import com.yetanalytics.hlaxapi.config.model.LookupExpression; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; @@ -19,65 +20,99 @@ private enum Context { CACHE_FILTER } + private record ValidationState( + Context context, + Map lookupDefinitions, + String location) { + } + + private static final ExpressionWalker.Visitor VALIDATION_VISITOR = + new ExpressionWalker.Visitor<>() { + @Override + public void visit(Expression expression, ValidationState state) { + switch (expression) { + case Criterion ignored -> { + } + case LogicalExpression ignored -> { + } + case LookupExpression lookup -> validateLookup(lookup, state); + case QueryExpression query -> validateQuery(query, state); + case Target target -> validateTarget(target, state); + case TriggerExpression ignored -> { + } + case ValueExpression ignored -> { + } + } + } + + @Override + public ValidationState stateForChild( + Expression parent, + ExpressionWalker.Child child, + ValidationState state) { + Context childContext = child.role() == ExpressionWalker.ChildRole.QUERY_FILTER + ? Context.CACHE_FILTER + : state.context; + String childLocation = switch (child.role()) { + case LEFT -> state.location + ".left"; + case RIGHT -> state.location + ".right"; + case OPERAND -> state.location + "[" + child.index() + "]"; + case QUERY_FILTER -> state.location + ".queryFilter"; + }; + return new ValidationState(childContext, state.lookupDefinitions, childLocation); + } + }; + private CriteriaExpressionValidator() { } public static void validateTrigger(Expression criteria, Map lookupDefinitions) { Map definitions = lookupDefinitions == null ? Map.of() : lookupDefinitions; - visit(criteria, Context.TRIGGER, definitions, "criteria"); + ExpressionWalker.walk( + criteria, + new ValidationState(Context.TRIGGER, definitions, "criteria"), + VALIDATION_VISITOR); } public static void validateCacheFilter(Expression criteria) { - visit(criteria, Context.CACHE_FILTER, Map.of(), "criteria"); + ExpressionWalker.walk( + criteria, + new ValidationState(Context.CACHE_FILTER, Map.of(), "criteria"), + VALIDATION_VISITOR); } - private static void visit( - Expression expression, - Context context, - Map definitions, - String location) { - if (expression == null || expression instanceof ValueExpression) { - return; - } - if (expression instanceof Criterion criterion) { - visit(criterion.left, context, definitions, location + ".left"); - visit(criterion.right, context, definitions, location + ".right"); - return; - } - if (expression instanceof LogicalExpression logical) { - for (int index = 0; index < logical.operands.size(); index++) { - visit(logical.operands.get(index), context, definitions, location + "[" + index + "]"); - } - return; - } - if (expression instanceof TriggerExpression) { - return; + private static void validateTarget(Target target, ValidationState state) { + if (state.context == Context.TRIGGER) { + throw new IllegalArgumentException( + state.location + " contains bare target " + target.parts + + "; use [\"trigger\", [...]] for incoming event values"); } - if (context == Context.CACHE_FILTER && expression instanceof Target) { - return; + } + + private static void validateQuery(QueryExpression query, ValidationState state) { + if (state.context != Context.TRIGGER) { + throw unsupported(query, state); } - if (context == Context.TRIGGER && expression instanceof QueryExpression query) { - visit(query.criteria, Context.CACHE_FILTER, definitions, location + ".queryFilter"); - return; + } + + private static void validateLookup(LookupExpression lookup, ValidationState state) { + if (state.context != Context.TRIGGER) { + throw unsupported(lookup, state); } - if (context == Context.TRIGGER && expression instanceof LookupExpression lookup) { - ObjectLookup definition = definitions.get(lookup.alias); - if (definition == null) { - throw new IllegalArgumentException( - location + " references unknown lookup alias '" + lookup.alias + "'"); - } - if (definition.clazz == null || definition.clazz.isBlank()) { - throw new IllegalArgumentException( - location + " references lookup alias '" + lookup.alias + "' without a class"); - } - return; + + ObjectLookup definition = state.lookupDefinitions.get(lookup.alias); + if (definition == null) { + throw new IllegalArgumentException( + state.location + " references unknown lookup alias '" + lookup.alias + "'"); } - if (context == Context.TRIGGER && expression instanceof Target target) { + if (definition.clazz == null || definition.clazz.isBlank()) { throw new IllegalArgumentException( - location + " contains bare target " + target.parts - + "; use [\"trigger\", [...]] for incoming event values"); + state.location + " references lookup alias '" + lookup.alias + "' without a class"); } - throw new IllegalArgumentException( - location + " contains unsupported " + expression.getClass().getSimpleName()); + } + + private static IllegalArgumentException unsupported(Expression expression, ValidationState state) { + return new IllegalArgumentException( + state.location + " contains unsupported " + expression.getClass().getSimpleName()); } } diff --git a/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java b/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java index b270ac1..dd5c09c 100644 --- a/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java +++ b/src/test/java/com/yetanalytics/hlaxapi/config/CriteriaExpressionParserTest.java @@ -85,6 +85,32 @@ void preservesNullComparisonOperands() throws Exception { assertTrue(right.value == null); } + @Test + void triggerValidationAppliesCacheFilterRulesInsideQueries() throws Exception { + Expression expression = CriteriaExpressionParser.parse(MAPPER.readTree(""" + [ + [ + "query", + "World", + ["Size"], + [["WorldId"], "=", ["lookup", "subject", ["MinimumSize"]]] + ], + ">", + 0 + ] + """)); + ObjectLookup subject = new ObjectLookup(); + subject.clazz = "World"; + + IllegalArgumentException error = assertThrows( + IllegalArgumentException.class, + () -> CriteriaExpressionValidator.validateTrigger(expression, Map.of("subject", subject))); + + assertEquals( + "criteria.left.queryFilter.right contains unsupported LookupExpression", + error.getMessage()); + } + @Test void rejectsExpressionRenderingOptionsAndMixedLogicalOperators() throws Exception { assertThrows( From 3d5f8b77643f3b18041e5dbec3d8dc011438ef0d Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Wed, 22 Jul 2026 16:08:32 -0400 Subject: [PATCH 13/14] migrate cache ref collector to use walker --- .../cache/QueryReferenceCollector.java | 84 ++++++++++++------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java index ddca04a..054b983 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java +++ b/src/main/java/com/yetanalytics/hlaxapi/cache/QueryReferenceCollector.java @@ -4,11 +4,13 @@ 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.ExpressionWalker; import com.yetanalytics.hlaxapi.config.model.LogicalExpression; import com.yetanalytics.hlaxapi.config.model.LookupExpression; import com.yetanalytics.hlaxapi.config.model.QueryExpression; import com.yetanalytics.hlaxapi.config.model.StatementTrigger; import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; import com.yetanalytics.hlaxapi.config.model.ValueExpression; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser; import com.yetanalytics.hlaxapi.injection.StatementInjectionParser.InlineInjection; @@ -27,6 +29,47 @@ public final class QueryReferenceCollector { private static final ObjectMapper MAPPER = new ObjectMapper(); + private record ReferenceState( + Map> references, + Map lookupClasses, + String activeCacheClass) { + } + + private static final ExpressionWalker.Visitor REFERENCE_VISITOR = + new ExpressionWalker.Visitor<>() { + @Override + public void visit(Expression expression, ReferenceState state) { + switch (expression) { + case Criterion ignored -> { + } + case LogicalExpression ignored -> { + } + case LookupExpression lookup -> addTarget( + state.references, + state.lookupClasses.get(lookup.alias), + lookup.target); + case QueryExpression query -> addTarget(state.references, query.clazz, query.target); + case Target target -> addTarget(state.references, state.activeCacheClass, target); + case TriggerExpression ignored -> { + } + case ValueExpression ignored -> { + } + } + } + + @Override + public ReferenceState stateForChild( + Expression parent, + ExpressionWalker.Child child, + ReferenceState state) { + String activeCacheClass = switch (child.role()) { + case QUERY_FILTER -> ((QueryExpression) parent).clazz; + case LEFT, RIGHT, OPERAND -> state.activeCacheClass; + }; + return new ReferenceState(state.references, state.lookupClasses, activeCacheClass); + } + }; + private QueryReferenceCollector() { } @@ -40,7 +83,7 @@ public static Map> collect(List triggers) continue; } Map lookupClasses = collectLookupDefinitions(trigger, references); - collectTriggerExpression(trigger.criteria, references, lookupClasses); + collectExpressionReferences(trigger.criteria, references, lookupClasses, null); if (trigger.statement == null) { continue; } @@ -65,25 +108,20 @@ private static Map collectLookupDefinitions( return; } lookupClasses.put(alias, lookup.clazz); - collectCriteriaTargets(references, lookup.clazz, lookup.criteria); + collectExpressionReferences(lookup.criteria, references, lookupClasses, lookup.clazz); }); return lookupClasses; } - private static void collectTriggerExpression( + private static void collectExpressionReferences( Expression expression, Map> references, - Map lookupClasses) { - if (expression instanceof QueryExpression query) { - collectQueryReference(query.clazz, query.target, query.criteria, references); - } else if (expression instanceof LookupExpression lookup) { - addTarget(references, lookupClasses.get(lookup.alias), lookup.target); - } else if (expression instanceof Criterion criterion) { - collectTriggerExpression(criterion.left, references, lookupClasses); - collectTriggerExpression(criterion.right, references, lookupClasses); - } else if (expression instanceof LogicalExpression logical) { - logical.operands.forEach(operand -> collectTriggerExpression(operand, references, lookupClasses)); - } + Map lookupClasses, + String activeCacheClass) { + ExpressionWalker.walk( + expression, + new ReferenceState(references, lookupClasses, activeCacheClass), + REFERENCE_VISITOR); } private static void collectFromNode( @@ -144,7 +182,7 @@ private static void collectQueryReference( } addTarget(references, className, target); - collectCriteriaTargets(references, className, criteria); + collectExpressionReferences(criteria, references, Map.of(), className); } private static void collectLookup( @@ -159,22 +197,6 @@ private static void collectLookup( 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) { if (className == null || className.isBlank()) { return; From 5c26ff2655337a57b8f52c3291da83400ccd389d Mon Sep 17 00:00:00 2001 From: Milton Reder Date: Thu, 23 Jul 2026 11:11:23 -0400 Subject: [PATCH 14/14] use ExpressionWalker.rewrite in InjectionHandler --- .../hlaxapi/InjectionHandler.java | 28 ++---- .../hlaxapi/InjectionHandlerTest.java | 95 +++++++++++++++++++ 2 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 src/test/java/com/yetanalytics/hlaxapi/InjectionHandlerTest.java diff --git a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java index d47c9e6..77dca75 100644 --- a/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java +++ b/src/main/java/com/yetanalytics/hlaxapi/InjectionHandler.java @@ -15,9 +15,8 @@ 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.ExpressionWalker; import com.yetanalytics.hlaxapi.config.model.ObjectLookup; import com.yetanalytics.hlaxapi.config.model.Target; import com.yetanalytics.hlaxapi.config.model.TriggerExpression; @@ -287,24 +286,13 @@ private Expression resolveTriggerExpressions(Expression expression, InjectionCon if (expression == null || context == null) { return expression; } - if (expression instanceof TriggerExpression triggerExpression) { - ValueResolution vr = handleTrigger(triggerExpression.target, context); - return new ValueExpression(vr.value()); - } - if (expression instanceof Criterion criterion) { - return new Criterion( - resolveTriggerExpressions(criterion.left, context), - criterion.operator, - resolveTriggerExpressions(criterion.right, context)); - } - if (expression instanceof LogicalExpression logicalExpression) { - return new LogicalExpression( - logicalExpression.operator, - logicalExpression.operands.stream() - .map(operand -> resolveTriggerExpressions(operand, context)) - .toList()); - } - return expression; + return ExpressionWalker.rewrite(expression, candidate -> { + if (candidate instanceof TriggerExpression triggerExpression) { + ValueResolution resolution = handleTrigger(triggerExpression.target, context); + return new ValueExpression(resolution.value()); + } + return candidate; + }); } // for test diff --git a/src/test/java/com/yetanalytics/hlaxapi/InjectionHandlerTest.java b/src/test/java/com/yetanalytics/hlaxapi/InjectionHandlerTest.java new file mode 100644 index 0000000..c2ff789 --- /dev/null +++ b/src/test/java/com/yetanalytics/hlaxapi/InjectionHandlerTest.java @@ -0,0 +1,95 @@ +package com.yetanalytics.hlaxapi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; + +import com.yetanalytics.hlaxapi.cache.ValueResolution; +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.QueryExpression; +import com.yetanalytics.hlaxapi.config.model.Target; +import com.yetanalytics.hlaxapi.config.model.TriggerExpression; +import com.yetanalytics.hlaxapi.config.model.ValueExpression; +import com.yetanalytics.hlaxapi.injection.InjectionContext; +import com.yetanalytics.hlaxapi.injection.InteractionInjectionContext; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class InjectionHandlerTest { + + @Test + void rewritesTriggerExpressionsInsideCriteriaAndQueryFilters() throws Exception { + List resolvedTargets = new ArrayList<>(); + InjectionHandler handler = new InjectionHandler() { + @Override + public ValueResolution handleTrigger(Target target, InjectionContext context) { + String name = String.valueOf(target.parts.get(0)); + resolvedTargets.add(name); + return ValueResolution.present("resolved:" + name); + } + }; + + TriggerExpression criterionTrigger = new TriggerExpression(target("Score")); + Criterion criterion = new Criterion( + criterionTrigger, + ComparisonOperator.GT, + new ValueExpression(10)); + Target queryTarget = target("EntityId"); + Target filterTarget = target("OwnerId"); + TriggerExpression filterTrigger = new TriggerExpression(target("DesiredOwnerId")); + Criterion queryFilter = new Criterion(filterTarget, ComparisonOperator.EQ, filterTrigger); + QueryExpression query = new QueryExpression("Entity", queryTarget, queryFilter); + LogicalExpression expression = new LogicalExpression(LogicalOperator.AND, List.of(criterion, query)); + + Expression resolved = resolveTriggerExpressions(handler, expression, context()); + + LogicalExpression resolvedLogical = assertInstanceOf(LogicalExpression.class, resolved); + Criterion resolvedCriterion = assertInstanceOf(Criterion.class, resolvedLogical.operands.get(0)); + ValueExpression resolvedCriterionValue = assertInstanceOf(ValueExpression.class, resolvedCriterion.left); + QueryExpression resolvedQuery = assertInstanceOf(QueryExpression.class, resolvedLogical.operands.get(1)); + Criterion resolvedFilter = assertInstanceOf(Criterion.class, resolvedQuery.criteria); + ValueExpression resolvedFilterValue = assertInstanceOf(ValueExpression.class, resolvedFilter.right); + + assertEquals("resolved:Score", resolvedCriterionValue.value); + assertEquals("resolved:DesiredOwnerId", resolvedFilterValue.value); + assertEquals(List.of("Score", "DesiredOwnerId"), resolvedTargets); + assertSame(queryTarget, resolvedQuery.target); + assertSame(filterTarget, resolvedFilter.left); + + assertNotSame(expression, resolvedLogical); + assertNotSame(criterion, resolvedCriterion); + assertNotSame(query, resolvedQuery); + assertNotSame(queryFilter, resolvedFilter); + assertSame(criterionTrigger, criterion.left); + assertSame(filterTrigger, queryFilter.right); + assertSame(expression, resolveTriggerExpressions(handler, expression, null)); + } + + private static Expression resolveTriggerExpressions( + InjectionHandler handler, + Expression expression, + InjectionContext context) throws Exception { + Method method = InjectionHandler.class.getDeclaredMethod( + "resolveTriggerExpressions", + Expression.class, + InjectionContext.class); + method.setAccessible(true); + return (Expression) method.invoke(handler, expression, context); + } + + private static Target target(String name) { + return new Target(List.of(name)); + } + + private static InteractionInjectionContext context() { + return new InteractionInjectionContext("TestInteraction", Map.of()); + } +}