Add support for parameters in String based queries.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(", ", "[", "]"));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user