From 53c886f7be7312c5cf90a1ae4299f8b3a736e204 Mon Sep 17 00:00:00 2001 From: Andres Felder <81707831+andyfelder16@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:23:43 -0300 Subject: [PATCH] insert Neo4j nodes with every property of their entity class --- .../java/controller/api/dto/UnitsInfoDto.java | 7 + .../dto/database/neo4j/Neo4jEntityDto.java | 37 +++ .../neo4j/Neo4jEntityPropertyDto.java | 52 ++++ .../neo4j/Neo4jEntityRelationshipDto.java | 28 ++ .../controller/internal/SutController.java | 49 ++++ client-java/instrumentation/pom.xml | 6 + .../java/instrumentation/ClassAnalyzer.java | 45 +++- .../java/instrumentation/Neo4jEntity.java | 63 +++++ .../instrumentation/Neo4jEntityAnalyzer.java | 249 ++++++++++++++++++ .../instrumentation/Neo4jEntityProperty.java | 90 +++++++ .../Neo4jEntityRelationship.java | 47 ++++ .../staticstate/UnitsInfoRecorder.java | 42 +++ .../examples/neo4j/AdopterNode.java | 40 +++ .../examples/neo4j/AuditedNode.java | 6 + .../examples/neo4j/NotAnEntity.java | 6 + .../examples/neo4j/PlayedRelationship.java | 13 + .../examples/neo4j/PlayerNode.java | 25 ++ .../examples/neo4j/UserNode.java | 15 ++ .../Neo4jEntityAnalyzerTest.java | 121 +++++++++ core-tests/e2e-tests/spring/pom.xml | 5 + .../spring/spring-rest-neo4j/pom.xml | 4 + .../findbyname/Neo4jEntityFindByNameApp.java | 23 ++ .../findbyname/Neo4jEntityFindByNameRest.java | 40 +++ .../foo/neo4j/entity/findbyname/Person.java | 55 ++++ .../Neo4jEntityFindByNameController.java | 16 ++ .../Neo4jEntityFindByNameEMTest.java | 84 ++++++ .../core/database/neo4j/Neo4jDbAction.kt | 41 ++- .../core/database/neo4j/Neo4jInsertBuilder.kt | 34 ++- .../core/database/neo4j/Neo4jSchema.kt | 34 +++ .../api/service/ApiWsStructureMutator.kt | 9 +- .../asyncapi/service/AsyncApiSampler.kt | 1 + .../enterprise/service/EnterpriseSampler.kt | 12 + .../problem/graphql/service/GraphQLSampler.kt | 1 + .../service/sampler/AbstractRestSampler.kt | 1 + .../core/problem/rpc/service/RPCSampler.kt | 1 + .../problem/webfrontend/service/WebSampler.kt | 1 + .../core/database/neo4j/Neo4jDbActionTest.kt | 19 ++ .../database/neo4j/Neo4jInsertBuilderTest.kt | 88 +++++++ 38 files changed, 1393 insertions(+), 17 deletions(-) create mode 100644 client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityDto.java create mode 100644 client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityPropertyDto.java create mode 100644 client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityRelationshipDto.java create mode 100644 client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntity.java create mode 100644 client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityAnalyzer.java create mode 100644 client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityProperty.java create mode 100644 client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityRelationship.java create mode 100644 client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/AdopterNode.java create mode 100644 client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/AuditedNode.java create mode 100644 client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/NotAnEntity.java create mode 100644 client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/PlayedRelationship.java create mode 100644 client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/PlayerNode.java create mode 100644 client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/UserNode.java create mode 100644 client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/Neo4jEntityAnalyzerTest.java create mode 100644 core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Neo4jEntityFindByNameApp.java create mode 100644 core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Neo4jEntityFindByNameRest.java create mode 100644 core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Person.java create mode 100644 core-tests/e2e-tests/spring/spring-rest-neo4j/src/test/java/com/foo/spring/rest/neo4j/entity/findbyname/Neo4jEntityFindByNameController.java create mode 100644 core-tests/e2e-tests/spring/spring-rest-neo4j/src/test/java/org/evomaster/e2etests/spring/rest/neo4j/entity/findbyname/Neo4jEntityFindByNameEMTest.java create mode 100644 core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jSchema.kt diff --git a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/UnitsInfoDto.java b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/UnitsInfoDto.java index af8e116ad9..ee6895a543 100644 --- a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/UnitsInfoDto.java +++ b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/UnitsInfoDto.java @@ -1,8 +1,10 @@ package org.evomaster.client.java.controller.api.dto; import org.evomaster.client.java.controller.api.dto.database.schema.ExtraConstraintsDto; +import org.evomaster.client.java.controller.api.dto.database.neo4j.Neo4jEntityDto; import java.util.HashSet; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; @@ -80,4 +82,9 @@ public class UnitsInfoDto { * Extra information extracted for example from JPA entities */ public List extraDatabaseConstraintsDtos; + + /** + * Neo4j entities found in the SUT, one per class mapped to nodes. Empty if the SUT has none. + */ + public List neo4jEntities = new ArrayList<>(); } diff --git a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityDto.java b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityDto.java new file mode 100644 index 0000000000..a2b2aaef08 --- /dev/null +++ b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityDto.java @@ -0,0 +1,37 @@ +package org.evomaster.client.java.controller.api.dto.database.neo4j; + +import java.util.ArrayList; +import java.util.List; + +/** + * The shape of the nodes of one Neo4j entity of the SUT: its labels, properties and relationships. + * Neo4j has no schema, so this is read from the SUT's mapping classes. + */ +public class Neo4jEntityDto { + + /** + * Fully qualified name of the entity class in the SUT. + */ + public String className; + + /** + * Labels of the nodes of this entity, the primary one first. + */ + public List labels = new ArrayList<>(); + + /** + * Properties of the nodes, in declaration order. + */ + public List properties = new ArrayList<>(); + + /** + * Relationships towards other entities, in declaration order. + */ + public List relationships = new ArrayList<>(); + + /** + * Default constructor + */ + public Neo4jEntityDto() { + } +} diff --git a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityPropertyDto.java b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityPropertyDto.java new file mode 100644 index 0000000000..c461e9ca04 --- /dev/null +++ b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityPropertyDto.java @@ -0,0 +1,52 @@ +package org.evomaster.client.java.controller.api.dto.database.neo4j; + +import org.evomaster.client.java.controller.api.dto.database.operations.Neo4jPropertyTypeDto; + +/** + * A property of the nodes of a Neo4j entity, as declared in the SUT's mapping of that entity. + */ +public class Neo4jEntityPropertyDto { + + /** + * Name of the property in the graph. + */ + public String name; + + /** + * Type the property is stored as. + */ + public Neo4jPropertyTypeDto type; + + /** + * Whether the property identifies the node. + */ + public Boolean isId; + + /** + * Whether the value is generated by the database or the mapping, so the SUT never assigns it. + */ + public Boolean isGenerated; + + /** + * Smallest value the property can hold when the SUT reads it back, if its Java type is narrower + * than the graph's integer; null otherwise. + */ + public Long minValue; + + /** + * Largest value the property can hold when the SUT reads it back, if its Java type is narrower + * than the graph's integer; null otherwise. + */ + public Long maxValue; + + /** + * The only values the property accepts, when it is backed by an enum; empty otherwise. + */ + public java.util.List enumValues = new java.util.ArrayList<>(); + + /** + * Default constructor + */ + public Neo4jEntityPropertyDto() { + } +} diff --git a/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityRelationshipDto.java b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityRelationshipDto.java new file mode 100644 index 0000000000..1c5ae1bc99 --- /dev/null +++ b/client-java/controller-api/src/main/java/org/evomaster/client/java/controller/api/dto/database/neo4j/Neo4jEntityRelationshipDto.java @@ -0,0 +1,28 @@ +package org.evomaster.client.java.controller.api.dto.database.neo4j; + +/** + * A relationship declared in a Neo4j entity, towards another entity. + */ +public class Neo4jEntityRelationshipDto { + + /** + * Relationship type in the graph. + */ + public String type; + + /** + * Labels of the entity at the other end, its primary label first. + */ + public java.util.List targetLabels = new java.util.ArrayList<>(); + + /** + * Whether the relationship starts from this entity (outgoing) or arrives to it. + */ + public Boolean isOutgoing; + + /** + * Default constructor + */ + public Neo4jEntityRelationshipDto() { + } +} 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 c753cf32f7..9f678f030d 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 @@ -75,6 +75,13 @@ import java.net.InetSocketAddress; import java.sql.Connection; import java.sql.SQLException; +import org.evomaster.client.java.controller.api.dto.database.neo4j.Neo4jEntityDto; +import org.evomaster.client.java.controller.api.dto.database.neo4j.Neo4jEntityPropertyDto; +import org.evomaster.client.java.controller.api.dto.database.neo4j.Neo4jEntityRelationshipDto; +import org.evomaster.client.java.controller.api.dto.database.operations.Neo4jPropertyTypeDto; +import org.evomaster.client.java.instrumentation.Neo4jEntity; +import org.evomaster.client.java.instrumentation.Neo4jEntityProperty; +import org.evomaster.client.java.instrumentation.Neo4jEntityRelationship; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -1975,10 +1982,52 @@ protected UnitsInfoDto getUnitsInfoDto(UnitsInfoRecorder recorder){ jpa.constraints = ec; return jpa; }).collect(Collectors.toList()); + dto.neo4jEntities = toNeo4jEntityDtos(recorder.getNeo4jEntities()); return dto; } + /** + * Relationships point to a class in the recorder, and to the labels of that class in the DTO, as the + * core only ever sees labels. + */ + private static List toNeo4jEntityDtos(List entities) { + Map> labelsByClass = new HashMap<>(); + for (Neo4jEntity e : entities) { + labelsByClass.put(e.getClassName(), e.getLabels()); + } + List dtos = new ArrayList<>(); + for (Neo4jEntity e : entities) { + Neo4jEntityDto dto = new Neo4jEntityDto(); + dto.className = e.getClassName(); + dto.labels = new ArrayList<>(e.getLabels()); + for (Neo4jEntityProperty p : e.getProperties()) { + Neo4jEntityPropertyDto pd = new Neo4jEntityPropertyDto(); + pd.name = p.getName(); + pd.type = Neo4jPropertyTypeDto.valueOf(p.getType()); + pd.isId = p.isId(); + pd.isGenerated = p.isGenerated(); + pd.minValue = p.getMinValue(); + pd.maxValue = p.getMaxValue(); + pd.enumValues = new ArrayList<>(p.getEnumValues()); + dto.properties.add(pd); + } + for (Neo4jEntityRelationship r : e.getRelationships()) { + List target = labelsByClass.get(r.getTargetClassName()); + if (target == null) { + continue; + } + Neo4jEntityRelationshipDto rd = new Neo4jEntityRelationshipDto(); + rd.type = r.getType(); + rd.targetLabels = new ArrayList<>(target); + rd.isOutgoing = r.isOutgoing(); + dto.relationships.add(rd); + } + dtos.add(dto); + } + return dtos; + } + @Override public Object getRPCClient(String interfaceName) { if (!(getProblemInfo() instanceof RPCProblem)) diff --git a/client-java/instrumentation/pom.xml b/client-java/instrumentation/pom.xml index 7182119e4c..b8d800fb0c 100644 --- a/client-java/instrumentation/pom.xml +++ b/client-java/instrumentation/pom.xml @@ -136,6 +136,12 @@ 2.2 test + + org.springframework.data + spring-data-neo4j + 6.1.4 + test + javax.validation validation-api diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/ClassAnalyzer.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/ClassAnalyzer.java index 5f769697e6..e04603d390 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/ClassAnalyzer.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/ClassAnalyzer.java @@ -50,7 +50,6 @@ public static void doAnalyze(Collection classNames) { try { ClassLoader loader = UnitsInfoRecorder.getInstance().getFirstClassLoader(name); if (loader == null) { - //could happen in tests loader = ClassAnalyzer.class.getClassLoader(); SimpleLogger.warn("No class loader registered for " + name); } @@ -77,6 +76,50 @@ public static void doAnalyze(Collection classNames) { } } + /** + * Reads the Spring Data Neo4j mapping of the given classes, registering an entity for each class + * annotated with {@code @Node}. Does nothing if the SUT does not use Spring Data Neo4j. + * + * @param classNames the classes to look at + */ + public static void doAnalyzeNeo4jEntities(Collection classNames) { + + if (!classesCanBeLoaded(Neo4jEntityAnalyzer.SPRING_DATA_NEO4J_NAMES)) { + return; + } + + for (String name : classNames) { + try { + Class klass = loadClass(name); + if (klass == null) { + continue; + } + Neo4jEntity entity = Neo4jEntityAnalyzer.analyze(klass); + if (entity != null) { + UnitsInfoRecorder.registerNewNeo4jEntity(entity); + } + } catch (Exception e) { + SimpleLogger.error("Failed to analyze Neo4j mapping of " + name, e); + } + } + } + + /** + * @return the class, loaded with the loader it was first seen with, or null if it cannot be loaded + */ + private static Class loadClass(String name) { + try { + ClassLoader loader = UnitsInfoRecorder.getInstance().getFirstClassLoader(name); + if (loader == null) { + loader = ClassAnalyzer.class.getClassLoader(); + } + return loader.loadClass(name); + } catch (ClassNotFoundException e) { + SimpleLogger.error("Failed to load class " + name, e); + return null; + } + } + private static boolean canUseJavaxJPA() { boolean canUseJavaxJPA = classesCanBeLoaded(JAVAX_NAMES); if (!canUseJavaxJPA) { diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntity.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntity.java new file mode 100644 index 0000000000..1ff4d70959 --- /dev/null +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntity.java @@ -0,0 +1,63 @@ +package org.evomaster.client.java.instrumentation; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * The shape of the nodes the SUT maps to one of its entity classes: labels, properties and relationships. + * Neo4j has no schema of its own, so this is what stands in for one. + */ +public class Neo4jEntity implements Serializable { + + /** Fully qualified name of the entity class. */ + private final String className; + + /** Labels of the nodes of this entity, the primary one first. */ + private final List labels; + + /** Properties of the nodes, in declaration order. */ + private final List properties; + + /** Relationships to other entities, in declaration order. */ + private final List relationships; + + /** + * @param className entity class + * @param labels labels of its nodes, at least one + * @param properties its properties + * @param relationships its relationships to other entities + */ + public Neo4jEntity(String className, List labels, List properties, + List relationships) { + this.className = Objects.requireNonNull(className); + if (labels == null || labels.isEmpty()) { + throw new IllegalArgumentException("An entity needs at least one label: " + className); + } + this.labels = Collections.unmodifiableList(labels); + this.properties = Collections.unmodifiableList(Objects.requireNonNull(properties)); + this.relationships = Collections.unmodifiableList(Objects.requireNonNull(relationships)); + } + + public String getClassName() { + return className; + } + + public List getLabels() { + return labels; + } + + public List getProperties() { + return properties; + } + + public List getRelationships() { + return relationships; + } + + @Override + public String toString() { + return className + labels + " " + properties + " " + relationships; + } +} diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityAnalyzer.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityAnalyzer.java new file mode 100644 index 0000000000..fe8c44077e --- /dev/null +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityAnalyzer.java @@ -0,0 +1,249 @@ +package org.evomaster.client.java.instrumentation; + +import org.evomaster.client.java.utils.SimpleLogger; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +/** + * Reads the Spring Data Neo4j mapping of a class, i.e. its {@code @Node} annotation and the annotations on + * its fields, to learn which labels, properties and relationships the nodes of that entity have. + *

+ * The annotations are looked up by name, so this class has no dependency on Spring Data. + */ +public class Neo4jEntityAnalyzer { + + private static final String PACKAGE = "org.springframework.data.neo4j.core.schema."; + private static final String NODE = PACKAGE + "Node"; + private static final String ID = PACKAGE + "Id"; + private static final String GENERATED_VALUE = PACKAGE + "GeneratedValue"; + private static final String PROPERTY = PACKAGE + "Property"; + private static final String RELATIONSHIP = PACKAGE + "Relationship"; + private static final String RELATIONSHIP_PROPERTIES = PACKAGE + "RelationshipProperties"; + private static final String TARGET_NODE = PACKAGE + "TargetNode"; + private static final String TRANSIENT = "org.springframework.data.annotation.Transient"; + private static final String VERSION = "org.springframework.data.annotation.Version"; + + /** Classes that must be loadable for a SUT to be using Spring Data Neo4j. */ + public static final List SPRING_DATA_NEO4J_NAMES = Arrays.asList(NODE, RELATIONSHIP); + + /** + * Default constructor + */ + public Neo4jEntityAnalyzer() {} + + /** + * @param klass a class of the SUT + * @return the entity the class maps, or {@code null} if it is not annotated with {@code @Node} + * @throws Exception if the annotations cannot be read + */ + public static Neo4jEntity analyze(Class klass) throws Exception { + + Annotation node = getAnnotationByName(klass.getAnnotations(), NODE); + if (node == null) { + return null; + } + + List properties = new ArrayList<>(); + List relationships = new ArrayList<>(); + + for (Field f : allInstanceFields(klass)) { + Annotation[] annotations = f.getAnnotations(); + if (getAnnotationByName(annotations, TRANSIENT) != null + || getAnnotationByName(annotations, VERSION) != null) { + continue; + } + + Annotation relationship = getAnnotationByName(annotations, RELATIONSHIP); + if (relationship != null) { + Neo4jEntityRelationship r = toRelationship(f, relationship); + if (r != null) { + relationships.add(r); + } + continue; + } + + String type = graphType(f.getType()); + if (type == null) { + SimpleLogger.debug("Skipping Neo4j property of unsupported type " + f.getType().getName() + + ": " + klass.getName() + "." + f.getName()); + continue; + } + boolean id = getAnnotationByName(annotations, ID) != null; + boolean generated = getAnnotationByName(annotations, GENERATED_VALUE) != null; + properties.add(new Neo4jEntityProperty(propertyName(f, annotations), type, id, generated, + minValue(f.getType()), maxValue(f.getType()), enumConstants(f.getType()))); + } + + return new Neo4jEntity(klass.getName(), labels(klass, node), properties, relationships); + } + + /** + * The labels of the nodes of the class: the primary label first, then any extra one. Without an + * explicit primary label, Spring Data uses the simple name of the class. + */ + private static List labels(Class klass, Annotation node) throws Exception { + List labels = new ArrayList<>(); + String primary = (String) attribute(node, "primaryLabel"); + String[] value = (String[]) attribute(node, "value"); + String[] extra = (String[]) attribute(node, "labels"); + if (primary != null && !primary.isEmpty()) { + labels.add(primary); + } + for (String[] group : Arrays.asList(value, extra)) { + for (String l : group) { + if (!l.isEmpty() && !labels.contains(l)) { + labels.add(l); + } + } + } + if (labels.isEmpty()) { + labels.add(klass.getSimpleName()); + } + return labels; + } + + private static String propertyName(Field f, Annotation[] annotations) throws Exception { + Annotation property = getAnnotationByName(annotations, PROPERTY); + if (property != null) { + for (String element : Arrays.asList("name", "value")) { + String name = (String) attribute(property, element); + if (name != null && !name.isEmpty()) { + return name; + } + } + } + return f.getName(); + } + + private static Neo4jEntityRelationship toRelationship(Field f, Annotation relationship) throws Exception { + String type = null; + for (String element : Arrays.asList("type", "value")) { + String t = (String) attribute(relationship, element); + if (t != null && !t.isEmpty()) { + type = t; + break; + } + } + if (type == null) { + return null; + } + Object direction = attribute(relationship, "direction"); + boolean outgoing = direction == null || "OUTGOING".equals(direction.toString()); + + Class target = elementClass(f); + if (target == null) { + return null; + } + if (getAnnotationByName(target.getAnnotations(), RELATIONSHIP_PROPERTIES) != null) { + target = allInstanceFields(target).stream() + .filter(t -> getAnnotationByName(t.getAnnotations(), TARGET_NODE) != null) + .map(Field::getType) + .findFirst().orElse(null); + if (target == null) { + return null; + } + } + return new Neo4jEntityRelationship(type, target.getName(), outgoing); + } + + /** + * @return the class of the field, or of its elements if it is a collection + */ + private static Class elementClass(Field f) { + if (!Collection.class.isAssignableFrom(f.getType())) { + return f.getType(); + } + Type generic = f.getGenericType(); + if (generic instanceof ParameterizedType) { + Type[] arguments = ((ParameterizedType) generic).getActualTypeArguments(); + if (arguments.length == 1 && arguments[0] instanceof Class) { + return (Class) arguments[0]; + } + } + return null; + } + + /** + * @return the Neo4j property type a Java type is stored as, or {@code null} for the types we do not + * know how to insert (collections, dates, nested objects...) + */ + static String graphType(Class javaType) { + if (javaType == String.class || javaType.isEnum()) { + return "STRING"; + } + if (javaType == Long.class || javaType == long.class || javaType == Integer.class || javaType == int.class + || javaType == Short.class || javaType == short.class || javaType == Byte.class || javaType == byte.class) { + return "INTEGER"; + } + if (javaType == Double.class || javaType == double.class || javaType == Float.class || javaType == float.class) { + return "FLOAT"; + } + if (javaType == Boolean.class || javaType == boolean.class) { + return "BOOLEAN"; + } + return null; + } + + /** + * @return the smallest value of an integer Java type narrower than long, so that an inserted value + * can be read back into the field; null when there is no such bound + */ + static Long minValue(Class javaType) { + if (javaType == Integer.class || javaType == int.class) return (long) Integer.MIN_VALUE; + if (javaType == Short.class || javaType == short.class) return (long) Short.MIN_VALUE; + if (javaType == Byte.class || javaType == byte.class) return (long) Byte.MIN_VALUE; + return null; + } + + static Long maxValue(Class javaType) { + if (javaType == Integer.class || javaType == int.class) return (long) Integer.MAX_VALUE; + if (javaType == Short.class || javaType == short.class) return (long) Short.MAX_VALUE; + if (javaType == Byte.class || javaType == byte.class) return (long) Byte.MAX_VALUE; + return null; + } + + /** + * @return the names of the constants of an enum type, which are the only strings the field accepts + */ + static List enumConstants(Class javaType) { + List names = new ArrayList<>(); + if (javaType.isEnum()) { + for (Object constant : javaType.getEnumConstants()) { + names.add(((Enum) constant).name()); + } + } + return names; + } + + /** Instance fields of the class and of its superclasses, superclass ones first. */ + private static List allInstanceFields(Class klass) { + List fields = new ArrayList<>(); + if (klass.getSuperclass() != null && klass.getSuperclass() != Object.class) { + fields.addAll(allInstanceFields(klass.getSuperclass())); + } + for (Field f : klass.getDeclaredFields()) { + if (!Modifier.isStatic(f.getModifiers()) && !f.isSynthetic()) { + fields.add(f); + } + } + return fields; + } + + private static Object attribute(Annotation annotation, String element) throws Exception { + return annotation.annotationType().getMethod(element).invoke(annotation); + } + + private static Annotation getAnnotationByName(Annotation[] annotations, String name) { + return Arrays.stream(annotations) + .filter(a -> a.annotationType().getName().equals(name)) + .findFirst().orElse(null); + } +} diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityProperty.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityProperty.java new file mode 100644 index 0000000000..667e64b6f4 --- /dev/null +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityProperty.java @@ -0,0 +1,90 @@ +package org.evomaster.client.java.instrumentation; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * A property of a Neo4j entity class, i.e. a field the SUT maps to a node property. + */ +public class Neo4jEntityProperty implements Serializable { + + /** Property name as stored in the graph, i.e. the field name unless overridden in the mapping. */ + private final String name; + + /** Property type in the graph: one of STRING, INTEGER, FLOAT or BOOLEAN. */ + private final String type; + + /** Whether the property identifies the node in the mapping. */ + private final boolean id; + + /** Whether the value of the property is generated by the database or the mapping, so never chosen by the SUT. */ + private final boolean generated; + + /** Smallest value the Java type of the field holds, for an INTEGER narrower than long; null otherwise. */ + private final Long minValue; + + /** Largest value the Java type of the field holds, for an INTEGER narrower than long; null otherwise. */ + private final Long maxValue; + + /** The constants of the field's enum type, for a STRING backed by an enum; empty otherwise. */ + private final List enumValues; + + /** + * @param name property name in the graph + * @param type STRING, INTEGER, FLOAT or BOOLEAN + * @param id whether it is the identifier of the entity + * @param generated whether its value is generated rather than assigned + * @param minValue lower bound of the Java type, or null + * @param maxValue upper bound of the Java type, or null + * @param enumValues constants of the enum type, or an empty list + */ + public Neo4jEntityProperty(String name, String type, boolean id, boolean generated, + Long minValue, Long maxValue, List enumValues) { + this.name = Objects.requireNonNull(name); + this.type = Objects.requireNonNull(type); + this.id = id; + this.generated = generated; + this.minValue = minValue; + this.maxValue = maxValue; + this.enumValues = Collections.unmodifiableList(enumValues == null ? Collections.emptyList() : enumValues); + } + + public Neo4jEntityProperty(String name, String type, boolean id, boolean generated) { + this(name, type, id, generated, null, null, null); + } + + public String getName() { + return name; + } + + public String getType() { + return type; + } + + public boolean isId() { + return id; + } + + public boolean isGenerated() { + return generated; + } + + public Long getMinValue() { + return minValue; + } + + public Long getMaxValue() { + return maxValue; + } + + public List getEnumValues() { + return enumValues; + } + + @Override + public String toString() { + return name + ":" + type + (id ? " (id)" : "") + (generated ? " (generated)" : ""); + } +} diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityRelationship.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityRelationship.java new file mode 100644 index 0000000000..424797d755 --- /dev/null +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/Neo4jEntityRelationship.java @@ -0,0 +1,47 @@ +package org.evomaster.client.java.instrumentation; + +import java.io.Serializable; +import java.util.Objects; + +/** + * A relationship declared in a Neo4j entity class, from the entity to another entity class. + */ +public class Neo4jEntityRelationship implements Serializable { + + /** Relationship type in the graph. */ + private final String type; + + /** Fully qualified name of the entity class at the other end. */ + private final String targetClassName; + + /** Whether the relationship goes from this entity to the target (outgoing) or the other way round. */ + private final boolean outgoing; + + /** + * @param type relationship type in the graph + * @param targetClassName class of the entity at the other end + * @param outgoing whether this entity is the start node + */ + public Neo4jEntityRelationship(String type, String targetClassName, boolean outgoing) { + this.type = Objects.requireNonNull(type); + this.targetClassName = Objects.requireNonNull(targetClassName); + this.outgoing = outgoing; + } + + public String getType() { + return type; + } + + public String getTargetClassName() { + return targetClassName; + } + + public boolean isOutgoing() { + return outgoing; + } + + @Override + public String toString() { + return (outgoing ? "-[:" + type + "]->" : "<-[:" + type + "]-") + targetClassName; + } +} diff --git a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/UnitsInfoRecorder.java b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/UnitsInfoRecorder.java index c07d00db26..b3d71ce35c 100644 --- a/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/UnitsInfoRecorder.java +++ b/client-java/instrumentation/src/main/java/org/evomaster/client/java/instrumentation/staticstate/UnitsInfoRecorder.java @@ -2,6 +2,7 @@ import org.evomaster.client.java.instrumentation.ClassAnalyzer; import org.evomaster.client.java.instrumentation.JpaConstraint; +import org.evomaster.client.java.instrumentation.Neo4jEntity; import java.io.Serializable; import java.util.Collections; @@ -49,6 +50,22 @@ public class UnitsInfoRecorder implements Serializable { private List jpaConstraints; + /** + * Neo4j entity classes found in the SUT, i.e. the shape of the nodes it stores. + */ + private final List neo4jEntities; + + /** + * Whether {@link #neo4jEntities} reflects the classes in {@link #coveredClassNames}. + */ + private boolean analyzedNeo4jEntities; + + /** + * Names of every class instrumented for coverage since the JVM started. Unlike {@link #unitNames}, this is + * kept across {@link #reset()}, as a class is loaded only once and could not be recorded again. + */ + private final Set coveredClassNames; + private volatile boolean analyzedClasses; /* @@ -87,6 +104,8 @@ private UnitsInfoRecorder(){ numberOfInstrumentedNumberComparisons = new AtomicInteger(0); parsedDtos = new ConcurrentHashMap<>(); jpaConstraints = new CopyOnWriteArrayList<>(); + neo4jEntities = new CopyOnWriteArrayList<>(); + coveredClassNames = new CopyOnWriteArraySet<>(); analyzedClasses = false; extractedSpecifiedDtos = new ConcurrentHashMap<>(); classLoaders = new ConcurrentHashMap<>(); @@ -106,6 +125,7 @@ public static void reset(){ which would make not possible to use the same SUT in 2 different E2E */ copy.classLoaders.putAll(singleton.classLoaders); + copy.coveredClassNames.addAll(singleton.coveredClassNames); singleton = copy; } @@ -129,8 +149,11 @@ public static void registerClassLoader(String className, ClassLoader classLoader public static void markNewUnit(String name){ synchronized (singleton) { singleton.unitNames.add(name); + singleton.coveredClassNames.add(name); singleton.analyzedClasses = false; singleton.jpaConstraints.clear(); + singleton.analyzedNeo4jEntities = false; + singleton.neo4jEntities.clear(); } } @@ -201,6 +224,10 @@ public static void registerNewJpaConstraint(JpaConstraint constraint){ singleton.jpaConstraints.add(constraint); } + public static void registerNewNeo4jEntity(Neo4jEntity entity){ + singleton.neo4jEntities.add(entity); + } + public ClassLoader getSutClassLoader(){ if(unitNames.isEmpty()){ return null; @@ -225,6 +252,21 @@ So, here we do it lazily, by forcing loading on get() } } + /** + * @return the Neo4j entities of the SUT, analyzed lazily like {@link #getJpaConstraints()}, but over every + * class instrumented so far rather than only those recorded since the last {@link #reset()} + */ + public List getNeo4jEntities(){ + synchronized (singleton) { + if (!analyzedNeo4jEntities) { + neo4jEntities.clear(); + ClassAnalyzer.doAnalyzeNeo4jEntities(coveredClassNames); + analyzedNeo4jEntities = true; + } + return Collections.unmodifiableList(neo4jEntities); + } + } + public boolean areClassesAnalyzed(){ return analyzedClasses; } diff --git a/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/AdopterNode.java b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/AdopterNode.java new file mode 100644 index 0000000000..b1d87039aa --- /dev/null +++ b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/AdopterNode.java @@ -0,0 +1,40 @@ +package com.foo.somedifferentpackage.examples.neo4j; + +import org.springframework.data.annotation.Transient; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Property; + +import java.time.LocalDate; +import java.util.List; + +@Node("Adopter") +public class AdopterNode { + + public static final String IGNORED = "static"; + + @Id + private String id; + + private String name; + + private Integer budget; + + private boolean hasYard; + + @Property("max_dogs") + private long maxDogs; + + private Double score; + + private Level level; + + private List tags; + + private LocalDate since; + + @Transient + private String notStored; + + public enum Level { BRONZE, GOLD } +} diff --git a/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/AuditedNode.java b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/AuditedNode.java new file mode 100644 index 0000000000..c3c2e68ee9 --- /dev/null +++ b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/AuditedNode.java @@ -0,0 +1,6 @@ +package com.foo.somedifferentpackage.examples.neo4j; + +public abstract class AuditedNode { + + private String createdBy; +} diff --git a/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/NotAnEntity.java b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/NotAnEntity.java new file mode 100644 index 0000000000..e8bdc03fda --- /dev/null +++ b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/NotAnEntity.java @@ -0,0 +1,6 @@ +package com.foo.somedifferentpackage.examples.neo4j; + +public class NotAnEntity { + + private String id; +} diff --git a/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/PlayedRelationship.java b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/PlayedRelationship.java new file mode 100644 index 0000000000..5893f0d557 --- /dev/null +++ b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/PlayedRelationship.java @@ -0,0 +1,13 @@ +package com.foo.somedifferentpackage.examples.neo4j; + +import org.springframework.data.neo4j.core.schema.RelationshipProperties; +import org.springframework.data.neo4j.core.schema.TargetNode; + +@RelationshipProperties +public class PlayedRelationship { + + private Integer score; + + @TargetNode + private AdopterNode match; +} diff --git a/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/PlayerNode.java b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/PlayerNode.java new file mode 100644 index 0000000000..3200b15d34 --- /dev/null +++ b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/PlayerNode.java @@ -0,0 +1,25 @@ +package com.foo.somedifferentpackage.examples.neo4j; + +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Relationship; + +import java.util.List; + +@Node +public class PlayerNode { + + @Id + private Integer id; + + private Integer rating; + + @Relationship(type = "HAS_USER") + private UserNode user; + + @Relationship(type = "PLAYED", direction = Relationship.Direction.INCOMING) + private List matches; + + @Relationship("KNOWS") + private List friends; +} diff --git a/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/UserNode.java b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/UserNode.java new file mode 100644 index 0000000000..554acc4a45 --- /dev/null +++ b/client-java/instrumentation/src/test/java/com/foo/somedifferentpackage/examples/neo4j/UserNode.java @@ -0,0 +1,15 @@ +package com.foo.somedifferentpackage.examples.neo4j; + +import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; + +@Node(primaryLabel = "User", labels = {"Account"}) +public class UserNode extends AuditedNode { + + @Id + @GeneratedValue + private Long id; + + private String username; +} diff --git a/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/Neo4jEntityAnalyzerTest.java b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/Neo4jEntityAnalyzerTest.java new file mode 100644 index 0000000000..b435eb8313 --- /dev/null +++ b/client-java/instrumentation/src/test/java/org/evomaster/client/java/instrumentation/Neo4jEntityAnalyzerTest.java @@ -0,0 +1,121 @@ +package org.evomaster.client.java.instrumentation; + +import com.foo.somedifferentpackage.examples.neo4j.AdopterNode; +import com.foo.somedifferentpackage.examples.neo4j.NotAnEntity; +import com.foo.somedifferentpackage.examples.neo4j.PlayerNode; +import com.foo.somedifferentpackage.examples.neo4j.UserNode; +import org.evomaster.client.java.instrumentation.staticstate.UnitsInfoRecorder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +class Neo4jEntityAnalyzerTest { + + @BeforeEach + void reset() { + UnitsInfoRecorder.reset(); + } + + @Test + void testPropertiesOfSupportedTypesWithNamesAndIds() throws Exception { + Neo4jEntity entity = Neo4jEntityAnalyzer.analyze(AdopterNode.class); + + assertEquals(AdopterNode.class.getName(), entity.getClassName()); + assertEquals(Arrays.asList("Adopter"), entity.getLabels()); + assertTrue(entity.getRelationships().isEmpty()); + + assertEquals(Arrays.asList("id", "name", "budget", "hasYard", "max_dogs", "score", "level"), + names(entity.getProperties())); + assertProperty(entity, "id", "STRING", true, false); + assertProperty(entity, "name", "STRING", false, false); + assertProperty(entity, "budget", "INTEGER", false, false); + assertProperty(entity, "hasYard", "BOOLEAN", false, false); + assertProperty(entity, "max_dogs", "INTEGER", false, false); + assertProperty(entity, "score", "FLOAT", false, false); + assertProperty(entity, "level", "STRING", false, false); + + Neo4jEntityProperty budget = property(entity, "budget"); + assertEquals((long) Integer.MIN_VALUE, budget.getMinValue()); + assertEquals((long) Integer.MAX_VALUE, budget.getMaxValue()); + assertNull(property(entity, "max_dogs").getMinValue()); + assertTrue(property(entity, "name").getEnumValues().isEmpty()); + assertEquals(Arrays.asList("BRONZE", "GOLD"), property(entity, "level").getEnumValues()); + } + + @Test + void testLabelsGeneratedIdAndInheritedProperties() throws Exception { + Neo4jEntity entity = Neo4jEntityAnalyzer.analyze(UserNode.class); + + assertEquals(Arrays.asList("User", "Account"), entity.getLabels()); + assertEquals(Arrays.asList("createdBy", "id", "username"), names(entity.getProperties())); + assertProperty(entity, "id", "INTEGER", true, true); + } + + @Test + void testRelationships() throws Exception { + Neo4jEntity entity = Neo4jEntityAnalyzer.analyze(PlayerNode.class); + + assertEquals(Arrays.asList("PlayerNode"), entity.getLabels()); + assertEquals(Arrays.asList("id", "rating"), names(entity.getProperties())); + + List relationships = entity.getRelationships(); + assertEquals(3, relationships.size()); + + assertEquals("HAS_USER", relationships.get(0).getType()); + assertEquals(UserNode.class.getName(), relationships.get(0).getTargetClassName()); + assertTrue(relationships.get(0).isOutgoing()); + + assertEquals("PLAYED", relationships.get(1).getType()); + assertEquals(AdopterNode.class.getName(), relationships.get(1).getTargetClassName()); + assertFalse(relationships.get(1).isOutgoing()); + + assertEquals("KNOWS", relationships.get(2).getType()); + assertEquals(PlayerNode.class.getName(), relationships.get(2).getTargetClassName()); + } + + @Test + void testNotAnEntity() throws Exception { + assertNull(Neo4jEntityAnalyzer.analyze(NotAnEntity.class)); + } + + @Test + void testRecorderCollectsTheEntitiesOfTheCoveredClassesAlsoAfterAReset() { + for (Class k : Arrays.asList(AdopterNode.class, NotAnEntity.class, UserNode.class)) { + UnitsInfoRecorder.registerClassLoader(k.getName(), k.getClassLoader()); + UnitsInfoRecorder.markNewUnit(k.getName()); + } + + List expected = Arrays.asList(AdopterNode.class.getName(), UserNode.class.getName()); + assertEquals(expected, classNames(UnitsInfoRecorder.getInstance().getNeo4jEntities())); + + UnitsInfoRecorder.reset(); + assertEquals(0, UnitsInfoRecorder.getInstance().getNumberOfUnits()); + assertEquals(expected, classNames(UnitsInfoRecorder.getInstance().getNeo4jEntities())); + } + + private static List classNames(List entities) { + return entities.stream().map(Neo4jEntity::getClassName).collect(Collectors.toList()); + } + + private static List names(List properties) { + return properties.stream().map(Neo4jEntityProperty::getName).collect(Collectors.toList()); + } + + private static Neo4jEntityProperty property(Neo4jEntity entity, String name) { + return entity.getProperties().stream() + .filter(it -> it.getName().equals(name)) + .findFirst().orElseThrow(() -> new AssertionError("No property " + name)); + } + + private static void assertProperty(Neo4jEntity entity, String name, String type, boolean id, boolean generated) { + Neo4jEntityProperty p = property(entity, name); + assertEquals(type, p.getType(), name); + assertEquals(id, p.isId(), name); + assertEquals(generated, p.isGenerated(), name); + } +} diff --git a/core-tests/e2e-tests/spring/pom.xml b/core-tests/e2e-tests/spring/pom.xml index 2ce26087e4..3f9243ff3b 100644 --- a/core-tests/e2e-tests/spring/pom.xml +++ b/core-tests/e2e-tests/spring/pom.xml @@ -76,6 +76,11 @@ spring-boot-starter-data-mongodb ${springboot.version} + + org.springframework.boot + spring-boot-starter-data-neo4j + ${springboot.version} + org.springframework.boot spring-boot-starter-data-redis diff --git a/core-tests/e2e-tests/spring/spring-rest-neo4j/pom.xml b/core-tests/e2e-tests/spring/spring-rest-neo4j/pom.xml index b347899373..4950dc3280 100644 --- a/core-tests/e2e-tests/spring/spring-rest-neo4j/pom.xml +++ b/core-tests/e2e-tests/spring/spring-rest-neo4j/pom.xml @@ -55,6 +55,10 @@ validation-api 2.0.1.Final + + org.springframework.boot + spring-boot-starter-data-neo4j + org.neo4j.driver neo4j-java-driver diff --git a/core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Neo4jEntityFindByNameApp.java b/core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Neo4jEntityFindByNameApp.java new file mode 100644 index 0000000000..4693f446f6 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Neo4jEntityFindByNameApp.java @@ -0,0 +1,23 @@ +package com.foo.neo4j.entity.findbyname; + +import com.foo.neo4j.OpenApiConfiguration; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.data.neo4j.Neo4jDataAutoConfiguration; +import org.springframework.boot.autoconfigure.data.neo4j.Neo4jRepositoriesAutoConfiguration; +import org.springframework.boot.autoconfigure.neo4j.Neo4jAutoConfiguration; + +/** + * Only the mapping annotations of Spring Data are used here; the queries go through the driver. + */ +@SpringBootApplication(exclude = { + Neo4jAutoConfiguration.class, + Neo4jDataAutoConfiguration.class, + Neo4jRepositoriesAutoConfiguration.class +}) +public class Neo4jEntityFindByNameApp extends OpenApiConfiguration { + + public static void main(String[] args) { + SpringApplication.run(Neo4jEntityFindByNameApp.class, args); + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Neo4jEntityFindByNameRest.java b/core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Neo4jEntityFindByNameRest.java new file mode 100644 index 0000000000..f3e88527a4 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Neo4jEntityFindByNameRest.java @@ -0,0 +1,40 @@ +package com.foo.neo4j.entity.findbyname; + +import com.foo.neo4j.AbstractNeo4jRest; +import org.neo4j.driver.Record; +import org.neo4j.driver.Result; +import org.neo4j.driver.Session; +import org.neo4j.driver.types.Node; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; + +/** + * Looks a person up by name and returns the whole node. Nothing writes to the graph, so the lookup can + * only succeed on data EvoMaster inserts, and the response shows which properties that data carries. + */ +@RestController +@RequestMapping(path = "/neo4jentityfindbyname") +public class Neo4jEntityFindByNameRest extends AbstractNeo4jRest { + + @GetMapping("/findPerson/{name}") + public ResponseEntity findPerson(@PathVariable("name") String name) { + try (Session session = driver.session()) { + Result result = session.run("MATCH (p:Person {name: $name}) RETURN p", Collections.singletonMap("name", name)); + if (!result.hasNext()) { + return ResponseEntity.notFound().build(); + } + Record record = result.next(); + Node node = record.get("p").asNode(); + return ResponseEntity.ok(new Person( + node.get("name").asString(null), + node.get("age").isNull() ? null : node.get("age").asInt(), + node.get("active").isNull() ? null : node.get("active").asBoolean(), + node.get("score").isNull() ? null : node.get("score").asDouble())); + } + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Person.java b/core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Person.java new file mode 100644 index 0000000000..59b0896dcd --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-neo4j/src/main/java/com/foo/neo4j/entity/findbyname/Person.java @@ -0,0 +1,55 @@ +package com.foo.neo4j.entity.findbyname; + +import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; + +/** + * The node as the SUT maps it. The mapping is what tells EvoMaster which properties a Person has, + * beyond the one its query compares. + */ +@Node("Person") +public class Person { + + @Id + @GeneratedValue + private Long id; + + private String name; + + private Integer age; + + private Boolean active; + + private Double score; + + public Person() { + } + + public Person(String name, Integer age, Boolean active, Double score) { + this.name = name; + this.age = age; + this.active = active; + this.score = score; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public Integer getAge() { + return age; + } + + public Boolean getActive() { + return active; + } + + public Double getScore() { + return score; + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-neo4j/src/test/java/com/foo/spring/rest/neo4j/entity/findbyname/Neo4jEntityFindByNameController.java b/core-tests/e2e-tests/spring/spring-rest-neo4j/src/test/java/com/foo/spring/rest/neo4j/entity/findbyname/Neo4jEntityFindByNameController.java new file mode 100644 index 0000000000..dcca6661eb --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-neo4j/src/test/java/com/foo/spring/rest/neo4j/entity/findbyname/Neo4jEntityFindByNameController.java @@ -0,0 +1,16 @@ +package com.foo.spring.rest.neo4j.entity.findbyname; + +import com.foo.neo4j.entity.findbyname.Neo4jEntityFindByNameApp; +import com.foo.spring.rest.neo4j.Neo4jController; + +public class Neo4jEntityFindByNameController extends Neo4jController { + + public Neo4jEntityFindByNameController() { + super(Neo4jEntityFindByNameApp.class); + } + + @Override + public String getPackagePrefixesToCover() { + return "com.foo.neo4j.entity.findbyname"; + } +} diff --git a/core-tests/e2e-tests/spring/spring-rest-neo4j/src/test/java/org/evomaster/e2etests/spring/rest/neo4j/entity/findbyname/Neo4jEntityFindByNameEMTest.java b/core-tests/e2e-tests/spring/spring-rest-neo4j/src/test/java/org/evomaster/e2etests/spring/rest/neo4j/entity/findbyname/Neo4jEntityFindByNameEMTest.java new file mode 100644 index 0000000000..24bdc3a39c --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-neo4j/src/test/java/org/evomaster/e2etests/spring/rest/neo4j/entity/findbyname/Neo4jEntityFindByNameEMTest.java @@ -0,0 +1,84 @@ +package org.evomaster.e2etests.spring.rest.neo4j.entity.findbyname; + +import com.foo.spring.rest.neo4j.entity.findbyname.Neo4jEntityFindByNameController; +import org.evomaster.core.EMConfig; +import org.evomaster.core.database.neo4j.Neo4jDbAction; +import org.evomaster.core.database.neo4j.Neo4jNodeTemplate; +import org.evomaster.core.database.neo4j.Neo4jPropertyGene; +import org.evomaster.core.problem.rest.data.HttpVerb; +import org.evomaster.core.problem.rest.data.RestIndividual; +import org.evomaster.core.search.EvaluatedIndividual; +import org.evomaster.core.search.Solution; +import org.evomaster.e2etests.utils.RestTestBase; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +public class Neo4jEntityFindByNameEMTest extends RestTestBase { + + @BeforeAll + public static void initClass() throws Exception { + EMConfig config = new EMConfig(); + config.setInstrumentMR_NEO4J(true); + config.setHeuristicsForNeo4j(true); + config.setExtractNeo4jExecutionInfo(true); + config.setGenerateNeo4jData(true); + RestTestBase.initClass(new Neo4jEntityFindByNameController(), config); + } + + @Test + public void testFindByNameInsertsThePropertiesOfTheEntityEM() throws Throwable { + + runTestHandlingFlakyAndCompilation( + "Neo4jEntityFindByNameEM", + "org.foo.spring.rest.neo4j.Neo4jEntityFindByNameEM", + 100, + true, + (args) -> { + setOption(args, "heuristicsForNeo4j", "true"); + setOption(args, "instrumentMR_NEO4J", "true"); + setOption(args, "extractNeo4jExecutionInfo", "true"); + setOption(args, "generateNeo4jData", "true"); + + Solution solution = initAndRun(args); + + assertFalse(solution.getIndividuals().isEmpty()); + assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/neo4jentityfindbyname/findPerson/{name}", null); + assertHasAtLeastOne(solution, HttpVerb.GET, 404, "/neo4jentityfindbyname/findPerson/{name}", null); + + /* + The query only compares the name. The other properties come from the Person + class, except the id, whose value is generated. + */ + Set expected = new HashSet<>(Arrays.asList("name", "age", "active", "score")); + boolean inserted = false; + for (EvaluatedIndividual ind : solution.getIndividuals()) { + for (Neo4jDbAction action : seeNeo4jActions(ind)) { + for (Neo4jNodeTemplate node : action.getNodes()) { + assertEquals(Arrays.asList("Person"), node.getLabels()); + Set keys = node.getProperties().stream() + .map(Neo4jPropertyGene::getKey).collect(Collectors.toSet()); + assertEquals(expected, keys); + inserted = true; + } + } + } + assertTrue(inserted, "No Neo4j insertion was generated"); + }, + 3); + } + + private static List seeNeo4jActions(EvaluatedIndividual ind) { + return ind.getIndividual().seeInitializingActions().stream() + .filter(a -> a instanceof Neo4jDbAction) + .map(a -> (Neo4jDbAction) a) + .collect(Collectors.toList()); + } +} diff --git a/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jDbAction.kt b/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jDbAction.kt index 9f68ee3250..73cb331313 100644 --- a/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jDbAction.kt +++ b/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jDbAction.kt @@ -8,6 +8,7 @@ import org.evomaster.client.java.controller.api.dto.database.operations.Neo4jPro import org.evomaster.core.search.action.Action import org.evomaster.core.search.action.EnvironmentAction import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.collection.EnumGene import org.evomaster.core.search.gene.Gene import org.evomaster.core.search.gene.numeric.DoubleGene import org.evomaster.core.search.gene.numeric.LongGene @@ -23,17 +24,26 @@ import org.evomaster.core.search.gene.string.StringGene data class Neo4jPropertyGene( val key: String, val type: Neo4jPropertyTypeDto, - val gene: Gene + val gene: Gene, + /** + * Whether the failed query fixed this property, so that the seed in [gene] is what the query looked + * for. Otherwise the property comes from the entity schema and its value is free. + */ + val fromQuery: Boolean = true ) { /** The value in the text form the controller reads, according to [type]. */ fun valueAsText(): String = when (type) { - Neo4jPropertyTypeDto.STRING -> (gene as StringGene).value + Neo4jPropertyTypeDto.STRING -> when (gene) { + is StringGene -> gene.value + is EnumGene<*> -> gene.getValueAsRawString() + else -> throw IllegalStateException("Unexpected gene for a string property: ${gene::class.simpleName}") + } Neo4jPropertyTypeDto.INTEGER -> (gene as LongGene).value.toString() Neo4jPropertyTypeDto.FLOAT -> (gene as DoubleGene).value.toString() Neo4jPropertyTypeDto.BOOLEAN -> (gene as BooleanGene).value.toString() } - fun copy(): Neo4jPropertyGene = Neo4jPropertyGene(key, type, gene.copy()) + fun copy(): Neo4jPropertyGene = Neo4jPropertyGene(key, type, gene.copy(), fromQuery) } /** @@ -90,6 +100,11 @@ class Neo4jDbAction( override fun seeTopGenes(): List = allProperties().map { it.gene } + /** + * The genes of the properties the failed query did not fix, i.e. those added from the entity schema. + */ + fun seeSchemaGenes(): List = allProperties().filter { !it.fromQuery }.map { it.gene } + override fun copyContent(): Action = Neo4jDbAction(nodes.map { it.copy() }, edges.map { it.copy() }, query) override fun getName(): String = @@ -98,33 +113,41 @@ class Neo4jDbAction( override fun getActionGroupKey(): String = Neo4jDbAction::class.java.name /** Stable key used to avoid adding the same inferred insertion twice. */ + /** + * Identifies the failed query this insertion answers: labels, relationships and the properties the + * query fixed. The properties added from the schema are left out, so that the key still matches the + * one the driver computes from the query alone. + */ fun insertionKey(): String { - val nodeDtos = nodes.mapIndexed { index, node -> toNodeDto(index.toLong(), node) } - val edgeDtos = edges.map { toEdgeDto(it, 0) } + val nodeDtos = nodes.mapIndexed { index, node -> toNodeDto(index.toLong(), node, onlyFromQuery = true) } + val edgeDtos = edges.map { toEdgeDto(it, 0, onlyFromQuery = true) } return Neo4jInsertionKeyBuilder.fromCommands(nodeDtos, edgeDtos) } /** * @param id the id the node gets in the batch it is sent in */ - fun toNodeDto(id: Long, node: Neo4jNodeTemplate): Neo4jNodeInsertionDto = + fun toNodeDto(id: Long, node: Neo4jNodeTemplate, onlyFromQuery: Boolean = false): Neo4jNodeInsertionDto = Neo4jNodeInsertionDto().also { dto -> dto.id = id dto.labels = node.labels.toMutableList() - dto.properties = node.properties.map { toEntryDto(it) }.toMutableList() + dto.properties = toEntryDtos(node.properties, onlyFromQuery) } /** * @param firstNodeId the id of the first node of this action in the batch it is sent in */ - fun toEdgeDto(edge: Neo4jEdgeTemplate, firstNodeId: Long): Neo4jEdgeInsertionDto = + fun toEdgeDto(edge: Neo4jEdgeTemplate, firstNodeId: Long, onlyFromQuery: Boolean = false): Neo4jEdgeInsertionDto = Neo4jEdgeInsertionDto().also { dto -> dto.type = edge.type dto.fromNodeId = firstNodeId + edge.fromIndex dto.toNodeId = firstNodeId + edge.toIndex - dto.properties = edge.properties.map { toEntryDto(it) }.toMutableList() + dto.properties = toEntryDtos(edge.properties, onlyFromQuery) } + private fun toEntryDtos(properties: List, onlyFromQuery: Boolean) = + properties.filter { !onlyFromQuery || it.fromQuery }.map { toEntryDto(it) }.toMutableList() + private fun toEntryDto(property: Neo4jPropertyGene): Neo4jInsertionEntryDto = Neo4jInsertionEntryDto(property.key, property.type, property.valueAsText()) } diff --git a/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jInsertBuilder.kt b/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jInsertBuilder.kt index a328397958..c426d07acb 100644 --- a/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jInsertBuilder.kt +++ b/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jInsertBuilder.kt @@ -7,6 +7,7 @@ import org.evomaster.client.java.controller.api.dto.database.operations.Neo4jNod import org.evomaster.client.java.controller.api.dto.database.operations.Neo4jPropertyTypeDto import org.evomaster.core.logging.LoggingUtil import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.collection.EnumGene import org.evomaster.core.search.gene.numeric.DoubleGene import org.evomaster.core.search.gene.numeric.LongGene import org.evomaster.core.search.gene.string.StringGene @@ -25,24 +26,29 @@ object Neo4jInsertBuilder { /** * @param failedQueries failed queries reported by the controller * @param existingInsertionKeys keys of the insertions already in the individual + * @param schema what the SUT's entities declare about each label; with it, a node also gets the + * properties the query did not fix, with free genes * @return one action per failed query not already represented by [existingInsertionKeys] */ fun buildInsertActions( failedQueries: List, - existingInsertionKeys: Set + existingInsertionKeys: Set, + schema: Neo4jSchema? = null ): List = failedQueries - .mapNotNull(::toActionOrNull) + .mapNotNull { toActionOrNull(it, schema) } .filterNot { it.insertionKey() in existingInsertionKeys } .distinctBy { it.insertionKey() } /** Converts one failed query into an action, or returns null when it cannot be inserted as reported. */ - private fun toActionOrNull(query: Neo4jFailedQueryDto): Neo4jDbAction? { + private fun toActionOrNull(query: Neo4jFailedQueryDto, schema: Neo4jSchema?): Neo4jDbAction? { val nodes = query.nodes ?: emptyList() if (nodes.isEmpty()) return null val indexById = nodes.mapIndexed { index, node -> node.id to index }.toMap() val nodeTemplates = nodes.map { node -> - Neo4jNodeTemplate(node.labels ?: emptyList(), toPropertyGenes(node.properties) ?: return null) + val labels = node.labels ?: emptyList() + val fromQuery = toPropertyGenes(node.properties) ?: return null + Neo4jNodeTemplate(labels, fromQuery + fromSchema(labels, fromQuery, schema)) } val edgeTemplates = (query.edges ?: emptyList()).map { edge -> toEdgeOrNull(edge, indexById) ?: return null @@ -62,6 +68,26 @@ object Neo4jInsertBuilder { } /** @return the genes for the properties, or null when one of them holds a value of the wrong type */ + /** + * Genes for the properties the schema declares for these labels and the query did not fix. + */ + private fun fromSchema(labels: List, fromQuery: List, schema: Neo4jSchema?): List { + if (schema == null) return emptyList() + val fixed = fromQuery.map { it.key }.toSet() + return schema.propertiesFor(labels) + .filter { it.name !in fixed && it.type != null } + .map { p -> + val gene = when (p.type!!) { + Neo4jPropertyTypeDto.STRING -> + if (p.enumValues.isNullOrEmpty()) StringGene(p.name) else EnumGene(p.name, p.enumValues) + Neo4jPropertyTypeDto.INTEGER -> LongGene(p.name, min = p.minValue, max = p.maxValue) + Neo4jPropertyTypeDto.FLOAT -> DoubleGene(p.name) + Neo4jPropertyTypeDto.BOOLEAN -> BooleanGene(p.name) + } + Neo4jPropertyGene(p.name, p.type, gene, fromQuery = false) + } + } + private fun toPropertyGenes(properties: List?): List? = (properties ?: emptyList()).map { toPropertyGeneOrNull(it) ?: return null } diff --git a/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jSchema.kt b/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jSchema.kt new file mode 100644 index 0000000000..3041184bec --- /dev/null +++ b/core/src/main/kotlin/org/evomaster/core/database/neo4j/Neo4jSchema.kt @@ -0,0 +1,34 @@ +package org.evomaster.core.database.neo4j + +import org.evomaster.client.java.controller.api.dto.database.neo4j.Neo4jEntityDto +import org.evomaster.client.java.controller.api.dto.database.neo4j.Neo4jEntityPropertyDto + +/** + * What the SUT's entity classes say about the nodes of each label: which properties they have and of + * which type. Neo4j has no schema, so this is the closest thing to one, and it is what lets an inserted + * node carry every property the SUT expects rather than only those the failed query mentioned. + */ +class Neo4jSchema(entities: List) { + + /** + * Key -> a label + * + * Value -> the entities whose nodes carry that label, in the order the driver reported them + */ + private val entitiesByLabel: Map> = + entities.flatMap { e -> e.labels.map { it to e } } + .groupBy({ it.first }, { it.second }) + + fun isEmpty() = entitiesByLabel.isEmpty() + + /** + * The properties a node with these labels is expected to have, without duplicates and without the + * ones whose value is generated, as the SUT never assigns those either. + */ + fun propertiesFor(labels: List): List = + labels.flatMap { entitiesByLabel[it] ?: emptyList() } + .distinct() + .flatMap { it.properties } + .filter { it.isGenerated != true } + .distinctBy { it.name } +} diff --git a/core/src/main/kotlin/org/evomaster/core/problem/api/service/ApiWsStructureMutator.kt b/core/src/main/kotlin/org/evomaster/core/problem/api/service/ApiWsStructureMutator.kt index 85f8bb7341..953c8078d2 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/api/service/ApiWsStructureMutator.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/api/service/ApiWsStructureMutator.kt @@ -21,6 +21,7 @@ import org.evomaster.core.database.dynamodb.DynamoDbAction import org.evomaster.core.database.dynamodb.DynamoDbInsertBuilder import org.evomaster.core.database.neo4j.Neo4jDbAction import org.evomaster.core.database.neo4j.Neo4jInsertBuilder +import org.evomaster.core.database.neo4j.Neo4jSchema import org.evomaster.client.java.controller.api.dto.database.execution.Neo4jFailedQueryDto import org.evomaster.core.search.EvaluatedIndividual import org.evomaster.core.search.GroupsOfChildren @@ -331,7 +332,7 @@ abstract class ApiWsStructureMutator : StructureMutator() { val oldNeo4jDbActions = mutableListOf().plus(ind.seeInitializingActions()) - val addedNeo4jDbInsertions = handleFailedNeo4jQueries(ind, failedQueries) + val addedNeo4jDbInsertions = handleFailedNeo4jQueries(ind, failedQueries, sampler.neo4jSchema) .let { if (it.isEmpty()) emptyList() else listOf(it) } if (mutatedGenes != null && config.isEnabledArchiveGeneSelection()) { @@ -640,7 +641,8 @@ abstract class ApiWsStructureMutator : StructureMutator() { private fun handleFailedNeo4jQueries( ind: T, - failedQueries: List + failedQueries: List, + schema: Neo4jSchema? ): List { val existingKeys = ind.seeInitializingActions() @@ -648,11 +650,12 @@ abstract class ApiWsStructureMutator : StructureMutator() { .map { it.insertionKey() } .toSet() - val addedActions = Neo4jInsertBuilder.buildInsertActions(failedQueries, existingKeys) + val addedActions = Neo4jInsertBuilder.buildInsertActions(failedQueries, existingKeys, schema) if (addedActions.isNotEmpty()) { ind.addInitializingActions(actions = addedActions) addedActions.forEach { action -> + action.seeSchemaGenes().forEach { gene -> gene.randomize(randomness, false) } action.seeTopGenes().forEach { gene -> gene.markAllAsInitialized() } } } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSampler.kt index 6f494ccd26..419b04ebed 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/asyncapi/service/AsyncApiSampler.kt @@ -80,6 +80,7 @@ class AsyncApiSampler : ApiWsSampler() { handleMessages(document.warnings + messages) initSqlInfo(infoDto) + initNeo4jInfo(infoDto) initAdHocInitialIndividuals() diff --git a/core/src/main/kotlin/org/evomaster/core/problem/enterprise/service/EnterpriseSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/enterprise/service/EnterpriseSampler.kt index 61bd4adc81..3f6f3acf73 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/enterprise/service/EnterpriseSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/enterprise/service/EnterpriseSampler.kt @@ -13,6 +13,7 @@ import org.evomaster.core.remote.service.RemoteController import org.evomaster.core.search.Individual import org.evomaster.core.search.service.Sampler import org.evomaster.core.search.service.WarningsAggregator +import org.evomaster.core.database.neo4j.Neo4jSchema import org.evomaster.core.database.sql.SqlAction import org.evomaster.core.database.sql.SqlInsertBuilder import org.evomaster.core.database.sql.schema.TableId @@ -35,6 +36,12 @@ abstract class EnterpriseSampler : Sampler() where T : Individual { protected val derivedParamHandler = DerivedParamHandler() var sqlInsertBuilder: SqlInsertBuilder? = null + + /** + * What the SUT's entity classes declare about its Neo4j nodes, if it has any. Null before the SUT + * info is read. + */ + var neo4jSchema: Neo4jSchema? = null protected set var existingSqlData : List = listOf() @@ -147,6 +154,11 @@ abstract class EnterpriseSampler : Sampler() where T : Individual { } } + open fun initNeo4jInfo(infoDto: SutInfoDto) { + val entities = infoDto.unitsInfoDto?.neo4jEntities ?: emptyList() + neo4jSchema = if (entities.isEmpty()) null else Neo4jSchema(entities) + } + fun extractFkTables(tables: Set): Set { if(sqlInsertBuilder == null || tables.isEmpty()) return tables diff --git a/core/src/main/kotlin/org/evomaster/core/problem/graphql/service/GraphQLSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/graphql/service/GraphQLSampler.kt index 45561531e2..4ef1d08071 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/graphql/service/GraphQLSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/graphql/service/GraphQLSampler.kt @@ -68,6 +68,7 @@ class GraphQLSampler : HttpWsSampler() { //TODO this will require refactoring initSqlInfo(infoDto) + initNeo4jInfo(infoDto) //initAdHocInitialIndividuals() //postInits() diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/sampler/AbstractRestSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/sampler/AbstractRestSampler.kt index ef92bbd431..b5fed9e8fd 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/sampler/AbstractRestSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/sampler/AbstractRestSampler.kt @@ -162,6 +162,7 @@ abstract class AbstractRestSampler : HttpWsSampler() { } initSqlInfo(infoDto) + initNeo4jInfo(infoDto) initHostnameInfo(infoDto) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rpc/service/RPCSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/rpc/service/RPCSampler.kt index c4c09fb4e2..bce7112bdb 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rpc/service/RPCSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rpc/service/RPCSampler.kt @@ -69,6 +69,7 @@ class RPCSampler: ApiWsSampler() { rpcHandler.initActionCluster(problem, actionCluster,scheduleActionCluster, infoDto) initSqlInfo(infoDto) + initNeo4jInfo(infoDto) initAdHocInitialIndividuals(infoDto) diff --git a/core/src/main/kotlin/org/evomaster/core/problem/webfrontend/service/WebSampler.kt b/core/src/main/kotlin/org/evomaster/core/problem/webfrontend/service/WebSampler.kt index f2acb0ed02..4b740e5f92 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/webfrontend/service/WebSampler.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/webfrontend/service/WebSampler.kt @@ -62,6 +62,7 @@ class WebSampler : EnterpriseSampler() { //TODO this will require refactoring initSqlInfo(infoDto) + initNeo4jInfo(infoDto) //initAdHocInitialIndividuals() //postInits() diff --git a/core/src/test/kotlin/org/evomaster/core/database/neo4j/Neo4jDbActionTest.kt b/core/src/test/kotlin/org/evomaster/core/database/neo4j/Neo4jDbActionTest.kt index 0a7adb4688..cc0be62f24 100644 --- a/core/src/test/kotlin/org/evomaster/core/database/neo4j/Neo4jDbActionTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/database/neo4j/Neo4jDbActionTest.kt @@ -69,4 +69,23 @@ class Neo4jDbActionTest { assertEquals(1, Neo4jExecution.fromDto(dto).failedQueries.size) assertTrue(Neo4jExecution.fromDto(null).failedQueries.isEmpty()) } + + @Test + fun insertionKeyIgnoresSchemaPropertiesButTheInsertionCarriesThem() { + val action = Neo4jDbAction( + listOf(Neo4jNodeTemplate(listOf("Person"), listOf( + Neo4jPropertyGene("name", Neo4jPropertyTypeDto.STRING, StringGene("name", "Ana")), + Neo4jPropertyGene("age", Neo4jPropertyTypeDto.INTEGER, LongGene("age", 30L), fromQuery = false)))), + emptyList(), "MATCH (a:Person {name: 'Ana'}) RETURN a") + val onlyQuery = Neo4jDbAction( + listOf(Neo4jNodeTemplate(listOf("Person"), listOf( + Neo4jPropertyGene("name", Neo4jPropertyTypeDto.STRING, StringGene("name", "Ana"))))), + emptyList(), "q") + + assertEquals(onlyQuery.insertionKey(), action.insertionKey()) + assertEquals(listOf("name", "age"), action.toNodeDto(0, action.nodes[0]).properties.map { it.propertyKey }) + assertEquals(1, action.seeSchemaGenes().size) + assertSame(action.nodes[0].properties[1].gene, action.seeSchemaGenes().single()) + assertFalse((action.copy() as Neo4jDbAction).nodes[0].properties[1].fromQuery) + } } diff --git a/core/src/test/kotlin/org/evomaster/core/database/neo4j/Neo4jInsertBuilderTest.kt b/core/src/test/kotlin/org/evomaster/core/database/neo4j/Neo4jInsertBuilderTest.kt index 1b234bafbe..c7c084282d 100644 --- a/core/src/test/kotlin/org/evomaster/core/database/neo4j/Neo4jInsertBuilderTest.kt +++ b/core/src/test/kotlin/org/evomaster/core/database/neo4j/Neo4jInsertBuilderTest.kt @@ -1,11 +1,14 @@ package org.evomaster.core.database.neo4j import org.evomaster.client.java.controller.api.dto.database.execution.Neo4jFailedQueryDto +import org.evomaster.client.java.controller.api.dto.database.neo4j.Neo4jEntityDto +import org.evomaster.client.java.controller.api.dto.database.neo4j.Neo4jEntityPropertyDto import org.evomaster.client.java.controller.api.dto.database.operations.Neo4jEdgeInsertionDto import org.evomaster.client.java.controller.api.dto.database.operations.Neo4jInsertionEntryDto import org.evomaster.client.java.controller.api.dto.database.operations.Neo4jNodeInsertionDto import org.evomaster.client.java.controller.api.dto.database.operations.Neo4jPropertyTypeDto import org.evomaster.core.search.gene.BooleanGene +import org.evomaster.core.search.gene.collection.EnumGene import org.evomaster.core.search.gene.numeric.DoubleGene import org.evomaster.core.search.gene.numeric.LongGene import org.evomaster.core.search.gene.string.StringGene @@ -32,6 +35,30 @@ class Neo4jInsertBuilderTest { fun entry(key: String, type: Neo4jPropertyTypeDto, value: String) = Neo4jInsertionEntryDto(key, type, value) + fun property(name: String, type: Neo4jPropertyTypeDto, id: Boolean = false, generated: Boolean = false, + min: Long? = null, max: Long? = null, enumValues: List = emptyList()) = + Neo4jEntityPropertyDto().also { + it.name = name; it.type = type; it.isId = id; it.isGenerated = generated + it.minValue = min; it.maxValue = max; it.enumValues = enumValues.toMutableList() + } + + fun entity(vararg labels: String, properties: List) = + Neo4jEntityDto().also { it.className = "com.foo." + labels[0]; it.labels = labels.toMutableList(); it.properties = properties.toMutableList() } + + /** What arimaa declares for Player and User. */ + fun arimaaSchema() = Neo4jSchema(listOf( + entity("Player", properties = listOf( + property("id", Neo4jPropertyTypeDto.INTEGER, id = true), + property("rating", Neo4jPropertyTypeDto.INTEGER), + property("gamesPlayed", Neo4jPropertyTypeDto.INTEGER))), + entity("User", "Account", properties = listOf( + property("id", Neo4jPropertyTypeDto.INTEGER, id = true, generated = true), + property("username", Neo4jPropertyTypeDto.STRING), + property("email", Neo4jPropertyTypeDto.STRING), + property("active", Neo4jPropertyTypeDto.BOOLEAN), + property("score", Neo4jPropertyTypeDto.FLOAT))) + )) + /** MATCH (p:Player)-[:HAS_USER]->(u:User {username: 'ana'}) that found nothing. */ fun playerWithUser(): Neo4jFailedQueryDto = Neo4jFailedQueryDto( "MATCH (p:Player)-[:HAS_USER]->(u:User {username: \$username}) RETURN p", @@ -99,4 +126,65 @@ class Neo4jInsertBuilderTest { assertTrue(actions.isEmpty()) } + + @Test + fun completesTheNodesWithThePropertiesTheSchemaDeclares() { + val action = Neo4jInsertBuilder.buildInsertActions(listOf(playerWithUser()), emptySet(), arimaaSchema()).single() + + val player = action.nodes[0].properties + assertEquals(listOf("id", "rating", "gamesPlayed"), player.map { it.key }) + assertTrue(player.all { !it.fromQuery && it.gene is LongGene }) + + val user = action.nodes[1].properties + assertEquals(listOf("username", "email", "active", "score"), user.map { it.key }) + assertTrue(user[0].fromQuery) + assertEquals("ana", (user[0].gene as StringGene).value) + assertTrue(user.drop(1).all { !it.fromQuery }) + assertTrue(user[1].gene is StringGene) + assertTrue(user[2].gene is BooleanGene) + assertTrue(user[3].gene is DoubleGene) + + assertEquals(1, action.edges.single().properties.size) + assertEquals(8, action.seeTopGenes().size) + assertEquals(6, action.seeSchemaGenes().size) + } + + @Test + fun schemaPropertiesDoNotChangeTheInsertionKey() { + val bare = Neo4jInsertBuilder.buildInsertActions(listOf(playerWithUser()), emptySet()).single() + val completed = Neo4jInsertBuilder.buildInsertActions(listOf(playerWithUser()), emptySet(), arimaaSchema()).single() + + assertEquals(bare.insertionKey(), completed.insertionKey()) + assertTrue(Neo4jInsertBuilder.buildInsertActions(listOf(playerWithUser()), setOf(completed.insertionKey()), arimaaSchema()).isEmpty()) + } + + @Test + fun labelsUnknownToTheSchemaAreLeftAsTheQuerySaysAndLabelsAreMerged() { + val query = Neo4jFailedQueryDto("q", listOf(node(0, "Ghost"), node(1, "Account", "Player")), emptyList()) + val action = Neo4jInsertBuilder.buildInsertActions(listOf(query), emptySet(), arimaaSchema()).single() + + assertTrue(action.nodes[0].properties.isEmpty()) + assertEquals(listOf("username", "email", "active", "score", "id", "rating", "gamesPlayed"), + action.nodes[1].properties.map { it.key }) + } + + @Test + fun schemaBoundsAndEnumsShapeTheFreeGenes() { + val schema = Neo4jSchema(listOf(entity("Adopter", properties = listOf( + property("id", Neo4jPropertyTypeDto.STRING, id = true), + property("budget", Neo4jPropertyTypeDto.INTEGER, min = Int.MIN_VALUE.toLong(), max = Int.MAX_VALUE.toLong()), + property("level", Neo4jPropertyTypeDto.STRING, enumValues = listOf("BRONZE", "GOLD")))))) + val query = Neo4jFailedQueryDto("q", listOf(node(0, "Adopter", properties = listOf(entry("id", Neo4jPropertyTypeDto.STRING, "a1")))), emptyList()) + + val props = Neo4jInsertBuilder.buildInsertActions(listOf(query), emptySet(), schema).single().nodes.single().properties + assertEquals(listOf("id", "budget", "level"), props.map { it.key }) + + val budget = props[1].gene as LongGene + assertEquals(Int.MIN_VALUE.toLong(), budget.min) + assertEquals(Int.MAX_VALUE.toLong(), budget.max) + + val level = props[2].gene as EnumGene<*> + assertEquals(listOf("BRONZE", "GOLD"), level.values) + assertEquals("BRONZE", props[2].valueAsText()) + } }