diff --git a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/ExtraHeuristicEntryDto.java b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/ExtraHeuristicEntryDto.java index 2a089f3ff3..c01fc19a04 100644 --- a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/ExtraHeuristicEntryDto.java +++ b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/ExtraHeuristicEntryDto.java @@ -9,9 +9,9 @@ public class ExtraHeuristicEntryDto implements Serializable { /** * The type of extra heuristic. - * Note: for the moment, we only have heuristics on SQL, MONGO, OPENSEARCH and REDIS commands + * Note: for the moment, we only have heuristics on SQL, MONGO, OPENSEARCH, REDIS and NEO4J commands */ - public enum Type {SQL, MONGO, OPENSEARCH, REDIS} + public enum Type {SQL, MONGO, OPENSEARCH, REDIS, NEO4J} /** * Should we try to minimize or maximize the heuristic? diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/SutHandler.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/SutHandler.java index 8758bfee09..2bf8236ece 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/SutHandler.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/SutHandler.java @@ -179,6 +179,14 @@ default void extractRPCSchema(){} default Object getMongoConnection() {return null;} + /** + * @return the Neo4j {@code org.neo4j.driver.Driver} of the SUT, or {@code null} if the SUT does + * not use Neo4j. Returned as {@code Object} and accessed by reflection, so the driver does not + * hard-depend on a specific {@code neo4j-java-driver} version. Used both to read the live graph + * when computing Cypher heuristics and (later) to insert test data. + */ + default Object getNeo4jConnection() {return null;} + default Object getOpenSearchConnection() {return null;} default ReflectionBasedRedisClient getRedisConnection() {return null;} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/EMController.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/EMController.java index 9d9a05683f..4e7736acc1 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/EMController.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/EMController.java @@ -386,6 +386,7 @@ public Response runSut(SutRunDto dto, @Context HttpServletRequest httpServletReq noKillSwitch(() -> sutController.initSqlHandler()); noKillSwitch(() -> sutController.registerOrExecuteInitSqlCommandsIfNeeded(true)); noKillSwitch(() -> sutController.initMongoHandler()); + noKillSwitch(() -> sutController.initNeo4jHandler()); noKillSwitch(() -> sutController.initOpenSearchHandler()); noKillSwitch(() -> sutController.initRedisHandler()); } else { diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/SutController.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/SutController.java index 16ef32b718..1cdee9a138 100644 --- a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/SutController.java +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/SutController.java @@ -34,6 +34,7 @@ import org.evomaster.client.java.sql.SqlScriptRunnerCached; import org.evomaster.client.java.sql.DbSpecification; import org.evomaster.client.java.controller.internal.db.mongo.MongoHandler; +import org.evomaster.client.java.controller.internal.db.neo4j.Neo4jHandler; import org.evomaster.client.java.sql.DbInfoExtractor; import org.evomaster.client.java.sql.internal.SqlHandler; import org.evomaster.client.java.controller.mongo.MongoScriptRunner; @@ -88,6 +89,8 @@ public abstract class SutController implements SutHandler, CustomizationHandler private final MongoHandler mongoHandler = new MongoHandler(); + private final Neo4jHandler neo4jHandler = new Neo4jHandler(); + private final OpenSearchHandler openSearchHandler = new OpenSearchHandler(); private final RedisHandler redisHandler = new RedisHandler(); @@ -349,6 +352,10 @@ public final void initMongoHandler() { } } + public final void initNeo4jHandler() { + neo4jHandler.setNeo4jConnection(getNeo4jConnection()); + } + // TODO: Refactor this initialization methods once Redis and OpenSearch implementations are done public final void initOpenSearchHandler() { // This is needed because the replacement use to get this info occurs during the start of the SUT. @@ -388,6 +395,7 @@ public final boolean doEmploySmartDbClean(){ public final void resetExtraHeuristics() { sqlHandler.reset(); mongoHandler.reset(); + neo4jHandler.reset(); redisHandler.reset(); } @@ -411,6 +419,7 @@ public final ExtraHeuristicsDto computeExtraHeuristics(boolean queryFromDatabase ExtraHeuristicsDto dto = new ExtraHeuristicsDto(); if (isSQLHeuristicsComputationAllowed() || isMongoHeuristicsComputationAllowed() + || isNeo4jHeuristicsComputationAllowed() || isOpenSearchHeuristicsComputationAllowed() || isRedisHeuristicsComputationAllowed()) { List additionalInfoList = getAdditionalInfoList(); @@ -420,6 +429,9 @@ public final ExtraHeuristicsDto computeExtraHeuristics(boolean queryFromDatabase if (isMongoHeuristicsComputationAllowed()) { computeMongoHeuristics(dto, additionalInfoList); } + if (isNeo4jHeuristicsComputationAllowed()) { + computeNeo4jHeuristics(dto, additionalInfoList); + } if (isOpenSearchHeuristicsComputationAllowed()) { computeOpenSearchHeuristics(dto, additionalInfoList); } @@ -438,6 +450,10 @@ private boolean isMongoHeuristicsComputationAllowed() { return mongoHandler.isCalculateHeuristics() || mongoHandler.isExtractMongoExecution(); } + private boolean isNeo4jHeuristicsComputationAllowed() { + return neo4jHandler.isCalculateHeuristics(); + } + private boolean isOpenSearchHeuristicsComputationAllowed() { return openSearchHandler.isCalculateHeuristics(); } @@ -528,6 +544,34 @@ public final void computeMongoHeuristics(ExtraHeuristicsDto dto, List additionalInfoList){ + if(neo4jHandler.isCalculateHeuristics()){ + if(!additionalInfoList.isEmpty()) { + AdditionalInfo last = additionalInfoList.get(additionalInfoList.size() - 1); + last.getNeo4JInfoData().forEach(it -> { + try { + neo4jHandler.handle(it); + } catch (Exception e){ + SimpleLogger.error("FAILED TO HANDLE NEO4J COMMAND"); + assert false; + } + }); + } + + neo4jHandler.getEvaluatedCommands().stream() + .map(p -> + new ExtraHeuristicEntryDto( + ExtraHeuristicEntryDto.Type.NEO4J, + ExtraHeuristicEntryDto.Objective.MINIMIZE_TO_ZERO, + p.neo4jCommand, + p.neo4jDistanceWithMetrics.neo4jDistance, + p.neo4jDistanceWithMetrics.numberOfEvaluatedNodes, + p.neo4jDistanceWithMetrics.neo4jDistanceEvaluationFailure + )) + .forEach(h -> dto.heuristics.add(h)); + } + } + public final void computeOpenSearchHeuristics(ExtraHeuristicsDto dto, List additionalInfoList) { if (openSearchHandler.isCalculateHeuristics()) { if (!additionalInfoList.isEmpty()) { diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jCommandWithDistance.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jCommandWithDistance.java new file mode 100644 index 0000000000..31ab499afe --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jCommandWithDistance.java @@ -0,0 +1,16 @@ +package org.evomaster.client.java.controller.internal.db.neo4j; + +/** + * Pairs a captured Cypher query with its computed distance to being satisfied by the live graph. + */ +public class Neo4jCommandWithDistance { + + public final String neo4jCommand; + + public final Neo4jDistanceWithMetrics neo4jDistanceWithMetrics; + + public Neo4jCommandWithDistance(String neo4jCommand, Neo4jDistanceWithMetrics neo4jDistanceWithMetrics) { + this.neo4jCommand = neo4jCommand; + this.neo4jDistanceWithMetrics = neo4jDistanceWithMetrics; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jDistanceWithMetrics.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jDistanceWithMetrics.java new file mode 100644 index 0000000000..012d2b3158 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jDistanceWithMetrics.java @@ -0,0 +1,29 @@ +package org.evomaster.client.java.controller.internal.db.neo4j; + +/** + * The result of scoring one captured Cypher query against the live graph: the distance to satisfying + * it ({@code 1 - ofTrue}, in {@code [0,1]}, 0 meaning satisfied), how many graph nodes were available + * when scoring, and whether the evaluation failed (e.g. the query could not be parsed). + */ +public class Neo4jDistanceWithMetrics { + + public final double neo4jDistance; + + public final int numberOfEvaluatedNodes; + + public final boolean neo4jDistanceEvaluationFailure; + + public Neo4jDistanceWithMetrics(double neo4jDistance, int numberOfEvaluatedNodes, + boolean neo4jDistanceEvaluationFailure) { + if (neo4jDistance < 0) { + throw new IllegalArgumentException("neo4jDistance must be non-negative but value is " + neo4jDistance); + } + if (numberOfEvaluatedNodes < 0) { + throw new IllegalArgumentException( + "numberOfEvaluatedNodes must be non-negative but value is " + numberOfEvaluatedNodes); + } + this.neo4jDistance = neo4jDistance; + this.numberOfEvaluatedNodes = numberOfEvaluatedNodes; + this.neo4jDistanceEvaluationFailure = neo4jDistanceEvaluationFailure; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jGraphReader.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jGraphReader.java new file mode 100644 index 0000000000..4e7e656f67 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jGraphReader.java @@ -0,0 +1,115 @@ +package org.evomaster.client.java.controller.internal.db.neo4j; + +import org.evomaster.client.java.controller.neo4j.data.Neo4jGraph; +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; +import org.evomaster.client.java.controller.neo4j.data.Neo4jRelationship; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Reads the live Neo4j graph into an in-memory {@link Neo4jGraph} snapshot, used as {@code G} when + * scoring Cypher queries. The whole graph is read with two read-only Cypher queries that return only + * primitive projections (ids, label/type names, property maps), so the reflection surface is just + * {@code Session.run} / {@code Result.list} / {@code Record.get} / {@code Value.as*} — never the + * driver's {@code Node}/{@code Relationship} types. + */ +public class Neo4jGraphReader { + + private static final String NODE_QUERY = + "MATCH (n) RETURN elementId(n) AS id, labels(n) AS labels, properties(n) AS props"; + + private static final String REL_QUERY = + "MATCH ()-[r]->() RETURN elementId(r) AS id, type(r) AS type, " + + "elementId(startNode(r)) AS src, elementId(endNode(r)) AS tgt, properties(r) AS props"; + + /** + * Reads all nodes and relationships from the database reachable through {@code driver}. + * + * @param driver the SUT's {@code org.neo4j.driver.Driver}, as an {@code Object} + * @return the in-memory graph snapshot + * @throws RuntimeException if the driver cannot be queried (wrapping the reflection failure) + */ + public Neo4jGraph read(Object driver) { + Object session = invoke(driver, "session"); + try { + List nodes = readNodes(session); + List relationships = readRelationships(session); + return new Neo4jGraph(nodes, relationships); + } finally { + invoke(session, "close"); + } + } + + private List readNodes(Object session) { + List nodes = new ArrayList<>(); + for (Object record : runAndList(session, NODE_QUERY)) { + String id = asString(get(record, "id")); + Set labels = toStringSet(asList(get(record, "labels"))); + Map props = asMap(get(record, "props")); + nodes.add(new Neo4jNode(id, labels, props)); + } + return nodes; + } + + private List readRelationships(Object session) { + List relationships = new ArrayList<>(); + for (Object record : runAndList(session, REL_QUERY)) { + String id = asString(get(record, "id")); + String type = asString(get(record, "type")); + String src = asString(get(record, "src")); + String tgt = asString(get(record, "tgt")); + Map props = asMap(get(record, "props")); + relationships.add(new Neo4jRelationship(id, type, src, tgt, props)); + } + return relationships; + } + + private List runAndList(Object session, String query) { + Object result = invoke(session, "run", new Class[]{String.class}, query); + return (List) invoke(result, "list"); + } + + private Object get(Object record, String key) { + return invoke(record, "get", new Class[]{String.class}, key); + } + + private String asString(Object value) { + return (String) invoke(value, "asString"); + } + + private List asList(Object value) { + return (List) invoke(value, "asList"); + } + + @SuppressWarnings("unchecked") + private Map asMap(Object value) { + return (Map) invoke(value, "asMap"); + } + + private Set toStringSet(List values) { + Set set = new LinkedHashSet<>(); + for (Object v : values) { + set.add(String.valueOf(v)); + } + return set; + } + + private Object invoke(Object target, String method) { + return invoke(target, method, new Class[0]); + } + + private Object invoke(Object target, String method, Class[] argTypes, Object... args) { + try { + Method m = target.getClass().getMethod(method, argTypes); + m.setAccessible(true); + return m.invoke(target, args); + } catch (ReflectiveOperationException e) { + throw new RuntimeException("Failed to read the Neo4j graph via reflection (" + method + ")", e); + } + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jHandler.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jHandler.java new file mode 100644 index 0000000000..4508a0aaf7 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jHandler.java @@ -0,0 +1,117 @@ +package org.evomaster.client.java.controller.internal.db.neo4j; + +import org.evomaster.client.java.controller.neo4j.data.Neo4jGraph; +import org.evomaster.client.java.controller.neo4j.heuristics.Neo4jHeuristicsCalculator; +import org.evomaster.client.java.controller.neo4j.operations.MatchOperation; +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.parser.CypherParserFactory; +import org.evomaster.client.java.controller.internal.TaintHandlerExecutionTracer; +import org.evomaster.client.java.instrumentation.Neo4JRunCommand; +import org.evomaster.client.java.utils.SimpleLogger; + +import java.util.ArrayList; +import java.util.List; + +/** + * Acts upon Cypher queries executed by the SUT (captured as {@link Neo4JRunCommand}s): for each + * captured query it computes how close the live graph is to satisfying it, as a distance to minimize. + * Only MATCH queries are scored; a query that does not parse as a MATCH (e.g. a write) is skipped. + */ +public class Neo4jHandler { + + /** Cypher queries captured from {@code Session.run}, pending evaluation. */ + private final List operations; + + /** The computed heuristics, one per scored query. */ + private final List commandsWithDistances; + + /** Whether to compute heuristics based on execution or not. */ + private volatile boolean calculateHeuristics; + + /** + * The SUT's {@code org.neo4j.driver.Driver}, kept as an {@code Object} and used by reflection so + * we do not hard-depend on a specific driver version. {@code null} when the SUT does not use Neo4j. + */ + private Object neo4jConnection = null; + + private final CypherParser parser = CypherParserFactory.buildParser(); + + private final Neo4jHeuristicsCalculator calculator = + new Neo4jHeuristicsCalculator(new TaintHandlerExecutionTracer()); + private final Neo4jGraphReader graphReader = new Neo4jGraphReader(); + + public Neo4jHandler() { + operations = new ArrayList<>(); + commandsWithDistances = new ArrayList<>(); + calculateHeuristics = true; + } + + public void reset() { + operations.clear(); + commandsWithDistances.clear(); + } + + public boolean isCalculateHeuristics() { + return calculateHeuristics; + } + + public void setCalculateHeuristics(boolean calculateHeuristics) { + this.calculateHeuristics = calculateHeuristics; + } + + public void setNeo4jConnection(Object neo4jConnection) { + this.neo4jConnection = neo4jConnection; + } + + public void handle(Neo4JRunCommand info) { + if (calculateHeuristics && info.getQuery() != null) { + operations.add(info); + } + } + + public List getEvaluatedCommands() { + + if (!calculateHeuristics || neo4jConnection == null || operations.isEmpty()) { + operations.clear(); + return commandsWithDistances; + } + + Neo4jGraph graph = null; + + for (Neo4JRunCommand op : operations) { + String query = op.getQuery(); + if (query == null) { + continue; + } + final MatchOperation parsed; + try { + parsed = parser.parse(query); + } catch (CypherParserException e) { + continue; + } + + if (graph == null) { + try { + graph = graphReader.read(neo4jConnection); + } catch (Exception e) { + SimpleLogger.uniqueWarn("Failed to read the Neo4j graph to compute heuristics: " + e.getMessage()); + break; + } + } + + Neo4jDistanceWithMetrics metrics; + try { + double distance = calculator.computeDistance(parsed, graph); + metrics = new Neo4jDistanceWithMetrics(distance, graph.nodeCount(), false); + } catch (Exception e) { + SimpleLogger.uniqueWarn("Failed to compute Neo4j heuristic for query: " + query); + metrics = new Neo4jDistanceWithMetrics(1.0, graph.nodeCount(), true); + } + commandsWithDistances.add(new Neo4jCommandWithDistance(query, metrics)); + } + + operations.clear(); + return commandsWithDistances; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jGraph.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jGraph.java new file mode 100644 index 0000000000..3334077600 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jGraph.java @@ -0,0 +1,49 @@ +package org.evomaster.client.java.controller.neo4j.data; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * An in-memory snapshot of a Neo4j graph ({@code G}): the set of nodes and relationships against + * which a parsed query is scored. Built either by hand in tests or by reading the live database + * through the driver. Nodes are indexed by id so a relationship's endpoints can be resolved quickly. + */ +public class Neo4jGraph { + + private final List nodes; + private final List relationships; + private final Map nodesById; + + public Neo4jGraph(List nodes, List relationships) { + this.nodes = nodes != null ? new ArrayList<>(nodes) : new ArrayList<>(); + this.relationships = relationships != null ? new ArrayList<>(relationships) : new ArrayList<>(); + this.nodesById = new LinkedHashMap<>(); + for (Neo4jNode n : this.nodes) { + nodesById.put(n.getId(), n); + } + } + + public List getNodes() { + return Collections.unmodifiableList(nodes); + } + + public List getRelationships() { + return Collections.unmodifiableList(relationships); + } + + public int nodeCount() { + return nodes.size(); + } + + public Neo4jNode getNodeById(String id) { + return nodesById.get(id); + } + + @Override + public String toString() { + return "Neo4jGraph{nodes=" + nodes.size() + ", relationships=" + relationships.size() + "}"; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jNode.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jNode.java new file mode 100644 index 0000000000..d08872b169 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jNode.java @@ -0,0 +1,59 @@ +package org.evomaster.client.java.controller.neo4j.data; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * A node of a captured Neo4j graph, used as the {@code G} side when computing the heuristic + * {@code H(Q, G)}. It is the in-memory counterpart of a stored node: a stable id (the driver's + * {@code elementId}), the set of labels, and the property map. + *

+ * This is a plain data holder built either by hand (tests) or by reading the live database; it has + * no relation to {@link org.evomaster.client.java.controller.neo4j.operations.PatternNode}, which is + * the structural node of a parsed query pattern. + */ +public class Neo4jNode { + + private final String id; + private final Set labels; + private final Map properties; + + public Neo4jNode(String id, Set labels, Map properties) { + this.id = Objects.requireNonNull(id, "id must not be null"); + this.labels = labels != null ? new LinkedHashSet<>(labels) : new LinkedHashSet<>(); + this.properties = properties != null ? new LinkedHashMap<>(properties) : new LinkedHashMap<>(); + } + + public String getId() { + return id; + } + + public Set getLabels() { + return Collections.unmodifiableSet(labels); + } + + public Map getProperties() { + return Collections.unmodifiableMap(properties); + } + + /** + * Returns true when the property is present on this node. Distinguishes an absent property + * (the operand cannot be valuated) from a present property whose value is {@code null}. + */ + public boolean hasProperty(String key) { + return properties.containsKey(key); + } + + public Object getProperty(String key) { + return properties.get(key); + } + + @Override + public String toString() { + return "Neo4jNode{" + id + ", labels=" + labels + ", props=" + properties + "}"; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jRelationship.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jRelationship.java new file mode 100644 index 0000000000..e27615f4fd --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/data/Neo4jRelationship.java @@ -0,0 +1,63 @@ +package org.evomaster.client.java.controller.neo4j.data; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * A relationship of a captured Neo4j graph: a stable id, a single type, the ids of its two endpoint + * nodes (source and target), and its property map. A relationship in Neo4j is always stored with a + * direction (source → target). + */ +public class Neo4jRelationship { + + private final String id; + private final String type; + private final String sourceId; + private final String targetId; + private final Map properties; + + public Neo4jRelationship(String id, String type, String sourceId, String targetId, + Map properties) { + this.id = Objects.requireNonNull(id, "id must not be null"); + this.type = Objects.requireNonNull(type, "type must not be null"); + this.sourceId = Objects.requireNonNull(sourceId, "sourceId must not be null"); + this.targetId = Objects.requireNonNull(targetId, "targetId must not be null"); + this.properties = properties != null ? new LinkedHashMap<>(properties) : new LinkedHashMap<>(); + } + + public String getId() { + return id; + } + + public String getType() { + return type; + } + + public String getSourceId() { + return sourceId; + } + + public String getTargetId() { + return targetId; + } + + public Map getProperties() { + return Collections.unmodifiableMap(properties); + } + + public boolean hasProperty(String key) { + return properties.containsKey(key); + } + + public Object getProperty(String key) { + return properties.get(key); + } + + @Override + public String toString() { + return "Neo4jRelationship{" + id + ", type=" + type + ", " + sourceId + "->" + targetId + + ", props=" + properties + "}"; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/ConditionRenamer.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/ConditionRenamer.java new file mode 100644 index 0000000000..f169d51da2 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/ConditionRenamer.java @@ -0,0 +1,111 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.conditions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Deep-clones a {@link CypherCondition} (or {@link Operand}) while substituting the variable names it + * refers to, according to a rename map. Used by {@link Neo4jPatternExpander} when it clones a + * variable-length edge into a chain: the edge's type/property conditions and the endpoints' labels + * must be re-bound to the fresh variable names of the cloned edges and intermediate nodes. + *

+ * Only variable names change; operators, literals and structure are preserved. A name not present in + * the map is left unchanged. + */ +final class ConditionRenamer { + + private ConditionRenamer() { + } + + static CypherCondition rename(CypherCondition c, Map renames) { + if (c instanceof LabelCondition) { + LabelCondition lc = (LabelCondition) c; + return new LabelCondition(map(lc.getVariableName(), renames), lc.getLabel()); + } + if (c instanceof AnyLabelCondition) { + return new AnyLabelCondition(map(((AnyLabelCondition) c).getVariableName(), renames)); + } + if (c instanceof TypeCondition) { + TypeCondition tc = (TypeCondition) c; + return new TypeCondition(map(tc.getVariableName(), renames), tc.getType()); + } + if (c instanceof PropertyCondition) { + PropertyCondition pc = (PropertyCondition) c; + return new PropertyCondition(map(pc.getVariableName(), renames), pc.getPropertyKey(), + rename(pc.getValue(), renames)); + } + if (c instanceof ComparisonCondition) { + ComparisonCondition cc = (ComparisonCondition) c; + return new ComparisonCondition(rename(cc.getLeft(), renames), cc.getOperator(), + rename(cc.getRight(), renames)); + } + if (c instanceof AndCondition) { + return new AndCondition(renameAll(((AndCondition) c).getConditions(), renames)); + } + if (c instanceof OrCondition) { + return new OrCondition(renameAll(((OrCondition) c).getConditions(), renames)); + } + if (c instanceof XorCondition) { + return new XorCondition(renameAll(((XorCondition) c).getConditions(), renames)); + } + if (c instanceof NotCondition) { + return new NotCondition(rename(((NotCondition) c).getCondition(), renames)); + } + return c; + } + + private static List renameAll(List conditions, + Map renames) { + List out = new ArrayList<>(conditions.size()); + for (CypherCondition c : conditions) { + out.add(rename(c, renames)); + } + return out; + } + + static Operand rename(Operand o, Map renames) { + if (o instanceof PropertyOperand) { + PropertyOperand po = (PropertyOperand) o; + return new PropertyOperand(map(po.getVariableName(), renames), po.getPropertyKey()); + } + if (o instanceof ArithmeticOperand) { + ArithmeticOperand ao = (ArithmeticOperand) o; + Operand right = ao.getRight() == null ? null : rename(ao.getRight(), renames); + return new ArithmeticOperand(ao.getOperator(), rename(ao.getLeft(), renames), right); + } + if (o instanceof ListOperand) { + List elements = new ArrayList<>(); + for (Operand e : ((ListOperand) o).getElements()) { + elements.add(rename(e, renames)); + } + return new ListOperand(elements); + } + return o; + } + + /** Returns the renamed name if present in the map, otherwise the original. */ + private static String map(String name, Map renames) { + String renamed = renames.get(name); + return renamed != null ? renamed : name; + } + + /** + * True when the condition references {@code variable} as a node/edge variable. Used to decide + * which endpoint conditions an intermediate node should inherit. + */ + static boolean referencesVariable(CypherCondition c, String variable) { + if (c instanceof LabelCondition) { + return variable.equals(((LabelCondition) c).getVariableName()); + } + if (c instanceof AnyLabelCondition) { + return variable.equals(((AnyLabelCondition) c).getVariableName()); + } + if (c instanceof PropertyCondition) { + return variable.equals(((PropertyCondition) c).getVariableName()); + } + return false; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java new file mode 100644 index 0000000000..8824e9de48 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jConditionEvaluator.java @@ -0,0 +1,419 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.conditions.*; +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; +import org.evomaster.client.java.controller.neo4j.data.Neo4jRelationship; +import org.evomaster.client.java.distance.heuristics.DistanceHelper; +import org.evomaster.client.java.distance.heuristics.Truthness; +import org.evomaster.client.java.distance.heuristics.TruthnessUtils; +import org.evomaster.client.java.sql.internal.TaintHandler; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.evomaster.client.java.controller.neo4j.heuristics.Neo4jHeuristicsCalculator.C; +import static org.evomaster.client.java.controller.neo4j.heuristics.Neo4jHeuristicsCalculator.FALSE_TRUTHNESS; +import static org.evomaster.client.java.controller.neo4j.heuristics.Neo4jHeuristicsCalculator.TRUE_TRUTHNESS; + +/** + * Evaluates the truthness {@code ρ(condition, m)} of a single {@link CypherCondition} under a + * structural mapping {@code m}, recursively over the typed boolean tree the parser produces + * (And/Or/Xor/Not, comparisons, label/type/property leaves). + *

+ * Returns {@code null} when a condition cannot be valuated under the mapping (e.g. an absent + * property, an unbound variable, or an opaque/raw operand); the caller's aggregation skips + * {@code null} results. + */ +class Neo4jConditionEvaluator { + + private static final Object UNRESOLVED = new Object(); + + private final TaintHandler taintHandler; + + Neo4jConditionEvaluator() { + this(null); + } + + Neo4jConditionEvaluator(TaintHandler taintHandler) { + this.taintHandler = taintHandler; + } + + /** + * Returns {@code ρ(condition, mapping)}, or {@code null} when the condition cannot be valuated + * and must be skipped by the aggregation. + */ + Truthness rho(CypherCondition condition, Neo4jMapping mapping) { + if (condition instanceof LabelCondition) { + LabelCondition lc = (LabelCondition) condition; + Neo4jNode node = mapping.getNode(lc.getVariableName()); + return node == null ? null : labelInSet(lc.getLabel(), node.getLabels()); + } + if (condition instanceof AnyLabelCondition) { + Neo4jNode node = mapping.getNode(((AnyLabelCondition) condition).getVariableName()); + if (node == null) { + return null; + } + return node.getLabels().isEmpty() ? FALSE_TRUTHNESS : TRUE_TRUTHNESS; + } + if (condition instanceof TypeCondition) { + TypeCondition tc = (TypeCondition) condition; + Neo4jRelationship rel = mapping.getEdge(tc.getVariableName()); + if (rel == null) { + return null; + } + taintStringEquals(rel.getType(), tc.getType()); + return stringEqualityTruthness(rel.getType(), tc.getType()); + } + if (condition instanceof PropertyCondition) { + return rhoProperty((PropertyCondition) condition, mapping); + } + if (condition instanceof ComparisonCondition) { + return rhoComparison((ComparisonCondition) condition, mapping); + } + if (condition instanceof AndCondition) { + return aggregate(((AndCondition) condition).getConditions(), mapping, true); + } + if (condition instanceof OrCondition) { + return aggregate(((OrCondition) condition).getConditions(), mapping, false); + } + if (condition instanceof XorCondition) { + return rhoXor(((XorCondition) condition).getConditions(), mapping); + } + if (condition instanceof NotCondition) { + Truthness inner = rho(((NotCondition) condition).getCondition(), mapping); + return inner == null ? null : inner.invert(); + } + return null; + } + + private Truthness rhoProperty(PropertyCondition pc, Neo4jMapping mapping) { + Object actual = resolveProperty(pc.getVariableName(), pc.getPropertyKey(), mapping); + if (actual == UNRESOLVED) { + return null; + } + Object expected = valuate(pc.getValue(), mapping); + if (expected == UNRESOLVED) { + return null; + } + return equalityTruthness(actual, expected); + } + + private Truthness rhoComparison(ComparisonCondition cc, Neo4jMapping mapping) { + switch (cc.getOperator()) { + case IS_NULL: + return presenceTruthness(cc.getLeft(), mapping, /*wantPresent=*/false); + case IS_NOT_NULL: + return presenceTruthness(cc.getLeft(), mapping, /*wantPresent=*/true); + case IN: + return rhoIn(cc, mapping); + case STARTS_WITH: + case ENDS_WITH: + case CONTAINS: + return rhoStringPredicate(cc, mapping); + default: + return rhoBinary(cc, mapping); + } + } + + private Truthness rhoBinary(ComparisonCondition cc, Neo4jMapping mapping) { + Object l = valuate(cc.getLeft(), mapping); + Object r = valuate(cc.getRight(), mapping); + if (l == UNRESOLVED || r == UNRESOLVED || l == null || r == null) { + return null; + } + switch (cc.getOperator()) { + case EQUALS: + return equalityTruthness(l, r); + case NOT_EQUALS: + return equalityTruthness(l, r).invert(); + case LESS_THAN: + return numericLessThan(l, r); + case GREATER_THAN: + return numericLessThan(r, l); + case LESS_THAN_OR_EQUALS: { + Truthness t = numericLessThan(r, l); + return t == null ? null : t.invert(); + } + case GREATER_THAN_OR_EQUALS: { + Truthness t = numericLessThan(l, r); + return t == null ? null : t.invert(); + } + default: + return null; + } + } + + private Truthness rhoIn(ComparisonCondition cc, Neo4jMapping mapping) { + Object l = valuate(cc.getLeft(), mapping); + if (l == UNRESOLVED || l == null || !(cc.getRight() instanceof ListOperand)) { + return null; + } + List elements = ((ListOperand) cc.getRight()).getElements(); + List truths = new ArrayList<>(); + for (Operand element : elements) { + Object ev = valuate(element, mapping); + if (ev == UNRESOLVED || ev == null) { + continue; + } + truths.add(equalityTruthness(l, ev)); + } + if (truths.isEmpty()) { + return null; + } + return TruthnessUtils.buildOrAggregationTruthness(truths.toArray(new Truthness[0])); + } + + private Truthness rhoStringPredicate(ComparisonCondition cc, Neo4jMapping mapping) { + Object l = valuate(cc.getLeft(), mapping); + Object r = valuate(cc.getRight(), mapping); + if (!(l instanceof String) || !(r instanceof String)) { + return null; + } + switch (cc.getOperator()) { + case STARTS_WITH: + return getStartsWith((String) l, (String) r); + case ENDS_WITH: + return getEndsWith((String) l, (String) r); + case CONTAINS: + return getContains((String) l, (String) r); + default: + return null; + } + } + + private Truthness rhoXor(List conditions, Neo4jMapping mapping) { + Truthness acc = null; + for (CypherCondition c : conditions) { + Truthness t = rho(c, mapping); + if (t == null) { + continue; + } + acc = (acc == null) ? t : TruthnessUtils.buildXorAggregationTruthness(acc, t); + } + return acc; + } + + /** AND/OR aggregation over a child list, skipping children that cannot be valuated. */ + private Truthness aggregate(List conditions, Neo4jMapping mapping, boolean and) { + List truths = new ArrayList<>(); + for (CypherCondition c : conditions) { + Truthness t = rho(c, mapping); + if (t != null) { + truths.add(t); + } + } + if (truths.isEmpty()) { + return null; + } + Truthness[] arr = truths.toArray(new Truthness[0]); + return and ? TruthnessUtils.buildAndAggregationTruthness(arr) + : TruthnessUtils.buildOrAggregationTruthness(arr); + } + + /** + * Valuates an operand under the mapping. Returns {@link #UNRESOLVED} for an absent property, + * an opaque {@link RawOperand}, a list (handled only inside IN), an unbound variable, or an + * arithmetic expression over a non-numeric / unresolved side. + */ + private Object valuate(Operand operand, Neo4jMapping mapping) { + if (operand instanceof LiteralOperand) { + return ((LiteralOperand) operand).getValue(); + } + if (operand instanceof PropertyOperand) { + PropertyOperand po = (PropertyOperand) operand; + return resolveProperty(po.getVariableName(), po.getPropertyKey(), mapping); + } + if (operand instanceof ArithmeticOperand) { + return valuateArithmetic((ArithmeticOperand) operand, mapping); + } + return UNRESOLVED; + } + + private Object valuateArithmetic(ArithmeticOperand ao, Neo4jMapping mapping) { + if (ao.getOperator() == ArithmeticOperator.NEGATE) { + Object v = valuate(ao.getLeft(), mapping); + Double d = asDouble(v); + return d == null ? UNRESOLVED : -d; + } + Double l = asDouble(valuate(ao.getLeft(), mapping)); + Double r = asDouble(valuate(ao.getRight(), mapping)); + if (l == null || r == null) { + return UNRESOLVED; + } + switch (ao.getOperator()) { + case PLUS: return l + r; + case MINUS: return l - r; + case TIMES: return l * r; + case DIVIDE: return r == 0d ? UNRESOLVED : l / r; + case MODULO: return r == 0d ? UNRESOLVED : l % r; + case POWER: return Math.pow(l, r); + default: return UNRESOLVED; + } + } + + /** Resolves {@code variable.key} from the node or relationship the variable is bound to. */ + private Object resolveProperty(String variable, String key, Neo4jMapping mapping) { + Neo4jNode node = mapping.getNode(variable); + if (node != null) { + return node.hasProperty(key) ? node.getProperty(key) : UNRESOLVED; + } + Neo4jRelationship rel = mapping.getEdge(variable); + if (rel != null) { + return rel.hasProperty(key) ? rel.getProperty(key) : UNRESOLVED; + } + return UNRESOLVED; + } + + /** ρ for IS NULL / IS NOT NULL: a presence check with no gradient. */ + private Truthness presenceTruthness(Operand operand, Neo4jMapping mapping, boolean wantPresent) { + if (!(operand instanceof PropertyOperand)) { + return null; + } + PropertyOperand po = (PropertyOperand) operand; + Object value = resolveProperty(po.getVariableName(), po.getPropertyKey(), mapping); + boolean present = value != UNRESOLVED && value != null; + boolean satisfied = wantPresent == present; + return satisfied ? TRUE_TRUTHNESS : FALSE_TRUTHNESS; + } + + private Truthness equalityTruthness(Object a, Object b) { + if (a == null || b == null) { + return null; + } + Double da = asDouble(a); + Double db = asDouble(b); + if (da != null && db != null) { + return TruthnessUtils.getEqualityTruthness((double) da, (double) db); + } + if (a instanceof String && b instanceof String) { + taintStringEquals((String) a, (String) b); + return stringEqualityTruthness((String) a, (String) b); + } + return a.equals(b) ? TRUE_TRUTHNESS : FALSE_TRUTHNESS; + } + + /** Feeds a string equality to the taint handler (no-op if there is no handler or no tainted input). */ + private void taintStringEquals(String a, String b) { + if (taintHandler != null && a != null && b != null) { + taintHandler.handleTaintForStringEquals(a, b, false); + } + } + + private Truthness numericLessThan(Object a, Object b) { + Double da = asDouble(a); + Double db = asDouble(b); + if (da == null || db == null) { + return null; + } + return TruthnessUtils.getLessThanTruthness((double) da, (double) db); + } + + /** + * Direct string equality truthness: {@code TRUE} when the strings are equal, otherwise {@code ofTrue} + * is the left-alignment edit distance scaled from base {@code C} (so it never drops below {@code C}) + * and {@code ofFalse = 1}. Used where a string is compared for equality on its own — relationship + * types and string-valued property/WHERE equality. + */ + static Truthness stringEqualityTruthness(String a, String b) { + if (a == null || b == null) { + return null; + } + if (a.equals(b)) { + return TRUE_TRUTHNESS; + } + double distance = DistanceHelper.getLeftAlignmentDistance(a, b); + double h = DistanceHelper.heuristicFromScaledDistanceWithBase(C, distance); + return new Truthness(h, 1d); + } + + /** + * Un-based string similarity ({@code ofTrue = 1 - normalize(distance)}), used only as the per-label + * score inside {@link #labelInSet}, which then re-bases the best of them with {@code scaleTrue(C, ...)}. + * Kept separate from {@link #stringEqualityTruthness} so the base {@code C} is applied exactly once + * on the label path (applying it here as well would double-count it). + */ + static Truthness stringSimilarityTruthness(String a, String b) { + if (a == null || b == null) { + return null; + } + long distance = DistanceHelper.getLeftAlignmentDistance(a, b); + double ofTrue = 1d - TruthnessUtils.normalizeValue((double) distance); + return new Truthness(ofTrue, a.equals(b) ? 0d : 1d); + } + + /** + * {@code label_in_set(L, labels)}: TRUE if the exact label is present; FALSE if the element has + * no labels; otherwise the best per-label string similarity scaled from base {@code C}. + */ + static Truthness labelInSet(String label, Set labels) { + if (labels.contains(label)) { + return TRUE_TRUTHNESS; + } + if (labels.isEmpty()) { + return FALSE_TRUTHNESS; + } + double maxOfTrue = 0d; + for (String present : labels) { + Truthness t = stringSimilarityTruthness(label, present); + if (t.getOfTrue() > maxOfTrue) { + maxOfTrue = t.getOfTrue(); + } + } + return TruthnessUtils.buildScaledTruthness(C, maxOfTrue); + } + + private static Double asDouble(Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + return null; + } + + static Truthness getStartsWith(String str, String prefix) { + if (str.startsWith(prefix)) { + return TRUE_TRUTHNESS; + } + int n = Math.min(str.length(), prefix.length()); + String actualPrefix = str.substring(0, n); + double distance = DistanceHelper.getLeftAlignmentDistance(actualPrefix, prefix); + double h = DistanceHelper.heuristicFromScaledDistanceWithBase(C, distance); + return new Truthness(h, 1d); + } + + static Truthness getEndsWith(String str, String suffix) { + if (str.endsWith(suffix)) { + return TRUE_TRUTHNESS; + } + int n = Math.min(str.length(), suffix.length()); + String actualSuffix = str.substring(str.length() - n); + String expectedSuffix = suffix.substring(suffix.length() - n); + double distance = DistanceHelper.getLeftAlignmentDistance(actualSuffix, expectedSuffix); + double h = DistanceHelper.heuristicFromScaledDistanceWithBase(C, distance); + return new Truthness(h, 1d); + } + + static Truthness getContains(String str, String substring) { + if (str.contains(substring)) { + return TRUE_TRUTHNESS; + } + double distance = getMinSubstringDistance(str, substring); + double h = DistanceHelper.heuristicFromScaledDistanceWithBase(C, distance); + return new Truthness(h, 1d); + } + + private static double getMinSubstringDistance(String str, String substring) { + if (str.length() < substring.length()) { + return DistanceHelper.getLeftAlignmentDistance(str, substring); + } + double minDistance = Double.MAX_VALUE; + for (int i = 0; i <= str.length() - substring.length(); i++) { + String window = str.substring(i, i + substring.length()); + double distance = DistanceHelper.getLeftAlignmentDistance(window, substring); + if (distance < minDistance) { + minDistance = distance; + } + } + return minDistance; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java new file mode 100644 index 0000000000..cd44067f15 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculator.java @@ -0,0 +1,187 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.conditions.CypherCondition; +import org.evomaster.client.java.controller.neo4j.data.Neo4jGraph; +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; +import org.evomaster.client.java.controller.neo4j.data.Neo4jRelationship; +import org.evomaster.client.java.controller.neo4j.operations.MatchOperation; +import org.evomaster.client.java.controller.neo4j.operations.MatchPattern; +import org.evomaster.client.java.controller.neo4j.operations.PatternEdge; +import org.evomaster.client.java.distance.heuristics.Truthness; +import org.evomaster.client.java.distance.heuristics.TruthnessUtils; +import org.evomaster.client.java.sql.internal.TaintHandler; + +import java.util.ArrayList; +import java.util.List; + +/** + * Computes the search heuristic {@code H(Q, G)} of a parsed Cypher MATCH query {@code Q} against a + * captured graph {@code G}, returning a {@link Truthness} (how close {@code G} is to satisfying + * {@code Q}). It works in {@link Truthness} end-to-end, reusing the shared {@link TruthnessUtils} + * primitives for the aggregations. + *

+ * {@code H(Q, G) = andAggregation(H_match(P_s, G), H_where(C_all, matched_elements(P_s, G)))}, where + * {@code P_s} is the structural pattern and {@code C_all} the conditions. + */ +public class Neo4jHeuristicsCalculator { + + public static final double C = 0.1; + public static final Truthness TRUE_TRUTHNESS = new Truthness(1, C); + public static final Truthness FALSE_TRUTHNESS = new Truthness(C, 1); + + private final Neo4jStructuralMatcher matcher = new Neo4jStructuralMatcher(); + private final Neo4jConditionEvaluator evaluator; + + public Neo4jHeuristicsCalculator() { + this(null); + } + + public Neo4jHeuristicsCalculator(TaintHandler taintHandler) { + this.evaluator = new Neo4jConditionEvaluator(taintHandler); + } + + public Truthness computeHeuristic(MatchOperation query, Neo4jGraph graph) { + Neo4jPatternExpander.ExpandedQuery expanded = new Neo4jPatternExpander().expand(query); + MatchPattern pattern = expanded.pattern; + List conditions = expanded.conditions; + + List mappings = matcher.matchedElements(pattern, graph); + + if (query.isOptional() && mappings.isEmpty()) { + return TRUE_TRUTHNESS; + } + + Truthness hMatch = hMatch(pattern, graph, mappings); + Truthness hWhere = hWhere(conditions, mappings); + return TruthnessUtils.buildAndAggregationTruthness(hMatch, hWhere); + } + + /** + * Converts a heuristic to the distance form: {@code 1 - ofTrue}, in + * {@code [0,1]}, where 0 means the query is satisfied. + */ + public double computeDistance(MatchOperation query, Neo4jGraph graph) { + return 1.0d - computeHeuristic(query, graph).getOfTrue(); + } + + private Truthness hMatch(MatchPattern pattern, Neo4jGraph graph, List mappings) { + Truthness nodes = hMatchNodes(pattern.nodeCount(), graph.nodeCount()); + Truthness edges = hMatchEdges(pattern.getEdges(), graph, mappings); + return TruthnessUtils.buildAndAggregationTruthness(nodes, edges); + } + + /** + * Count-based node availability: enough graph nodes to bind the pattern's nodes. Pure cardinality, + * no label/property check (those are conditions evaluated by H_where). + */ + Truthness hMatchNodes(int required, int available) { + if (required == 0) { + return TRUE_TRUTHNESS; + } + if (available == 0) { + return FALSE_TRUTHNESS; + } + if (available >= required) { + return TRUE_TRUTHNESS; + } + return TruthnessUtils.buildScaledTruthness(C, (double) available / required); + } + + /** + * Edge availability: the best, over all node mappings, of whether every pattern edge has a + * matching graph relationship under that mapping. Empty edge set is vacuously satisfied. + */ + private Truthness hMatchEdges(List patternEdges, Neo4jGraph graph, + List mappings) { + if (mappings.isEmpty()) { + return FALSE_TRUTHNESS; + } + if (patternEdges.isEmpty()) { + return TRUE_TRUTHNESS; + } + // matched_elements already binds every pattern edge to an existing relationship, so edgeMatch is + // TRUE for any mapping here; the per-mapping scaled form is kept for the general case and stays + // correct should the matcher ever yield node-only mappings. + double maxOfTrue = 0d; + for (Neo4jMapping mapping : mappings) { + Truthness t = edgesForMapping(patternEdges, graph, mapping); + if (t.isTrue()) { + return TRUE_TRUTHNESS; + } + if (t.getOfTrue() > maxOfTrue) { + maxOfTrue = t.getOfTrue(); + } + } + return TruthnessUtils.buildScaledTruthness(C, maxOfTrue); + } + + private Truthness edgesForMapping(List patternEdges, Neo4jGraph graph, + Neo4jMapping mapping) { + Truthness[] perEdge = new Truthness[patternEdges.size()]; + for (int i = 0; i < patternEdges.size(); i++) { + perEdge[i] = edgeMatch(patternEdges.get(i), graph, mapping); + } + return TruthnessUtils.buildAndAggregationTruthness(perEdge); + } + + /** Existence-only edge check: TRUE if some graph relationship matches the edge's endpoints. */ + private Truthness edgeMatch(PatternEdge edge, Neo4jGraph graph, Neo4jMapping mapping) { + Neo4jNode source = mapping.getNode(edge.getSourceVariable()); + Neo4jNode target = mapping.getNode(edge.getTargetVariable()); + if (source == null || target == null) { + return FALSE_TRUTHNESS; + } + for (Neo4jRelationship rel : graph.getRelationships()) { + boolean forward = rel.getSourceId().equals(source.getId()) + && rel.getTargetId().equals(target.getId()); + boolean backward = !edge.isDirected() + && rel.getSourceId().equals(target.getId()) + && rel.getTargetId().equals(source.getId()); + if (forward || backward) { + return TRUE_TRUTHNESS; + } + } + return FALSE_TRUTHNESS; + } + + /** + * Best, over all matched elements, of how well the conditions hold; if no mapping satisfies them + * fully, the best partial score scaled from base {@code C}. No mappings means the structure was + * absent, so the conditions cannot hold: FALSE. + */ + private Truthness hWhere(List conditions, List mappings) { + if (mappings.isEmpty()) { + return FALSE_TRUTHNESS; + } + double maxOfTrue = 0d; + for (Neo4jMapping mapping : mappings) { + Truthness t = matchConditions(conditions, mapping); + if (t.isTrue()) { + return TRUE_TRUTHNESS; + } + if (t.getOfTrue() > maxOfTrue) { + maxOfTrue = t.getOfTrue(); + } + } + return TruthnessUtils.buildScaledTruthness(C, maxOfTrue); + } + + /** + * AND-aggregates the truthness of every condition under one mapping. Conditions that cannot be + * valuated (absent property, opaque/raw) are skipped; if none remain, the mapping vacuously + * satisfies the (empty) constraint set. + */ + private Truthness matchConditions(List conditions, Neo4jMapping mapping) { + List truths = new ArrayList<>(); + for (CypherCondition c : conditions) { + Truthness t = evaluator.rho(c, mapping); + if (t != null) { + truths.add(t); + } + } + if (truths.isEmpty()) { + return TRUE_TRUTHNESS; + } + return TruthnessUtils.buildAndAggregationTruthness(truths.toArray(new Truthness[0])); + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java new file mode 100644 index 0000000000..8f809e5246 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jMapping.java @@ -0,0 +1,68 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; +import org.evomaster.client.java.controller.neo4j.data.Neo4jRelationship; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A single structural mapping {@code m = (μ, ε)} from a query pattern {@code P_s} onto a graph + * {@code G}: it binds each pattern node variable to a graph node ({@code μ}) and each pattern edge + * variable to a graph relationship ({@code ε}). Bindings are keyed by the variable name the parser + * assigned (a real name like {@code n}, or an anonymous {@code _anon_node_0} / {@code _anon_rel_0}), + * which is shared between the {@code PatternEdge}/{@code PatternNode} and the conditions that refer + * to it, so condition valuation can resolve a variable to its bound graph element. + */ +class Neo4jMapping { + + private final Map nodeBindings; + private final Map edgeBindings; + + Neo4jMapping() { + this.nodeBindings = new LinkedHashMap<>(); + this.edgeBindings = new LinkedHashMap<>(); + } + + private Neo4jMapping(Map nodeBindings, Map edgeBindings) { + this.nodeBindings = new LinkedHashMap<>(nodeBindings); + this.edgeBindings = new LinkedHashMap<>(edgeBindings); + } + + Neo4jMapping copy() { + return new Neo4jMapping(nodeBindings, edgeBindings); + } + + Neo4jNode getNode(String variable) { + return nodeBindings.get(variable); + } + + Neo4jRelationship getEdge(String variable) { + return edgeBindings.get(variable); + } + + boolean isNodeBound(String variable) { + return nodeBindings.containsKey(variable); + } + + void bindNode(String variable, Neo4jNode node) { + nodeBindings.put(variable, node); + } + + void bindEdge(String variable, Neo4jRelationship relationship) { + edgeBindings.put(variable, relationship); + } + + /** + * True when this graph relationship is already used by some pattern edge in this mapping. Cypher + * enforces relationship uniqueness within a single MATCH, so the enumerator avoids reusing one. + */ + boolean usesRelationship(Neo4jRelationship relationship) { + return edgeBindings.containsValue(relationship); + } + + @Override + public String toString() { + return "Neo4jMapping{nodes=" + nodeBindings + ", edges=" + edgeBindings + "}"; + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jPatternExpander.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jPatternExpander.java new file mode 100644 index 0000000000..666d480484 --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jPatternExpander.java @@ -0,0 +1,362 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.conditions.CypherCondition; +import org.evomaster.client.java.controller.neo4j.conditions.PropertyCondition; +import org.evomaster.client.java.controller.neo4j.conditions.TypeCondition; +import org.evomaster.client.java.controller.neo4j.operations.MatchOperation; +import org.evomaster.client.java.controller.neo4j.operations.MatchPattern; +import org.evomaster.client.java.controller.neo4j.operations.PatternEdge; +import org.evomaster.client.java.controller.neo4j.operations.PatternNode; +import org.evomaster.client.java.controller.neo4j.operations.QuantifiedPathPattern; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Flattens a {@link MatchPattern} into one with no variable-length relationships and no quantified + * path patterns (QPPs), so the rest of the calculator only ever deals with plain nodes and edges. + * Two independent expansions run in sequence, each to the construct's lower bound {@code L} (if the + * lower bound is satisfied the query succeeds, so the upper bound is irrelevant): + *

    + *
  1. {@link #expandQuantifiedPaths}: every QPP ({@code ((a)-[]->(b)){1,3}}) is replaced by + * {@code L} clones of its sub-pattern, chained end to end and spliced onto the outer nodes it sits + * between (see {@link QuantifiedPathPattern#getEntryVariable()}/{@link QuantifiedPathPattern#getExitVariable()}). + * Runs first so its output — plain nodes/edges, some possibly still variable-length — feeds the + * next step.
  2. + *
  3. The pre-existing variable-length-edge expansion ({@code *}, {@code +}, {@code *n..m}): a + * lower bound {@code L ≥ 2} becomes a chain of {@code L} cloned edges with {@code L-1} fresh + * intermediate nodes inheriting both endpoints' labels/properties; {@code L == 1} is a single + * ordinary edge; {@code L == 0} merges the two endpoints (a zero-length path identifies them) and + * drops the edge. This also expands variable-length edges that were themselves inside a QPP's + * sub-pattern, since step 1 clones them unchanged (min/max preserved) into the flat edge list.
  4. + *
+ */ +class Neo4jPatternExpander { + + static final class ExpandedQuery { + final MatchPattern pattern; + final List conditions; + + ExpandedQuery(MatchPattern pattern, List conditions) { + this.pattern = pattern; + this.conditions = conditions; + } + } + + private int counter = 0; + + ExpandedQuery expand(MatchOperation query) { + ExpandedQuery afterQpp = expandQuantifiedPaths(query.getPattern(), query.getConditions()); + return expandVariableLengthEdges(afterQpp.pattern, afterQpp.conditions); + } + + private ExpandedQuery expandVariableLengthEdges(MatchPattern pattern, List conditions) { + boolean hasVariableLength = false; + for (PatternEdge e : pattern.getEdges()) { + if (e.isVariableLength()) { + hasVariableLength = true; + break; + } + } + if (!hasVariableLength) { + return new ExpandedQuery(pattern, conditions); + } + + List nodes = new ArrayList<>(pattern.getNodes()); + List edges = new ArrayList<>(); + List original = conditions; + List working = new ArrayList<>(conditions); + + Map merges = new HashMap<>(); + Set nodesToRemove = new HashSet<>(); + + for (PatternEdge e : pattern.getEdges()) { + if (!e.isVariableLength()) { + edges.add(e); + continue; + } + int lower = e.getMinLength() != null ? e.getMinLength() : 0; + if (lower <= 1) { + if (lower == 0) { + merges.put(e.getTargetVariable(), e.getSourceVariable()); + nodesToRemove.add(e.getTargetVariable()); + dropEdgeConditions(working, e.getVariableName()); + } else { + edges.add(new PatternEdge(e.getVariableName(), e.getSourceVariable(), + e.getTargetVariable(), e.isDirected())); + } + } else { + expandChain(e, lower, original, nodes, edges, working); + } + } + + if (!merges.isEmpty()) { + Map resolved = resolveMerges(merges); + working = renameAll(working, resolved); + edges = renameEndpoints(edges, resolved); + nodes.removeIf(n -> nodesToRemove.contains(n.getVariableName())); + } + + return new ExpandedQuery(new MatchPattern(nodes, edges), working); + } + + /** + * Replaces every QPP in {@code pattern} with {@code L} concrete clones of its sub-pattern (see + * {@link #expandQpp}), recursing into nested QPPs first so each clone is built from an + * already-flattened sub-pattern. A pattern with no QPPs is returned unchanged. + */ + private ExpandedQuery expandQuantifiedPaths(MatchPattern pattern, List conditions) { + if (pattern.getQuantifiedPaths().isEmpty()) { + return new ExpandedQuery(pattern, conditions); + } + + List nodes = new ArrayList<>(pattern.getNodes()); + List edges = new ArrayList<>(pattern.getEdges()); + List working = new ArrayList<>(conditions); + + Map merges = new HashMap<>(); + Set nodesToRemove = new HashSet<>(); + + for (QuantifiedPathPattern qpp : pattern.getQuantifiedPaths()) { + expandQpp(qpp, nodes, edges, working, merges, nodesToRemove); + } + + if (!merges.isEmpty()) { + Map resolved = resolveMerges(merges); + working = renameAll(working, resolved); + edges = renameEndpoints(edges, resolved); + nodes.removeIf(n -> nodesToRemove.contains(n.getVariableName())); + } + + return new ExpandedQuery(new MatchPattern(nodes, edges), working); + } + + /** + * Expands one QPP to its lower bound {@code L}, splicing onto {@link QuantifiedPathPattern#getEntryVariable()} + * / {@link QuantifiedPathPattern#getExitVariable()} (either may be {@code null} — the QPP opens or + * closes the whole pattern, or is the whole pattern). {@code L == 0} merges entry and exit (when + * both exist) exactly like a zero-length edge, via the same {@code merges} map so it composes with + * plain zero-length edges resolved later; the sub-pattern's own conditions never materialize. + * {@code L ≥ 1} clones the sub-pattern {@code L} times, chaining each copy's own exit-side node to + * the next copy's own entry-side node with a fresh name, and binding the first copy's entry-side + * and the last copy's exit-side to the outer variables when present. + */ + private void expandQpp(QuantifiedPathPattern qpp, List nodes, List edges, + List conditions, Map merges, + Set nodesToRemove) { + ExpandedQuery flatSub = expandQuantifiedPaths(qpp.getSubPattern(), qpp.getConditions()); + List subNodes = flatSub.pattern.getNodes(); + if (subNodes.isEmpty()) { + return; + } + // The sub-pattern's own boundary is the source of its first edge and the target of its last + // edge, in source-text order — not the first/last *distinct* node variable, which would pick + // the wrong endpoint when the sub-pattern cycles back onto an earlier variable (e.g. + // (a)-[:R]->(b)-[:S]->(a) must exit on "a", not "b", the last newly-introduced name). + List subEdges = flatSub.pattern.getEdges(); + String subEntry = subEdges.isEmpty() + ? subNodes.get(0).getVariableName() + : subEdges.get(0).getSourceVariable(); + String subExit = subEdges.isEmpty() + ? subNodes.get(subNodes.size() - 1).getVariableName() + : subEdges.get(subEdges.size() - 1).getTargetVariable(); + + String outerEntry = qpp.getEntryVariable(); + String outerExit = qpp.getExitVariable(); + int lower = qpp.getMin(); + + if (lower == 0) { + if (outerEntry != null && outerExit != null) { + merges.put(outerExit, outerEntry); + nodesToRemove.add(outerExit); + } + return; + } + + String prevExit = outerEntry; + for (int rep = 1; rep <= lower; rep++) { + boolean last = (rep == lower); + Map boundary = new HashMap<>(); + + String entryName = prevExit != null ? prevExit : subEntry; + boundary.put(subEntry, entryName); + + String exitName; + if (subEntry.equals(subExit)) { + // The sub-pattern's own entry and exit are the same variable (a single bare node, or a + // cyclic sub-pattern that returns to its own start): this repetition begins and ends on + // the same node. If that's also the last repetition and there's an outer exit variable, + // the outer exit is really an alias for the same node too — merge it in transitively, + // the same way a zero-length QPP merges its entry and exit. + exitName = entryName; + if (last && outerExit != null && !outerExit.equals(exitName)) { + merges.put(outerExit, exitName); + nodesToRemove.add(outerExit); + } + } else if (last && outerExit != null) { + exitName = outerExit; + } else { + exitName = freshName("qpp_node"); + } + boundary.put(subExit, exitName); + + cloneSubPatternInto(flatSub.pattern, flatSub.conditions, boundary, nodes, edges, conditions); + prevExit = exitName; + } + } + + /** + * Adds one renamed clone of {@code subPattern} (nodes, edges, conditions) into the accumulating + * outer lists. {@code boundary} pins the sub-pattern's own entry/exit node names to their bound + * names for this repetition; every other node/edge variable gets a fresh globally-unique name so + * repetitions never collide. A node already present in {@code nodes} (an outer node, or the + * previous repetition's exit) is not re-added. + */ + private void cloneSubPatternInto(MatchPattern subPattern, List subConditions, + Map boundary, List nodes, + List edges, List conditions) { + Map renames = new HashMap<>(boundary); + for (PatternNode n : subPattern.getNodes()) { + renames.putIfAbsent(n.getVariableName(), freshName("qpp_node")); + } + for (PatternEdge e : subPattern.getEdges()) { + if (e.getVariableName() != null) { + renames.putIfAbsent(e.getVariableName(), freshName("qpp_rel")); + } + } + + for (PatternNode n : subPattern.getNodes()) { + String newName = renames.get(n.getVariableName()); + if (!containsNode(nodes, newName)) { + nodes.add(new PatternNode(newName)); + } + } + for (PatternEdge e : subPattern.getEdges()) { + String src = renames.getOrDefault(e.getSourceVariable(), e.getSourceVariable()); + String tgt = renames.getOrDefault(e.getTargetVariable(), e.getTargetVariable()); + String var = e.getVariableName() != null ? renames.get(e.getVariableName()) : null; + edges.add(new PatternEdge(var, src, tgt, e.isDirected(), + e.isVariableLength(), e.getMinLength(), e.getMaxLength())); + } + for (CypherCondition c : subConditions) { + conditions.add(ConditionRenamer.rename(c, renames)); + } + } + + private boolean containsNode(List nodes, String variableName) { + for (PatternNode n : nodes) { + if (n.getVariableName().equals(variableName)) { + return true; + } + } + return false; + } + + /** + * Resolves chained merges to their root, so endpoints identified across several zero-length hops + * (e.g. {@code (a)-[*0]->(b)-[*0]->(c)} gives {@code b→a, c→b}) all collapse to the same variable + * rather than to an already-removed intermediate. The cycle guard keeps a pathological pattern that + * merges a variable back onto itself from looping. + */ + private Map resolveMerges(Map merges) { + Map resolved = new HashMap<>(); + for (String variable : merges.keySet()) { + String root = variable; + Set seen = new HashSet<>(); + while (merges.containsKey(root) && seen.add(root)) { + root = merges.get(root); + } + resolved.put(variable, root); + } + return resolved; + } + + private void expandChain(PatternEdge edge, int lower, List original, + List nodes, List edges, + List conditions) { + String source = edge.getSourceVariable(); + String target = edge.getTargetVariable(); + String edgeVar = edge.getVariableName(); + + List srcLabels = endpointConditions(original, source); + List tgtLabels = endpointConditions(original, target); + List edgeConds = edgeConditions(original, edgeVar); + + String prev = source; + for (int k = 1; k <= lower; k++) { + boolean last = (k == lower); + String currentNode = last ? target : freshName("node"); + if (!last) { + nodes.add(new PatternNode(currentNode)); + // Intermediate node inherits the labels/properties of both endpoints. + conditions.addAll(renameAll(srcLabels, Collections.singletonMap(source, currentNode))); + conditions.addAll(renameAll(tgtLabels, Collections.singletonMap(target, currentNode))); + } + // First hop reuses the original edge variable (its conditions already apply); later hops + // get a fresh variable with the edge's type/property conditions cloned onto it. + String hopVar = (k == 1) ? edgeVar : freshName("rel"); + edges.add(new PatternEdge(hopVar, prev, currentNode, edge.isDirected())); + if (k > 1) { + conditions.addAll(renameAll(edgeConds, Collections.singletonMap(edgeVar, hopVar))); + } + prev = currentNode; + } + } + + /** Label / any-label / property conditions on a node variable (its inheritable structure). */ + private List endpointConditions(List conditions, String variable) { + List out = new ArrayList<>(); + for (CypherCondition c : conditions) { + if (ConditionRenamer.referencesVariable(c, variable)) { + out.add(c); + } + } + return out; + } + + /** Type / property conditions on an edge variable. */ + private List edgeConditions(List conditions, String edgeVar) { + List out = new ArrayList<>(); + for (CypherCondition c : conditions) { + if (c instanceof TypeCondition && edgeVar.equals(((TypeCondition) c).getVariableName())) { + out.add(c); + } else if (c instanceof PropertyCondition + && edgeVar.equals(((PropertyCondition) c).getVariableName())) { + out.add(c); + } + } + return out; + } + + private void dropEdgeConditions(List conditions, String edgeVar) { + conditions.removeAll(edgeConditions(conditions, edgeVar)); + } + + private List renameAll(List conditions, Map renames) { + List out = new ArrayList<>(conditions.size()); + for (CypherCondition c : conditions) { + out.add(ConditionRenamer.rename(c, renames)); + } + return out; + } + + private List renameEndpoints(List edges, Map renames) { + List out = new ArrayList<>(edges.size()); + for (PatternEdge e : edges) { + String src = renames.getOrDefault(e.getSourceVariable(), e.getSourceVariable()); + String tgt = renames.getOrDefault(e.getTargetVariable(), e.getTargetVariable()); + out.add(new PatternEdge(e.getVariableName(), src, tgt, e.isDirected(), + e.isVariableLength(), e.getMinLength(), e.getMaxLength())); + } + return out; + } + + private String freshName(String kind) { + return "_exp_" + kind + "_" + (counter++); + } +} diff --git a/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jStructuralMatcher.java b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jStructuralMatcher.java new file mode 100644 index 0000000000..3a51ccfa8c --- /dev/null +++ b/client-java/controller/src/main/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jStructuralMatcher.java @@ -0,0 +1,130 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.data.Neo4jGraph; +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; +import org.evomaster.client.java.controller.neo4j.data.Neo4jRelationship; +import org.evomaster.client.java.controller.neo4j.operations.MatchPattern; +import org.evomaster.client.java.controller.neo4j.operations.PatternEdge; +import org.evomaster.client.java.controller.neo4j.operations.PatternNode; +import org.evomaster.client.java.utils.SimpleLogger; + +import java.util.ArrayList; +import java.util.List; + +/** + * Enumerates {@code matched_elements(P_s, G)}: every structurally valid mapping {@code (μ, ε)} of a + * stripped pattern onto a graph. A mapping binds each pattern node variable to a graph node and each + * pattern edge variable to a graph relationship such that the relationship's endpoints agree with the + * node bindings (an undirected edge matches either orientation), repeated variables resolve to the + * same element (equijoins), and no graph relationship is used twice in one mapping. + */ +class Neo4jStructuralMatcher { + + /** + * Upper bound on the number of mappings enumerated, to avoid combinatorial blow-up on large + * graphs / patterns. Once reached, enumeration stops and the partial set is used. + */ + static final int MAX_NUM_MAPPINGS = 2000; + + List matchedElements(MatchPattern pattern, Neo4jGraph graph) { + List results = new ArrayList<>(); + List edges = pattern.getEdges(); + + if (edges.isEmpty()) { + extendIsolatedNodes(pattern.getNodes(), 0, new Neo4jMapping(), graph, results); + } else { + backtrackEdges(edges, 0, new Neo4jMapping(), pattern, graph, results); + } + + if (results.size() >= MAX_NUM_MAPPINGS) { + SimpleLogger.uniqueWarn("Neo4j structural matching hit the cap of " + MAX_NUM_MAPPINGS + + " mappings; the heuristic is computed over a partial set of mappings."); + } + return results; + } + + private void backtrackEdges(List edges, int index, Neo4jMapping current, + MatchPattern pattern, Neo4jGraph graph, List results) { + if (results.size() >= MAX_NUM_MAPPINGS) { + return; + } + if (index == edges.size()) { + extendIsolatedNodes(pattern.getNodes(), 0, current, graph, results); + return; + } + + PatternEdge edge = edges.get(index); + for (Neo4jRelationship rel : graph.getRelationships()) { + if (current.usesRelationship(rel)) { + continue; + } + tryOrientation(current, edge, rel, rel.getSourceId(), rel.getTargetId(), + edges, index, pattern, graph, results); + if (!edge.isDirected()) { + tryOrientation(current, edge, rel, rel.getTargetId(), rel.getSourceId(), + edges, index, pattern, graph, results); + } + if (results.size() >= MAX_NUM_MAPPINGS) { + return; + } + } + } + + /** + * Binds {@code edge} to {@code rel} with the given endpoint orientation, if consistent with the + * bindings already in {@code current}, then recurses to the next edge. + */ + private void tryOrientation(Neo4jMapping current, PatternEdge edge, Neo4jRelationship rel, + String sourceNodeId, String targetNodeId, + List edges, int index, MatchPattern pattern, + Neo4jGraph graph, List results) { + if (!consistentNode(current, edge.getSourceVariable(), sourceNodeId) + || !consistentNode(current, edge.getTargetVariable(), targetNodeId)) { + return; + } + Neo4jMapping next = current.copy(); + next.bindNode(edge.getSourceVariable(), graph.getNodeById(sourceNodeId)); + next.bindNode(edge.getTargetVariable(), graph.getNodeById(targetNodeId)); + next.bindEdge(edge.getVariableName(), rel); + backtrackEdges(edges, index + 1, next, pattern, graph, results); + } + + private boolean consistentNode(Neo4jMapping current, String variable, String nodeId) { + if (!current.isNodeBound(variable)) { + return true; + } + Neo4jNode bound = current.getNode(variable); + return bound != null && bound.getId().equals(nodeId); + } + + /** + * Cartesian extension over pattern nodes not yet bound by an edge: each ranges over all graph + * nodes. For a fully edge-connected pattern this is a no-op (all nodes already bound). + */ + private void extendIsolatedNodes(List nodes, int index, Neo4jMapping current, + Neo4jGraph graph, List results) { + if (results.size() >= MAX_NUM_MAPPINGS) { + return; + } + if (index == nodes.size()) { + results.add(current.copy()); + return; + } + PatternNode node = nodes.get(index); + if (current.isNodeBound(node.getVariableName())) { + extendIsolatedNodes(nodes, index + 1, current, graph, results); + return; + } + if (graph.getNodes().isEmpty()) { + return; + } + for (Neo4jNode graphNode : graph.getNodes()) { + Neo4jMapping next = current.copy(); + next.bindNode(node.getVariableName(), graphNode); + extendIsolatedNodes(nodes, index + 1, next, graph, results); + if (results.size() >= MAX_NUM_MAPPINGS) { + return; + } + } + } +} 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 index ef4393335d..b4fa09a1b0 100644 --- 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 @@ -20,6 +20,13 @@ * 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. + *
+ * {@link #getEntryVariable()} and {@link #getExitVariable()} are the outer pattern variables this + * QPP splices between (e.g. {@code a} and {@code b} in {@code (a) ((x)-[]->(y)){1,3} (b)}), so the + * calculator can bind the first/last repetition's boundary node to the surrounding pattern. Either + * is {@code null} when there is no outer node on that side (the QPP opens or closes the pattern). + * When two QPPs are directly adjacent with no node between them, the parser binds them to the same + * synthesized variable, so this is never {@code null} because two QPPs happen to touch. */ public class QuantifiedPathPattern { @@ -27,17 +34,26 @@ public class QuantifiedPathPattern { private final List conditions; private final int min; private final Integer max; + private final String entryVariable; + private final String exitVariable; public QuantifiedPathPattern(MatchPattern subPattern, int min, Integer max) { - this(subPattern, Collections.emptyList(), min, max); + this(subPattern, Collections.emptyList(), min, max, null, null); } public QuantifiedPathPattern(MatchPattern subPattern, List conditions, int min, Integer max) { + this(subPattern, conditions, min, max, null, null); + } + + public QuantifiedPathPattern(MatchPattern subPattern, List conditions, int min, Integer max, + String entryVariable, String exitVariable) { this.subPattern = Objects.requireNonNull(subPattern, "subPattern must not be null"); this.conditions = Collections.unmodifiableList( conditions != null ? new ArrayList<>(conditions) : new ArrayList<>()); this.min = min; this.max = max; + this.entryVariable = entryVariable; + this.exitVariable = exitVariable; } public MatchPattern getSubPattern() { @@ -64,13 +80,36 @@ public boolean isUnboundedMax() { return max == null; } + /** The outer variable immediately before this QPP, or {@code null} if it opens the pattern. */ + public String getEntryVariable() { + return entryVariable; + } + + /** The outer variable immediately after this QPP, or {@code null} if it closes the pattern. */ + public String getExitVariable() { + return exitVariable; + } + + /** Returns a copy of this QPP with its entry/exit variables replaced. */ + public QuantifiedPathPattern withBoundary(String newEntryVariable, String newExitVariable) { + return new QuantifiedPathPattern(subPattern, conditions, min, max, newEntryVariable, newExitVariable); + } + @Override public String toString() { - StringBuilder sb = new StringBuilder("QuantifiedPathPattern{").append(subPattern); + StringBuilder sb = new StringBuilder("QuantifiedPathPattern{"); + if (entryVariable != null) { + sb.append("entry=").append(entryVariable).append(", "); + } + sb.append(subPattern); if (!conditions.isEmpty()) { sb.append(", conditions=").append(conditions); } - sb.append(" {").append(min).append(",").append(max != null ? max : "").append("}}"); + sb.append(" {").append(min).append(",").append(max != null ? max : "").append("}"); + if (exitVariable != null) { + sb.append(", exit=").append(exitVariable); + } + sb.append("}"); return sb.toString(); } } 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 index 562ef3cd3f..4cea3d307f 100644 --- 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 @@ -85,10 +85,13 @@ private void processPattern(Cypher25Parser.PatternContext ctx, PatternAcc 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) ... + // Walk children in source order so nodes/relationships/QPPs splice together correctly: + // (n0) -rel0- (n1) -rel1- (n2) ... , where a parenthesizedPath (QPP) can stand in for a node. String previousNode = null; RelInfo pendingRel = null; + // Index into acc.quantifiedPaths of the most recently added QPP still waiting for its exit + // variable (the node, or synthesized join, that comes after it); -1 when none is pending. + int pendingQppIndex = -1; for (ParseTree child : children(ctx)) { if (child instanceof Cypher25Parser.NodePatternContext) { @@ -97,17 +100,42 @@ private void processPatternElement(Cypher25Parser.PatternElementContext ctx, Pat addEdge(pendingRel, previousNode, current, acc); pendingRel = null; } + if (pendingQppIndex >= 0) { + bindQppExit(acc, pendingQppIndex, current); + pendingQppIndex = -1; + } 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)); + QuantifiedPathPattern qpp = processParenthesizedPath((Cypher25Parser.ParenthesizedPathContext) child); + String entry = previousNode; + if (entry == null && pendingQppIndex >= 0) { + // Two QPPs directly adjacent with no node between them: the grammar still requires + // the graph to be continuous there, so synthesize the shared join variable both + // QPPs bind to (same mechanism as an anonymous node, just not written by the user). + entry = "_anon_qpp_join_" + (anonNodeCounter++); + bindQppExit(acc, pendingQppIndex, entry); + } + acc.quantifiedPaths.add(qpp.withBoundary(entry, null)); + pendingQppIndex = acc.quantifiedPaths.size() - 1; + // A QPP can never be followed directly by a relationshipPattern (the grammar only + // allows one after a nodePattern), so previousNode is only ever read again either by + // the next nodePattern (which overwrites it unconditionally) or the adjacency check + // above (which relies on it being null here to detect two QPPs touching). + previousNode = null; } } } + /** Rebinds the exit variable of an already-added QPP, keeping its entry variable unchanged. */ + private void bindQppExit(PatternAcc acc, int index, String exitVariable) { + QuantifiedPathPattern qpp = acc.quantifiedPaths.get(index); + acc.quantifiedPaths.set(index, qpp.withBoundary(qpp.getEntryVariable(), exitVariable)); + } + private QuantifiedPathPattern processParenthesizedPath(Cypher25Parser.ParenthesizedPathContext ctx) { // parenthesizedPath : LPAREN pattern (WHERE expression)? RPAREN quantifier? PatternAcc sub = new PatternAcc(); diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jHandlerTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jHandlerTest.java new file mode 100644 index 0000000000..fc4f8c0a70 --- /dev/null +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/internal/db/neo4j/Neo4jHandlerTest.java @@ -0,0 +1,185 @@ +package org.evomaster.client.java.controller.internal.db.neo4j; + +import org.evomaster.client.java.instrumentation.Neo4JRunCommand; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Exercises {@link Neo4jHandler} and {@link Neo4jGraphReader} end-to-end against a hand-rolled fake + * driver that exposes the same method names the reader reflects over ({@code session}/{@code run}/ + * {@code list}/{@code get}/{@code asString}/{@code asList}/{@code asMap}/{@code close}). This validates + * the reflection plumbing and the parse→score→DTO pipeline without needing a live Neo4j instance. + *

+ * Integers are returned as {@code Long} to mimic the real driver's value mapping. + */ +class Neo4jHandlerTest { + + private static final String MATCH_QUERY = + "MATCH (a:Person {age: 25})-[r:KNOWS]->(b:Person) WHERE b.age > 30 RETURN b"; + + private FakeDriver example1Driver() { + List nodes = Arrays.asList( + nodeRecord("n1", labels("Person"), props("age", 25L, "name", "Ana")), + nodeRecord("n2", labels("Person"), props("age", 28L, "name", "Luis")), + nodeRecord("n3", labels("Animal"), props("age", 5L, "name", "Rex")), + nodeRecord("n4", labels("Person"), props("age", 40L, "name", "Carlos"))); + List rels = Arrays.asList( + relRecord("e1", "KNOWS", "n1", "n2"), + relRecord("e2", "LIKES", "n1", "n3"), + relRecord("e3", "KNOWS", "n3", "n4")); + return new FakeDriver(nodes, rels); + } + + @Test + void testScoresMatchQueryAgainstLiveGraph() { + Neo4jHandler handler = new Neo4jHandler(); + handler.setNeo4jConnection(example1Driver()); + handler.handle(new Neo4JRunCommand(MATCH_QUERY, null, true, 1)); + + List evaluated = handler.getEvaluatedCommands(); + + assertEquals(1, evaluated.size()); + Neo4jCommandWithDistance result = evaluated.get(0); + assertEquals(MATCH_QUERY, result.neo4jCommand); + assertFalse(result.neo4jDistanceWithMetrics.neo4jDistanceEvaluationFailure); + assertEquals(4, result.neo4jDistanceWithMetrics.numberOfEvaluatedNodes); + // distance = 1 - ofTrue; ofTrue ≈ 0.939 → distance ≈ 0.061. + assertEquals(0.061, result.neo4jDistanceWithMetrics.neo4jDistance, 0.005); + } + + @Test + void testNonMatchQueryIsSkipped() { + Neo4jHandler handler = new Neo4jHandler(); + handler.setNeo4jConnection(example1Driver()); + handler.handle(new Neo4JRunCommand("CREATE (n:Person {name: 'Zoe'})", null, true, 1)); + handler.handle(new Neo4JRunCommand(MATCH_QUERY, null, true, 1)); + + List evaluated = handler.getEvaluatedCommands(); + // The write query does not parse as a MATCH and is skipped; only the read query is scored. + assertEquals(1, evaluated.size()); + assertEquals(MATCH_QUERY, evaluated.get(0).neo4jCommand); + } + + @Test + void testNoConnectionYieldsNoHeuristics() { + Neo4jHandler handler = new Neo4jHandler(); + handler.handle(new Neo4JRunCommand(MATCH_QUERY, null, true, 1)); + assertTrue(handler.getEvaluatedCommands().isEmpty()); + } + + // --- fake Neo4j driver (only the methods the reader reflects over) --------------------------- + + public static final class FakeDriver { + private final List nodes; + private final List rels; + + FakeDriver(List nodes, List rels) { + this.nodes = nodes; + this.rels = rels; + } + + public FakeSession session() { + return new FakeSession(nodes, rels); + } + } + + public static final class FakeSession { + private final List nodes; + private final List rels; + + FakeSession(List nodes, List rels) { + this.nodes = nodes; + this.rels = rels; + } + + public FakeResult run(String query) { + return new FakeResult(query.contains("labels(n)") ? nodes : rels); + } + + public void close() { + } + } + + public static final class FakeResult { + private final List records; + + FakeResult(List records) { + this.records = records; + } + + public List list() { + return records; + } + } + + public static final class FakeRecord { + private final Map fields; + + FakeRecord(Map fields) { + this.fields = fields; + } + + public FakeValue get(String key) { + return new FakeValue(fields.get(key)); + } + } + + public static final class FakeValue { + private final Object value; + + FakeValue(Object value) { + this.value = value; + } + + public String asString() { + return (String) value; + } + + @SuppressWarnings("unchecked") + public List asList() { + return (List) value; + } + + @SuppressWarnings("unchecked") + public Map asMap() { + return (Map) value; + } + } + + private static FakeRecord nodeRecord(String id, List labels, Map props) { + Map f = new LinkedHashMap<>(); + f.put("id", id); + f.put("labels", labels); + f.put("props", props); + return new FakeRecord(f); + } + + private static FakeRecord relRecord(String id, String type, String src, String tgt) { + Map f = new LinkedHashMap<>(); + f.put("id", id); + f.put("type", type); + f.put("src", src); + f.put("tgt", tgt); + f.put("props", new LinkedHashMap()); + return new FakeRecord(f); + } + + private static List labels(String... ls) { + return new ArrayList<>(Arrays.asList(ls)); + } + + private static Map props(Object... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put((String) kv[i], kv[i + 1]); + } + return m; + } +} diff --git a/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java new file mode 100644 index 0000000000..1359debecc --- /dev/null +++ b/client-java/controller/src/test/java/org/evomaster/client/java/controller/neo4j/heuristics/Neo4jHeuristicsCalculatorTest.java @@ -0,0 +1,420 @@ +package org.evomaster.client.java.controller.neo4j.heuristics; + +import org.evomaster.client.java.controller.neo4j.conditions.ComparisonCondition; +import org.evomaster.client.java.controller.neo4j.conditions.CypherCondition; +import org.evomaster.client.java.controller.neo4j.conditions.PropertyOperand; +import org.evomaster.client.java.controller.neo4j.conditions.TypeCondition; +import org.evomaster.client.java.controller.neo4j.data.Neo4jGraph; +import org.evomaster.client.java.controller.neo4j.data.Neo4jNode; +import org.evomaster.client.java.controller.neo4j.data.Neo4jRelationship; +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.parser.CypherParser; +import org.evomaster.client.java.controller.neo4j.parser.CypherParserException; +import org.evomaster.client.java.controller.neo4j.parser.CypherParserFactory; +import org.evomaster.client.java.distance.heuristics.Truthness; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Validates {@link Neo4jHeuristicsCalculator} end-to-end (parser → calculator) against two worked + * examples, plus unit checks of the building blocks. + *

+ * Note on exact values: the heuristic calls EvoMaster's real {@code TruthnessUtils} (equality + * {@code 1/(1+d)}, less-than {@code 1/(1.1+d)}, both un-based), so the tests assert the real computed + * value and, more importantly, the qualitative structure and the gradient (a one-property mutation + * flips the query to satisfied). + */ +class Neo4jHeuristicsCalculatorTest { + + private final CypherParser parser = CypherParserFactory.buildParser(); + private final Neo4jHeuristicsCalculator calculator = new Neo4jHeuristicsCalculator(); + + private static final String EXAMPLE_QUERY = + "MATCH (a:Person {age: 25})-[r:KNOWS]->(b:Person) WHERE b.age > 30 RETURN b"; + + private Neo4jGraph example1Graph(int n2Age) { + Neo4jNode n1 = node("n1", labels("Person"), props("age", 25, "name", "Ana")); + Neo4jNode n2 = node("n2", labels("Person"), props("age", n2Age, "name", "Luis")); + Neo4jNode n3 = node("n3", labels("Animal"), props("age", 5, "name", "Rex")); + Neo4jNode n4 = node("n4", labels("Person"), props("age", 40, "name", "Carlos")); + Neo4jRelationship e1 = rel("e1", "KNOWS", "n1", "n2"); + Neo4jRelationship e2 = rel("e2", "LIKES", "n1", "n3"); + Neo4jRelationship e3 = rel("e3", "KNOWS", "n3", "n4"); + return new Neo4jGraph(Arrays.asList(n1, n2, n3, n4), Arrays.asList(e1, e2, e3)); + } + + @Test + void testExample1StructuralMappings() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + assertEquals(3, new Neo4jStructuralMatcher() + .matchedElements(q.getPattern(), example1Graph(28)).size()); + } + + @Test + void testExample1NotSatisfiedButStrongGradient() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + Truthness h = calculator.computeHeuristic(q, example1Graph(28)); + + // Not satisfied: b.age (28) is not > 30 on any mapping. + assertFalse(h.isTrue()); + assertEquals(1.0, h.getOfFalse(), 1e-9); + // Structure fully available and the best mapping satisfies 4 of 5 conditions: close to satisfied. + assertTrue(h.getOfTrue() > 0.9); + } + + @Test + void testComputeDistanceMirrorsHeuristic() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + Neo4jGraph g = example1Graph(28); + // The distance reported to the fitness DTO is 1 - ofTrue, and is positive for an unsatisfied query. + double distance = calculator.computeDistance(q, g); + assertEquals(1.0 - calculator.computeHeuristic(q, g).getOfTrue(), distance, 1e-9); + assertTrue(distance > 0); + } + + @Test + void testExample1SatisfiedAfterAgeMutation() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + // Mutating Luis's age 28 → 31 makes the best mapping (a→n1, b→n2) fully satisfy the query. + Truthness h = calculator.computeHeuristic(q, example1Graph(31)); + assertTrue(h.isTrue()); + assertEquals(1.0, h.getOfTrue(), 1e-9); + } + + // Worked example 2: partial match, clear gradient + + private Neo4jGraph example2Graph() { + Neo4jNode n1 = node("n1", labels("Person"), props("age", 27, "name", "Ana")); + Neo4jNode n2 = node("n2", labels("Person"), props("age", 35, "name", "Luis")); + Neo4jNode n3 = node("n3", labels("Person"), props("age", 22, "name", "Maria")); + Neo4jRelationship e1 = rel("e1", "LIKES", "n1", "n2"); + Neo4jRelationship e2 = rel("e2", "KNOWS", "n2", "n3"); + return new Neo4jGraph(Arrays.asList(n1, n2, n3), Arrays.asList(e1, e2)); + } + + @Test + void testExample2PartialMatch() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + Truthness h = calculator.computeHeuristic(q, example2Graph()); + + assertFalse(h.isTrue()); + assertEquals(1.0, h.getOfFalse(), 1e-9); + // Partial match: a clear gradient, well above the unsatisfiable floor. + assertTrue(h.getOfTrue() > 0.8); + } + + @Test + void testExample2SatisfiedOnTunedGraph() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + // a:Person{age:25} -KNOWS-> b:Person{age:40>30} + Neo4jNode a = node("a", labels("Person"), props("age", 25)); + Neo4jNode b = node("b", labels("Person"), props("age", 40)); + Neo4jGraph g = new Neo4jGraph(Arrays.asList(a, b), + Collections.singletonList(rel("e", "KNOWS", "a", "b"))); + assertTrue(calculator.computeHeuristic(q, g).isTrue()); + } + + @Test + void testNoStructuralMatchStillScoresNodesByCount() throws CypherParserException { + MatchOperation q = parser.parse(EXAMPLE_QUERY); + // Right nodes exist but there is no relationship: matched_elements is empty, so H_where and + // H_match_edges are FALSE, but H_match_nodes is still TRUE (2 needed ≤ 2 available). + Neo4jNode a = node("a", labels("Person"), props("age", 25)); + Neo4jNode b = node("b", labels("Person"), props("age", 40)); + Neo4jGraph g = new Neo4jGraph(Arrays.asList(a, b), Collections.emptyList()); + Truthness h = calculator.computeHeuristic(q, g); + assertFalse(h.isTrue()); + assertEquals(1.0, h.getOfFalse(), 1e-9); + assertTrue(h.getOfTrue() > Neo4jHeuristicsCalculator.C); + } + + // Variable-length relationships expanded to lower bound + + @Test + void testVariableLengthLowerBoundTwoExpandsToChain() throws CypherParserException { + MatchOperation q = parser.parse("MATCH (a)-[:KNOWS*2]->(b) RETURN b"); + Neo4jPatternExpander.ExpandedQuery expanded = new Neo4jPatternExpander().expand(q); + // *2 → two hops with one fresh intermediate node: 3 nodes, 2 edges. + assertEquals(3, expanded.pattern.nodeCount()); + assertEquals(2, expanded.pattern.edgeCount()); + // The KNOWS type condition is cloned onto both hops. + long typeConds = expanded.conditions.stream() + .filter(c -> c instanceof org.evomaster.client.java.controller.neo4j.conditions.TypeCondition) + .count(); + assertEquals(2, typeConds); + } + + @Test + void testVariableLengthTwoHopSatisfied() throws CypherParserException { + MatchOperation q = parser.parse("MATCH (a)-[:KNOWS*2]->(b) RETURN b"); + // n1 -KNOWS-> n2 -KNOWS-> n3 : a two-hop KNOWS path exists. + Neo4jNode n1 = node("n1", labels(), props()); + Neo4jNode n2 = node("n2", labels(), props()); + Neo4jNode n3 = node("n3", labels(), props()); + Neo4jGraph g = new Neo4jGraph(Arrays.asList(n1, n2, n3), + Arrays.asList(rel("e1", "KNOWS", "n1", "n2"), rel("e2", "KNOWS", "n2", "n3"))); + assertTrue(calculator.computeHeuristic(q, g).isTrue()); + } + + @Test + void testVariableLengthTwoHopNotSatisfiedWithSingleHop() throws CypherParserException { + MatchOperation q = parser.parse("MATCH (a)-[:KNOWS*2]->(b) RETURN b"); + // Only a single hop exists: the two-edge expansion cannot be structurally matched. + Neo4jNode n1 = node("n1", labels(), props()); + Neo4jNode n2 = node("n2", labels(), props()); + Neo4jGraph g = new Neo4jGraph(Arrays.asList(n1, n2), + Collections.singletonList(rel("e1", "KNOWS", "n1", "n2"))); + assertFalse(calculator.computeHeuristic(q, g).isTrue()); + } + + @Test + void testChainedZeroLengthMergesResolveTransitively() throws CypherParserException { + // (a)-[*0..2]->(b)-[*0..2]->(c): both zero-length lower bounds identify a, b and c as one node, + // so the condition on c must follow the chain to a, not stop at the removed intermediate b. + MatchOperation q = parser.parse("MATCH (a)-[*0..2]->(b)-[*0..2]->(c) WHERE c.age > 5 RETURN a"); + Neo4jPatternExpander.ExpandedQuery expanded = new Neo4jPatternExpander().expand(q); + assertEquals(1, expanded.pattern.nodeCount()); + assertEquals(0, expanded.pattern.edgeCount()); + for (CypherCondition c : expanded.conditions) { + assertFalse(ConditionRenamer.referencesVariable(c, "b")); + assertFalse(ConditionRenamer.referencesVariable(c, "c")); + } + } + + // Quantified path patterns (QPPs) expanded to lower bound, spliced onto their entry/exit variables + + @Test + void testQuantifiedPathLowerBoundOneSplicesOntoOuterNodes() throws CypherParserException { + MatchOperation q = parser.parse("MATCH (a) ((x)-[:KNOWS]->(y)){1,3} (b) RETURN a"); + Neo4jPatternExpander.ExpandedQuery expanded = new Neo4jPatternExpander().expand(q); + // Lower bound 1: a single edge directly between the outer nodes, no fresh intermediates. + assertEquals(2, expanded.pattern.nodeCount()); + assertEquals(1, expanded.pattern.edgeCount()); + PatternEdge edge = expanded.pattern.getEdges().get(0); + assertEquals("a", edge.getSourceVariable()); + assertEquals("b", edge.getTargetVariable()); + assertEquals(1, expanded.conditions.stream().filter(TypeCondition.class::isInstance).count()); + } + + @Test + void testQuantifiedPathLowerBoundThreeExpandsToChain() throws CypherParserException { + MatchOperation q = parser.parse("MATCH (a) ((x)-[:KNOWS]->(y)){3} (b) RETURN a"); + Neo4jPatternExpander.ExpandedQuery expanded = new Neo4jPatternExpander().expand(q); + // {3} -> three hops chained through 2 fresh intermediate nodes: a, b, plus the 2 fresh ones. + assertEquals(4, expanded.pattern.nodeCount()); + assertEquals(3, expanded.pattern.edgeCount()); + // The KNOWS type condition is cloned onto every hop. + assertEquals(3, expanded.conditions.stream().filter(TypeCondition.class::isInstance).count()); + } + + @Test + void testQuantifiedPathTwoRepsSatisfied() throws CypherParserException { + MatchOperation q = parser.parse("MATCH (a) ((x)-[:KNOWS]->(y)){2} (b) RETURN a"); + // n1 -KNOWS-> n2 -KNOWS-> n3 : a two-hop KNOWS path exists. + Neo4jNode n1 = node("n1", labels(), props()); + Neo4jNode n2 = node("n2", labels(), props()); + Neo4jNode n3 = node("n3", labels(), props()); + Neo4jGraph g = new Neo4jGraph(Arrays.asList(n1, n2, n3), + Arrays.asList(rel("e1", "KNOWS", "n1", "n2"), rel("e2", "KNOWS", "n2", "n3"))); + assertTrue(calculator.computeHeuristic(q, g).isTrue()); + } + + @Test + void testQuantifiedPathTwoRepsNotSatisfiedWithSingleHop() throws CypherParserException { + MatchOperation q = parser.parse("MATCH (a) ((x)-[:KNOWS]->(y)){2} (b) RETURN a"); + // Only a single hop exists: the two-edge expansion cannot be structurally matched. + Neo4jNode n1 = node("n1", labels(), props()); + Neo4jNode n2 = node("n2", labels(), props()); + Neo4jGraph g = new Neo4jGraph(Arrays.asList(n1, n2), + Collections.singletonList(rel("e1", "KNOWS", "n1", "n2"))); + assertFalse(calculator.computeHeuristic(q, g).isTrue()); + } + + @Test + void testQuantifiedPathZeroLengthMergesResolveTransitively() throws CypherParserException { + // Two {0,1} QPPs chained through "b": both collapse, and "b"/"c" must resolve to the same + // root as the plain zero-length-edge case, so the WHERE on "c" ends up bound to "a". + MatchOperation q = parser.parse( + "MATCH (a) ((x)-[:R]->(y)){0,1} (b) ((p)-[:S]->(q)){0,1} (c) WHERE c.age > 5 RETURN a"); + Neo4jPatternExpander.ExpandedQuery expanded = new Neo4jPatternExpander().expand(q); + assertEquals(1, expanded.pattern.nodeCount()); + assertEquals(0, expanded.pattern.edgeCount()); + + ComparisonCondition where = (ComparisonCondition) expanded.conditions.stream() + .filter(ComparisonCondition.class::isInstance).findFirst() + .orElseThrow(() -> new AssertionError("expected the WHERE condition to survive expansion")); + assertEquals("a", ((PropertyOperand) where.getLeft()).getVariableName()); + } + + @Test + void testCyclicQuantifiedSubPatternExitIsTheReusedVariableNotTheLastNewOne() throws CypherParserException { + // The sub-pattern cycles back onto "a" (its own entry variable), so its true exit is "a" — + // not "b", the last *newly introduced* name. The outer exit ("end") must merge into "a"/"start" + // transitively, the same way a zero-length QPP's entry and exit merge. + MatchOperation q = parser.parse("MATCH (start) ((a)-[:R]->(b)-[:S]->(a)){1} (end) RETURN start"); + Neo4jPatternExpander.ExpandedQuery expanded = new Neo4jPatternExpander().expand(q); + // start/end collapse to one node (the cycle's shared endpoint) plus the one fresh "b" clone. + assertEquals(2, expanded.pattern.nodeCount()); + assertEquals(2, expanded.pattern.edgeCount()); + for (PatternEdge e : expanded.pattern.getEdges()) { + assertFalse("end".equals(e.getSourceVariable()) || "end".equals(e.getTargetVariable())); + } + + Neo4jNode n1 = node("n1", labels(), props()); + Neo4jNode n2 = node("n2", labels(), props()); + Neo4jGraph closesTheCycle = new Neo4jGraph(Arrays.asList(n1, n2), + Arrays.asList(rel("e1", "R", "n1", "n2"), rel("e2", "S", "n2", "n1"))); + assertTrue(calculator.computeHeuristic(q, closesTheCycle).isTrue()); + + Neo4jNode n3 = node("n3", labels(), props()); + Neo4jGraph doesNotClose = new Neo4jGraph(Arrays.asList(n1, n2, n3), + Arrays.asList(rel("e1", "R", "n1", "n2"), rel("e2", "S", "n2", "n3"))); + assertFalse(calculator.computeHeuristic(q, doesNotClose).isTrue()); + } + + @Test + void testBareNodeSubPatternMergesOuterEndpoints() throws CypherParserException { + // A QPP with no relationship at all: repeating a single node adds no structure, so it just + // identifies the outer entry and exit as the same node, however many times it repeats. + MatchOperation q = parser.parse("MATCH (start) ((a)){2,3} (end) RETURN start"); + Neo4jPatternExpander.ExpandedQuery expanded = new Neo4jPatternExpander().expand(q); + assertEquals(1, expanded.pattern.nodeCount()); + assertEquals(0, expanded.pattern.edgeCount()); + + Neo4jNode n1 = node("n1", labels(), props()); + assertTrue(calculator.computeHeuristic(q, new Neo4jGraph(Collections.singletonList(n1), + Collections.emptyList())).isTrue()); + assertFalse(calculator.computeHeuristic(q, new Neo4jGraph(Collections.emptyList(), + Collections.emptyList())).isTrue()); + } + + @Test + void testQuantifiedPathAsSolePatternMatchesSelfContained() throws CypherParserException { + // No outer nodes at all: the QPP's own variables are used as-is, chained on themselves. + MatchOperation q = parser.parse("MATCH ((x)-[:KNOWS]->(y)){2} RETURN x"); + Neo4jNode n1 = node("n1", labels(), props()); + Neo4jNode n2 = node("n2", labels(), props()); + Neo4jNode n3 = node("n3", labels(), props()); + Neo4jGraph satisfied = new Neo4jGraph(Arrays.asList(n1, n2, n3), + Arrays.asList(rel("e1", "KNOWS", "n1", "n2"), rel("e2", "KNOWS", "n2", "n3"))); + assertTrue(calculator.computeHeuristic(q, satisfied).isTrue()); + + Neo4jGraph notSatisfied = new Neo4jGraph(Arrays.asList(n1, n2), + Collections.singletonList(rel("e1", "KNOWS", "n1", "n2"))); + assertFalse(calculator.computeHeuristic(q, notSatisfied).isTrue()); + } + + @Test + void testAdjacentQuantifiedPathsShareExpandedBoundary() throws CypherParserException { + // Two QPPs with nothing named between them: the synthesized shared boundary must be a real + // graph-node continuity constraint, not two independent, unrelated hops. + MatchOperation q = parser.parse("MATCH ((a)-[:LIKES]->(b))+ ((c)-[:KNOWS]->(d))+ RETURN a, d"); + + Neo4jNode n1 = node("n1", labels(), props()); + Neo4jNode n2 = node("n2", labels(), props()); + Neo4jNode n3 = node("n3", labels(), props()); + Neo4jGraph chained = new Neo4jGraph(Arrays.asList(n1, n2, n3), + Arrays.asList(rel("e1", "LIKES", "n1", "n2"), rel("e2", "KNOWS", "n2", "n3"))); + assertTrue(calculator.computeHeuristic(q, chained).isTrue()); + + // Same two edges exist, but KNOWS starts from a different node than LIKES ends at: broken chain. + Neo4jNode n4 = node("n4", labels(), props()); + Neo4jGraph broken = new Neo4jGraph(Arrays.asList(n1, n2, n3, n4), + Arrays.asList(rel("e1", "LIKES", "n1", "n2"), rel("e2", "KNOWS", "n4", "n3"))); + assertFalse(calculator.computeHeuristic(q, broken).isTrue()); + } + + @Test + void testVariableLengthEdgeInsideQuantifiedPathAlsoExpanded() throws CypherParserException { + // A QPP wrapping a single repetition whose own inner edge is variable-length: QPP expansion + // clones the *2 edge unchanged, and the existing variable-length-edge pass expands it after. + MatchOperation q = parser.parse("MATCH (a) ((x)-[:KNOWS*2]->(y)){1} (b) RETURN a"); + Neo4jPatternExpander.ExpandedQuery expanded = new Neo4jPatternExpander().expand(q); + assertEquals(3, expanded.pattern.nodeCount()); + assertEquals(2, expanded.pattern.edgeCount()); + + Neo4jNode n1 = node("n1", labels(), props()); + Neo4jNode n2 = node("n2", labels(), props()); + Neo4jNode n3 = node("n3", labels(), props()); + Neo4jGraph g = new Neo4jGraph(Arrays.asList(n1, n2, n3), + Arrays.asList(rel("e1", "KNOWS", "n1", "n2"), rel("e2", "KNOWS", "n2", "n3"))); + assertTrue(calculator.computeHeuristic(q, g).isTrue()); + } + + // Unit checks of the building blocks + + @Test + void testHMatchNodesCountBased() { + // enough nodes → TRUE + assertTrue(calculator.hMatchNodes(2, 4).isTrue()); + assertTrue(calculator.hMatchNodes(2, 2).isTrue()); + // no nodes in graph → FALSE + assertEquals(1.0, calculator.hMatchNodes(2, 0).getOfFalse(), 1e-9); + assertFalse(calculator.hMatchNodes(2, 0).isTrue()); + // no nodes required → TRUE + assertTrue(calculator.hMatchNodes(0, 0).isTrue()); + // partial availability → scaled in (C, 1) + Truthness partial = calculator.hMatchNodes(4, 2); + assertEquals(1.0, partial.getOfFalse(), 1e-9); + assertEquals(Neo4jHeuristicsCalculator.C + 0.9 * (2.0 / 4.0), partial.getOfTrue(), 1e-9); + } + + @Test + void testStringEqualityTruthness() { + assertTrue(Neo4jConditionEvaluator.stringEqualityTruthness("KNOWS", "KNOWS").isTrue()); + Truthness diff = Neo4jConditionEvaluator.stringEqualityTruthness("KNOWS", "LIKES"); + assertEquals(1.0, diff.getOfFalse(), 1e-9); + assertTrue(diff.getOfTrue() < 1.0); + } + + @Test + void testLabelInSet() { + assertTrue(Neo4jConditionEvaluator.labelInSet("Person", labels("Person")).isTrue()); + assertEquals(1.0, + Neo4jConditionEvaluator.labelInSet("Person", labels()).getOfFalse(), 1e-9); + assertFalse(Neo4jConditionEvaluator.labelInSet("Person", labels()).isTrue()); + // present-but-different label: scaled, never reaches 1 + Truthness t = Neo4jConditionEvaluator.labelInSet("Person", labels("Animal")); + assertEquals(1.0, t.getOfFalse(), 1e-9); + assertTrue(t.getOfTrue() >= Neo4jHeuristicsCalculator.C && t.getOfTrue() < 1.0); + } + + @Test + void testStartsWith() { + assertTrue(Neo4jConditionEvaluator.getStartsWith("hello", "hel").isTrue()); + Truthness t = Neo4jConditionEvaluator.getStartsWith("hello", "xyz"); + assertEquals(1.0, t.getOfFalse(), 1e-9); + assertTrue(t.getOfTrue() >= Neo4jHeuristicsCalculator.C && t.getOfTrue() < 1.0); + } + + // helpers + + private static Neo4jNode node(String id, Set labels, Map props) { + return new Neo4jNode(id, labels, props); + } + + private static Neo4jRelationship rel(String id, String type, String from, String to) { + return new Neo4jRelationship(id, type, from, to, Collections.emptyMap()); + } + + private static Set labels(String... ls) { + return new LinkedHashSet<>(Arrays.asList(ls)); + } + + private static Map props(Object... kv) { + Map m = new LinkedHashMap<>(); + for (int i = 0; i < kv.length; i += 2) { + m.put((String) kv[i], kv[i + 1]); + } + return m; + } +} 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 43eeb7c4c1..1e117d7d32 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 @@ -474,6 +474,62 @@ void testQuantifiedPathMixedWithPlainElements() { assertEquals(4, op.getPattern().nodeCount()); // start, a, b, end assertEquals(2, op.getPattern().edgeCount()); // the two plain edges assertEquals(1, op.getPattern().quantifiedPathCount()); + + QuantifiedPathPattern qpp = op.getPattern().getQuantifiedPaths().get(0); + assertEquals("a", qpp.getEntryVariable()); + assertEquals("b", qpp.getExitVariable()); + } + + @Test + void testQuantifiedPathAtPatternStart() { + MatchOperation op = parse("MATCH ((a)-[:KNOWS]->(b))+ (c) RETURN a"); + QuantifiedPathPattern qpp = op.getPattern().getQuantifiedPaths().get(0); + assertNull(qpp.getEntryVariable()); + assertEquals("c", qpp.getExitVariable()); + } + + @Test + void testQuantifiedPathAtPatternEnd() { + MatchOperation op = parse("MATCH (c) ((a)-[:KNOWS]->(b))+ RETURN a"); + QuantifiedPathPattern qpp = op.getPattern().getQuantifiedPaths().get(0); + assertEquals("c", qpp.getEntryVariable()); + assertNull(qpp.getExitVariable()); + } + + @Test + void testQuantifiedPathAsWholePatternHasNoBoundary() { + QuantifiedPathPattern qpp = parse("MATCH ((a)-[:KNOWS]->(b))+ RETURN a") + .getPattern().getQuantifiedPaths().get(0); + assertNull(qpp.getEntryVariable()); + assertNull(qpp.getExitVariable()); + } + + @Test + void testAdjacentQuantifiedPathsShareSynthesizedBoundary() { + MatchOperation op = parse("MATCH ((a)-[:LIKES]->(b))+ ((c)-[:KNOWS]->(d))+ RETURN a, d"); + assertEquals(2, op.getPattern().quantifiedPathCount()); + + QuantifiedPathPattern qpp1 = op.getPattern().getQuantifiedPaths().get(0); + QuantifiedPathPattern qpp2 = op.getPattern().getQuantifiedPaths().get(1); + assertNull(qpp1.getEntryVariable()); + assertNotNull(qpp1.getExitVariable()); + assertEquals(qpp1.getExitVariable(), qpp2.getEntryVariable()); + assertNull(qpp2.getExitVariable()); + } + + @Test + void testThreeAdjacentQuantifiedPathsChainBoundaries() { + MatchOperation op = parse("MATCH ((a)-[:R]->(b))+ ((c)-[:S]->(d))+ ((e)-[:T]->(f))+ RETURN a"); + QuantifiedPathPattern qpp1 = op.getPattern().getQuantifiedPaths().get(0); + QuantifiedPathPattern qpp2 = op.getPattern().getQuantifiedPaths().get(1); + QuantifiedPathPattern qpp3 = op.getPattern().getQuantifiedPaths().get(2); + + assertNull(qpp1.getEntryVariable()); + assertEquals(qpp1.getExitVariable(), qpp2.getEntryVariable()); + assertEquals(qpp2.getExitVariable(), qpp3.getEntryVariable()); + assertNull(qpp3.getExitVariable()); + + assertNotEquals(qpp1.getExitVariable(), qpp2.getExitVariable()); } @Test @@ -485,12 +541,17 @@ void testQuantifiedPathNested() { assertEquals(1, outer.getMin()); assertEquals(3, outer.getMax().intValue()); assertEquals(1, outer.getSubPattern().quantifiedPathCount()); + assertNull(outer.getEntryVariable()); + assertNull(outer.getExitVariable()); 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()); + + assertNull(inner.getEntryVariable()); + assertNull(inner.getExitVariable()); } @Test diff --git a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt index ed830a3220..2e9e315b5d 100644 --- a/core/src/main/kotlin/org/evomaster/core/EMConfig.kt +++ b/core/src/main/kotlin/org/evomaster/core/EMConfig.kt @@ -1941,6 +1941,11 @@ class EMConfig { @DependsOnFalseFor("blackBox") var heuristicsForRedis = false + @Experimental + @Cfg("Tracking of Neo4j commands to improve test generation") + @DependsOnFalseFor("blackBox") + var heuristicsForNeo4j = false + @Cfg("Enable extracting SQL execution info") @DependsOnFalseFor("blackBox") var extractSqlExecutionInfo = true diff --git a/core/src/main/kotlin/org/evomaster/core/problem/enterprise/service/EnterpriseFitness.kt b/core/src/main/kotlin/org/evomaster/core/problem/enterprise/service/EnterpriseFitness.kt index 93fd33c7c7..0d9ce2b759 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/enterprise/service/EnterpriseFitness.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/enterprise/service/EnterpriseFitness.kt @@ -378,6 +378,10 @@ abstract class EnterpriseFitness : FitnessFunction() where T : Individual handleRedisHeuristics(dto, fv) } + if (configuration.heuristicsForNeo4j) { + handleNeo4jHeuristics(dto, fv) + } + if (configuration.extractRedisExecutionInfo) { for (i in 0 until dto.extraHeuristics.size) { val extra = dto.extraHeuristics[i] @@ -493,4 +497,37 @@ abstract class EnterpriseFitness : FitnessFunction() where T : Individual } } } + + private fun handleNeo4jHeuristics(dto: TestResultsDto, fv: FitnessValue) { + for (i in 0 until dto.extraHeuristics.size) { + + val extra = dto.extraHeuristics[i] + + extraHeuristicsLogger.writeHeuristics(extra.heuristics, i) + + val toMinimize = extra.heuristics + .filter { + it != null + && it.objective == ExtraHeuristicEntryDto.Objective.MINIMIZE_TO_ZERO + && it.type == ExtraHeuristicEntryDto.Type.NEO4J + }.map { it.value } + .toList() + + if (toMinimize.isNotEmpty()) { + fv.setExtraToMinimize(i, toMinimize) + } + + extra.heuristics + .filterNotNull().forEach { + if (it.type == ExtraHeuristicEntryDto.Type.NEO4J) { + statistics.reportNumberOfEvaluatedNodesForNeo4jHeuristic(it.numberOfEvaluatedRecords) + if (it.extraHeuristicEvaluationFailure) { + statistics.reportNeo4jHeuristicEvaluationFailure() + } else { + statistics.reportNeo4jHeuristicEvaluationSuccess() + } + } + } + } + } } diff --git a/core/src/main/kotlin/org/evomaster/core/search/service/Statistics.kt b/core/src/main/kotlin/org/evomaster/core/search/service/Statistics.kt index 94da2c063a..874fb69037 100644 --- a/core/src/main/kotlin/org/evomaster/core/search/service/Statistics.kt +++ b/core/src/main/kotlin/org/evomaster/core/search/service/Statistics.kt @@ -96,6 +96,11 @@ class Statistics : SearchListener { private var redisHeuristicEvaluationFailureCount = 0 private val redisDocumentsAverageCalculator = IncrementalAverage() + // neo4j heuristic evaluation statistic + private var neo4jHeuristicEvaluationSuccessCount = 0 + private var neo4jHeuristicEvaluationFailureCount = 0 + private val neo4jNodesAverageCalculator = IncrementalAverage() + class Pair(val header: String, val element: String) @@ -192,6 +197,10 @@ class Statistics : SearchListener { redisDocumentsAverageCalculator.addValue(numberOfEvaluatedDocuments) } + fun reportNumberOfEvaluatedNodesForNeo4jHeuristic(numberOfEvaluatedNodes: Int) { + neo4jNodesAverageCalculator.addValue(numberOfEvaluatedNodes) + } + fun reportSqlParsingFailures(numberOfParsingFailures: Int) { if (numberOfParsingFailures<0) { throw IllegalArgumentException("Invalid number of parsing failures: $numberOfParsingFailures") @@ -223,6 +232,14 @@ class Statistics : SearchListener { redisHeuristicEvaluationFailureCount++ } + fun reportNeo4jHeuristicEvaluationSuccess() { + neo4jHeuristicEvaluationSuccessCount++ + } + + fun reportNeo4jHeuristicEvaluationFailure() { + neo4jHeuristicEvaluationFailureCount++ + } + fun getMongoHeuristicsEvaluationCount(): Int = mongoHeuristicEvaluationSuccessCount + mongoHeuristicEvaluationFailureCount fun getSqlHeuristicsEvaluationCount(): Int = sqlHeuristicEvaluationSuccessCount + sqlHeuristicEvaluationFailureCount @@ -235,6 +252,10 @@ class Statistics : SearchListener { fun averageNumberOfEvaluatedDocumentsForRedisHeuristics(): Double = redisDocumentsAverageCalculator.mean + fun getNeo4jHeuristicsEvaluationCount(): Int = neo4jHeuristicEvaluationSuccessCount + neo4jHeuristicEvaluationFailureCount + + fun averageNumberOfEvaluatedNodesForNeo4jHeuristics(): Double = neo4jNodesAverageCalculator.mean + override fun newActionsEvaluated(n: Int) { if(!epc.isInSearch()){ @@ -381,6 +402,10 @@ class Statistics : SearchListener { add(Pair("sqlHeuristicsEvaluationFailures","$sqlHeuristicEvaluationFailureCount" )) add(Pair("sqlHeuristicsEvaluationCount","${getSqlHeuristicsEvaluationCount()}")) + // statistics info for Neo4j Heuristics + add(Pair("averageNumberOfEvaluatedNodesForNeo4jHeuristics","${averageNumberOfEvaluatedNodesForNeo4jHeuristics()}")) + add(Pair("neo4jHeuristicsEvaluationCount","${getNeo4jHeuristicsEvaluationCount()}")) + for(phase in ExecutionPhaseController.Phase.entries){ add(Pair("phase_${phase.name}", "${epc.getPhaseDurationInSeconds(phase)}")) } diff --git a/core/src/test/kotlin/org/evomaster/core/search/service/StatisticsNeo4jTest.kt b/core/src/test/kotlin/org/evomaster/core/search/service/StatisticsNeo4jTest.kt new file mode 100644 index 0000000000..12abc78188 --- /dev/null +++ b/core/src/test/kotlin/org/evomaster/core/search/service/StatisticsNeo4jTest.kt @@ -0,0 +1,22 @@ +package org.evomaster.core.search.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class StatisticsNeo4jTest { + + @Test + fun testNeo4jHeuristicsAverage() { + val statistics = Statistics() + statistics.reportNumberOfEvaluatedNodesForNeo4jHeuristic(10) + statistics.reportNumberOfEvaluatedNodesForNeo4jHeuristic(20) + statistics.reportNumberOfEvaluatedNodesForNeo4jHeuristic(30) + + repeat(3) { + statistics.reportNeo4jHeuristicEvaluationSuccess() + } + + assertEquals(3, statistics.getNeo4jHeuristicsEvaluationCount()) + assertEquals((10 + 20 + 30).toDouble() / 3, statistics.averageNumberOfEvaluatedNodesForNeo4jHeuristics()) + } +} diff --git a/docs/options.md b/docs/options.md index 39190eeb64..a6bb782f06 100644 --- a/docs/options.md +++ b/docs/options.md @@ -302,6 +302,7 @@ There are 3 types of options: |`generateRedisData`| __Boolean__. Enable EvoMaster to generate Redis data with direct accesses to the database. *Depends on*: `blackBox=false`. *Default value*: `false`.| |`generateSqlDataWithDSE`| __Boolean__. Enable EvoMaster to generate SQL data with direct accesses to the database. Use Dynamic Symbolic Execution. *Depends on*: `blackBox=false`. *Default value*: `false`.| |`handleFlakiness`| __Boolean__. Specify whether to detect flakiness and handle the flakiness in assertions during post handling of fuzzing. Note that flakiness is now supported only for fuzzing REST APIs. *Default value*: `false`.| +|`heuristicsForNeo4j`| __Boolean__. Tracking of Neo4j commands to improve test generation. *Depends on*: `blackBox=false`. *Default value*: `false`.| |`heuristicsForRedis`| __Boolean__. Tracking of Redis commands to improve test generation. *Depends on*: `blackBox=false`. *Default value*: `false`.| |`heuristicsForSQLAdvanced`| __Boolean__. If using SQL heuristics, enable more advanced version. *Depends on*: `blackBox=false`. *Default value*: `false`.| |`httpOracles`| __Boolean__. Extra checks on HTTP properties in returned responses, used as automated oracles to detect faults. *Default value*: `false`.|