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/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; 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/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/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/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) { 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()); + } +} 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( 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) { + } +}