From 8018d5d1334e410aba5ba3b2a0f8a7aa4c253ec3 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Mon, 20 May 2019 23:01:59 +0200 Subject: [PATCH] Add support for parameters in String based queries. --- .../repository/query/AbstractNeo4jQuery.java | 47 ++++ .../query/Neo4jQueryLookupStrategy.java | 22 +- .../repository/query/Neo4jQueryMethod.java | 24 ++ .../repository/query/PartTreeNeo4jQuery.java | 176 ++++++------ .../query/StringBasedNeo4jQuery.java | 172 ++++++++++-- .../neo4j/integration/PersonRepository.java | 8 + .../data/neo4j/integration/RepositoryIT.java | 14 + .../query/Neo4jQueryLookupStrategyTest.java | 83 ------ .../repository/query/RepositoryQueryTest.java | 253 ++++++++++++++++++ 9 files changed, 578 insertions(+), 221 deletions(-) delete mode 100644 spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/query/Neo4jQueryLookupStrategyTest.java create mode 100644 spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/query/RepositoryQueryTest.java diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/AbstractNeo4jQuery.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/AbstractNeo4jQuery.java index a38f6e9a3..be27b423b 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/AbstractNeo4jQuery.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/AbstractNeo4jQuery.java @@ -18,6 +18,13 @@ */ package org.springframework.data.neo4j.repository.query; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.data.domain.Range; +import org.springframework.data.geo.Circle; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Metrics; import org.springframework.data.neo4j.core.NodeManager; import org.springframework.data.neo4j.core.PreparedQuery; import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext; @@ -86,4 +93,44 @@ abstract class AbstractNeo4jQuery implements RepositoryQuery { * @return True if the query has an explicit limit set. */ protected abstract boolean isLimiting(); + + /** + * Converts parameter as needed by the query generated, which is not covered by standard conversion services. + * + * @param parameter The parameter to fit into the generated query. + * @return A parameter that fits the place holders of a generated query + */ + final Object convertParameter(Object parameter) { + if (parameter instanceof Range) { + Range range = (Range) parameter; + Map map = new HashMap<>(); + range.getLowerBound().getValue().map(this::convertParameter).ifPresent(v -> map.put("lb", v)); + range.getUpperBound().getValue().map(this::convertParameter).ifPresent(v -> map.put("ub", v)); + return map; + } else if (parameter instanceof Distance) { + return calculateDistanceInMeter((Distance) parameter); + } else if (parameter instanceof Circle) { + Circle circle = (Circle) parameter; + Map map = new HashMap<>(); + map.put("x", convertParameter(circle.getCenter().getX())); + map.put("y", convertParameter(circle.getCenter().getY())); + map.put("radius", convertParameter(calculateDistanceInMeter(circle.getRadius()))); + return map; + } + + // Good hook to check the NodeManager whether the thing is an entity and we replace the value with a known id. + + return parameter; + } + + private static double calculateDistanceInMeter(Distance distance) { + + if (distance.getMetric() == Metrics.KILOMETERS) { + return distance.getValue() / 0.001d; + } else if (distance.getMetric() == Metrics.MILES) { + return distance.getValue() / 0.00062137d; + } else { + return distance.getValue(); + } + } } diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryLookupStrategy.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryLookupStrategy.java index e1da8cf3c..079ced55d 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryLookupStrategy.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryLookupStrategy.java @@ -21,11 +21,8 @@ package org.springframework.data.neo4j.repository.query; import lombok.RequiredArgsConstructor; import java.lang.reflect.Method; -import java.util.Optional; import org.apiguardian.api.API; -import org.springframework.core.annotation.AnnotatedElementUtils; -import org.springframework.data.mapping.MappingException; import org.springframework.data.neo4j.core.NodeManager; import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext; import org.springframework.data.projection.ProjectionFactory; @@ -59,24 +56,11 @@ public final class Neo4jQueryLookupStrategy implements QueryLookupStrategy { Neo4jQueryMethod queryMethod = new Neo4jQueryMethod(method, metadata, factory); - Optional optionalQueryAnnotation = getQueryAnnotationOf(method); - if (optionalQueryAnnotation.isPresent()) { - return new StringBasedNeo4jQuery(nodeManager, mappingContext, queryMethod, - getCypherQuery(optionalQueryAnnotation), optionalQueryAnnotation); + if (queryMethod.hasQueryAnnotation()) { + return StringBasedNeo4jQuery + .create(nodeManager, mappingContext, evaluationContextProvider, queryMethod); } return new PartTreeNeo4jQuery(nodeManager, mappingContext, queryMethod); } - - /** - * @return the {@link Query} annotation that is applied to the method or an empty {@link Optional} if none available. - */ - static Optional getQueryAnnotationOf(Method method) { - return Optional.ofNullable(AnnotatedElementUtils.findMergedAnnotation(method, Query.class)); - } - - static String getCypherQuery(Optional optionalQueryAnnotation) { - return optionalQueryAnnotation.map(Query::value).filter(s -> !s.isEmpty()) - .orElseThrow(() -> new MappingException("Expected @Query annotation to have a value, but it did not.")); - } } diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryMethod.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryMethod.java index c038a6a46..d333dff47 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryMethod.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryMethod.java @@ -22,13 +22,16 @@ import static java.lang.String.*; import java.lang.reflect.Method; import java.util.List; +import java.util.Optional; import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.query.Parameter; import org.springframework.data.repository.query.Parameters; import org.springframework.data.repository.query.QueryMethod; +import org.springframework.lang.Nullable; /** * Neo4j specific implementation of {@link QueryMethod}. It contains a custom implementation of {@link Parameter} which @@ -40,6 +43,11 @@ import org.springframework.data.repository.query.QueryMethod; */ final class Neo4jQueryMethod extends QueryMethod { + /** + * Optional query annotation of the method. + */ + private @Nullable final Query queryAnnotation; + /** * Creates a new {@link Neo4jQueryMethod} from the given parameters. Looks up the correct query to use for following * invocations of the method given. @@ -50,6 +58,22 @@ final class Neo4jQueryMethod extends QueryMethod { */ Neo4jQueryMethod(Method method, RepositoryMetadata metadata, ProjectionFactory factory) { super(method, metadata, factory); + + this.queryAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, Query.class); + } + + /** + * @return True if the underlying method has been annotated with {@code @Query}. + */ + boolean hasQueryAnnotation() { + return this.queryAnnotation != null; + } + + /** + * @return the {@link Query} annotation that is applied to the method or an empty {@link Optional} if none available. + */ + Optional getQueryAnnotation() { + return Optional.ofNullable(this.queryAnnotation); } @Override diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/PartTreeNeo4jQuery.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/PartTreeNeo4jQuery.java index 8e84f68ce..692600748 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/PartTreeNeo4jQuery.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/PartTreeNeo4jQuery.java @@ -30,16 +30,11 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; -import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import org.neo4j.driver.types.Point; -import org.springframework.data.domain.Range; -import org.springframework.data.geo.Circle; -import org.springframework.data.geo.Distance; -import org.springframework.data.geo.Metrics; import org.springframework.data.neo4j.core.NodeManager; import org.springframework.data.neo4j.core.PreparedQuery; import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext; @@ -64,7 +59,6 @@ import org.springframework.util.Assert; @Slf4j final class PartTreeNeo4jQuery extends AbstractNeo4jQuery { - // /** * A set of the temporal types that are directly passable to the driver and support a meaningful comparision in a * temporal sense (after, before). @@ -93,7 +87,8 @@ final class PartTreeNeo4jQuery extends AbstractNeo4jQuery { this.tree = new PartTree(queryMethod.getName(), domainType); // Validate parts. Sort properties will be validated by Spring Data already. - this.tree.flatMap(OrPart::stream).forEach(part -> validatePart(part)); + PartValidator validator = new PartValidator(queryMethod); + this.tree.flatMap(OrPart::stream).forEach(validator::validatePart); } @Override @@ -118,105 +113,17 @@ final class PartTreeNeo4jQuery extends AbstractNeo4jQuery { .build(); } - void validatePart(Part part) { - - validateIgnoreCase(part); - switch (part.getType()) { - case AFTER: - case BEFORE: - validateTemporalProperty(part); - break; - case IS_EMPTY: - case IS_NOT_EMPTY: - validateCollectionProperty(part); - break; - case NEAR: - case WITHIN: - validatePointProperty(part); - break; - } - } - + /** + * Checks whether the given part can be queried without case sensitivity. + * + * @param part + * @return True when {@code part} can be queried case insensitive. + */ static boolean canIgnoreCase(Part part) { return part.getProperty().getLeafType() == String.class && TYPES_SUPPORTING_CASE_INSENSITIVITY .contains(part.getType()); } - private String formatTypes(Collection types) { - return types.stream().flatMap(t -> t.getKeywords().stream()).collect(joining(", ", "[", "]")); - } - - void validateIgnoreCase(Part part) { - - Assert.state(part.shouldIgnoreCase() != Part.IgnoreCaseType.ALWAYS || canIgnoreCase(part), - () -> String.format( - "Can not derive query for '%s': Only the case of String based properties can be ignored within the following keywords: %s", - super.queryMethod, - formatTypes(TYPES_SUPPORTING_CASE_INSENSITIVITY))); - } - - void validateTemporalProperty(Part part) { - - Assert.state(COMPARABLE_TEMPORAL_TYPES.contains(part.getProperty().getLeafType()), () -> String - .format( - "Can not derive query for '%s': The keywords %s work only with properties with one of the following types: %s", - super.queryMethod, formatTypes(Collections.singletonList(part.getType())), - COMPARABLE_TEMPORAL_TYPES)); - } - - void validateCollectionProperty(Part part) { - Assert.state(part.getProperty().getLeafProperty().isCollection(), () -> String - .format("Can not derive query for '%s': The keywords %s work only with collection properties", - super.queryMethod, - formatTypes(Collections.singletonList(part.getType())))); - } - - void validatePointProperty(Part part) { - - Assert.state(ClassTypeInformation.from(Point.class) - .isAssignableFrom(part.getProperty().getLeafProperty().getTypeInformation()), () -> String - .format("Can not derive query for '%s': %s works only with spatial properties", super.queryMethod, - part.getType())); - } - - /** - * Converts parameter as needed by the query generated, which is not covered by standard conversion services - * - * @param parameter The parameter to fit into the generated query. - * @return A parameter that fits the place holders of a generated query - */ - static Object convertParameter(Object parameter) { - if (parameter instanceof Range) { - Range range = (Range) parameter; - Map map = new HashMap<>(); - range.getLowerBound().getValue().map(PartTreeNeo4jQuery::convertParameter).ifPresent(v -> map.put("lb", v)); - range.getUpperBound().getValue().map(PartTreeNeo4jQuery::convertParameter).ifPresent(v -> map.put("ub", v)); - return map; - } else if (parameter instanceof Distance) { - return calculateDistanceInMeter((Distance) parameter); - } else if (parameter instanceof Circle) { - Circle circle = (Circle) parameter; - Map map = new HashMap<>(); - map.put("x", convertParameter(circle.getCenter().getX())); - map.put("y", convertParameter(circle.getCenter().getY())); - map.put("radius", convertParameter(calculateDistanceInMeter(circle.getRadius()))); - return map; - } - return parameter; - } - - private static double calculateDistanceInMeter(Distance distance) { - - if (distance.getMetric() == Metrics.KILOMETERS) { - return distance.getValue() / 0.001d; - } else if (distance.getMetric() == Metrics.MILES) { - return distance.getValue() / 0.00062137d; - } else { - return distance.getValue(); - } - } - - @Override protected boolean isCountQuery() { return tree.isCountProjection(); @@ -236,4 +143,71 @@ final class PartTreeNeo4jQuery extends AbstractNeo4jQuery { protected boolean isLimiting() { return tree.isLimiting(); } + + static class PartValidator { + + private final Neo4jQueryMethod queryMethod; + + PartValidator(Neo4jQueryMethod queryMethod) { + this.queryMethod = queryMethod; + } + + void validatePart(Part part) { + + validateIgnoreCase(part); + switch (part.getType()) { + case AFTER: + case BEFORE: + validateTemporalProperty(part); + break; + case IS_EMPTY: + case IS_NOT_EMPTY: + validateCollectionProperty(part); + break; + case NEAR: + case WITHIN: + validatePointProperty(part); + break; + } + } + + private void validateIgnoreCase(Part part) { + + Assert.state(part.shouldIgnoreCase() != Part.IgnoreCaseType.ALWAYS || canIgnoreCase(part), + () -> String.format( + "Can not derive query for '%s': Only the case of String based properties can be ignored within the following keywords: %s", + queryMethod, + formatTypes(TYPES_SUPPORTING_CASE_INSENSITIVITY))); + } + + private void validateTemporalProperty(Part part) { + + Assert.state(COMPARABLE_TEMPORAL_TYPES.contains(part.getProperty().getLeafType()), () -> String + .format( + "Can not derive query for '%s': The keywords %s work only with properties with one of the following types: %s", + queryMethod, formatTypes(Collections.singletonList(part.getType())), + COMPARABLE_TEMPORAL_TYPES)); + } + + private void validateCollectionProperty(Part part) { + + Assert.state(part.getProperty().getLeafProperty().isCollection(), () -> String + .format("Can not derive query for '%s': The keywords %s work only with collection properties", + queryMethod, + formatTypes(Collections.singletonList(part.getType())))); + } + + private void validatePointProperty(Part part) { + + Assert.state(ClassTypeInformation.from(Point.class) + .isAssignableFrom(part.getProperty().getLeafProperty().getTypeInformation()), () -> String + .format("Can not derive query for '%s': %s works only with spatial properties", queryMethod, + part.getType())); + } + + private static String formatTypes(Collection types) { + return types.stream().flatMap(t -> t.getKeywords().stream()).collect(joining(", ", "[", "]")); + } + + } } diff --git a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/StringBasedNeo4jQuery.java b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/StringBasedNeo4jQuery.java index d81875be1..010a09d30 100644 --- a/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/StringBasedNeo4jQuery.java +++ b/spring-data-neo4j/src/main/java/org/springframework/data/neo4j/repository/query/StringBasedNeo4jQuery.java @@ -18,16 +18,36 @@ */ package org.springframework.data.neo4j.repository.query; -import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.Optional; +import org.springframework.data.mapping.MappingException; import org.springframework.data.neo4j.core.NodeManager; import org.springframework.data.neo4j.core.PreparedQuery; import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext; +import org.springframework.data.repository.query.Parameter; +import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.SpelEvaluator; +import org.springframework.data.repository.query.SpelQueryContext; +import org.springframework.data.repository.query.SpelQueryContext.SpelExtractor; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; /** - * Implementation of {@link RepositoryQuery} for String based custom Cypher query. + * Implementation of {@link RepositoryQuery} for query methods annotated with {@link Query @Query}. + * + * + * The flow to handle queries with SpEL parameters is as follows + *
    + *
  1. Parse template as something that has SpEL-expressions in it
  2. + *
  3. Replace the SpEL-expressions with Neo4j Statement template parameters
  4. + *
  5. The parameters passed here _and_ the values that might have been computed during SpEL-parsing
  6. + *
+ * The main ingredient is a SpelEvaluator, that parses a template and replaces SpEL expressions + * with real Neo4j parameters. * * @author Gerrit Meier * @author Michael J. Simons @@ -35,30 +55,105 @@ import org.springframework.data.repository.query.RepositoryQuery; */ final class StringBasedNeo4jQuery extends AbstractNeo4jQuery { - private final String cypherQuery; + /** + * Used for extracting SpEL expressions inside Cypher query templates. + */ + static final SpelQueryContext SPEL_QUERY_CONTEXT = SpelQueryContext + .of(StringBasedNeo4jQuery::parameterNameSource, StringBasedNeo4jQuery::replacementSource); + /** + * Is this a count projection? + */ private final boolean countQuery; + + /** + * Is this an exists projection? + */ private final boolean existsQuery; + + /** + * Is this a modifying delete query? + */ private final boolean deleteQuery; - StringBasedNeo4jQuery(NodeManager nodeManager, Neo4jMappingContext mappingContext, Neo4jQueryMethod queryMethod, - String cypherQuery, - Optional optionalQueryAnnotation) { + /** + * Used to evaluate the expression found while parsing the cypher template of this query against the actual parameters + * with the help of the formal parameters during the building of the {@link PreparedQuery}. + */ + private final SpelEvaluator spelEvaluator; + + /** + * The Cypher string used for this query. The cypher query will not be changed after parsed via {@link #SPEL_QUERY_CONTEXT}. + * All SpEL expressions will be substituted via "native" parameter placeholders. This will be done via the {@link #spelEvaluator}. + */ + private final String cypherQuery; + + /** + * Create a {@link StringBasedNeo4jQuery} for a query method that is annotated with {@link Query @Query}. The annotation + * is expected to have a value. + * + * @param nodeManager + * @param mappingContext + * @param evaluationContextProvider + * @param queryMethod + * @return A new instance of a String based Neo4j query. + */ + static StringBasedNeo4jQuery create(NodeManager nodeManager, Neo4jMappingContext mappingContext, + QueryMethodEvaluationContextProvider evaluationContextProvider, + Neo4jQueryMethod queryMethod) { + + Query queryAnnotation = queryMethod.getQueryAnnotation() + .orElseThrow(() -> new MappingException("Expected @Query annotation on the query method!")); + + String cypherTemplate = Optional.ofNullable(queryAnnotation.value()) + .filter(StringUtils::hasText) + .orElseThrow(() -> new MappingException("Expected @Query annotation to have a value, but it did not.")); + + return new StringBasedNeo4jQuery(nodeManager, mappingContext, evaluationContextProvider, queryMethod, + cypherTemplate, queryAnnotation.count(), queryAnnotation.exists(), queryAnnotation.delete()); + } + + /** + * Create a {@link StringBasedNeo4jQuery} based on an explicit Cypher template. + * + * @param nodeManager + * @param mappingContext + * @param evaluationContextProvider + * @param queryMethod + * @param cypherTemplate The template to use. + * @return A new instance of a String based Neo4j query. + */ + static StringBasedNeo4jQuery create(NodeManager nodeManager, Neo4jMappingContext mappingContext, + QueryMethodEvaluationContextProvider evaluationContextProvider, + Neo4jQueryMethod queryMethod, String cypherTemplate) { + + Assert.hasText(cypherTemplate, "Cannot create String based Neo4j query without a cypher template."); + + return new StringBasedNeo4jQuery(nodeManager, mappingContext, evaluationContextProvider, queryMethod, + cypherTemplate, false, false, false); + } + + private StringBasedNeo4jQuery(NodeManager nodeManager, + Neo4jMappingContext mappingContext, QueryMethodEvaluationContextProvider evaluationContextProvider, + Neo4jQueryMethod queryMethod, String cypherTemplate, boolean countQuery, + boolean existsQuery, boolean deleteQuery) { super(nodeManager, mappingContext, queryMethod); - this.cypherQuery = cypherQuery; + this.countQuery = countQuery; + this.existsQuery = existsQuery; + this.deleteQuery = deleteQuery; - if (optionalQueryAnnotation.isPresent()) { - Query queryAnnotation = optionalQueryAnnotation.get(); - countQuery = queryAnnotation.count(); - existsQuery = queryAnnotation.exists(); - deleteQuery = queryAnnotation.delete(); - } else { - countQuery = false; - existsQuery = false; - deleteQuery = false; - } + SpelExtractor spelExtractor = SPEL_QUERY_CONTEXT.parse(cypherTemplate); + this.spelEvaluator = new SpelEvaluator(evaluationContextProvider, queryMethod.getParameters(), spelExtractor); + this.cypherQuery = spelExtractor.getQueryString(); + } + + static String getQueryTemplate(Query queryAnnotation) { + + return Optional.ofNullable(queryAnnotation.value()) + .filter(StringUtils::hasText) + .orElseThrow(() -> new MappingException("Expected @Query annotation to have a value, but it did not.")); } @Override @@ -66,7 +161,7 @@ final class StringBasedNeo4jQuery extends AbstractNeo4jQuery { return PreparedQuery.queryFor(super.domainType) .withCypherQuery(cypherQuery) - .withParameters(Collections.emptyMap()) // TODO Map parameters. + .withParameters(bindParameters(parameters)) .usingMappingFunction(mappingContext.getMappingFunctionFor(super.domainType).orElse(null)) // Null is fine .build(); } @@ -90,4 +185,45 @@ final class StringBasedNeo4jQuery extends AbstractNeo4jQuery { protected boolean isLimiting() { return false; } + + Map bindParameters(Object[] actualParameters) { + + final Parameters formalParameters = queryMethod.getParameters(); + + Map resolvedParameters = new HashMap<>(spelEvaluator.evaluate(actualParameters)); + formalParameters.stream() + .filter(Parameter::isBindable) + .forEach(parameter -> { + + int parameterIndex = parameter.getIndex(); + Object parameterValue = super.convertParameter(actualParameters[parameterIndex]); + + // Add the parameter under it's name when possible + parameter.getName() + .ifPresent(parameterName -> resolvedParameters.put(parameterName, parameterValue)); + // Always add under its index. + resolvedParameters.put(Integer.toString(parameterIndex), parameterValue); + }); + + return resolvedParameters; + } + + /** + * @param index + * @param originalSpelExpression Not used for configuring parameter names atm. + * @return + */ + private static String parameterNameSource(int index, @SuppressWarnings("unused") String originalSpelExpression) { + return "__SpEL__" + index; + } + + /** + * @param originalPrefix The prefix passed to the replacement source is either ':' or '?', so that isn't usable for + * Cypher templates and therefore ignored. + * @param parameterName + * @return + */ + private static String replacementSource(@SuppressWarnings("unused") String originalPrefix, String parameterName) { + return "$" + parameterName; + } } diff --git a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/integration/PersonRepository.java b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/integration/PersonRepository.java index 9fc1d58fd..2e7c46c2c 100644 --- a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/integration/PersonRepository.java +++ b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/integration/PersonRepository.java @@ -28,6 +28,7 @@ import org.springframework.data.geo.Circle; import org.springframework.data.geo.Distance; import org.springframework.data.neo4j.repository.Neo4jRepository; import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; /** @@ -49,6 +50,13 @@ public interface PersonRepository extends Neo4jRepository getOptionalPersonViaQuery(); + @Query("MATCH (n:PersonWithAllConstructor{name:$name}) return n") + Optional getOptionalPersonViaQuery(@Param("name") String name); + + @Query("MATCH (n:PersonWithAllConstructor{name::#{#part1 + #part2}}) return n") + Optional getOptionalPersonViaQuery(@Param("part1") String part1, + @Param("part2") String part2); + @Query("MATCH (n:PersonWithNoConstructor) return n") List getAllPersonsWithNoConstructorViaQuery(); diff --git a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/integration/RepositoryIT.java b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/integration/RepositoryIT.java index 5acf5a402..c0b420a13 100644 --- a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/integration/RepositoryIT.java +++ b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/integration/RepositoryIT.java @@ -331,6 +331,20 @@ class RepositoryIT { assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME); } + @Test + void loadOptionalPersonWithAllConstructorWithParameter() { + Optional person = repository.getOptionalPersonViaQuery(TEST_PERSON1_NAME); + assertThat(person).isPresent(); + assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME); + } + + @Test + void loadOptionalPersonWithAllConstructorWithSpelParameters() { + Optional person = repository.getOptionalPersonViaQuery(TEST_PERSON1_NAME.substring(0, 2), TEST_PERSON1_NAME.substring(2)); + assertThat(person).isPresent(); + assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME); + } + @Test void loadAllPersonsWithNoConstructor() { List persons = repository.getAllPersonsWithNoConstructorViaQuery(); diff --git a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/query/Neo4jQueryLookupStrategyTest.java b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/query/Neo4jQueryLookupStrategyTest.java deleted file mode 100644 index c272ad706..000000000 --- a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/query/Neo4jQueryLookupStrategyTest.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (c) 2019 "Neo4j," - * Neo4j Sweden AB [https://neo4j.com] - * - * This file is part of Neo4j. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.neo4j.repository.query; - -import static org.assertj.core.api.Assertions.*; - -import java.lang.reflect.Method; -import java.util.List; -import java.util.Optional; - -import org.junit.jupiter.api.Test; -import org.springframework.data.mapping.MappingException; -import org.springframework.data.repository.Repository; - -/** - * @author Gerrit Meier - * @author Michael J. Simons - */ -class Neo4jQueryLookupStrategyTest { - - private static final String CUSTOM_CYPHER_QUERY = "MATCH (n) return n"; - - @Test - void shouldFindAnnotatedQuery() throws Exception { - - Method method = queryMethod("annotatedQuery"); - Optional optionalQueryAnnotation = Neo4jQueryLookupStrategy.getQueryAnnotationOf(method); - assertThat(Neo4jQueryLookupStrategy.getCypherQuery(optionalQueryAnnotation)).isEqualTo(CUSTOM_CYPHER_QUERY); - } - - @Test - void shouldDetectInvalidAnnotation() throws Exception { - - Method method = queryMethod("invalidAnnotatedQuery"); - Optional optionalQueryAnnotation = Neo4jQueryLookupStrategy.getQueryAnnotationOf(method); - assertThatExceptionOfType(MappingException.class) - .isThrownBy(() -> Neo4jQueryLookupStrategy.getCypherQuery(optionalQueryAnnotation)) - .withMessage("Expected @Query annotation to have a value, but it did not."); - } - - @Test - void findQueryAnnotation() throws Exception { - - Method method = queryMethod("annotatedQuery"); - Optional optionalQueryAnnotation = Neo4jQueryLookupStrategy.getQueryAnnotationOf(method); - assertThat(optionalQueryAnnotation).isPresent(); - } - - private Method queryMethod(String name, Class... parameters) throws Exception { - Class repositoryClass = PersonRepository.class; - - return repositoryClass.getMethod(name, parameters); - } - - interface PersonRepository extends Repository { - - @Query(CUSTOM_CYPHER_QUERY) - List annotatedQuery(); - - @Query - List invalidAnnotatedQuery(); - } - - class Person { - } - -} diff --git a/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/query/RepositoryQueryTest.java b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/query/RepositoryQueryTest.java new file mode 100644 index 000000000..be8c885ef --- /dev/null +++ b/spring-data-neo4j/src/test/java/org/springframework/data/neo4j/repository/query/RepositoryQueryTest.java @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.repository.query; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.neo4j.driver.Values; +import org.neo4j.driver.types.Point; +import org.springframework.data.annotation.Id; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.neo4j.core.NodeManager; +import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; +import org.springframework.data.repository.query.Param; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.SpelQueryContext; +import org.springframework.util.ReflectionUtils; + +/** + * Unit tests for + *
    + *
  • {@link Neo4jQueryLookupStrategy}
  • + *
  • {@link Neo4jQueryMethod}
  • + *
  • {@link StringBasedNeo4jQuery}
  • + *
+ * + * @author Michael J. Simons + */ +@ExtendWith(MockitoExtension.class) +final class RepositoryQueryTest { + + private static final String CUSTOM_CYPHER_QUERY = "MATCH (n) return n"; + + private static final RepositoryMetadata TEST_REPOSITORY_METADATA = new DefaultRepositoryMetadata( + TestRepository.class); + + private static final ProjectionFactory PROJECTION_FACTORY = new SpelAwareProxyProjectionFactory(); + + @Mock + NodeManager nodeManager; + + @Mock + Neo4jMappingContext schema; + + @Mock + NamedQueries namedQueries; + + @Nested + class Neo4jQueryMethodTest { + + @Test + void findQueryAnnotation() { + + Neo4jQueryMethod neo4jQueryMethod = neo4jQueryMethod("annotatedQueryWithValidTemplate"); + + Optional optionalQueryAnnotation = neo4jQueryMethod.getQueryAnnotation(); + assertThat(optionalQueryAnnotation).isPresent(); + } + } + + @Nested + class Neo4jQueryLookupStrategyTest { + + @Test + void shouldSelectPartTreeNeo4jQuery() { + + final Neo4jQueryLookupStrategy lookupStrategy = new Neo4jQueryLookupStrategy(mock(NodeManager.class), mock( + Neo4jMappingContext.class), QueryMethodEvaluationContextProvider.DEFAULT); + + RepositoryQuery query = lookupStrategy + .resolveQuery(queryMethod("findById", Object.class), TEST_REPOSITORY_METADATA, PROJECTION_FACTORY, + namedQueries); + assertThat(query).isInstanceOf(PartTreeNeo4jQuery.class); + } + + @Test + void shouldSelectStringBasedNeo4jQuery() { + + final Neo4jQueryLookupStrategy lookupStrategy = new Neo4jQueryLookupStrategy(mock(NodeManager.class), mock( + Neo4jMappingContext.class), QueryMethodEvaluationContextProvider.DEFAULT); + + RepositoryQuery query = lookupStrategy + .resolveQuery(queryMethod("annotatedQueryWithValidTemplate"), TEST_REPOSITORY_METADATA, + PROJECTION_FACTORY, namedQueries); + assertThat(query).isInstanceOf(StringBasedNeo4jQuery.class); + } + } + + @Nested + class StringBasedNeo4jQueryTest { + + @Test + void spelQueryContextShouldBeConfiguredCorrectly() { + + SpelQueryContext spelQueryContext = StringBasedNeo4jQuery.SPEL_QUERY_CONTEXT; + + String template; + String query; + SpelQueryContext.SpelExtractor spelExtractor; + + template = "MATCH (user:User) WHERE user.name = :#{#searchUser.name} and user.middleName = ?#{#searchUser.middleName} RETURN user"; + + spelExtractor = spelQueryContext.parse(template); + query = spelExtractor.getQueryString(); + + assertThat(query) + .isEqualTo( + "MATCH (user:User) WHERE user.name = $__SpEL__0 and user.middleName = $__SpEL__1 RETURN user"); + + template = "MATCH (user:User) WHERE user.name=?#{[0]} and user.name=:#{[0]} RETURN user"; + spelExtractor = spelQueryContext.parse(template); + query = spelExtractor.getQueryString(); + + assertThat(query) + .isEqualTo("MATCH (user:User) WHERE user.name=$__SpEL__0 and user.name=$__SpEL__1 RETURN user"); + } + + @Test + void shouldExtractQueryTemplate() { + + Neo4jQueryMethod method = neo4jQueryMethod("annotatedQueryWithValidTemplate"); + + assertThat(StringBasedNeo4jQuery.getQueryTemplate(method.getQueryAnnotation().get())) + .isEqualTo(CUSTOM_CYPHER_QUERY); + } + + @Test + void shouldDetectInvalidAnnotation() { + + Neo4jQueryMethod method = neo4jQueryMethod("annotatedQueryWithoutTemplate"); + + assertThatExceptionOfType(MappingException.class) + .isThrownBy( + () -> StringBasedNeo4jQuery.create(mock(NodeManager.class), mock(Neo4jMappingContext.class), + QueryMethodEvaluationContextProvider.DEFAULT, method)) + .withMessage("Expected @Query annotation to have a value, but it did not."); + } + + @Test + void shouldBindParameters() { + + Neo4jQueryMethod method = RepositoryQueryTest + .neo4jQueryMethod("annotatedQueryWithValidTemplate", String.class, String.class); + + StringBasedNeo4jQuery repositoryQuery = StringBasedNeo4jQuery.create(mock(NodeManager.class), + mock(Neo4jMappingContext.class), QueryMethodEvaluationContextProvider.DEFAULT, + method); + + Map resolveParameters = repositoryQuery + .bindParameters(new Object[] { "A String", "Another String" }); + + assertThat(resolveParameters) + .containsEntry("0", "A String") + .containsEntry("1", "Another String"); + } + + @Test + void shouldResolveNamedParameters() { + + Neo4jQueryMethod method = RepositoryQueryTest + .neo4jQueryMethod("findByDontDoThisInRealLiveNamed", org.neo4j.driver.types.Point.class, String.class, + String.class); + + StringBasedNeo4jQuery repositoryQuery = StringBasedNeo4jQuery.create(mock(NodeManager.class), + mock(Neo4jMappingContext.class), QueryMethodEvaluationContextProvider.DEFAULT, + method); + + Point thePoint = Values.point(4223, 1, 2).asPoint(); + Map resolveParameters = repositoryQuery.bindParameters( + new Object[] { thePoint, "TheName", "TheFirstName" }); + + assertThat(resolveParameters) + .hasSize(8) + .containsEntry("0", thePoint) + .containsEntry("location", thePoint) + .containsEntry("1", "TheName") + .containsEntry("name", "TheName") + .containsEntry("2", "TheFirstName") + .containsEntry("firstName", "TheFirstName") + .containsEntry("__SpEL__0", "TheFirstName") + .containsEntry("__SpEL__1", "TheNameTheFirstName"); + } + } + + static Method queryMethod(String name, Class... parameters) { + + return ReflectionUtils.findMethod(TestRepository.class, name, parameters); + } + + static Neo4jQueryMethod neo4jQueryMethod(String name, Class... parameters) { + + return new Neo4jQueryMethod(ReflectionUtils.findMethod(TestRepository.class, name, parameters), + TEST_REPOSITORY_METADATA, PROJECTION_FACTORY); + } + + static class TestEntity { + @Id + private Long id; + } + + interface TestRepository extends CrudRepository { + + @Query("MATCH (n:Test) WHERE n.name = $name AND n.firstName = :#{#firstName} AND n.fullName = ?#{#name + #firstName} AND p.location = $location return n") + Optional findByDontDoThisInRealLiveNamed(@Param("location") org.neo4j.driver.types.Point location, + @Param("name") String name, + @Param("firstName") String aFirstName); + + @Query("MATCH (n:Test) WHERE n.name = $0 OR n.name = $1") + List annotatedQueryWithValidTemplate(String name, String anotherName); + + @Query(CUSTOM_CYPHER_QUERY) + List annotatedQueryWithValidTemplate(); + + @Query + List annotatedQueryWithoutTemplate(); + } + + private RepositoryQueryTest() { + } +}