Add support for parameters in String based queries.

This commit is contained in:
Michael Simons
2019-05-20 23:01:59 +02:00
parent ed962790ae
commit 8018d5d133
9 changed files with 578 additions and 221 deletions

View File

@@ -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<String, Object> 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<String, Object> 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();
}
}
}

View File

@@ -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<Query> 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<Query> getQueryAnnotationOf(Method method) {
return Optional.ofNullable(AnnotatedElementUtils.findMergedAnnotation(method, Query.class));
}
static String getCypherQuery(Optional<Query> optionalQueryAnnotation) {
return optionalQueryAnnotation.map(Query::value).filter(s -> !s.isEmpty())
.orElseThrow(() -> new MappingException("Expected @Query annotation to have a value, but it did not."));
}
}

View File

@@ -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<Query> getQueryAnnotation() {
return Optional.ofNullable(this.queryAnnotation);
}
@Override

View File

@@ -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<Part.Type> 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<String, Object> 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<String, Object> 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<Part.Type> types) {
return types.stream().flatMap(t -> t.getKeywords().stream()).collect(joining(", ", "[", "]"));
}
}
}

View File

@@ -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
* <ol>
* <li>Parse template as something that has SpEL-expressions in it</li>
* <li>Replace the SpEL-expressions with Neo4j Statement template parameters</li>
* <li>The parameters passed here _and_ the values that might have been computed during SpEL-parsing</li>
* </ol>
* 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<Query> 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<String, Object> bindParameters(Object[] actualParameters) {
final Parameters<?, ?> formalParameters = queryMethod.getParameters();
Map<String, Object> 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;
}
}

View File

@@ -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<PersonWithAllConstruct
@Query("MATCH (n:PersonWithAllConstructor{name:'Test'}) return n")
Optional<PersonWithAllConstructor> getOptionalPersonViaQuery();
@Query("MATCH (n:PersonWithAllConstructor{name:$name}) return n")
Optional<PersonWithAllConstructor> getOptionalPersonViaQuery(@Param("name") String name);
@Query("MATCH (n:PersonWithAllConstructor{name::#{#part1 + #part2}}) return n")
Optional<PersonWithAllConstructor> getOptionalPersonViaQuery(@Param("part1") String part1,
@Param("part2") String part2);
@Query("MATCH (n:PersonWithNoConstructor) return n")
List<PersonWithNoConstructor> getAllPersonsWithNoConstructorViaQuery();

View File

@@ -331,6 +331,20 @@ class RepositoryIT {
assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME);
}
@Test
void loadOptionalPersonWithAllConstructorWithParameter() {
Optional<PersonWithAllConstructor> person = repository.getOptionalPersonViaQuery(TEST_PERSON1_NAME);
assertThat(person).isPresent();
assertThat(person.get().getName()).isEqualTo(TEST_PERSON1_NAME);
}
@Test
void loadOptionalPersonWithAllConstructorWithSpelParameters() {
Optional<PersonWithAllConstructor> 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<PersonWithNoConstructor> persons = repository.getAllPersonsWithNoConstructorViaQuery();

View File

@@ -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<Query> optionalQueryAnnotation = Neo4jQueryLookupStrategy.getQueryAnnotationOf(method);
assertThat(Neo4jQueryLookupStrategy.getCypherQuery(optionalQueryAnnotation)).isEqualTo(CUSTOM_CYPHER_QUERY);
}
@Test
void shouldDetectInvalidAnnotation() throws Exception {
Method method = queryMethod("invalidAnnotatedQuery");
Optional<Query> 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<Query> optionalQueryAnnotation = Neo4jQueryLookupStrategy.getQueryAnnotationOf(method);
assertThat(optionalQueryAnnotation).isPresent();
}
private Method queryMethod(String name, Class<?>... parameters) throws Exception {
Class<PersonRepository> repositoryClass = PersonRepository.class;
return repositoryClass.getMethod(name, parameters);
}
interface PersonRepository extends Repository<Person, Long> {
@Query(CUSTOM_CYPHER_QUERY)
List<Person> annotatedQuery();
@Query
List<Person> invalidAnnotatedQuery();
}
class Person {
}
}

View File

@@ -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
* <ul>
* <li>{@link Neo4jQueryLookupStrategy}</li>
* <li>{@link Neo4jQueryMethod}</li>
* <li>{@link StringBasedNeo4jQuery}</li>
* </ul>
*
* @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<Query> 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<String, Object> 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<String, Object> 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<TestEntity, Long> {
@Query("MATCH (n:Test) WHERE n.name = $name AND n.firstName = :#{#firstName} AND n.fullName = ?#{#name + #firstName} AND p.location = $location return n")
Optional<TestEntity> 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<TestEntity> annotatedQueryWithValidTemplate(String name, String anotherName);
@Query(CUSTOM_CYPHER_QUERY)
List<TestEntity> annotatedQueryWithValidTemplate();
@Query
List<TestEntity> annotatedQueryWithoutTemplate();
}
private RepositoryQueryTest() {
}
}