getConditions() {
+ return Collections.unmodifiableList(conditions);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("(");
+ for (int i = 0; i < conditions.size(); i++) {
+ if (i > 0) sb.append(" AND ");
+ sb.append(conditions.get(i));
+ }
+ sb.append(")");
+ return sb.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ AndCondition that = (AndCondition) o;
+ return conditions.equals(that.conditions);
+ }
+
+ @Override
+ public int hashCode() {
+ return conditions.hashCode();
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AnyLabelCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AnyLabelCondition.java
new file mode 100644
index 0000000000..f0081c5b44
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/AnyLabelCondition.java
@@ -0,0 +1,37 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * The any-label wildcard % in a label expression, e.g. (n:%). Satisfied when the element
+ * bound to the variable has a non-empty label set.
+ */
+public class AnyLabelCondition implements CypherCondition {
+
+ private final String variableName;
+
+ public AnyLabelCondition(String variableName) {
+ this.variableName = variableName;
+ }
+
+ public String getVariableName() {
+ return variableName;
+ }
+
+ @Override
+ public String toString() {
+ return variableName + ":%";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ return Objects.equals(variableName, ((AnyLabelCondition) o).variableName);
+ }
+
+ @Override
+ public int hashCode() {
+ return variableName != null ? variableName.hashCode() : 0;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ArithmeticOperand.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ArithmeticOperand.java
new file mode 100644
index 0000000000..db3f7d06d5
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ArithmeticOperand.java
@@ -0,0 +1,78 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * An operand that is an arithmetic expression over other operands, e.g. {@code p.age + 5} or
+ * {@code -n.weight}. Keeps the structure (operator + sub-operands) instead of the original text,
+ * so the inner {@link PropertyOperand}s stay resolvable per mapping and the heuristics calculator
+ * can valuate {@code v(x op y) = apply(op, v(left), v(right))}.
+ *
+ * Fully-literal subtrees are folded to a {@link LiteralOperand} at parse time, so this class only
+ * ever holds an expression that still depends on the graph (at least one property reference).
+ *
+ *
For {@link ArithmeticOperator#NEGATE} (unary minus) the operand is {@link #getLeft()} and
+ * {@link #getRight()} is {@code null}.
+ */
+public final class ArithmeticOperand implements Operand {
+
+ private final ArithmeticOperator operator;
+ private final Operand left;
+ private final Operand right;
+
+ public ArithmeticOperand(ArithmeticOperator operator, Operand left, Operand right) {
+ this.operator = operator;
+ this.left = left;
+ this.right = right;
+ }
+
+ public ArithmeticOperator getOperator() {
+ return operator;
+ }
+
+ public Operand getLeft() {
+ return left;
+ }
+
+ public Operand getRight() {
+ return right;
+ }
+
+ @Override
+ public String toString() {
+ if (operator == ArithmeticOperator.NEGATE) {
+ return "-" + left;
+ }
+ return "(" + left + " " + symbol() + " " + right + ")";
+ }
+
+ private String symbol() {
+ switch (operator) {
+ case PLUS: return "+";
+ case MINUS: return "-";
+ case TIMES: return "*";
+ case DIVIDE: return "/";
+ case MODULO: return "%";
+ case POWER: return "^";
+ default: return "?";
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ ArithmeticOperand that = (ArithmeticOperand) o;
+ return operator == that.operator
+ && Objects.equals(left, that.left)
+ && Objects.equals(right, that.right);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = operator != null ? operator.hashCode() : 0;
+ result = 31 * result + (left != null ? left.hashCode() : 0);
+ result = 31 * result + (right != null ? right.hashCode() : 0);
+ return result;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ArithmeticOperator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ArithmeticOperator.java
new file mode 100644
index 0000000000..5062903856
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ArithmeticOperator.java
@@ -0,0 +1,15 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+/**
+ * The arithmetic connectives an {@link ArithmeticOperand} can carry. Binary operators map to the
+ * Cypher tokens {@code + - * / % ^}; {@link #NEGATE} is unary minus (its operand is the left side).
+ */
+public enum ArithmeticOperator {
+ PLUS,
+ MINUS,
+ TIMES,
+ DIVIDE,
+ MODULO,
+ POWER,
+ NEGATE
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ComparisonCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ComparisonCondition.java
new file mode 100644
index 0000000000..7073307024
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ComparisonCondition.java
@@ -0,0 +1,59 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * A comparison from a WHERE clause: left OP right.
+ * Both sides are typed {@link Operand}s so each can be valuated against the graph
+ * independently. The right operand is null for the unary IS NULL / IS NOT NULL operators.
+ */
+public class ComparisonCondition implements CypherCondition {
+
+ private final Operand left;
+ private final ComparisonOperator operator;
+ private final Operand right;
+
+ public ComparisonCondition(Operand left, ComparisonOperator operator, Operand right) {
+ this.left = left;
+ this.operator = operator;
+ this.right = right;
+ }
+
+ public Operand getLeft() {
+ return left;
+ }
+
+ public ComparisonOperator getOperator() {
+ return operator;
+ }
+
+ public Operand getRight() {
+ return right;
+ }
+
+ @Override
+ public String toString() {
+ if (right == null) {
+ return left + " " + operator.getSymbol();
+ }
+ return left + " " + operator.getSymbol() + " " + right;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ ComparisonCondition that = (ComparisonCondition) o;
+ return operator == that.operator
+ && Objects.equals(left, that.left)
+ && Objects.equals(right, that.right);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = left != null ? left.hashCode() : 0;
+ result = 31 * result + (operator != null ? operator.hashCode() : 0);
+ result = 31 * result + (right != null ? right.hashCode() : 0);
+ return result;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ComparisonOperator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ComparisonOperator.java
new file mode 100644
index 0000000000..1a28b290cc
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ComparisonOperator.java
@@ -0,0 +1,38 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+/**
+ * Comparison operators used in Cypher WHERE clauses.
+ */
+public enum ComparisonOperator {
+ EQUALS("="),
+ NOT_EQUALS("<>"),
+ LESS_THAN("<"),
+ LESS_THAN_OR_EQUALS("<="),
+ GREATER_THAN(">"),
+ GREATER_THAN_OR_EQUALS(">="),
+ STARTS_WITH("STARTS WITH"),
+ ENDS_WITH("ENDS WITH"),
+ CONTAINS("CONTAINS"),
+ IN("IN"),
+ IS_NULL("IS NULL"),
+ IS_NOT_NULL("IS NOT NULL");
+
+ private final String symbol;
+
+ ComparisonOperator(String symbol) {
+ this.symbol = symbol;
+ }
+
+ public String getSymbol() {
+ return symbol;
+ }
+
+ public static ComparisonOperator fromSymbol(String symbol) {
+ for (ComparisonOperator op : values()) {
+ if (op.symbol.equalsIgnoreCase(symbol)) {
+ return op;
+ }
+ }
+ return null;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherCondition.java
new file mode 100644
index 0000000000..7fea8e2348
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/CypherCondition.java
@@ -0,0 +1,14 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+/**
+ * Represents a condition extracted from a Cypher query.
+ * Conditions can come from:
+ * - Node labels: (n:Person) → LabelCondition
+ * - Node properties: (n {name: "Alice"}) → PropertyCondition
+ * - Edge types: -[:KNOWS]-> → TypeCondition
+ * - Edge properties: -[:KNOWS {since: 2020}]-> → PropertyCondition
+ * - WHERE clause: WHERE n.age > 25 → ComparisonCondition
+ * - Logical operators: AND, OR, NOT
+ */
+public interface CypherCondition {
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/LabelCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/LabelCondition.java
new file mode 100644
index 0000000000..248580bdd4
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/LabelCondition.java
@@ -0,0 +1,47 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * Represents a label condition: n:Label
+ * Extracted from patterns like (n:Person) or (n:Person:Employee)
+ */
+public class LabelCondition implements CypherCondition {
+
+ private final String variableName;
+ private final String label;
+
+ public LabelCondition(String variableName, String label) {
+ this.variableName = variableName;
+ this.label = label;
+ }
+
+ public String getVariableName() {
+ return variableName;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ @Override
+ public String toString() {
+ return variableName + ":" + label;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ LabelCondition that = (LabelCondition) o;
+ if (!Objects.equals(variableName, that.variableName)) return false;
+ return Objects.equals(label, that.label);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = variableName != null ? variableName.hashCode() : 0;
+ result = 31 * result + (label != null ? label.hashCode() : 0);
+ return result;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ListOperand.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ListOperand.java
new file mode 100644
index 0000000000..33062cc348
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/ListOperand.java
@@ -0,0 +1,39 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A list operand, e.g. the right-hand side of x IN [1, 2, 3]. Holds the element operands
+ * so a membership test can be valuated element by element.
+ */
+public final class ListOperand implements Operand {
+
+ private final List elements;
+
+ public ListOperand(List elements) {
+ this.elements = elements != null ? new ArrayList<>(elements) : new ArrayList<>();
+ }
+
+ public List getElements() {
+ return Collections.unmodifiableList(elements);
+ }
+
+ @Override
+ public String toString() {
+ return elements.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ return elements.equals(((ListOperand) o).elements);
+ }
+
+ @Override
+ public int hashCode() {
+ return elements.hashCode();
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/LiteralOperand.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/LiteralOperand.java
new file mode 100644
index 0000000000..b3d300371d
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/LiteralOperand.java
@@ -0,0 +1,39 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * A constant operand: a string, number, boolean or null literal.
+ */
+public final class LiteralOperand implements Operand {
+
+ private final Object value;
+
+ public LiteralOperand(Object value) {
+ this.value = value;
+ }
+
+ public Object getValue() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ if (value == null) {
+ return "null";
+ }
+ return value instanceof String ? "\"" + value + "\"" : String.valueOf(value);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ return Objects.equals(value, ((LiteralOperand) o).value);
+ }
+
+ @Override
+ public int hashCode() {
+ return value != null ? value.hashCode() : 0;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/NotCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/NotCondition.java
new file mode 100644
index 0000000000..c4b5f64be0
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/NotCondition.java
@@ -0,0 +1,37 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * Represents a logical NOT of a condition.
+ */
+public class NotCondition implements CypherCondition {
+
+ private final CypherCondition condition;
+
+ public NotCondition(CypherCondition condition) {
+ this.condition = condition;
+ }
+
+ public CypherCondition getCondition() {
+ return condition;
+ }
+
+ @Override
+ public String toString() {
+ return "NOT " + condition;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ NotCondition that = (NotCondition) o;
+ return Objects.equals(condition, that.condition);
+ }
+
+ @Override
+ public int hashCode() {
+ return condition != null ? condition.hashCode() : 0;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/Operand.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/Operand.java
new file mode 100644
index 0000000000..a0a44cd40c
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/Operand.java
@@ -0,0 +1,9 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+/**
+ * One side of a comparison in a WHERE clause: a {@link PropertyOperand} resolved from the
+ * matched element, a {@link LiteralOperand} constant, or a {@link RawOperand} kept unchanged.
+ * Modelling each side explicitly lets {@code n.age > m.age} be told apart from {@code n.age > "m.age"}.
+ */
+public interface Operand {
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/OrCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/OrCondition.java
new file mode 100644
index 0000000000..2f0b47f873
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/OrCondition.java
@@ -0,0 +1,45 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Represents a logical OR of multiple conditions.
+ */
+public class OrCondition implements CypherCondition {
+
+ private final List conditions;
+
+ public OrCondition(List conditions) {
+ this.conditions = conditions != null ? new ArrayList<>(conditions) : new ArrayList<>();
+ }
+
+ public List getConditions() {
+ return Collections.unmodifiableList(conditions);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("(");
+ for (int i = 0; i < conditions.size(); i++) {
+ if (i > 0) sb.append(" OR ");
+ sb.append(conditions.get(i));
+ }
+ sb.append(")");
+ return sb.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ OrCondition that = (OrCondition) o;
+ return conditions.equals(that.conditions);
+ }
+
+ @Override
+ public int hashCode() {
+ return conditions.hashCode();
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/PropertyCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/PropertyCondition.java
new file mode 100644
index 0000000000..ba80898fa6
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/PropertyCondition.java
@@ -0,0 +1,57 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * Represents a property equality condition {@code n.key = value} extracted from an inline property
+ * map like {@code {name: "Alice"}}. The value is a typed {@link Operand}, the same representation a
+ * WHERE comparison uses, so {@code {age: 25 + 5}} folds to a literal and {@code {at: time("11:11")}}
+ * keeps the expression as a {@link RawOperand} instead of guessing a value.
+ */
+public class PropertyCondition implements CypherCondition {
+
+ private final String variableName;
+ private final String propertyKey;
+ private final Operand value;
+
+ public PropertyCondition(String variableName, String propertyKey, Operand value) {
+ this.variableName = variableName;
+ this.propertyKey = propertyKey;
+ this.value = value;
+ }
+
+ public String getVariableName() {
+ return variableName;
+ }
+
+ public String getPropertyKey() {
+ return propertyKey;
+ }
+
+ public Operand getValue() {
+ return value;
+ }
+
+ @Override
+ public String toString() {
+ return variableName + "." + propertyKey + " = " + value;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PropertyCondition that = (PropertyCondition) o;
+ if (!Objects.equals(variableName, that.variableName)) return false;
+ if (!Objects.equals(propertyKey, that.propertyKey)) return false;
+ return Objects.equals(value, that.value);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = variableName != null ? variableName.hashCode() : 0;
+ result = 31 * result + (propertyKey != null ? propertyKey.hashCode() : 0);
+ result = 31 * result + (value != null ? value.hashCode() : 0);
+ return result;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/PropertyOperand.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/PropertyOperand.java
new file mode 100644
index 0000000000..dc596047dd
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/PropertyOperand.java
@@ -0,0 +1,47 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * A property reference operand: variable.key (e.g. n.age), resolved from the graph
+ * element bound to the variable under the current mapping.
+ */
+public final class PropertyOperand implements Operand {
+
+ private final String variableName;
+ private final String propertyKey;
+
+ public PropertyOperand(String variableName, String propertyKey) {
+ this.variableName = variableName;
+ this.propertyKey = propertyKey;
+ }
+
+ public String getVariableName() {
+ return variableName;
+ }
+
+ public String getPropertyKey() {
+ return propertyKey;
+ }
+
+ @Override
+ public String toString() {
+ return variableName + "." + propertyKey;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PropertyOperand that = (PropertyOperand) o;
+ return Objects.equals(variableName, that.variableName)
+ && Objects.equals(propertyKey, that.propertyKey);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = variableName != null ? variableName.hashCode() : 0;
+ result = 31 * result + (propertyKey != null ? propertyKey.hashCode() : 0);
+ return result;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawCondition.java
new file mode 100644
index 0000000000..64884725cc
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawCondition.java
@@ -0,0 +1,39 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * A WHERE predicate kept unchanged because the structural model does not decompose
+ * it (e.g. a boolean-returning function call or a bare boolean expression). It exists
+ * so that no part of the query is silently dropped: the boolean structure around it
+ * (AND/OR/XOR/NOT) is still preserved, and this leaf carries the original text.
+ */
+public class RawCondition implements CypherCondition {
+
+ private final String expression;
+
+ public RawCondition(String expression) {
+ this.expression = expression;
+ }
+
+ public String getExpression() {
+ return expression;
+ }
+
+ @Override
+ public String toString() {
+ return expression;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ return Objects.equals(expression, ((RawCondition) o).expression);
+ }
+
+ @Override
+ public int hashCode() {
+ return expression != null ? expression.hashCode() : 0;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawOperand.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawOperand.java
new file mode 100644
index 0000000000..ddf94e9ad7
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawOperand.java
@@ -0,0 +1,37 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * An operand kept unchanged because the model does not decompose it: arithmetic such as
+ * 25 + 5, a function call, or a list. Carries the original text so nothing is dropped.
+ */
+public final class RawOperand implements Operand {
+
+ private final String text;
+
+ public RawOperand(String text) {
+ this.text = text;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ @Override
+ public String toString() {
+ return text;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ return Objects.equals(text, ((RawOperand) o).text);
+ }
+
+ @Override
+ public int hashCode() {
+ return text != null ? text.hashCode() : 0;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/TypeCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/TypeCondition.java
new file mode 100644
index 0000000000..7177078b81
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/TypeCondition.java
@@ -0,0 +1,47 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.Objects;
+
+/**
+ * Represents a relationship type condition: type(r) = T
+ * Extracted from patterns like -[:KNOWS]-> or -[r:WORKS_AT]->
+ */
+public class TypeCondition implements CypherCondition {
+
+ private final String variableName;
+ private final String type;
+
+ public TypeCondition(String variableName, String type) {
+ this.variableName = variableName;
+ this.type = type;
+ }
+
+ public String getVariableName() {
+ return variableName;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ @Override
+ public String toString() {
+ return "type(" + variableName + ") = " + type;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ TypeCondition that = (TypeCondition) o;
+ if (!Objects.equals(variableName, that.variableName)) return false;
+ return Objects.equals(type, that.type);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = variableName != null ? variableName.hashCode() : 0;
+ result = 31 * result + (type != null ? type.hashCode() : 0);
+ return result;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/XorCondition.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/XorCondition.java
new file mode 100644
index 0000000000..0eb3e60cf3
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/XorCondition.java
@@ -0,0 +1,45 @@
+package org.evomaster.client.java.controller.neo4j.conditions;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Represents a logical XOR of multiple conditions.
+ */
+public class XorCondition implements CypherCondition {
+
+ private final List conditions;
+
+ public XorCondition(List conditions) {
+ this.conditions = conditions != null ? new ArrayList<>(conditions) : new ArrayList<>();
+ }
+
+ public List getConditions() {
+ return Collections.unmodifiableList(conditions);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("(");
+ for (int i = 0; i < conditions.size(); i++) {
+ if (i > 0) sb.append(" XOR ");
+ sb.append(conditions.get(i));
+ }
+ sb.append(")");
+ return sb.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ XorCondition that = (XorCondition) o;
+ return conditions.equals(that.conditions);
+ }
+
+ @Override
+ public int hashCode() {
+ return conditions.hashCode();
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/CypherQueryOperation.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/CypherQueryOperation.java
new file mode 100644
index 0000000000..3f166560bf
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/CypherQueryOperation.java
@@ -0,0 +1,7 @@
+package org.evomaster.client.java.controller.neo4j.operations;
+
+/**
+ * Represents a parsed Cypher query operation.
+ */
+public abstract class CypherQueryOperation {
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/MatchOperation.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/MatchOperation.java
new file mode 100644
index 0000000000..0070ee7088
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/MatchOperation.java
@@ -0,0 +1,98 @@
+package org.evomaster.client.java.controller.neo4j.operations;
+
+import org.evomaster.client.java.controller.neo4j.conditions.CypherCondition;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Represents a parsed MATCH query containing the structural pattern and extracted conditions.
+ *
+ * The structural pattern contains only the graph topology (nodes and edges).
+ * Conditions include labels, relationship types, property constraints, and WHERE clause predicates.
+ *
+ * A query may assign several path variables ({@code MATCH p = ..., q = ...}); all are kept in order.
+ * {@link #isOptional()} is true when the query involves an {@code OPTIONAL MATCH} (left-join semantics).
+ */
+public class MatchOperation extends CypherQueryOperation {
+
+ private final MatchPattern pattern;
+ private final List conditions;
+ private final List pathVariables;
+ private final boolean optional;
+
+ public MatchOperation(MatchPattern pattern, List conditions) {
+ this(pattern, conditions, (String) null);
+ }
+
+ public MatchOperation(MatchPattern pattern, List conditions, String pathVariable) {
+ this(pattern, conditions,
+ pathVariable != null ? Collections.singletonList(pathVariable) : Collections.emptyList(),
+ false);
+ }
+
+ public MatchOperation(MatchPattern pattern, List conditions,
+ List pathVariables, boolean optional) {
+ this.pattern = pattern;
+ this.conditions = conditions != null ? new ArrayList<>(conditions) : new ArrayList<>();
+ this.pathVariables = pathVariables != null ? new ArrayList<>(pathVariables) : new ArrayList<>();
+ this.optional = optional;
+ }
+
+ /**
+ * Returns the structural pattern containing nodes and edges.
+ */
+ public MatchPattern getPattern() {
+ return pattern;
+ }
+
+ /**
+ * Returns all conditions extracted from both inline patterns and WHERE clause.
+ */
+ public List getConditions() {
+ return Collections.unmodifiableList(conditions);
+ }
+
+ /**
+ * Returns the first path variable assigned by the pattern (e.g. {@code path = ...}), or null if
+ * none. Kept for callers that expect a single assignment; {@link #getPathVariables()} returns all.
+ */
+ public String getPathVariable() {
+ return pathVariables.isEmpty() ? null : pathVariables.get(0);
+ }
+
+ /**
+ * Returns every path variable assigned by the query, in source order (empty if there are none).
+ */
+ public List getPathVariables() {
+ return Collections.unmodifiableList(pathVariables);
+ }
+
+ /**
+ * Returns true when the query involves an {@code OPTIONAL MATCH}. The match still succeeds with
+ * no binding when the pattern is absent, so a consumer should not penalize a missing optional part.
+ */
+ public boolean isOptional() {
+ return optional;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("MatchOperation{");
+ if (optional) {
+ sb.append("optional, ");
+ }
+ if (!pathVariables.isEmpty()) {
+ sb.append("pathVariables=").append(pathVariables).append(", ");
+ }
+ sb.append("pattern=").append(pattern);
+ sb.append(", conditions=[");
+ for (int i = 0; i < conditions.size(); i++) {
+ if (i > 0) sb.append(", ");
+ sb.append(conditions.get(i));
+ }
+ sb.append("]}");
+ return sb.toString();
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/MatchPattern.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/MatchPattern.java
new file mode 100644
index 0000000000..e1cc706cb8
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/MatchPattern.java
@@ -0,0 +1,76 @@
+package org.evomaster.client.java.controller.neo4j.operations;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Represents the structural MATCH pattern (P_s) after stripping labels and properties.
+ * Contains nodes, edges, and any quantified path patterns (repeated sub-patterns).
+ */
+public class MatchPattern {
+
+ private final List nodes;
+ private final List edges;
+ private final List quantifiedPaths;
+
+ public MatchPattern(List nodes, List edges) {
+ this(nodes, edges, null);
+ }
+
+ public MatchPattern(List nodes, List edges,
+ List quantifiedPaths) {
+ this.nodes = nodes != null ? new ArrayList<>(nodes) : new ArrayList<>();
+ this.edges = edges != null ? new ArrayList<>(edges) : new ArrayList<>();
+ this.quantifiedPaths = quantifiedPaths != null ? new ArrayList<>(quantifiedPaths) : new ArrayList<>();
+ }
+
+ public List getNodes() {
+ return Collections.unmodifiableList(nodes);
+ }
+
+ public List getEdges() {
+ return Collections.unmodifiableList(edges);
+ }
+
+ public List getQuantifiedPaths() {
+ return Collections.unmodifiableList(quantifiedPaths);
+ }
+
+ public int nodeCount() {
+ return nodes.size();
+ }
+
+ public int edgeCount() {
+ return edges.size();
+ }
+
+ public int quantifiedPathCount() {
+ return quantifiedPaths.size();
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("MatchPattern{nodes=[");
+ for (int i = 0; i < nodes.size(); i++) {
+ if (i > 0) sb.append(", ");
+ sb.append(nodes.get(i));
+ }
+ sb.append("], edges=[");
+ for (int i = 0; i < edges.size(); i++) {
+ if (i > 0) sb.append(", ");
+ sb.append(edges.get(i));
+ }
+ sb.append("]");
+ if (!quantifiedPaths.isEmpty()) {
+ sb.append(", quantifiedPaths=[");
+ for (int i = 0; i < quantifiedPaths.size(); i++) {
+ if (i > 0) sb.append(", ");
+ sb.append(quantifiedPaths.get(i));
+ }
+ sb.append("]");
+ }
+ sb.append("}");
+ return sb.toString();
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/PatternEdge.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/PatternEdge.java
new file mode 100644
index 0000000000..a51080d370
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/PatternEdge.java
@@ -0,0 +1,111 @@
+package org.evomaster.client.java.controller.neo4j.operations;
+
+import java.util.Objects;
+
+/**
+ * Represents an edge in a structural MATCH pattern.
+ * Contains source/target variable names, direction, and variable-length path information.
+ * Type and properties are not part of the structural pattern - they become conditions.
+ */
+public class PatternEdge {
+
+ private final String variableName;
+ private final String sourceVariable;
+ private final String targetVariable;
+ private final boolean directed;
+ private final boolean variableLength;
+ private final Integer minLength;
+ private final Integer maxLength;
+
+ public PatternEdge(String variableName, String sourceVariable, String targetVariable, boolean directed) {
+ this(variableName, sourceVariable, targetVariable, directed, false, null, null);
+ }
+
+ public PatternEdge(String variableName, String sourceVariable, String targetVariable,
+ boolean directed, boolean variableLength, Integer minLength, Integer maxLength) {
+ this.variableName = variableName;
+ this.sourceVariable = sourceVariable;
+ this.targetVariable = targetVariable;
+ this.directed = directed;
+ this.variableLength = variableLength;
+ this.minLength = minLength;
+ this.maxLength = maxLength;
+ }
+
+ public String getVariableName() {
+ return variableName;
+ }
+
+ public String getSourceVariable() {
+ return sourceVariable;
+ }
+
+ public String getTargetVariable() {
+ return targetVariable;
+ }
+
+ public boolean isDirected() {
+ return directed;
+ }
+
+ public boolean isVariableLength() {
+ return variableLength;
+ }
+
+ public Integer getMinLength() {
+ return minLength;
+ }
+
+ public Integer getMaxLength() {
+ return maxLength;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder relPart = new StringBuilder("[");
+ if (variableName != null) {
+ relPart.append(variableName);
+ }
+ if (variableLength) {
+ relPart.append("*");
+ if (minLength != null || maxLength != null) {
+ if (minLength != null) relPart.append(minLength);
+ relPart.append("..");
+ if (maxLength != null) relPart.append(maxLength);
+ }
+ }
+ relPart.append("]");
+
+ if (directed) {
+ return "(" + sourceVariable + ")-" + relPart + "->(" + targetVariable + ")";
+ } else {
+ return "(" + sourceVariable + ")-" + relPart + "-(" + targetVariable + ")";
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PatternEdge that = (PatternEdge) o;
+ if (directed != that.directed) return false;
+ if (variableLength != that.variableLength) return false;
+ if (!Objects.equals(variableName, that.variableName)) return false;
+ if (!Objects.equals(sourceVariable, that.sourceVariable)) return false;
+ if (!Objects.equals(targetVariable, that.targetVariable)) return false;
+ if (!Objects.equals(minLength, that.minLength)) return false;
+ return Objects.equals(maxLength, that.maxLength);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = variableName != null ? variableName.hashCode() : 0;
+ result = 31 * result + (sourceVariable != null ? sourceVariable.hashCode() : 0);
+ result = 31 * result + (targetVariable != null ? targetVariable.hashCode() : 0);
+ result = 31 * result + (directed ? 1 : 0);
+ result = 31 * result + (variableLength ? 1 : 0);
+ result = 31 * result + (minLength != null ? minLength.hashCode() : 0);
+ result = 31 * result + (maxLength != null ? maxLength.hashCode() : 0);
+ return result;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/PatternNode.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/PatternNode.java
new file mode 100644
index 0000000000..32f3164523
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/PatternNode.java
@@ -0,0 +1,37 @@
+package org.evomaster.client.java.controller.neo4j.operations;
+
+/**
+ * Represents a node in a structural MATCH pattern (P_s).
+ * Contains only the variable name, not labels or properties (those are conditions).
+ */
+public class PatternNode {
+
+ private final String variableName;
+
+ public PatternNode(String variableName) {
+ this.variableName = variableName;
+ }
+
+ public String getVariableName() {
+ return variableName;
+ }
+
+ @Override
+ public String toString() {
+ return "(" + (variableName != null ? variableName : "") + ")";
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PatternNode that = (PatternNode) o;
+ if (variableName == null) return that.variableName == null;
+ return variableName.equals(that.variableName);
+ }
+
+ @Override
+ public int hashCode() {
+ return variableName != null ? variableName.hashCode() : 0;
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/QuantifiedPathPattern.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/QuantifiedPathPattern.java
new file mode 100644
index 0000000000..309d6aaf57
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/operations/QuantifiedPathPattern.java
@@ -0,0 +1,74 @@
+package org.evomaster.client.java.controller.neo4j.operations;
+
+import org.evomaster.client.java.controller.neo4j.conditions.CypherCondition;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A quantified path pattern (QPP), i.e. a sub-pattern repeated a number of times,
+ * such as {@code ((a)-[:KNOWS]->(b)){1,3}}.
+ *
+ * Unlike a variable-length relationship ({@code *1..3}), which repeats a single
+ * edge, a QPP repeats an entire {@link MatchPattern} (multiple nodes and edges,
+ * and possibly nested QPPs). The repetition bounds are {@code [min, max]}, where a
+ * {@code null} max means unbounded ({@code +} or {@code *}).
+ *
+ * The labels, relationship types, properties and inline {@code WHERE} that constrain the
+ * sub-pattern are kept in {@link #getConditions()}, scoped to this QPP rather than mixed into
+ * the outer operation's list — so when the calculator expands the repetition it can clone the
+ * sub-pattern's conditions per hop.
+ */
+public class QuantifiedPathPattern {
+
+ private final MatchPattern subPattern;
+ private final List conditions;
+ private final int min;
+ private final Integer max;
+
+ public QuantifiedPathPattern(MatchPattern subPattern, int min, Integer max) {
+ this(subPattern, Collections.emptyList(), min, max);
+ }
+
+ public QuantifiedPathPattern(MatchPattern subPattern, List conditions, int min, Integer max) {
+ this.subPattern = subPattern;
+ this.conditions = conditions != null ? new ArrayList<>(conditions) : new ArrayList<>();
+ this.min = min;
+ this.max = max;
+ }
+
+ public MatchPattern getSubPattern() {
+ return subPattern;
+ }
+
+ /**
+ * Returns the conditions scoped to this sub-pattern (labels, relationship types, properties,
+ * inline WHERE), in source order. Empty when the sub-pattern is purely structural.
+ */
+ public List getConditions() {
+ return Collections.unmodifiableList(conditions);
+ }
+
+ public int getMin() {
+ return min;
+ }
+
+ public Integer getMax() {
+ return max;
+ }
+
+ public boolean isUnboundedMax() {
+ return max == null;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder("QuantifiedPathPattern{").append(subPattern);
+ if (!conditions.isEmpty()) {
+ sb.append(", conditions=").append(conditions);
+ }
+ sb.append(" {").append(min).append(",").append(max != null ? max : "").append("}}");
+ return sb.toString();
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/CypherParser.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/CypherParser.java
new file mode 100644
index 0000000000..fcbfe5e662
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/CypherParser.java
@@ -0,0 +1,22 @@
+package org.evomaster.client.java.controller.neo4j.parser;
+
+import org.evomaster.client.java.controller.neo4j.operations.MatchOperation;
+
+/**
+ * Parses the MATCH clause of a Cypher query into a structural {@link MatchOperation}.
+ *
+ * Implementations are built on top of the official Neo4j Cypher grammar (ANTLR4),
+ * so that any query accepted by this parser is guaranteed to be valid for Neo4j.
+ */
+public interface CypherParser {
+
+ /**
+ * Parse a Cypher query, extracting its MATCH pattern and conditions.
+ *
+ * @param query the Cypher query string
+ * @return the structural pattern and conditions of the MATCH clause
+ * @throws CypherParserException if the query is not syntactically valid Cypher,
+ * or does not contain a MATCH clause
+ */
+ MatchOperation parse(String query) throws CypherParserException;
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/CypherParserException.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/CypherParserException.java
new file mode 100644
index 0000000000..8848565582
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/CypherParserException.java
@@ -0,0 +1,20 @@
+package org.evomaster.client.java.controller.neo4j.parser;
+
+/**
+ * Thrown when a Cypher query cannot be parsed: either it is not syntactically
+ * valid Cypher, or it does not contain the expected MATCH clause.
+ */
+public class CypherParserException extends Exception {
+
+ public CypherParserException(String message) {
+ super(message);
+ }
+
+ public CypherParserException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public CypherParserException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/CypherParserFactory.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/CypherParserFactory.java
new file mode 100644
index 0000000000..a487a4c8dc
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/CypherParserFactory.java
@@ -0,0 +1,16 @@
+package org.evomaster.client.java.controller.neo4j.parser;
+
+import org.evomaster.client.java.controller.neo4j.parser.cypher25.Cypher25AntlrParser;
+
+/**
+ * Builds the default {@link CypherParser} implementation.
+ */
+public class CypherParserFactory {
+
+ private CypherParserFactory() {
+ }
+
+ public static CypherParser buildParser() {
+ return new Cypher25AntlrParser();
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/cypher25/Cypher25AntlrParser.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/cypher25/Cypher25AntlrParser.java
new file mode 100644
index 0000000000..d1c0884d4f
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/cypher25/Cypher25AntlrParser.java
@@ -0,0 +1,61 @@
+package org.evomaster.client.java.controller.neo4j.parser.cypher25;
+
+import org.antlr.v4.runtime.*;
+import org.antlr.v4.runtime.misc.ParseCancellationException;
+import org.evomaster.client.java.controller.neo4j.cypher25.Cypher25Lexer;
+import org.evomaster.client.java.controller.neo4j.cypher25.Cypher25Parser;
+import org.evomaster.client.java.controller.neo4j.parser.CypherParser;
+import org.evomaster.client.java.controller.neo4j.parser.CypherParserException;
+import org.evomaster.client.java.controller.neo4j.operations.MatchOperation;
+
+/**
+ * {@link CypherParser} implementation built on the official Neo4j {@code Cypher25}
+ * ANTLR grammar. Because the grammar is the one Neo4j itself uses, any query this
+ * parser accepts is guaranteed to be valid Cypher.
+ *
+ * Syntax errors are not swallowed: the lexer and parser are wired with a listener
+ * that fails fast, and the error (with line/column) is surfaced as a
+ * {@link CypherParserException}.
+ */
+public class Cypher25AntlrParser implements CypherParser {
+
+ private static final ThrowingErrorListener THROWING_LISTENER = new ThrowingErrorListener();
+
+ @Override
+ public MatchOperation parse(String query) throws CypherParserException {
+ if (query == null || query.trim().isEmpty()) {
+ throw new CypherParserException("Query is null or empty");
+ }
+
+ try {
+ Cypher25Lexer lexer = new Cypher25Lexer(CharStreams.fromString(query));
+ lexer.removeErrorListeners();
+ lexer.addErrorListener(THROWING_LISTENER);
+
+ Cypher25Parser parser = new Cypher25Parser(new CommonTokenStream(lexer));
+ parser.removeErrorListeners();
+ parser.addErrorListener(THROWING_LISTENER);
+
+ Cypher25Parser.StatementsContext tree = parser.statements();
+
+ Cypher25MatchVisitor visitor = new Cypher25MatchVisitor();
+ visitor.visit(tree);
+
+ if (!visitor.foundMatch()) {
+ throw new CypherParserException("Query does not contain a MATCH clause");
+ }
+ return visitor.toOperation();
+
+ } catch (ParseCancellationException e) {
+ throw new CypherParserException(e.getMessage(), e);
+ }
+ }
+
+ private static final class ThrowingErrorListener extends BaseErrorListener {
+ @Override
+ public void syntaxError(Recognizer, ?> recognizer, Object offendingSymbol,
+ int line, int charPositionInLine, String msg, RecognitionException e) {
+ throw new ParseCancellationException("line " + line + ":" + charPositionInLine + " " + msg);
+ }
+ }
+}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/cypher25/Cypher25MatchVisitor.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/cypher25/Cypher25MatchVisitor.java
new file mode 100644
index 0000000000..562ef3cd3f
--- /dev/null
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/parser/cypher25/Cypher25MatchVisitor.java
@@ -0,0 +1,834 @@
+package org.evomaster.client.java.controller.neo4j.parser.cypher25;
+
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.antlr.v4.runtime.tree.TerminalNode;
+import org.evomaster.client.java.controller.neo4j.cypher25.Cypher25Parser;
+import org.evomaster.client.java.controller.neo4j.cypher25.Cypher25ParserBaseVisitor;
+import org.evomaster.client.java.controller.neo4j.conditions.*;
+import org.evomaster.client.java.controller.neo4j.operations.*;
+
+import java.util.*;
+import java.util.function.Function;
+
+/**
+ * Translates the parse tree produced by the Neo4j {@code Cypher25Parser} grammar
+ * into the internal {@link MatchOperation} model.
+ *
+ * Only the rules relevant to MATCH pattern matching are overridden; every other
+ * rule falls through to the default {@code visitChildren}, so the visitor stays
+ * compact while the underlying grammar remains the full Cypher language.
+ *
+ * Scope: node/relationship structure, label and relationship-type expressions, inline
+ * node/relationship properties, variable-length paths ({@code *1..3}), quantified path
+ * patterns ({@code ((a)-[]->(b)){1,3}}, possibly nested), path assignment, and the WHERE
+ * clause. Label/type expressions and the WHERE clause are both parsed into a faithful
+ * boolean tree ({@link AndCondition} / {@link OrCondition} / {@link XorCondition} /
+ * {@link NotCondition}, with parentheses) mirroring the grammar's precedence: {@code (n:A&B|C)}
+ * becomes {@code OrCondition[AndCondition[A,B], C]} and {@code [:A|B]} an OR of types, while
+ * comparison leaves carry typed {@link Operand}s so both sides of {@code n.age > m.age} stay
+ * property references. Predicates not decomposed
+ * structurally (functions, bare booleans) are kept unchanged as {@link RawCondition}.
+ */
+class Cypher25MatchVisitor extends Cypher25ParserBaseVisitor {
+
+ private final PatternAcc root = new PatternAcc();
+ private final List conditions = new ArrayList<>();
+ /** Where conditions are collected right now; the root list by default, a QPP's own list inside one. */
+ private List conditionSink = conditions;
+
+ private final List pathVariables = new ArrayList<>();
+ private boolean optional;
+ private boolean foundMatch;
+ private int anonNodeCounter;
+ private int anonRelCounter;
+
+ boolean foundMatch() {
+ return foundMatch;
+ }
+
+ MatchOperation toOperation() {
+ return new MatchOperation(root.toPattern(), conditions, pathVariables, optional);
+ }
+
+ @Override
+ public Void visitMatchClause(Cypher25Parser.MatchClauseContext ctx) {
+ foundMatch = true;
+ // matchClause : OPTIONAL? MATCH ... — one OPTIONAL clause makes the whole (merged) operation optional.
+ if (ctx.OPTIONAL() != null) {
+ optional = true;
+ }
+
+ for (Cypher25Parser.PatternContext pattern : ctx.patternList().pattern()) {
+ processPattern(pattern, root);
+ }
+
+ if (ctx.whereClause() != null) {
+ processWhere(ctx.whereClause().expression());
+ }
+
+ // The clause is fully handled here; do not recurse into it again.
+ return null;
+ }
+
+ private void processPattern(Cypher25Parser.PatternContext ctx, PatternAcc acc) {
+ // pattern : (variable EQ)? selector? anonymousPattern
+ if (ctx.variable() != null) {
+ pathVariables.add(name(ctx.variable()));
+ }
+
+ Cypher25Parser.AnonymousPatternContext anon = ctx.anonymousPattern();
+ if (anon.patternElement() != null) {
+ processPatternElement(anon.patternElement(), acc);
+ } else if (anon.shortestPathPattern() != null) {
+ processPatternElement(anon.shortestPathPattern().patternElement(), acc);
+ }
+ }
+
+ private void processPatternElement(Cypher25Parser.PatternElementContext ctx, PatternAcc acc) {
+ // Walk children in source order so nodes and relationships pair up correctly:
+ // (n0) -rel0- (n1) -rel1- (n2) ...
+ String previousNode = null;
+ RelInfo pendingRel = null;
+
+ for (ParseTree child : children(ctx)) {
+ if (child instanceof Cypher25Parser.NodePatternContext) {
+ String current = processNode((Cypher25Parser.NodePatternContext) child, acc);
+ if (pendingRel != null && previousNode != null) {
+ addEdge(pendingRel, previousNode, current, acc);
+ pendingRel = null;
+ }
+ previousNode = current;
+ } else if (child instanceof Cypher25Parser.RelationshipPatternContext) {
+ pendingRel = processRelationship((Cypher25Parser.RelationshipPatternContext) child);
+ } else if (child instanceof Cypher25Parser.QuantifierContext && pendingRel != null) {
+ applyQuantifier(pendingRel, (Cypher25Parser.QuantifierContext) child);
+ } else if (child instanceof Cypher25Parser.ParenthesizedPathContext) {
+ acc.quantifiedPaths.add(processParenthesizedPath((Cypher25Parser.ParenthesizedPathContext) child));
+ }
+ }
+ }
+
+ private QuantifiedPathPattern processParenthesizedPath(Cypher25Parser.ParenthesizedPathContext ctx) {
+ // parenthesizedPath : LPAREN pattern (WHERE expression)? RPAREN quantifier?
+ PatternAcc sub = new PatternAcc();
+ // Scope the sub-pattern's labels/types/properties and inline WHERE to this QPP, not the outer
+ // list — restore the previous sink afterwards so a nested QPP nests its conditions correctly.
+ List subConditions = new ArrayList<>();
+ List previousSink = conditionSink;
+ conditionSink = subConditions;
+ try {
+ processPattern(ctx.pattern(), sub);
+ if (ctx.expression() != null) {
+ processWhere(ctx.expression()); // inline WHERE filtering the sub-path
+ }
+ } finally {
+ conditionSink = previousSink;
+ }
+
+ // A parenthesized path without a quantifier is only a grouping; treat as {1,1}.
+ Bounds bounds = ctx.quantifier() != null
+ ? quantifierBounds(ctx.quantifier())
+ : new Bounds(1, 1);
+
+ return new QuantifiedPathPattern(sub.toPattern(), subConditions, bounds.min, bounds.max);
+ }
+
+ private String processNode(Cypher25Parser.NodePatternContext ctx, PatternAcc acc) {
+ // nodePattern : LPAREN variable? labelExpression? properties? (WHERE expression)? RPAREN
+ String variable = ctx.variable() != null
+ ? name(ctx.variable())
+ : "_anon_node_" + (anonNodeCounter++);
+
+ acc.nodes.putIfAbsent(variable, new PatternNode(variable));
+
+ if (ctx.labelExpression() != null) {
+ addLabelConditions(variable, ctx.labelExpression());
+ }
+ if (ctx.properties() != null) {
+ for (Map.Entry p : properties(ctx.properties()).entrySet()) {
+ conditionSink.add(new PropertyCondition(variable, p.getKey(), p.getValue()));
+ }
+ }
+ if (ctx.expression() != null) {
+ processWhere(ctx.expression()); // inline predicate: (n WHERE ...)
+ }
+ return variable;
+ }
+
+ private RelInfo processRelationship(Cypher25Parser.RelationshipPatternContext ctx) {
+ // relationshipPattern : leftArrow? arrowLine (LBRACKET variable? labelExpression?
+ // pathLength? properties? (WHERE expression)? RBRACKET)? arrowLine rightArrow?
+ RelInfo rel = new RelInfo();
+ rel.leftArrow = ctx.leftArrow() != null;
+ rel.directed = rel.leftArrow || ctx.rightArrow() != null;
+ rel.variable = ctx.variable() != null
+ ? name(ctx.variable())
+ : "_anon_rel_" + (anonRelCounter++);
+
+ if (ctx.labelExpression() != null) {
+ // Relationship types use the same grammar rule as node labels, so the same
+ // faithful boolean tree applies: [:A|B] is an OR of types, [:!A] a negation.
+ String relVar = rel.variable;
+ rel.typeCondition = labelTree(ctx.labelExpression(), relVar, type -> new TypeCondition(relVar, type));
+ }
+ if (ctx.pathLength() != null) {
+ applyPathLength(rel, ctx.pathLength());
+ }
+ if (ctx.properties() != null) {
+ rel.properties.putAll(properties(ctx.properties()));
+ }
+ if (ctx.expression() != null) {
+ processWhere(ctx.expression()); // inline predicate: -[r WHERE ...]->
+ }
+ return rel;
+ }
+
+ private void addEdge(RelInfo rel, String left, String right, PatternAcc acc) {
+ // A left arrow (<-[]-) reverses the logical source/target.
+ String source = rel.leftArrow ? right : left;
+ String target = rel.leftArrow ? left : right;
+
+ acc.edges.add(new PatternEdge(rel.variable, source, target, rel.directed,
+ rel.variableLength, rel.minLength, rel.maxLength));
+
+ if (rel.typeCondition != null) {
+ conditionSink.add(rel.typeCondition);
+ }
+ for (Map.Entry p : rel.properties.entrySet()) {
+ conditionSink.add(new PropertyCondition(rel.variable, p.getKey(), p.getValue()));
+ }
+ }
+
+ private void addLabelConditions(String variable, Cypher25Parser.LabelExpressionContext ctx) {
+ CypherCondition tree = labelTree(ctx, variable, label -> new LabelCondition(variable, label));
+ if (tree != null) {
+ conditionSink.add(tree);
+ }
+ }
+
+ /**
+ * Builds the boolean tree of a label/type expression following the grammar's precedence:
+ * level 4 is OR, 3 is AND ({@code &} or {@code :}), 2 is NOT ({@code !}), 1 is an atom (a
+ * name, the {@code %} wildcard, or a parenthesised sub-expression). The two surface forms,
+ * {@code :A&B} (COLON) and {@code IS A&B}, have identical structure but distinct generated
+ * contexts, so each is walked by its own typed traversal; both share the {@link #or},
+ * {@link #and} and {@link #negate} combinators and the {@code leaf} factory, which makes the
+ * leaf for a bare name ({@link LabelCondition} for nodes, {@link TypeCondition} for
+ * relationships). Returns null for dynamic-label forms.
+ */
+ private CypherCondition labelTree(Cypher25Parser.LabelExpressionContext ctx,
+ String variable, Function leaf) {
+ if (ctx.labelExpression4() != null) {
+ return buildLabelOr(ctx.labelExpression4(), variable, leaf);
+ }
+ if (ctx.labelExpression4Is() != null) {
+ return buildLabelOrIs(ctx.labelExpression4Is(), variable, leaf);
+ }
+ return null;
+ }
+
+ private CypherCondition or(List terms) {
+ return terms.size() == 1 ? terms.get(0) : new OrCondition(terms);
+ }
+
+ private CypherCondition and(List terms) {
+ return terms.size() == 1 ? terms.get(0) : new AndCondition(terms);
+ }
+
+ private CypherCondition negate(int notCount, CypherCondition inner) {
+ // An odd number of '!' negates; an even number cancels out.
+ return notCount % 2 == 1 ? new NotCondition(inner) : inner;
+ }
+
+ private CypherCondition buildLabelOr(Cypher25Parser.LabelExpression4Context ctx,
+ String variable, Function leaf) {
+ List terms = new ArrayList<>();
+ for (Cypher25Parser.LabelExpression3Context p : ctx.labelExpression3()) {
+ terms.add(buildLabelAnd(p, variable, leaf));
+ }
+ return or(terms);
+ }
+
+ private CypherCondition buildLabelAnd(Cypher25Parser.LabelExpression3Context ctx,
+ String variable, Function leaf) {
+ List terms = new ArrayList<>();
+ for (Cypher25Parser.LabelExpression2Context p : ctx.labelExpression2()) {
+ terms.add(buildLabelNot(p, variable, leaf));
+ }
+ return and(terms);
+ }
+
+ private CypherCondition buildLabelNot(Cypher25Parser.LabelExpression2Context ctx,
+ String variable, Function leaf) {
+ return negate(ctx.EXCLAMATION_MARK().size(), buildLabelAtom(ctx.labelExpression1(), variable, leaf));
+ }
+
+ private CypherCondition buildLabelAtom(Cypher25Parser.LabelExpression1Context ctx,
+ String variable, Function leaf) {
+ if (ctx instanceof Cypher25Parser.ParenthesizedLabelExpressionContext) {
+ return buildLabelOr(
+ ((Cypher25Parser.ParenthesizedLabelExpressionContext) ctx).labelExpression4(), variable, leaf);
+ }
+ if (ctx instanceof Cypher25Parser.AnyLabelContext) {
+ return new AnyLabelCondition(variable);
+ }
+ if (ctx instanceof Cypher25Parser.LabelNameContext) {
+ return leaf.apply(name(((Cypher25Parser.LabelNameContext) ctx).symbolicNameString()));
+ }
+ return new RawCondition(ctx.getText());
+ }
+
+ private CypherCondition buildLabelOrIs(Cypher25Parser.LabelExpression4IsContext ctx,
+ String variable, Function leaf) {
+ List terms = new ArrayList<>();
+ for (Cypher25Parser.LabelExpression3IsContext p : ctx.labelExpression3Is()) {
+ terms.add(buildLabelAndIs(p, variable, leaf));
+ }
+ return or(terms);
+ }
+
+ private CypherCondition buildLabelAndIs(Cypher25Parser.LabelExpression3IsContext ctx,
+ String variable, Function leaf) {
+ List terms = new ArrayList<>();
+ for (Cypher25Parser.LabelExpression2IsContext p : ctx.labelExpression2Is()) {
+ terms.add(buildLabelNotIs(p, variable, leaf));
+ }
+ return and(terms);
+ }
+
+ private CypherCondition buildLabelNotIs(Cypher25Parser.LabelExpression2IsContext ctx,
+ String variable, Function leaf) {
+ return negate(ctx.EXCLAMATION_MARK().size(), buildLabelAtomIs(ctx.labelExpression1Is(), variable, leaf));
+ }
+
+ private CypherCondition buildLabelAtomIs(Cypher25Parser.LabelExpression1IsContext ctx,
+ String variable, Function leaf) {
+ if (ctx instanceof Cypher25Parser.ParenthesizedLabelExpressionIsContext) {
+ return buildLabelOrIs(
+ ((Cypher25Parser.ParenthesizedLabelExpressionIsContext) ctx).labelExpression4Is(), variable, leaf);
+ }
+ if (ctx instanceof Cypher25Parser.AnyLabelIsContext) {
+ return new AnyLabelCondition(variable);
+ }
+ if (ctx instanceof Cypher25Parser.LabelNameIsContext) {
+ return leaf.apply(name(((Cypher25Parser.LabelNameIsContext) ctx).symbolicLabelNameString()));
+ }
+ return new RawCondition(ctx.getText());
+ }
+
+ private void applyPathLength(RelInfo rel, Cypher25Parser.PathLengthContext ctx) {
+ rel.variableLength = true;
+ if (ctx.single != null) {
+ rel.minLength = parseInt(ctx.single.getText());
+ rel.maxLength = rel.minLength;
+ } else {
+ if (ctx.from != null) rel.minLength = parseInt(ctx.from.getText());
+ if (ctx.to != null) rel.maxLength = parseInt(ctx.to.getText());
+ }
+ }
+
+ private void applyQuantifier(RelInfo rel, Cypher25Parser.QuantifierContext ctx) {
+ Bounds bounds = quantifierBounds(ctx);
+ rel.variableLength = true;
+ rel.minLength = bounds.min;
+ rel.maxLength = bounds.max;
+ }
+
+ private Bounds quantifierBounds(Cypher25Parser.QuantifierContext ctx) {
+ if (ctx.PLUS() != null) {
+ return new Bounds(1, null);
+ }
+ if (ctx.TIMES() != null) {
+ return new Bounds(0, null);
+ }
+ if (ctx.COMMA() != null) {
+ int min = ctx.from != null ? parseInt(ctx.from.getText()) : 0;
+ Integer max = ctx.to != null ? parseInt(ctx.to.getText()) : null;
+ return new Bounds(min, max);
+ }
+ int exact = parseInt(ctx.UNSIGNED_DECIMAL_INTEGER(0).getText());
+ return new Bounds(exact, exact);
+ }
+
+ /**
+ * Builds the full boolean tree of the WHERE expression and adds it as a single
+ * condition. The connective structure (OR / XOR / AND / NOT, with parentheses)
+ * is preserved faithfully; leaves are comparisons. Predicates that are not decomposed
+ * structurally (functions, bare booleans) are kept unchanged as {@link RawCondition}
+ * rather than dropped, so no part of the query is lost.
+ */
+ private void processWhere(Cypher25Parser.ExpressionContext ctx) {
+ CypherCondition where = buildExpression(ctx);
+ if (where != null) {
+ conditionSink.add(where);
+ }
+ }
+
+ // expression : expression11 (OR expression11)*
+ private CypherCondition buildExpression(Cypher25Parser.ExpressionContext ctx) {
+ List parts = ctx.expression11();
+ if (parts.size() == 1) {
+ return buildXor(parts.get(0));
+ }
+ List terms = new ArrayList<>();
+ for (Cypher25Parser.Expression11Context p : parts) {
+ terms.add(buildXor(p));
+ }
+ return new OrCondition(terms);
+ }
+
+ // expression11 : expression10 (XOR expression10)*
+ private CypherCondition buildXor(Cypher25Parser.Expression11Context ctx) {
+ List parts = ctx.expression10();
+ if (parts.size() == 1) {
+ return buildAnd(parts.get(0));
+ }
+ List terms = new ArrayList<>();
+ for (Cypher25Parser.Expression10Context p : parts) {
+ terms.add(buildAnd(p));
+ }
+ return new XorCondition(terms);
+ }
+
+ // expression10 : expression9 (AND expression9)*
+ private CypherCondition buildAnd(Cypher25Parser.Expression10Context ctx) {
+ List parts = ctx.expression9();
+ if (parts.size() == 1) {
+ return buildNot(parts.get(0));
+ }
+ List terms = new ArrayList<>();
+ for (Cypher25Parser.Expression9Context p : parts) {
+ terms.add(buildNot(p));
+ }
+ return new AndCondition(terms);
+ }
+
+ // expression9 : NOT* expression8
+ private CypherCondition buildNot(Cypher25Parser.Expression9Context ctx) {
+ CypherCondition inner = buildComparison(ctx.expression8());
+ // An odd number of NOTs negates; an even number cancels out.
+ return ctx.NOT().size() % 2 == 1 ? new NotCondition(inner) : inner;
+ }
+
+ // expression8 : expression7 ((EQ | NEQ | LE | GE | LT | GT) expression7)*
+ private CypherCondition buildComparison(Cypher25Parser.Expression8Context ctx) {
+ List operands = ctx.expression7();
+ if (operands.size() == 1) {
+ return buildExpression7(operands.get(0));
+ }
+ // A chain a op1 b op2 c expands to (a op1 b) AND (b op2 c), as Cypher chains comparisons.
+ // Operators are read in source order so a mixed chain (a < b <= c) keeps each one.
+ List operators = comparisonOperators(ctx);
+ List links = new ArrayList<>();
+ for (int i = 0; i < operators.size(); i++) {
+ links.add(new ComparisonCondition(
+ operand(operands.get(i)), operators.get(i), operand(operands.get(i + 1))));
+ }
+ return links.size() == 1 ? links.get(0) : new AndCondition(links);
+ }
+
+ /** The comparison operators of an expression8 chain, in source order. */
+ private List comparisonOperators(Cypher25Parser.Expression8Context ctx) {
+ List operators = new ArrayList<>();
+ for (ParseTree child : children(ctx)) {
+ if (child instanceof TerminalNode) {
+ ComparisonOperator op = comparisonOperator(((TerminalNode) child).getSymbol().getType());
+ if (op != null) {
+ operators.add(op);
+ }
+ }
+ }
+ return operators;
+ }
+
+ // expression7 : expression6 comparisonExpression6?
+ private CypherCondition buildExpression7(Cypher25Parser.Expression7Context ctx) {
+ if (ctx.comparisonExpression6() != null) {
+ CypherCondition c = stringOrNullComparison(ctx);
+ if (c != null) {
+ return c;
+ }
+ }
+ // A parenthesized boolean group, e.g. (a OR b): recurse into the inner expression.
+ Cypher25Parser.ExpressionContext nested = enclosedExpression(ctx);
+ if (nested != null) {
+ return buildExpression(nested);
+ }
+ // A predicate that is not decomposed structurally (function, bare boolean).
+ return new RawCondition(ctx.getText());
+ }
+
+ private CypherCondition stringOrNullComparison(Cypher25Parser.Expression7Context ctx) {
+ Operand left = operand(ctx.expression6());
+ Cypher25Parser.ComparisonExpression6Context cmp = ctx.comparisonExpression6();
+
+ if (cmp instanceof Cypher25Parser.StringAndListComparisonContext) {
+ Cypher25Parser.StringAndListComparisonContext s =
+ (Cypher25Parser.StringAndListComparisonContext) cmp;
+ ComparisonOperator op = null;
+ if (s.STARTS() != null) op = ComparisonOperator.STARTS_WITH;
+ else if (s.ENDS() != null) op = ComparisonOperator.ENDS_WITH;
+ else if (s.CONTAINS() != null) op = ComparisonOperator.CONTAINS;
+ else if (s.IN() != null) op = ComparisonOperator.IN;
+ if (op != null) {
+ return new ComparisonCondition(left, op, operand(s.expression6()));
+ }
+ } else if (cmp instanceof Cypher25Parser.NullComparisonContext) {
+ Cypher25Parser.NullComparisonContext n = (Cypher25Parser.NullComparisonContext) cmp;
+ ComparisonOperator op = n.NOT() != null
+ ? ComparisonOperator.IS_NOT_NULL
+ : ComparisonOperator.IS_NULL;
+ return new ComparisonCondition(left, op, (Operand) null);
+ }
+ return null; // TYPE / NORMALIZED comparison: not modelled
+ }
+
+ /**
+ * Classifies one side of a comparison: a property reference {@code variable.key} as a
+ * {@link PropertyOperand}, a list {@code [..]} as a {@link ListOperand} of element operands,
+ * an operand that is exactly a literal as a {@link LiteralOperand}, an arithmetic expression
+ * ({@code + - * / % ^}, unary minus) as an {@link ArithmeticOperand} (folded to a literal when
+ * every leaf is a numeric literal), and anything else (a function call) as a {@link RawOperand}.
+ */
+ private Operand operand(ParseTree expression) {
+ PropertyOperand property = propertyReference(expression);
+ if (property != null) {
+ return property;
+ }
+ Cypher25Parser.ListLiteralContext list = soleList(expression);
+ if (list != null) {
+ List elements = new ArrayList<>();
+ for (Cypher25Parser.ExpressionContext element : list.expression()) {
+ elements.add(operand(element));
+ }
+ return new ListOperand(elements);
+ }
+ Cypher25Parser.LiteralContext literal = soleLiteral(expression);
+ if (literal != null) {
+ return new LiteralOperand(convertLiteral(literal));
+ }
+ Operand arithmetic = arithmetic(expression);
+ if (arithmetic != null) {
+ return arithmetic;
+ }
+ return new RawOperand(expression.getText());
+ }
+
+ /**
+ * Builds an {@link ArithmeticOperand} tree for an operand that is an arithmetic expression,
+ * or {@code null} when the operand is not arithmetic (so the caller keeps it as a
+ * {@link RawOperand}). Sub-operands go back through {@link #operand}, so a nested {@code p.age}
+ * stays a {@link PropertyOperand}; an all-numeric-literal subtree folds to a {@link LiteralOperand}.
+ */
+ private Operand arithmetic(ParseTree expression) {
+ return buildArithmetic(descendSingleChild(expression));
+ }
+
+ private Operand buildArithmetic(ParseTree node) {
+ if (node instanceof Cypher25Parser.ParenthesizedExpressionContext) {
+ return operand(((Cypher25Parser.ParenthesizedExpressionContext) node).expression());
+ }
+ if (node instanceof Cypher25Parser.Expression6Context // + - || (left-assoc)
+ || node instanceof Cypher25Parser.Expression5Context) { // * / % (left-assoc)
+ return buildLeftAssoc(node);
+ }
+ if (node instanceof Cypher25Parser.Expression4Context) { // ^ (right-assoc)
+ return buildPower((Cypher25Parser.Expression4Context) node);
+ }
+ if (node instanceof Cypher25Parser.Expression3Context) { // unary + / -
+ return buildUnary((Cypher25Parser.Expression3Context) node);
+ }
+ return null;
+ }
+
+ /** Folds {@code operand (OP operand)*} left to right; null if any operator is non-arithmetic (e.g. {@code ||}). */
+ private Operand buildLeftAssoc(ParseTree node) {
+ Operand acc = null;
+ ArithmeticOperator pending = null;
+ for (ParseTree child : children(node)) {
+ if (child instanceof TerminalNode) {
+ pending = arithmeticOperator(((TerminalNode) child).getSymbol().getType());
+ if (pending == null) {
+ return null;
+ }
+ } else {
+ Operand current = operand(child);
+ acc = acc == null ? current : arithmeticNode(pending, acc, current);
+ }
+ }
+ return acc;
+ }
+
+ /** {@code expression3 (POW expression3)*} folded right-associatively, as exponentiation associates. */
+ private Operand buildPower(Cypher25Parser.Expression4Context node) {
+ List parts = node.expression3();
+ Operand acc = operand(parts.get(parts.size() - 1));
+ for (int i = parts.size() - 2; i >= 0; i--) {
+ acc = arithmeticNode(ArithmeticOperator.POWER, operand(parts.get(i)), acc);
+ }
+ return acc;
+ }
+
+ /** {@code (PLUS | MINUS) expression2}: unary minus negates, unary plus is identity. */
+ private Operand buildUnary(Cypher25Parser.Expression3Context node) {
+ Operand inner = operand(node.expression2());
+ return node.MINUS() != null ? arithmeticNode(ArithmeticOperator.NEGATE, inner, null) : inner;
+ }
+
+ /** A folded literal when the operands evaluate to one, else a structural {@link ArithmeticOperand}. */
+ private Operand arithmeticNode(ArithmeticOperator op, Operand left, Operand right) {
+ Operand folded = fold(op, left, right);
+ return folded != null ? folded : new ArithmeticOperand(op, left, right);
+ }
+
+ /** Evaluates an arithmetic node when every operand is a numeric literal; otherwise null. */
+ private Operand fold(ArithmeticOperator op, Operand left, Operand right) {
+ Object l = numericLiteral(left);
+ if (l == null) {
+ return null;
+ }
+ if (op == ArithmeticOperator.NEGATE) {
+ return l instanceof Long
+ ? new LiteralOperand(-(Long) l)
+ : new LiteralOperand(-(Double) l);
+ }
+ Object r = numericLiteral(right);
+ if (r == null) {
+ return null;
+ }
+ return l instanceof Long && r instanceof Long
+ ? foldLong(op, (Long) l, (Long) r)
+ : foldDouble(op, ((Number) l).doubleValue(), ((Number) r).doubleValue());
+ }
+
+ /** Integer arithmetic; leaves the node unfolded (null) on overflow or division by zero. */
+ private Operand foldLong(ArithmeticOperator op, long a, long b) {
+ try {
+ switch (op) {
+ case PLUS: return new LiteralOperand(Math.addExact(a, b));
+ case MINUS: return new LiteralOperand(Math.subtractExact(a, b));
+ case TIMES: return new LiteralOperand(Math.multiplyExact(a, b));
+ case DIVIDE: return b == 0 ? null : new LiteralOperand(a / b);
+ case MODULO: return b == 0 ? null : new LiteralOperand(a % b);
+ case POWER: return new LiteralOperand(Math.pow(a, b)); // Neo4j's ^ yields a float
+ default: return null;
+ }
+ } catch (ArithmeticException overflow) {
+ return null;
+ }
+ }
+
+ /** Floating-point arithmetic; leaves the node unfolded (null) on division by zero. */
+ private Operand foldDouble(ArithmeticOperator op, double a, double b) {
+ switch (op) {
+ case PLUS: return new LiteralOperand(a + b);
+ case MINUS: return new LiteralOperand(a - b);
+ case TIMES: return new LiteralOperand(a * b);
+ case DIVIDE: return b == 0 ? null : new LiteralOperand(a / b);
+ case MODULO: return b == 0 ? null : new LiteralOperand(a % b);
+ case POWER: return new LiteralOperand(Math.pow(a, b));
+ default: return null;
+ }
+ }
+
+ private Object numericLiteral(Operand operand) {
+ if (operand instanceof LiteralOperand) {
+ Object value = ((LiteralOperand) operand).getValue();
+ if (value instanceof Long || value instanceof Double) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ private ArithmeticOperator arithmeticOperator(int tokenType) {
+ if (tokenType == Cypher25Parser.PLUS) return ArithmeticOperator.PLUS;
+ if (tokenType == Cypher25Parser.MINUS) return ArithmeticOperator.MINUS;
+ if (tokenType == Cypher25Parser.TIMES) return ArithmeticOperator.TIMES;
+ if (tokenType == Cypher25Parser.DIVIDE) return ArithmeticOperator.DIVIDE;
+ if (tokenType == Cypher25Parser.PERCENT) return ArithmeticOperator.MODULO;
+ if (tokenType == Cypher25Parser.POW) return ArithmeticOperator.POWER;
+ return null; // DOUBLEBAR (||) etc.: not numeric arithmetic
+ }
+
+ /** The list literal an operand reduces to when it is nothing but {@code [..]}, else null. */
+ private Cypher25Parser.ListLiteralContext soleList(ParseTree expression) {
+ ParseTree node = descendSingleChild(expression);
+ return node instanceof Cypher25Parser.ListLiteralContext
+ ? (Cypher25Parser.ListLiteralContext) node
+ : null;
+ }
+
+ /** A property reference {@code variable.key}, or null if the operand is not exactly that. */
+ private PropertyOperand propertyReference(ParseTree expression) {
+ ParseTree node = descendSingleChild(expression);
+ if (!(node instanceof Cypher25Parser.Expression2Context)) {
+ return null;
+ }
+ Cypher25Parser.Expression2Context e2 = (Cypher25Parser.Expression2Context) node;
+ List postfixes = e2.postFix();
+ if (postfixes.size() != 1 || !(postfixes.get(0) instanceof Cypher25Parser.PropertyPostfixContext)) {
+ return null; // not a single `.key` access (e.g. indexing, or a longer chain)
+ }
+ Cypher25Parser.VariableContext base = e2.expression1().variable();
+ if (base == null) {
+ return null; // the base is not a bare variable (e.g. a function result)
+ }
+ Cypher25Parser.PropertyPostfixContext postfix =
+ (Cypher25Parser.PropertyPostfixContext) postfixes.get(0);
+ String key = name(postfix.property().propertyKeyName().symbolicNameString());
+ return new PropertyOperand(name(base), key);
+ }
+
+ /** The literal an operand reduces to when it is nothing but a literal, else null. */
+ private Cypher25Parser.LiteralContext soleLiteral(ParseTree tree) {
+ ParseTree t = tree;
+ while (t != null) {
+ if (t instanceof Cypher25Parser.LiteralContext) {
+ return (Cypher25Parser.LiteralContext) t;
+ }
+ if (t.getChildCount() != 1) {
+ return null; // a branch (operator, arguments): not a bare literal
+ }
+ t = t.getChild(0);
+ }
+ return null;
+ }
+
+ /** Follows the single-child chain down to the first node that branches (or a leaf). */
+ private ParseTree descendSingleChild(ParseTree tree) {
+ ParseTree t = tree;
+ while (t.getChildCount() == 1) {
+ t = t.getChild(0);
+ }
+ return t;
+ }
+
+ /** Descends a single-child chain; returns the inner expression if it is a parenthesized group. */
+ private Cypher25Parser.ExpressionContext enclosedExpression(ParseTree tree) {
+ if (tree instanceof Cypher25Parser.ParenthesizedExpressionContext) {
+ return ((Cypher25Parser.ParenthesizedExpressionContext) tree).expression();
+ }
+ if (tree.getChildCount() == 1) {
+ return enclosedExpression(tree.getChild(0));
+ }
+ return null;
+ }
+
+ private ComparisonOperator comparisonOperator(int tokenType) {
+ if (tokenType == Cypher25Parser.EQ) return ComparisonOperator.EQUALS;
+ if (tokenType == Cypher25Parser.NEQ || tokenType == Cypher25Parser.INVALID_NEQ) return ComparisonOperator.NOT_EQUALS;
+ if (tokenType == Cypher25Parser.LE) return ComparisonOperator.LESS_THAN_OR_EQUALS;
+ if (tokenType == Cypher25Parser.GE) return ComparisonOperator.GREATER_THAN_OR_EQUALS;
+ if (tokenType == Cypher25Parser.LT) return ComparisonOperator.LESS_THAN;
+ if (tokenType == Cypher25Parser.GT) return ComparisonOperator.GREATER_THAN;
+ return null;
+ }
+
+ private Map properties(Cypher25Parser.PropertiesContext ctx) {
+ // properties : map | parameter
+ Map result = new LinkedHashMap<>();
+ Cypher25Parser.MapContext map = ctx.map();
+ if (map == null) {
+ return result; // parameterised properties ($props) carry no literal values
+ }
+ List keys = map.propertyKeyName();
+ List values = map.expression();
+ for (int i = 0; i < keys.size(); i++) {
+ // A property value is classified exactly like a comparison operand, so it folds
+ // constants and keeps functions/parameters as a RawOperand instead of guessing.
+ result.put(name(keys.get(i).symbolicNameString()), operand(values.get(i)));
+ }
+ return result;
+ }
+
+ private Object convertLiteral(Cypher25Parser.LiteralContext literal) {
+ if (literal instanceof Cypher25Parser.StringsLiteralContext) {
+ String raw = literal.getText();
+ return raw.length() >= 2 ? raw.substring(1, raw.length() - 1) : raw;
+ }
+ if (literal instanceof Cypher25Parser.NummericLiteralContext) {
+ String number = literal.getText();
+ try {
+ return number.contains(".") || number.toLowerCase().contains("e")
+ ? (Object) Double.parseDouble(number)
+ : (Object) Long.parseLong(number);
+ } catch (NumberFormatException e) {
+ return number;
+ }
+ }
+ if (literal instanceof Cypher25Parser.BooleanLiteralContext) {
+ return literal.getText().equalsIgnoreCase("true");
+ }
+ if (literal instanceof Cypher25Parser.KeywordLiteralContext) {
+ return literal.getText().equalsIgnoreCase("null") ? null : literal.getText();
+ }
+ return literal.getText();
+ }
+
+ private String name(Cypher25Parser.VariableContext ctx) {
+ return name(ctx.symbolicNameString());
+ }
+
+ private String name(Cypher25Parser.SymbolicNameStringContext ctx) {
+ return unquote(ctx.getText());
+ }
+
+ private String name(Cypher25Parser.SymbolicLabelNameStringContext ctx) {
+ return unquote(ctx.getText());
+ }
+
+ /** Strips the backtick quoting from an escaped identifier, leaving plain ones untouched. */
+ private String unquote(String text) {
+ if (text.length() >= 2 && text.charAt(0) == '`' && text.charAt(text.length() - 1) == '`') {
+ return text.substring(1, text.length() - 1).replace("``", "`");
+ }
+ return text;
+ }
+
+ private List children(ParseTree parent) {
+ List result = new ArrayList<>(parent.getChildCount());
+ for (int i = 0; i < parent.getChildCount(); i++) {
+ result.add(parent.getChild(i));
+ }
+ return result;
+ }
+
+ private Integer parseInt(String text) {
+ return Integer.valueOf(text);
+ }
+
+ /** Accumulates the nodes, edges and quantified paths of one (sub-)pattern. */
+ private static final class PatternAcc {
+ final Map nodes = new LinkedHashMap<>();
+ final List edges = new ArrayList<>();
+ final List quantifiedPaths = new ArrayList<>();
+
+ MatchPattern toPattern() {
+ return new MatchPattern(new ArrayList<>(nodes.values()), edges, quantifiedPaths);
+ }
+ }
+
+ /** Repetition bounds [min, max]; a null max means unbounded. */
+ private static final class Bounds {
+ final int min;
+ final Integer max;
+
+ Bounds(int min, Integer max) {
+ this.min = min;
+ this.max = max;
+ }
+ }
+
+ /** Mutable accumulator for a relationship while its endpoints are being resolved. */
+ private static final class RelInfo {
+ String variable;
+ CypherCondition typeCondition;
+ final Map properties = new LinkedHashMap<>();
+ boolean leftArrow;
+ boolean directed;
+ boolean variableLength;
+ Integer minLength;
+ Integer maxLength;
+ }
+}
diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/parser/Cypher25AntlrParserTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/parser/Cypher25AntlrParserTest.java
new file mode 100644
index 0000000000..efd0872e14
--- /dev/null
+++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/parser/Cypher25AntlrParserTest.java
@@ -0,0 +1,808 @@
+package org.evomaster.client.java.controller.neo4j.parser;
+
+import org.evomaster.client.java.controller.neo4j.conditions.*;
+import org.evomaster.client.java.controller.neo4j.operations.MatchOperation;
+import org.evomaster.client.java.controller.neo4j.operations.PatternEdge;
+import org.evomaster.client.java.controller.neo4j.operations.QuantifiedPathPattern;
+import org.junit.jupiter.api.Test;
+import org.evomaster.client.java.controller.neo4j.parser.cypher25.Cypher25AntlrParser;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for {@link Cypher25AntlrParser}, the parser built on the official Neo4j
+ * Cypher25 grammar. Invalid input fails with a {@link CypherParserException}, and label/type
+ * expressions are preserved as a faithful boolean tree ({@link OrCondition}/{@link AndCondition}/
+ * {@link NotCondition} over {@link LabelCondition}/{@link TypeCondition} leaves), the same
+ * structure used for the WHERE clause.
+ */
+class Cypher25AntlrParserTest {
+
+ private final CypherParser parser = CypherParserFactory.buildParser();
+
+ private MatchOperation parse(String query) {
+ try {
+ return parser.parse(query);
+ } catch (CypherParserException e) {
+ throw new AssertionError("Expected a successful parse for: " + query, e);
+ }
+ }
+
+ private void assertFails(String query) {
+ assertThrows(CypherParserException.class, () -> parser.parse(query));
+ }
+
+ private long countOf(MatchOperation op, Class type) {
+ return op.getConditions().stream().filter(type::isInstance).count();
+ }
+
+ @Test
+ void testAllNodes() {
+ MatchOperation op = parse("MATCH (n) RETURN n");
+ assertEquals(1, op.getPattern().nodeCount());
+ assertEquals(0, op.getPattern().edgeCount());
+ assertEquals(0, op.getConditions().size());
+ assertEquals("n", op.getPattern().getNodes().get(0).getVariableName());
+ }
+
+ @Test
+ void testNodeWithLabel() {
+ MatchOperation op = parse("MATCH (movie:Movie) RETURN movie.title");
+ assertEquals(1, op.getPattern().nodeCount());
+ assertEquals(1, op.getConditions().size());
+ LabelCondition label = (LabelCondition) op.getConditions().get(0);
+ assertEquals("movie", label.getVariableName());
+ assertEquals("Movie", label.getLabel());
+ }
+
+ @Test
+ void testNodeWithMultipleLabelsAnd() {
+ MatchOperation op = parse("MATCH (e:Person:Employee) RETURN e");
+ AndCondition and = (AndCondition) op.getConditions().get(0);
+ assertEquals(2, and.getConditions().size());
+ assertTrue(and.getConditions().stream().allMatch(LabelCondition.class::isInstance));
+ }
+
+ @Test
+ void testNodeWithMultipleLabelsAmpersand() {
+ MatchOperation op = parse("MATCH (e:Person&Employee) RETURN e");
+ AndCondition and = (AndCondition) op.getConditions().get(0);
+ assertEquals(2, and.getConditions().size());
+ assertTrue(and.getConditions().stream().allMatch(LabelCondition.class::isInstance));
+ }
+
+ @Test
+ void testLabelExpressionOr() {
+ MatchOperation op = parse("MATCH (n:Movie|Person) RETURN n.name");
+ OrCondition or = (OrCondition) op.getConditions().get(0);
+ assertEquals(2, or.getConditions().size());
+ assertTrue(or.getConditions().stream().allMatch(LabelCondition.class::isInstance));
+ assertEquals("n", ((LabelCondition) or.getConditions().get(0)).getVariableName());
+ assertTrue(or.getConditions().stream()
+ .anyMatch(c -> "Movie".equals(((LabelCondition) c).getLabel())));
+ assertTrue(or.getConditions().stream()
+ .anyMatch(c -> "Person".equals(((LabelCondition) c).getLabel())));
+ }
+
+ @Test
+ void testNodeLabelNegation() {
+ // (n:!Person): the node must NOT have the label.
+ MatchOperation op = parse("MATCH (n:!Person) RETURN n");
+ NotCondition not = (NotCondition) op.getConditions().get(0);
+ assertEquals("Person", ((LabelCondition) not.getCondition()).getLabel());
+ }
+
+ @Test
+ void testNodeLabelMixedAndOr() {
+ // (n:A&B|C) parses as (A AND B) OR C; & binds tighter than |.
+ MatchOperation op = parse("MATCH (n:A&B|C) RETURN n");
+ OrCondition or = (OrCondition) op.getConditions().get(0);
+ assertEquals(2, or.getConditions().size());
+ AndCondition and = (AndCondition) or.getConditions().get(0);
+ assertEquals(2, and.getConditions().size());
+ assertInstanceOf(LabelCondition.class, or.getConditions().get(1));
+ }
+
+ @Test
+ void testNodeLabelParenthesizedRegroup() {
+ // (n:A&(B|C)) regroups to A AND (B OR C).
+ MatchOperation op = parse("MATCH (n:A&(B|C)) RETURN n");
+ AndCondition and = (AndCondition) op.getConditions().get(0);
+ assertEquals(2, and.getConditions().size());
+ assertInstanceOf(LabelCondition.class, and.getConditions().get(0));
+ assertEquals(2, ((OrCondition) and.getConditions().get(1)).getConditions().size());
+ }
+
+ @Test
+ void testNodeAnyLabelWildcard() {
+ MatchOperation op = parse("MATCH (n:%) RETURN n");
+ AnyLabelCondition any = (AnyLabelCondition) op.getConditions().get(0);
+ assertEquals("n", any.getVariableName());
+ }
+
+ @Test
+ void testNodeIsLabel() {
+ // The GQL 'IS' form is equivalent to the colon form.
+ MatchOperation op = parse("MATCH (n IS Person) RETURN n");
+ LabelCondition label = (LabelCondition) op.getConditions().get(0);
+ assertEquals("n", label.getVariableName());
+ assertEquals("Person", label.getLabel());
+ }
+
+ @Test
+ void testNodeIsLabelExpression() {
+ // The 'IS' form supports the same &|! operators as the colon form.
+ MatchOperation op = parse("MATCH (n IS A&B|C) RETURN n");
+ OrCondition or = (OrCondition) op.getConditions().get(0);
+ assertEquals(2, or.getConditions().size());
+ assertEquals(2, ((AndCondition) or.getConditions().get(0)).getConditions().size());
+ }
+
+ @Test
+ void testRelationshipIsType() {
+ MatchOperation op = parse("MATCH (a)-[r IS KNOWS]->(b) RETURN a, b");
+ TypeCondition type = (TypeCondition) op.getConditions().stream()
+ .filter(TypeCondition.class::isInstance).findFirst()
+ .orElseThrow(() -> new AssertionError("no TypeCondition for IS relationship type"));
+ assertEquals("KNOWS", type.getType());
+ }
+
+ @Test
+ void testNodeInlineWhere() {
+ MatchOperation op = parse("MATCH (n WHERE n.age > 18) RETURN n");
+ ComparisonCondition cc = comparison(op);
+ assertEquals(ComparisonOperator.GREATER_THAN, cc.getOperator());
+ assertEquals("age", ((PropertyOperand) cc.getLeft()).getPropertyKey());
+ }
+
+ @Test
+ void testRelationshipInlineWhere() {
+ MatchOperation op = parse("MATCH (a)-[r WHERE r.weight > 5]->(b) RETURN a");
+ ComparisonCondition cc = comparison(op);
+ assertEquals("weight", ((PropertyOperand) cc.getLeft()).getPropertyKey());
+ }
+
+ @Test
+ void testAnonymousNode() {
+ MatchOperation op = parse("MATCH (:Person) RETURN count(*)");
+ assertEquals(1, op.getPattern().nodeCount());
+ assertTrue(op.getPattern().getNodes().get(0).getVariableName().startsWith("_anon_"));
+ }
+
+ @Test
+ void testNodeWithProperty() {
+ MatchOperation op = parse("MATCH (p:Person {name: \"Alice\"}) RETURN p");
+ assertEquals(1, countOf(op, PropertyCondition.class));
+ PropertyCondition prop = (PropertyCondition) op.getConditions().stream()
+ .filter(PropertyCondition.class::isInstance).findFirst()
+ .orElseThrow(() -> new AssertionError("no PropertyCondition found"));
+ assertEquals("p", prop.getVariableName());
+ assertEquals("name", prop.getPropertyKey());
+ assertEquals(new LiteralOperand("Alice"), prop.getValue());
+ }
+
+ @Test
+ void testPropertyConstantArithmeticFolded() {
+ // A property value is an operand too, so a constant expression folds at parse time.
+ MatchOperation op = parse("MATCH (p {age: 25 + 5}) RETURN p");
+ assertTrue(op.getConditions().stream().anyMatch(c ->
+ c instanceof PropertyCondition && new LiteralOperand(30L).equals(((PropertyCondition) c).getValue())));
+ }
+
+ @Test
+ void testPropertyFunctionValueKeptAsRawOperand() {
+ // A function-call value is kept whole as a RawOperand, not misread as an inner literal.
+ PropertyCondition prop = (PropertyCondition) parse("MATCH (p {at: time(\"11:11\")}) RETURN p")
+ .getConditions().stream().filter(PropertyCondition.class::isInstance).findFirst().orElseThrow(AssertionError::new);
+ assertInstanceOf(RawOperand.class, prop.getValue());
+ assertEquals("time(\"11:11\")", ((RawOperand) prop.getValue()).getText());
+ }
+
+ @Test
+ void testPropertyParameterValueKeptAsRawOperand() {
+ PropertyCondition prop = (PropertyCondition) parse("MATCH (p {age: $minAge}) RETURN p")
+ .getConditions().stream().filter(PropertyCondition.class::isInstance).findFirst().orElseThrow(AssertionError::new);
+ assertInstanceOf(RawOperand.class, prop.getValue());
+ }
+
+ @Test
+ void testNodeWithMultipleProperties() {
+ MatchOperation op = parse("MATCH (p:Person {name: \"Alice\", age: 30}) RETURN p");
+ assertEquals(2, countOf(op, PropertyCondition.class));
+ }
+
+ @Test
+ void testPropertyWithSingleQuotes() {
+ MatchOperation op = parse("MATCH (p:Person {name: 'Alice'}) RETURN p");
+ assertTrue(op.getConditions().stream().anyMatch(c ->
+ c instanceof PropertyCondition && new LiteralOperand("Alice").equals(((PropertyCondition) c).getValue())));
+ }
+
+ @Test
+ void testNumericPropertyValues() {
+ MatchOperation op = parse("MATCH (p:Person {age: 30, salary: 50000.50}) RETURN p");
+ assertTrue(op.getConditions().stream().anyMatch(c ->
+ c instanceof PropertyCondition && new LiteralOperand(30L).equals(((PropertyCondition) c).getValue())));
+ assertTrue(op.getConditions().stream().anyMatch(c ->
+ c instanceof PropertyCondition && new LiteralOperand(50000.50).equals(((PropertyCondition) c).getValue())));
+ }
+
+ @Test
+ void testBooleanPropertyValues() {
+ MatchOperation op = parse("MATCH (p:Person {active: true, deleted: false}) RETURN p");
+ assertTrue(op.getConditions().stream().anyMatch(c ->
+ c instanceof PropertyCondition && new LiteralOperand(true).equals(((PropertyCondition) c).getValue())));
+ assertTrue(op.getConditions().stream().anyMatch(c ->
+ c instanceof PropertyCondition && new LiteralOperand(false).equals(((PropertyCondition) c).getValue())));
+ }
+
+ @Test
+ void testSimpleRelationshipWithBrackets() {
+ MatchOperation op = parse("MATCH (a)-[r]->(b) RETURN a, b");
+ assertEquals(2, op.getPattern().nodeCount());
+ assertEquals(1, op.getPattern().edgeCount());
+ PatternEdge edge = op.getPattern().getEdges().get(0);
+ assertEquals("a", edge.getSourceVariable());
+ assertEquals("b", edge.getTargetVariable());
+ assertTrue(edge.isDirected());
+ }
+
+ @Test
+ void testEmptyRelationshipDirected() {
+ MatchOperation op = parse("MATCH (a)-->(b) RETURN a, b");
+ assertEquals(1, op.getPattern().edgeCount());
+ assertTrue(op.getPattern().getEdges().get(0).isDirected());
+ }
+
+ @Test
+ void testEmptyRelationshipUndirected() {
+ MatchOperation op = parse("MATCH (a)--(b) RETURN a, b");
+ assertFalse(op.getPattern().getEdges().get(0).isDirected());
+ }
+
+ @Test
+ void testLeftArrowRelationship() {
+ MatchOperation op = parse("MATCH (a)<-[r]-(b) RETURN a, b");
+ PatternEdge edge = op.getPattern().getEdges().get(0);
+ assertEquals("b", edge.getSourceVariable());
+ assertEquals("a", edge.getTargetVariable());
+ assertTrue(edge.isDirected());
+ }
+
+ @Test
+ void testRelationshipWithType() {
+ MatchOperation op = parse("MATCH (a)-[r:KNOWS]->(b) RETURN a, b");
+ assertTrue(op.getConditions().stream().anyMatch(c ->
+ c instanceof TypeCondition && "KNOWS".equals(((TypeCondition) c).getType())));
+ }
+
+ @Test
+ void testMultipleRelationshipTypes() {
+ // [:ACTED_IN|DIRECTED] is an OR over the two types, not an (impossible) AND.
+ MatchOperation op = parse("MATCH (:Movie)<-[:ACTED_IN|DIRECTED]-(person:Person) RETURN person");
+ assertEquals(2, op.getPattern().nodeCount());
+ assertEquals(1, op.getPattern().edgeCount());
+ OrCondition or = (OrCondition) op.getConditions().stream()
+ .filter(OrCondition.class::isInstance).findFirst()
+ .orElseThrow(() -> new AssertionError("no OrCondition for relationship types"));
+ assertEquals(2, or.getConditions().size());
+ assertTrue(or.getConditions().stream().allMatch(TypeCondition.class::isInstance));
+ }
+
+ @Test
+ void testRelationshipTypeNegation() {
+ MatchOperation op = parse("MATCH (a)-[r:!KNOWS]->(b) RETURN a, b");
+ NotCondition not = (NotCondition) op.getConditions().stream()
+ .filter(NotCondition.class::isInstance).findFirst()
+ .orElseThrow(() -> new AssertionError("no NotCondition for relationship type"));
+ assertEquals("KNOWS", ((TypeCondition) not.getCondition()).getType());
+ }
+
+ @Test
+ void testRelationshipWithProperties() {
+ MatchOperation op = parse("MATCH (a)-[r:ACTED_IN {role: 'Harry Potter'}]-(b) RETURN a, b");
+ assertTrue(op.getConditions().stream().anyMatch(c ->
+ c instanceof PropertyCondition && new LiteralOperand("Harry Potter").equals(((PropertyCondition) c).getValue())));
+ }
+
+ @Test
+ void testVariableLengthAny() {
+ PatternEdge edge = parse("MATCH (a)-[*]->(b) RETURN a, b").getPattern().getEdges().get(0);
+ assertTrue(edge.isVariableLength());
+ }
+
+ @Test
+ void testVariableLengthRange() {
+ PatternEdge edge = parse("MATCH (a)-[*1..3]->(b) RETURN a, b").getPattern().getEdges().get(0);
+ assertTrue(edge.isVariableLength());
+ assertEquals(1, edge.getMinLength().intValue());
+ assertEquals(3, edge.getMaxLength().intValue());
+ }
+
+ @Test
+ void testVariableLengthMinOnly() {
+ PatternEdge edge = parse("MATCH (a)-[*2..]->(b) RETURN a, b").getPattern().getEdges().get(0);
+ assertEquals(2, edge.getMinLength().intValue());
+ assertNull(edge.getMaxLength());
+ }
+
+ @Test
+ void testVariableLengthMaxOnly() {
+ PatternEdge edge = parse("MATCH (a)-[*..5]->(b) RETURN a, b").getPattern().getEdges().get(0);
+ assertNull(edge.getMinLength());
+ assertEquals(5, edge.getMaxLength().intValue());
+ }
+
+ @Test
+ void testVariableLengthExact() {
+ PatternEdge edge = parse("MATCH (a)-[*3]->(b) RETURN a, b").getPattern().getEdges().get(0);
+ assertEquals(3, edge.getMinLength().intValue());
+ assertEquals(3, edge.getMaxLength().intValue());
+ }
+
+ @Test
+ void testVariableLengthWithType() {
+ MatchOperation op = parse("MATCH (a)-[:KNOWS*1..3]->(b) RETURN a, b");
+ PatternEdge edge = op.getPattern().getEdges().get(0);
+ assertEquals(1, edge.getMinLength().intValue());
+ assertEquals(3, edge.getMaxLength().intValue());
+ assertTrue(op.getConditions().stream().anyMatch(c ->
+ c instanceof TypeCondition && "KNOWS".equals(((TypeCondition) c).getType())));
+ }
+
+ @Test
+ void testChainedPattern() {
+ MatchOperation op = parse("MATCH (a)-[r1]->(b)-[r2]->(c) RETURN a, b, c");
+ assertEquals(3, op.getPattern().nodeCount());
+ assertEquals(2, op.getPattern().edgeCount());
+ }
+
+ @Test
+ void testMultiplePatternsWithComma() {
+ MatchOperation op = parse("MATCH (a:Person), (b:Movie) RETURN a, b");
+ assertEquals(2, op.getPattern().nodeCount());
+ assertEquals(0, op.getPattern().edgeCount());
+ assertEquals(2, countOf(op, LabelCondition.class));
+ }
+
+ @Test
+ void testMultipleRelationshipPatterns() {
+ MatchOperation op = parse("MATCH (a)-[:KNOWS]->(b), (a)-[:WORKS_WITH]->(c) RETURN b, c");
+ assertEquals(3, op.getPattern().nodeCount());
+ assertEquals(2, op.getPattern().edgeCount());
+ }
+
+ @Test
+ void testPathAssignment() {
+ MatchOperation op = parse("MATCH path = (a)-[:KNOWS]->(b) RETURN path");
+ assertEquals(2, op.getPattern().nodeCount());
+ assertEquals(1, op.getPattern().edgeCount());
+ assertEquals("path", op.getPathVariable());
+ assertEquals(1, op.getPathVariables().size());
+ assertEquals("path", op.getPathVariables().get(0));
+ assertFalse(op.isOptional());
+ }
+
+ @Test
+ void testMultiplePathAssignmentsKept() {
+ // MATCH p = ..., q = ... keeps both path variables, in source order.
+ MatchOperation op = parse("MATCH p = (a)-[:KNOWS]->(b), q = (c)-[:LIKES]->(d) RETURN p, q");
+ assertEquals(2, op.getPathVariables().size());
+ assertEquals("p", op.getPathVariables().get(0));
+ assertEquals("q", op.getPathVariables().get(1));
+ assertEquals("p", op.getPathVariable());
+ }
+
+ @Test
+ void testOptionalMatchFlag() {
+ assertTrue(parse("OPTIONAL MATCH (p)-[:KNOWS]->(f) RETURN f").isOptional());
+ assertFalse(parse("MATCH (p)-[:KNOWS]->(f) RETURN f").isOptional());
+ }
+
+ @Test
+ void testQuantifiedPathRange() {
+ MatchOperation op = parse("MATCH ((a)-[:KNOWS]->(b)){1,3} RETURN a");
+ assertEquals(0, op.getPattern().nodeCount());
+ assertEquals(0, op.getPattern().edgeCount());
+ assertEquals(1, op.getPattern().quantifiedPathCount());
+
+ QuantifiedPathPattern qpp = op.getPattern().getQuantifiedPaths().get(0);
+ assertEquals(1, qpp.getMin());
+ assertEquals(3, qpp.getMax().intValue());
+ assertEquals(2, qpp.getSubPattern().nodeCount());
+ assertEquals(1, qpp.getSubPattern().edgeCount());
+ // The :KNOWS type is scoped to the QPP, not flattened into the outer operation's list.
+ assertEquals(0, countOf(op, TypeCondition.class));
+ assertEquals(1, qpp.getConditions().stream().filter(TypeCondition.class::isInstance).count());
+ }
+
+ @Test
+ void testQuantifiedPathConditionsScopedToSubPattern() {
+ // Labels, types and the inline WHERE of a QPP belong to the QPP, not the global condition list.
+ MatchOperation op = parse("MATCH ((a:Person)-[:KNOWS]->(b) WHERE a.age > 30){1,3} RETURN a");
+ assertEquals(0, op.getConditions().size());
+ QuantifiedPathPattern qpp = op.getPattern().getQuantifiedPaths().get(0);
+ assertEquals(1, qpp.getConditions().stream().filter(LabelCondition.class::isInstance).count());
+ assertEquals(1, qpp.getConditions().stream().filter(TypeCondition.class::isInstance).count());
+ assertEquals(1, qpp.getConditions().stream().filter(ComparisonCondition.class::isInstance).count());
+ }
+
+ @Test
+ void testQuantifiedPathOneOrMore() {
+ QuantifiedPathPattern qpp = parse("MATCH ((a)-[:KNOWS]->(b))+ RETURN a")
+ .getPattern().getQuantifiedPaths().get(0);
+ assertEquals(1, qpp.getMin());
+ assertNull(qpp.getMax());
+ assertTrue(qpp.isUnboundedMax());
+ }
+
+ @Test
+ void testQuantifiedPathZeroOrMore() {
+ QuantifiedPathPattern qpp = parse("MATCH ((a)-[:KNOWS]->(b))* RETURN a")
+ .getPattern().getQuantifiedPaths().get(0);
+ assertEquals(0, qpp.getMin());
+ assertNull(qpp.getMax());
+ }
+
+ @Test
+ void testQuantifiedPathExact() {
+ QuantifiedPathPattern qpp = parse("MATCH ((a)-[:KNOWS]->(b)){2} RETURN a")
+ .getPattern().getQuantifiedPaths().get(0);
+ assertEquals(2, qpp.getMin());
+ assertEquals(2, qpp.getMax().intValue());
+ }
+
+ @Test
+ void testQuantifiedPathMultiEdgeSubPath() {
+ QuantifiedPathPattern qpp = parse("MATCH ((a)-[:R]->(b)-[:S]->(c)){1,2} RETURN a")
+ .getPattern().getQuantifiedPaths().get(0);
+ assertEquals(3, qpp.getSubPattern().nodeCount());
+ assertEquals(2, qpp.getSubPattern().edgeCount());
+ }
+
+ @Test
+ void testQuantifiedPathMixedWithPlainElements() {
+ MatchOperation op = parse(
+ "MATCH (start)-[:R]->(a) ((a)-[:KNOWS]->(b)){1,3} (b)-[:S]->(end) RETURN start");
+ assertEquals(4, op.getPattern().nodeCount()); // start, a, b, end
+ assertEquals(2, op.getPattern().edgeCount()); // the two plain edges
+ assertEquals(1, op.getPattern().quantifiedPathCount());
+ }
+
+ @Test
+ void testQuantifiedPathNested() {
+ MatchOperation op = parse("MATCH (((a)-[:R]->(b)){1,2}){1,3} RETURN a");
+ assertEquals(1, op.getPattern().quantifiedPathCount());
+
+ QuantifiedPathPattern outer = op.getPattern().getQuantifiedPaths().get(0);
+ assertEquals(1, outer.getMin());
+ assertEquals(3, outer.getMax().intValue());
+ assertEquals(1, outer.getSubPattern().quantifiedPathCount());
+
+ QuantifiedPathPattern inner = outer.getSubPattern().getQuantifiedPaths().get(0);
+ assertEquals(1, inner.getMin());
+ assertEquals(2, inner.getMax().intValue());
+ assertEquals(2, inner.getSubPattern().nodeCount());
+ assertEquals(1, inner.getSubPattern().edgeCount());
+ }
+
+ @Test
+ void testWhereEquality() {
+ MatchOperation op = parse("MATCH (p:Person) WHERE p.age = 25 RETURN p");
+ ComparisonCondition cc = comparison(op);
+ PropertyOperand left = (PropertyOperand) cc.getLeft();
+ assertEquals("p", left.getVariableName());
+ assertEquals("age", left.getPropertyKey());
+ assertEquals(ComparisonOperator.EQUALS, cc.getOperator());
+ assertEquals(25L, ((LiteralOperand) cc.getRight()).getValue());
+ }
+
+ @Test
+ void testWhereGreaterThan() {
+ assertEquals(ComparisonOperator.GREATER_THAN,
+ comparison(parse("MATCH (p:Person) WHERE p.age > 25 RETURN p")).getOperator());
+ }
+
+ @Test
+ void testWhereLessThan() {
+ assertEquals(ComparisonOperator.LESS_THAN,
+ comparison(parse("MATCH (p:Person) WHERE p.age < 25 RETURN p")).getOperator());
+ }
+
+ @Test
+ void testWhereNotEquals() {
+ assertEquals(ComparisonOperator.NOT_EQUALS,
+ comparison(parse("MATCH (p:Person) WHERE p.age <> 25 RETURN p")).getOperator());
+ }
+
+ @Test
+ void testWhereGreaterThanOrEqual() {
+ assertEquals(ComparisonOperator.GREATER_THAN_OR_EQUALS,
+ comparison(parse("MATCH (p:Person) WHERE p.age >= 25 RETURN p")).getOperator());
+ }
+
+ @Test
+ void testWhereLessThanOrEqual() {
+ assertEquals(ComparisonOperator.LESS_THAN_OR_EQUALS,
+ comparison(parse("MATCH (p:Person) WHERE p.age <= 25 RETURN p")).getOperator());
+ }
+
+ @Test
+ void testWhereMultipleConditionsAnd() {
+ MatchOperation op = parse("MATCH (p) WHERE p.age > 18 AND p.age < 65 RETURN p");
+ assertEquals(1, op.getConditions().size());
+ AndCondition and = (AndCondition) op.getConditions().get(0);
+ assertEquals(2, and.getConditions().size());
+ assertTrue(and.getConditions().stream().allMatch(ComparisonCondition.class::isInstance));
+ }
+
+ @Test
+ void testWhereOr() {
+ MatchOperation op = parse("MATCH (p) WHERE p.age < 18 OR p.age > 65 RETURN p");
+ OrCondition or = (OrCondition) op.getConditions().get(0);
+ assertEquals(2, or.getConditions().size());
+ assertTrue(or.getConditions().stream().allMatch(ComparisonCondition.class::isInstance));
+ }
+
+ @Test
+ void testWhereNot() {
+ MatchOperation op = parse("MATCH (p) WHERE NOT p.active = true RETURN p");
+ NotCondition not = (NotCondition) op.getConditions().get(0);
+ assertInstanceOf(ComparisonCondition.class, not.getCondition());
+ }
+
+ @Test
+ void testWhereDoubleNegationCancels() {
+ MatchOperation op = parse("MATCH (p) WHERE NOT NOT p.x = 1 RETURN p");
+ assertInstanceOf(ComparisonCondition.class, op.getConditions().get(0));
+ }
+
+ @Test
+ void testWhereXor() {
+ MatchOperation op = parse("MATCH (p) WHERE p.x = 1 XOR p.y = 2 RETURN p");
+ XorCondition xor = (XorCondition) op.getConditions().get(0);
+ assertEquals(2, xor.getConditions().size());
+ }
+
+ @Test
+ void testWhereParenthesesGroup() {
+ // The parens force (b OR c) to be one branch of the AND.
+ MatchOperation op = parse("MATCH (p) WHERE p.a = 1 AND (p.b = 2 OR p.c = 3) RETURN p");
+ AndCondition and = (AndCondition) op.getConditions().get(0);
+ assertEquals(2, and.getConditions().size());
+ assertInstanceOf(ComparisonCondition.class, and.getConditions().get(0));
+ OrCondition or = (OrCondition) and.getConditions().get(1);
+ assertEquals(2, or.getConditions().size());
+ }
+
+ @Test
+ void testWhereNotOfGroup() {
+ MatchOperation op = parse("MATCH (p) WHERE NOT (p.a = 1 OR p.b = 2) RETURN p");
+ NotCondition not = (NotCondition) op.getConditions().get(0);
+ assertInstanceOf(OrCondition.class, not.getCondition());
+ }
+
+ @Test
+ void testWherePrecedenceOrOfAnds() {
+ // a AND b OR c == (a AND b) OR c
+ MatchOperation op = parse("MATCH (p) WHERE p.a = 1 AND p.b = 2 OR p.c = 3 RETURN p");
+ OrCondition or = (OrCondition) op.getConditions().get(0);
+ assertEquals(2, or.getConditions().size());
+ assertInstanceOf(AndCondition.class, or.getConditions().get(0));
+ assertInstanceOf(ComparisonCondition.class, or.getConditions().get(1));
+ }
+
+ @Test
+ void testWhereStringComparison() {
+ ComparisonCondition cc = comparison(parse("MATCH (p:Person) WHERE p.name = \"Alice\" RETURN p"));
+ assertEquals("name", ((PropertyOperand) cc.getLeft()).getPropertyKey());
+ assertEquals("Alice", ((LiteralOperand) cc.getRight()).getValue());
+ }
+
+ @Test
+ void testWhereStartsWith() {
+ assertEquals(ComparisonOperator.STARTS_WITH,
+ comparison(parse("MATCH (p:Person) WHERE p.name STARTS WITH 'A' RETURN p")).getOperator());
+ }
+
+ @Test
+ void testWhereEndsWith() {
+ assertEquals(ComparisonOperator.ENDS_WITH,
+ comparison(parse("MATCH (p:Person) WHERE p.name ENDS WITH 'son' RETURN p")).getOperator());
+ }
+
+ @Test
+ void testWhereContains() {
+ assertEquals(ComparisonOperator.CONTAINS,
+ comparison(parse("MATCH (p:Person) WHERE p.name CONTAINS 'li' RETURN p")).getOperator());
+ }
+
+ @Test
+ void testWhereIsNull() {
+ assertEquals(ComparisonOperator.IS_NULL,
+ comparison(parse("MATCH (p:Person) WHERE p.email IS NULL RETURN p")).getOperator());
+ }
+
+ @Test
+ void testWhereIsNotNull() {
+ assertEquals(ComparisonOperator.IS_NOT_NULL,
+ comparison(parse("MATCH (p:Person) WHERE p.email IS NOT NULL RETURN p")).getOperator());
+ }
+
+ @Test
+ void testComparisonPropertyVsLiteral() {
+ ComparisonCondition cc = comparison(parse("MATCH (p) WHERE p.age > 25 RETURN p"));
+ PropertyOperand left = (PropertyOperand) cc.getLeft();
+ assertEquals("p", left.getVariableName());
+ assertEquals("age", left.getPropertyKey());
+ assertEquals(25L, ((LiteralOperand) cc.getRight()).getValue());
+ }
+
+ @Test
+ void testComparisonPropertyVsProperty() {
+ // Typed operands keep both sides of n.age > m.age as property references.
+ ComparisonCondition cc = comparison(parse("MATCH (n)-->(m) WHERE n.age > m.age RETURN n"));
+ PropertyOperand left = (PropertyOperand) cc.getLeft();
+ PropertyOperand right = (PropertyOperand) cc.getRight();
+ assertEquals("n", left.getVariableName());
+ assertEquals("age", left.getPropertyKey());
+ assertEquals("m", right.getVariableName());
+ assertEquals("age", right.getPropertyKey());
+ }
+
+ @Test
+ void testComparisonInListDecomposed() {
+ ComparisonCondition cc = comparison(parse("MATCH (p) WHERE p.age IN [18, 21, 65] RETURN p"));
+ assertEquals(ComparisonOperator.IN, cc.getOperator());
+ ListOperand list = (ListOperand) cc.getRight();
+ assertEquals(3, list.getElements().size());
+ assertEquals(18L, ((LiteralOperand) list.getElements().get(0)).getValue());
+ assertEquals(65L, ((LiteralOperand) list.getElements().get(2)).getValue());
+ }
+
+ @Test
+ void testComparisonInListWithPropertyElement() {
+ // Elements are parsed recursively, so a property reference inside the list is preserved.
+ ComparisonCondition cc = comparison(parse("MATCH (p) WHERE p.age IN [p.min, 40] RETURN p"));
+ ListOperand list = (ListOperand) cc.getRight();
+ assertEquals("min", ((PropertyOperand) list.getElements().get(0)).getPropertyKey());
+ assertEquals(40L, ((LiteralOperand) list.getElements().get(1)).getValue());
+ }
+
+ @Test
+ void testComparisonConstantArithmeticFolded() {
+ // A fully-literal RHS is evaluated at parse time, so the calculator sees a single value.
+ ComparisonCondition cc = comparison(parse("MATCH (p) WHERE p.age > 25 + 5 RETURN p"));
+ assertInstanceOf(PropertyOperand.class, cc.getLeft());
+ assertEquals(30L, ((LiteralOperand) cc.getRight()).getValue());
+ }
+
+ @Test
+ void testComparisonConstantArithmeticPrecedenceFolded() {
+ // Precedence and a double operand: 2 + 3 * 4 = 14, kept as a double once a double is involved.
+ ComparisonCondition cc = comparison(parse("MATCH (p) WHERE p.age > 2 + 3 * 4.0 RETURN p"));
+ assertEquals(14.0, ((LiteralOperand) cc.getRight()).getValue());
+ }
+
+ @Test
+ void testConstantArithmeticFoldsInLongDomainWithoutPrecisionLoss() {
+ // 2^53 + 1 is exact as a long but not as a double; folding in the long domain keeps it.
+ ComparisonCondition cc = comparison(parse("MATCH (p) WHERE p.age = 9007199254740992 + 1 RETURN p"));
+ assertEquals(9007199254740993L, ((LiteralOperand) cc.getRight()).getValue());
+ }
+
+ @Test
+ void testConstantArithmeticOverflowLeftUnfolded() {
+ // Long overflow would yield a wrong wrapped constant, so the node is kept structural instead.
+ ComparisonCondition cc = comparison(
+ parse("MATCH (p) WHERE p.age > 9223372036854775807 + 1 RETURN p"));
+ assertInstanceOf(ArithmeticOperand.class, cc.getRight());
+ }
+
+ @Test
+ void testComparisonNonConstantArithmeticKeepsPropertyRef() {
+ // A graph-dependent RHS stays a structured tree, with the inner property reference preserved.
+ ComparisonCondition cc = comparison(parse("MATCH (a)-[]->(p) WHERE a.age > p.age + 5 RETURN a"));
+ ArithmeticOperand rhs = (ArithmeticOperand) cc.getRight();
+ assertEquals(ArithmeticOperator.PLUS, rhs.getOperator());
+ assertEquals("age", ((PropertyOperand) rhs.getLeft()).getPropertyKey());
+ assertEquals(5L, ((LiteralOperand) rhs.getRight()).getValue());
+ }
+
+ @Test
+ void testChainedComparisonExpandsToAnd() {
+ // a < b < c ≡ (a < b) AND (b < c) — Cypher chains comparisons, no operand is dropped.
+ AndCondition and = (AndCondition) parse("MATCH (p) WHERE 18 < p.age < 65 RETURN p")
+ .getConditions().get(0);
+ assertEquals(2, and.getConditions().size());
+ ComparisonCondition first = (ComparisonCondition) and.getConditions().get(0);
+ ComparisonCondition second = (ComparisonCondition) and.getConditions().get(1);
+ assertEquals(ComparisonOperator.LESS_THAN, first.getOperator());
+ assertEquals(18L, ((LiteralOperand) first.getLeft()).getValue());
+ assertEquals("age", ((PropertyOperand) first.getRight()).getPropertyKey());
+ // The middle operand is repeated as the left side of the second link.
+ assertEquals("age", ((PropertyOperand) second.getLeft()).getPropertyKey());
+ assertEquals(65L, ((LiteralOperand) second.getRight()).getValue());
+ }
+
+ @Test
+ void testChainedComparisonMixedOperatorsKeptInOrder() {
+ // A mixed chain keeps each operator: a <= b < c.
+ AndCondition and = (AndCondition) parse("MATCH (p) WHERE 18 <= p.age < 65 RETURN p")
+ .getConditions().get(0);
+ assertEquals(ComparisonOperator.LESS_THAN_OR_EQUALS,
+ ((ComparisonCondition) and.getConditions().get(0)).getOperator());
+ assertEquals(ComparisonOperator.LESS_THAN,
+ ((ComparisonCondition) and.getConditions().get(1)).getOperator());
+ }
+
+ @Test
+ void testComparisonNonNumericConcatKeptRaw() {
+ // String concatenation (||) is not numeric arithmetic, so it stays a RawOperand.
+ ComparisonCondition cc = comparison(parse("MATCH (p) WHERE p.name = \"a\" || \"b\" RETURN p"));
+ assertInstanceOf(RawOperand.class, cc.getRight());
+ }
+
+ @Test
+ void testComparisonStringLiteralOperand() {
+ ComparisonCondition cc = comparison(parse("MATCH (p) WHERE p.name = \"Alice\" RETURN p"));
+ assertEquals("Alice", ((LiteralOperand) cc.getRight()).getValue());
+ }
+
+ @Test
+ void testComplexExample() {
+ MatchOperation op = parse(
+ "MATCH (p:Person {name: \"Alice\"})-[:KNOWS]->(f:Person) WHERE f.age > 25 RETURN f");
+ assertEquals(2, op.getPattern().nodeCount());
+ assertEquals(1, op.getPattern().edgeCount());
+ assertEquals(2, countOf(op, LabelCondition.class));
+ assertEquals(1, countOf(op, PropertyCondition.class));
+ assertEquals(1, countOf(op, TypeCondition.class));
+ assertEquals(1, countOf(op, ComparisonCondition.class));
+ }
+
+ @Test
+ void testDocExampleMultipleRelChain() {
+ MatchOperation op = parse(
+ "MATCH (:Person)-[:ACTED_IN]->(movie:Movie)<-[:DIRECTED]-(director) RETURN movie, director");
+ assertEquals(3, op.getPattern().nodeCount());
+ assertEquals(2, op.getPattern().edgeCount());
+ }
+
+ @Test
+ void testMatchCaseInsensitive() {
+ assertEquals(1, parse("match (n:Person) return n").getPattern().nodeCount());
+ }
+
+ @Test
+ void testNullQueryFails() {
+ assertFails(null);
+ }
+
+ @Test
+ void testEmptyQueryFails() {
+ assertFails("");
+ }
+
+ @Test
+ void testWhitespaceOnlyQueryFails() {
+ assertFails(" \t\n ");
+ }
+
+ @Test
+ void testNonMatchQueryFails() {
+ assertFails("CREATE (n:Person) RETURN n");
+ }
+
+ @Test
+ void testSyntaxErrorFails() {
+ assertFails("MATCH (n:Person RETURN n"); // missing closing paren
+ }
+
+ private ComparisonCondition comparison(MatchOperation op) {
+ return (ComparisonCondition) op.getConditions().stream()
+ .filter(ComparisonCondition.class::isInstance)
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no ComparisonCondition found"));
+ }
+}
From 2e7d1d8a7f17eb321da9833fdc76ab93deb7a605 Mon Sep 17 00:00:00 2001
From: Andres Felder <81707831+andyfelder16@users.noreply.github.com>
Date: Tue, 9 Jun 2026 13:27:50 -0300
Subject: [PATCH 2/2] update operand javadocs, tidy test imports and helper
placement
---
.../java/controller/neo4j/conditions/Operand.java | 9 ++++++---
.../controller/neo4j/conditions/RawOperand.java | 4 ++--
.../neo4j/parser/Cypher25AntlrParserTest.java | 15 ++++++++-------
3 files changed, 16 insertions(+), 12 deletions(-)
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/Operand.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/Operand.java
index a0a44cd40c..530619b5c1 100644
--- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/Operand.java
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/Operand.java
@@ -1,9 +1,12 @@
package org.evomaster.client.java.controller.neo4j.conditions;
/**
- * One side of a comparison in a WHERE clause: a {@link PropertyOperand} resolved from the
- * matched element, a {@link LiteralOperand} constant, or a {@link RawOperand} kept unchanged.
- * Modelling each side explicitly lets {@code n.age > m.age} be told apart from {@code n.age > "m.age"}.
+ * One operand of a condition: a side of a WHERE comparison or an inline property value. Concrete
+ * kinds are {@link PropertyOperand} (a {@code variable.key} resolved from the matched element),
+ * {@link LiteralOperand} (a constant), {@link ArithmeticOperand} (an arithmetic expression),
+ * {@link ListOperand} (an {@code IN} list), and {@link RawOperand} (kept verbatim when not
+ * decomposed). Modelling each explicitly lets {@code n.age > m.age} be told apart from
+ * {@code n.age > "m.age"}.
*/
public interface Operand {
}
diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawOperand.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawOperand.java
index ddf94e9ad7..76cbef3426 100644
--- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawOperand.java
+++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/conditions/RawOperand.java
@@ -3,8 +3,8 @@
import java.util.Objects;
/**
- * An operand kept unchanged because the model does not decompose it: arithmetic such as
- * 25 + 5, a function call, or a list. Carries the original text so nothing is dropped.
+ * An operand kept unchanged because the model does not decompose it: a function call, a parameter,
+ * or string concatenation. Carries the original text so nothing is dropped.
*/
public final class RawOperand implements Operand {
diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/parser/Cypher25AntlrParserTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/parser/Cypher25AntlrParserTest.java
index efd0872e14..43eeb7c4c1 100644
--- a/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/parser/Cypher25AntlrParserTest.java
+++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/parser/Cypher25AntlrParserTest.java
@@ -4,8 +4,8 @@
import org.evomaster.client.java.controller.neo4j.operations.MatchOperation;
import org.evomaster.client.java.controller.neo4j.operations.PatternEdge;
import org.evomaster.client.java.controller.neo4j.operations.QuantifiedPathPattern;
-import org.junit.jupiter.api.Test;
import org.evomaster.client.java.controller.neo4j.parser.cypher25.Cypher25AntlrParser;
+import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@@ -36,6 +36,13 @@ private long countOf(MatchOperation op, Class typ
return op.getConditions().stream().filter(type::isInstance).count();
}
+ private ComparisonCondition comparison(MatchOperation op) {
+ return (ComparisonCondition) op.getConditions().stream()
+ .filter(ComparisonCondition.class::isInstance)
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("no ComparisonCondition found"));
+ }
+
@Test
void testAllNodes() {
MatchOperation op = parse("MATCH (n) RETURN n");
@@ -799,10 +806,4 @@ void testSyntaxErrorFails() {
assertFails("MATCH (n:Person RETURN n"); // missing closing paren
}
- private ComparisonCondition comparison(MatchOperation op) {
- return (ComparisonCondition) op.getConditions().stream()
- .filter(ComparisonCondition.class::isInstance)
- .findFirst()
- .orElseThrow(() -> new AssertionError("no ComparisonCondition found"));
- }
}