feat: Add support for GeoResults, GeoPage and Flux<GeoResult>. (#2939)

This adds support for returning `GeoResults<T>` and `GeoPage<T>` from any imperative repository on "near" queries, with and without maximum or range distance. These iterates will contain `GeoResult<T>`, with each including the distance to the required reference point.

For reactive only `Flux<GeoResult<T>>` is supported.

This PR also fixes an issues that would occur when using a "near" query on a domain that might contain circles, as the reference place was not passed in the pen-ultimate query generated in those cases.

Closes #2908
This commit is contained in:
Michael Simons
2024-08-16 15:03:30 +02:00
parent 48691a7e42
commit 9cc5a85b98
22 changed files with 681 additions and 113 deletions

View File

@@ -1259,7 +1259,7 @@ public final class Neo4jTemplate implements
boolean containsPossibleCircles = entityMetaData != null && entityMetaData.containsPossibleCircles(queryFragments::includeField);
if (cypherQuery == null || containsPossibleCircles) {
Statement statement;
if (containsPossibleCircles && !queryFragments.isScalarValueReturn()) {
NodesAndRelationshipsByIdStatementProvider nodesAndRelationshipsById =
createNodesAndRelationshipsByIdStatementProvider(entityMetaData, queryFragments, queryFragmentsAndParameters.getParameters());
@@ -1267,13 +1267,12 @@ public final class Neo4jTemplate implements
if (nodesAndRelationshipsById.hasRootNodeIds()) {
return Optional.empty();
}
cypherQuery = renderer.render(nodesAndRelationshipsById.toStatement(entityMetaData));
finalParameters = nodesAndRelationshipsById.getParameters();
statement = nodesAndRelationshipsById.toStatement(entityMetaData);
} else {
Statement statement = queryFragments.toStatement();
cypherQuery = renderer.render(statement);
finalParameters = TemplateSupport.mergeParameters(statement, finalParameters);
statement = queryFragments.toStatement();
}
cypherQuery = renderer.render(statement);
finalParameters = TemplateSupport.mergeParameters(statement, finalParameters);
}
Neo4jClient.MappingSpec<T> newMappingSpec = neo4jClient.query(cypherQuery)

View File

@@ -728,9 +728,11 @@ public final class ReactiveNeo4jTemplate implements
boolean containsPossibleCircles = entityMetaData != null && entityMetaData.containsPossibleCircles(queryFragments::includeField);
if (containsPossibleCircles && !queryFragments.isScalarValueReturn()) {
return createNodesAndRelationshipsByIdStatementProvider(entityMetaData, queryFragments, queryFragmentsAndParameters.getParameters())
.flatMap(finalQueryAndParameters ->
createExecutableQuery(domainType, resultType, renderer.render(finalQueryAndParameters.toStatement(entityMetaData)),
finalQueryAndParameters.getParameters()));
.flatMap(finalQueryAndParameters -> {
var statement = finalQueryAndParameters.toStatement(entityMetaData);
return createExecutableQuery(domainType, resultType, renderer.render(statement),
statement.getCatalog().getParameters());
});
}
return createExecutableQuery(domainType, resultType, queryFragments.toStatement(), queryFragmentsAndParameters.getParameters());
@@ -1146,9 +1148,11 @@ public final class ReactiveNeo4jTemplate implements
if (containsPossibleCircles && !queryFragments.isScalarValueReturn()) {
return createNodesAndRelationshipsByIdStatementProvider(entityMetaData, queryFragments, finalParameters)
.map(nodesAndRelationshipsById -> {
ReactiveNeo4jClient.MappingSpec<T> mappingSpec = this.neo4jClient.query(renderer.render(
nodesAndRelationshipsById.toStatement(entityMetaData)))
.bindAll(nodesAndRelationshipsById.getParameters()).fetchAs(resultType);
var statement = nodesAndRelationshipsById.toStatement(entityMetaData);
ReactiveNeo4jClient.MappingSpec<T> mappingSpec = this.neo4jClient
.query(renderer.render(statement))
.bindAll(statement.getCatalog().getParameters())
.fetchAs(resultType);
ReactiveNeo4jClient.RecordFetchSpec<T> fetchSpec = preparedQuery.getOptionalMappingFunction()
.map(mappingSpec::mappedBy).orElse(mappingSpec);

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.data.neo4j.core;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
@@ -35,6 +36,7 @@ import java.util.stream.StreamSupport;
import org.apiguardian.api.API;
import org.neo4j.cypherdsl.core.Cypher;
import org.neo4j.cypherdsl.core.Expression;
import org.neo4j.cypherdsl.core.FunctionInvocation;
import org.neo4j.cypherdsl.core.Functions;
import org.neo4j.cypherdsl.core.Named;
@@ -200,15 +202,7 @@ public final class TemplateSupport {
this.parameters.put(RELATIONSHIP_IDS, relationshipsIds);
this.parameters.put(RELATED_NODE_IDS, relatedNodeIds);
this.queryFragments = queryFragments;
}
Map<String, Object> getParameters() {
Map<String, Object> result = new HashMap<>(3);
result.put(ROOT_NODE_IDS, convertToLongIdOrStringElementId(this.parameters.get(ROOT_NODE_IDS)));
result.put(RELATIONSHIP_IDS, convertToLongIdOrStringElementId(this.parameters.get(RELATIONSHIP_IDS)));
result.put(RELATED_NODE_IDS, convertToLongIdOrStringElementId(this.parameters.get(RELATED_NODE_IDS)));
return Collections.unmodifiableMap(result);
}
boolean hasRootNodeIds() {
@@ -220,15 +214,22 @@ public final class TemplateSupport {
String primaryLabel = nodeDescription.getPrimaryLabel();
Node rootNodes = Cypher.node(primaryLabel).named(ROOT_NODE_IDS);
Node relatedNodes = Cypher.anyNode(RELATED_NODE_IDS);
List<Expression> projection = new ArrayList<>();
projection.add(Constants.NAME_OF_TYPED_ROOT_NODE.apply(nodeDescription).as(Constants.NAME_OF_SYNTHESIZED_ROOT_NODE));
projection.add(Cypher.name(Constants.NAME_OF_SYNTHESIZED_RELATIONS));
projection.add(Cypher.name(Constants.NAME_OF_SYNTHESIZED_RELATED_NODES));
projection.addAll(queryFragments.getAdditionalReturnExpressions());
Relationship relationships = Cypher.anyNode().relationshipBetween(Cypher.anyNode()).named(RELATIONSHIP_IDS);
return Cypher.match(rootNodes)
.where(elementIdFunction.apply(rootNodes).in(Cypher.parameter(ROOT_NODE_IDS)))
.where(elementIdFunction.apply(rootNodes).in(Cypher.parameter(ROOT_NODE_IDS, convertToLongIdOrStringElementId(this.parameters.get(ROOT_NODE_IDS)))))
.with(Functions.collect(rootNodes).as(Constants.NAME_OF_ROOT_NODE))
.optionalMatch(relationships)
.where(elementIdFunction.apply(relationships).in(Cypher.parameter(RELATIONSHIP_IDS)))
.where(elementIdFunction.apply(relationships).in(Cypher.parameter(RELATIONSHIP_IDS, convertToLongIdOrStringElementId(this.parameters.get(RELATIONSHIP_IDS)))))
.with(Constants.NAME_OF_ROOT_NODE, Functions.collectDistinct(relationships).as(Constants.NAME_OF_SYNTHESIZED_RELATIONS))
.optionalMatch(relatedNodes)
.where(elementIdFunction.apply(relatedNodes).in(Cypher.parameter(RELATED_NODE_IDS)))
.where(elementIdFunction.apply(relatedNodes).in(Cypher.parameter(RELATED_NODE_IDS, convertToLongIdOrStringElementId(this.parameters.get(RELATED_NODE_IDS)))))
.with(
Constants.NAME_OF_ROOT_NODE,
Cypher.name(Constants.NAME_OF_SYNTHESIZED_RELATIONS).as(Constants.NAME_OF_SYNTHESIZED_RELATIONS),
@@ -240,11 +241,7 @@ public final class TemplateSupport {
Cypher.name(Constants.NAME_OF_SYNTHESIZED_RELATIONS),
Cypher.name(Constants.NAME_OF_SYNTHESIZED_RELATED_NODES))
.orderBy(queryFragments.getOrderBy())
.returning(
Constants.NAME_OF_TYPED_ROOT_NODE.apply(nodeDescription).as(Constants.NAME_OF_SYNTHESIZED_ROOT_NODE),
Cypher.name(Constants.NAME_OF_SYNTHESIZED_RELATIONS),
Cypher.name(Constants.NAME_OF_SYNTHESIZED_RELATED_NODES)
)
.returning(projection)
.skip(queryFragments.getSkip())
.limit(queryFragments.getLimit()).build();
}

View File

@@ -725,35 +725,40 @@ public enum CypherGenerator {
* @param nodeDescription Description of the root node
* @param includeField A predicate derived from the set of included properties. This is only relevant in various forms
* of projections which allow to exclude one or more fields.
* @param additionalExpressions any additional expressions to add to the return statement
* @return An expression to be returned by a Cypher statement
*/
public Collection<Expression> createReturnStatementForMatch(Neo4jPersistentEntity<?> nodeDescription,
Predicate<PropertyFilter.RelaxedPropertyPath> includeField) {
Predicate<PropertyFilter.RelaxedPropertyPath> includeField, Expression... additionalExpressions) {
List<RelationshipDescription> processedRelationships = new ArrayList<>();
if (nodeDescription.containsPossibleCircles(includeField)) {
return createGenericReturnStatement();
return createGenericReturnStatement(additionalExpressions);
} else {
return Collections.singleton(projectPropertiesAndRelationships(
PropertyFilter.RelaxedPropertyPath.withRootType(nodeDescription.getUnderlyingClass()),
nodeDescription,
Constants.NAME_OF_TYPED_ROOT_NODE.apply(nodeDescription),
includeField,
null,
processedRelationships));
List<Expression> returnContent = new ArrayList<>();
returnContent.add(projectPropertiesAndRelationships(
PropertyFilter.RelaxedPropertyPath.withRootType(nodeDescription.getUnderlyingClass()),
nodeDescription,
Constants.NAME_OF_TYPED_ROOT_NODE.apply(nodeDescription),
includeField,
null,
processedRelationships));
Collections.addAll(returnContent, additionalExpressions);
return returnContent;
}
}
public Collection<Expression> createGenericReturnStatement() {
public Collection<Expression> createGenericReturnStatement(Expression... additionalExpressions) {
List<Expression> returnExpressions = new ArrayList<>();
returnExpressions.add(Cypher.name(Constants.NAME_OF_SYNTHESIZED_ROOT_NODE));
returnExpressions.add(Cypher.name(Constants.NAME_OF_SYNTHESIZED_RELATED_NODES));
returnExpressions.add(Cypher.name(Constants.NAME_OF_SYNTHESIZED_RELATIONS));
returnExpressions.addAll(Arrays.asList(additionalExpressions));
return returnExpressions;
}
private MapProjection projectPropertiesAndRelationships(PropertyFilter.RelaxedPropertyPath parentPath, Neo4jPersistentEntity<?> nodeDescription, SymbolicName nodeName,
Predicate<PropertyFilter.RelaxedPropertyPath> includedProperties, @Nullable RelationshipDescription relationshipDescription, List<RelationshipDescription> processedRelationships) {
Predicate<PropertyFilter.RelaxedPropertyPath> includedProperties, @Nullable RelationshipDescription relationshipDescription, List<RelationshipDescription> processedRelationships, Expression... additionalExpressions) {
Collection<RelationshipDescription> relationships = ((DefaultNeo4jPersistentEntity<?>) nodeDescription).getRelationshipsInHierarchy(includedProperties, parentPath);
relationships.removeIf(r -> !includedProperties.test(parentPath.append(r.getFieldName())));

View File

@@ -32,6 +32,8 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.data.domain.SliceImpl;
import org.springframework.data.geo.GeoPage;
import org.springframework.data.geo.GeoResult;
import org.springframework.data.neo4j.core.Neo4jOperations;
import org.springframework.data.neo4j.core.PreparedQuery;
import org.springframework.data.neo4j.core.PropertyFilterSupport;
@@ -45,6 +47,7 @@ import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.data.repository.query.ResultProcessor;
import org.springframework.data.repository.query.ReturnedType;
import org.springframework.data.support.PageableExecutionUtils;
import org.springframework.data.util.TypeInformation;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -76,10 +79,32 @@ abstract class AbstractNeo4jQuery extends Neo4jQuerySupport implements Repositor
return this.queryMethod;
}
/**
* {@return whether the query is a geo near query}
*/
boolean isGeoNearQuery() {
var repositoryMethod = queryMethod.getMethod();
Class<?> returnType = repositoryMethod.getReturnType();
for (Class<?> type : Neo4jQueryMethod.GEO_NEAR_RESULTS) {
if (type.isAssignableFrom(returnType)) {
return true;
}
}
if (Iterable.class.isAssignableFrom(returnType)) {
TypeInformation<?> from = TypeInformation.fromReturnTypeOf(repositoryMethod);
return GeoResult.class.equals(from.getComponentType().getType());
}
return GeoPage.class.isAssignableFrom(returnType);
}
@Override
public final Object execute(Object[] parameters) {
boolean incrementLimit = queryMethod.incrementLimit();
boolean geoNearQuery = isGeoNearQuery();
Neo4jParameterAccessor parameterAccessor = new Neo4jParameterAccessor(
(Neo4jQueryMethod.Neo4jParameters) this.queryMethod.getParameters(),
parameters);
@@ -88,7 +113,7 @@ abstract class AbstractNeo4jQuery extends Neo4jQuerySupport implements Repositor
ReturnedType returnedType = resultProcessor.getReturnedType();
PreparedQuery<?> preparedQuery = prepareQuery(returnedType.getReturnedType(),
PropertyFilterSupport.getInputProperties(resultProcessor, factory, mappingContext), parameterAccessor,
null, getMappingFunction(resultProcessor), incrementLimit ? l -> l + 1 : UnaryOperator.identity());
null, getMappingFunction(resultProcessor, geoNearQuery), incrementLimit ? l -> l + 1 : UnaryOperator.identity());
Object rawResult = new Neo4jQueryExecution.DefaultQueryExecution(neo4jOperations).execute(preparedQuery, queryMethod.asCollectionQuery());
@@ -107,7 +132,10 @@ abstract class AbstractNeo4jQuery extends Neo4jQuerySupport implements Repositor
rawResult = createSlice(incrementLimit, parameterAccessor, (List<?>) rawResult);
} else if (queryMethod.isScrollQuery()) {
rawResult = createWindow(resultProcessor, incrementLimit, parameterAccessor, (List<?>) rawResult, preparedQuery.getQueryFragmentsAndParameters());
} else if (geoNearQuery) {
rawResult = newGeoResults(rawResult);
}
return resultProcessor.processResult(rawResult, preparingConverter);
}
@@ -121,6 +149,11 @@ abstract class AbstractNeo4jQuery extends Neo4jQuerySupport implements Repositor
return neo4jOperations.toExecutableQuery(countQuery).getRequiredSingleResult();
};
if (isGeoNearQuery()) {
return new GeoPage<>(newGeoResults(rawResult), parameterAccessor.getPageable(), totalSupplier.getAsLong());
}
return PageableExecutionUtils.getPage(rawResult, parameterAccessor.getPageable(), totalSupplier);
}

View File

@@ -23,6 +23,7 @@ import java.util.function.UnaryOperator;
import org.neo4j.driver.types.MapAccessor;
import org.neo4j.driver.types.TypeSystem;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.geo.GeoResult;
import org.springframework.data.neo4j.core.PreparedQuery;
import org.springframework.data.neo4j.core.PropertyFilterSupport;
import org.springframework.data.neo4j.core.ReactiveNeo4jOperations;
@@ -35,6 +36,7 @@ import org.springframework.data.repository.query.QueryMethod;
import org.springframework.data.repository.query.RepositoryQuery;
import org.springframework.data.repository.query.ResultProcessor;
import org.springframework.data.repository.query.ReturnedType;
import org.springframework.data.util.TypeInformation;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
@@ -67,17 +69,39 @@ abstract class AbstractReactiveNeo4jQuery extends Neo4jQuerySupport implements R
return this.queryMethod;
}
/**
* {@return whether the query is a geo near query}
*/
boolean isGeoNearQuery() {
var repositoryMethod = queryMethod.getMethod();
Class<?> returnType = repositoryMethod.getReturnType();
for (Class<?> type : Neo4jQueryMethod.GEO_NEAR_RESULTS) {
if (type.isAssignableFrom(returnType)) {
return true;
}
}
if (Flux.class.isAssignableFrom(returnType)) {
TypeInformation<?> from = TypeInformation.fromReturnTypeOf(repositoryMethod);
return GeoResult.class.equals(from.getComponentType().getType());
}
return false;
}
@Override
public final Object execute(Object[] parameters) {
boolean incrementLimit = queryMethod.incrementLimit();
boolean geoNearQuery = isGeoNearQuery();
Neo4jParameterAccessor parameterAccessor = new Neo4jParameterAccessor((Neo4jQueryMethod.Neo4jParameters) this.queryMethod.getParameters(), parameters);
ResultProcessor resultProcessor = queryMethod.getResultProcessor().withDynamicProjection(parameterAccessor);
ReturnedType returnedType = resultProcessor.getReturnedType();
PreparedQuery<?> preparedQuery = prepareQuery(returnedType.getReturnedType(),
PropertyFilterSupport.getInputProperties(resultProcessor, factory, mappingContext), parameterAccessor,
null, getMappingFunction(resultProcessor), incrementLimit ? l -> l + 1 : UnaryOperator.identity());
null, getMappingFunction(resultProcessor, geoNearQuery), incrementLimit ? l -> l + 1 : UnaryOperator.identity());
Object rawResult = new Neo4jQueryExecution.ReactiveQueryExecution(neo4jOperations).execute(preparedQuery,
queryMethod.asCollectionQuery());

View File

@@ -30,7 +30,6 @@ import java.util.function.BiFunction;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.neo4j.cypherdsl.core.Condition;
import org.neo4j.cypherdsl.core.Conditions;
@@ -82,9 +81,6 @@ import org.springframework.lang.Nullable;
final class CypherQueryCreator extends AbstractQueryCreator<QueryFragmentsAndParameters, Condition> {
private final Neo4jMappingContext mappingContext;
private final QueryMethod queryMethod;
private final Class<?> domainType;
private final NodeDescription<?> nodeDescription;
private final Neo4jQueryType queryType;
@@ -118,6 +114,8 @@ final class CypherQueryCreator extends AbstractQueryCreator<QueryFragmentsAndPar
private final boolean keysetRequiresSort;
private final List<Expression> distanceExpressions = new ArrayList<>();
/**
* Can be used to modify the limit of a paged or sliced query.
*/
@@ -130,10 +128,8 @@ final class CypherQueryCreator extends AbstractQueryCreator<QueryFragmentsAndPar
super(tree, actualParameters);
this.mappingContext = mappingContext;
this.queryMethod = queryMethod;
this.domainType = domainType;
this.nodeDescription = this.mappingContext.getRequiredNodeDescription(this.domainType);
this.nodeDescription = this.mappingContext.getRequiredNodeDescription(domainType);
this.queryType = queryType;
this.isDistinct = tree.isDistinct();
@@ -248,11 +244,11 @@ final class CypherQueryCreator extends AbstractQueryCreator<QueryFragmentsAndPar
queryFragments.setLimit(limitModifier.apply(pagingParameter.isUnpaged() ? maxResults.intValue() : pagingParameter.getPageSize()));
}
queryFragments.setReturnBasedOn(nodeDescription, includedProperties, isDistinct);
queryFragments.setOrderBy(Stream
.concat(sortItems.stream(),
theSort.stream().map(CypherAdapterUtils.sortAdapterFor(nodeDescription)))
.collect(Collectors.toList()));
var finalSortItems = new ArrayList<>(this.sortItems);
theSort.stream().map(CypherAdapterUtils.sortAdapterFor(nodeDescription)).forEach(finalSortItems::add);
queryFragments.setReturnBasedOn(nodeDescription, includedProperties, isDistinct, this.distanceExpressions);
queryFragments.setOrderBy(finalSortItems);
}
// closing action: add the condition and path match
@@ -371,7 +367,7 @@ final class CypherQueryCreator extends AbstractQueryCreator<QueryFragmentsAndPar
Expression property = toCypherProperty(path, ignoreCase);
if (lowerBoundOrRange.value instanceof Range) {
return createRangeConditionForProperty(property, lowerBoundOrRange);
return createRangeConditionForExpression(property, lowerBoundOrRange);
} else {
Parameter upperBound = nextRequiredParameter(actualParameters, leafProperty);
return property.gte(toCypherParameter(lowerBoundOrRange, ignoreCase))
@@ -401,18 +397,22 @@ final class CypherQueryCreator extends AbstractQueryCreator<QueryFragmentsAndPar
Expression distanceFunction = Functions.distance(toCypherProperty(path, false), referencePoint);
// Add the distance expression for that property as additional, artificial property to be later retrieved and mapped
Neo4jPersistentEntity<?> owner = (Neo4jPersistentEntity<?>) leafProperty.getOwner();
String containerName = getContainerName(path, owner);
this.distanceExpressions.add(distanceFunction.as("__distance_" + containerName + "_" + leafProperty.getPropertyName() + "__"));
this.sortItems.add(distanceFunction.ascending());
if (other.filter(p -> p.hasValueOfType(Distance.class)).isPresent()) {
return distanceFunction.lte(toCypherParameter(other.get(), false));
} else if (other.filter(p -> p.hasValueOfType(Range.class)).isPresent()) {
return createRangeConditionForProperty(distanceFunction, other.get());
return createRangeConditionForExpression(distanceFunction, other.get());
} else {
// We only have a point toCypherParameter, that's ok, but we have to put back the last toCypherParameter when it
// wasn't null
// We only have a point toCypherParameter, that's ok, but we have to put back the last toCypherParameter when it wasn't null
other.ifPresent(this.lastParameter::offer);
// Also, we cannot filter, but need to sort in the end.
this.sortItems.add(distanceFunction.ascending());
return Conditions.noCondition();
// A `NULL` distance makes no sense in a result asking for places nearby. It would be an arbitrary choice mapping null to zero or a max value.
return distanceFunction.isNotNull();
}
}
@@ -448,24 +448,24 @@ final class CypherQueryCreator extends AbstractQueryCreator<QueryFragmentsAndPar
}
/**
* @param property property for which the range should get checked
* @param expression property for which the range should get checked
* @param rangeParameter parameter that expresses the range
* @return The equivalent of a A BETWEEN B AND C expression for a given range.
* @return The equivalent of a {@code A BETWEEN B AND C} expression for a given range.
*/
private Condition createRangeConditionForProperty(Expression property, Parameter rangeParameter) {
private Condition createRangeConditionForExpression(Expression expression, Parameter rangeParameter) {
Range range = (Range) rangeParameter.value;
Range<?> range = (Range<?>) rangeParameter.value;
Condition betweenCondition = Conditions.noCondition();
if (range.getLowerBound().isBounded()) {
Expression parameterPlaceholder = createCypherParameter(rangeParameter.nameOrIndex + ".lb", false);
betweenCondition = betweenCondition.and(
range.getLowerBound().isInclusive() ? property.gte(parameterPlaceholder) : property.gt(parameterPlaceholder));
range.getLowerBound().isInclusive() ? expression.gte(parameterPlaceholder) : expression.gt(parameterPlaceholder));
}
if (range.getUpperBound().isBounded()) {
Expression parameterPlaceholder = createCypherParameter(rangeParameter.nameOrIndex + ".ub", false);
betweenCondition = betweenCondition.and(
range.getUpperBound().isInclusive() ? property.lte(parameterPlaceholder) : property.lt(parameterPlaceholder));
range.getUpperBound().isInclusive() ? expression.lte(parameterPlaceholder) : expression.lt(parameterPlaceholder));
}
return betweenCondition;
}

View File

@@ -15,17 +15,22 @@
*/
package org.springframework.data.neo4j.repository.query;
import java.io.Serializable;
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.geo.GeoPage;
import org.springframework.data.geo.GeoResult;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.neo4j.repository.support.CypherdslStatementExecutor;
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.ParametersSource;
import org.springframework.data.repository.query.QueryMethod;
import org.springframework.data.util.TypeInformation;
import org.springframework.lang.Nullable;
@@ -44,6 +49,8 @@ import org.springframework.util.StringUtils;
*/
class Neo4jQueryMethod extends QueryMethod {
static final List<Class<? extends Serializable>> GEO_NEAR_RESULTS = List.of(GeoResult.class, GeoResults.class, GeoPage.class);
/**
* Optional query annotation of the method.
*/
@@ -53,6 +60,8 @@ class Neo4jQueryMethod extends QueryMethod {
private final boolean cypherBasedProjection;
private final Method method;
/**
* Creates a new {@link Neo4jQueryMethod} from the given parameters. Looks up the correct query to use for following
* invocations of the method given.
@@ -78,10 +87,10 @@ class Neo4jQueryMethod extends QueryMethod {
boolean cypherBasedProjection) {
super(method, metadata, factory);
Class<?> declaringClass = method.getDeclaringClass();
this.repositoryName = declaringClass.getName();
this.method = method;
this.repositoryName = this.method.getDeclaringClass().getName();
this.cypherBasedProjection = cypherBasedProjection;
this.queryAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, Query.class);
this.queryAnnotation = AnnotatedElementUtils.findMergedAnnotation(this.method, Query.class);
}
String getRepositoryName() {
@@ -111,14 +120,14 @@ class Neo4jQueryMethod extends QueryMethod {
}
@Override
protected Parameters<Neo4jParameters, Neo4jParameter> createParameters(Method method, TypeInformation<?> domainType) {
return new Neo4jParameters(method, domainType);
protected Parameters<Neo4jParameters, Neo4jParameter> createParameters(ParametersSource parametersSource) {
return new Neo4jParameters(parametersSource);
}
static class Neo4jParameters extends Parameters<Neo4jParameters, Neo4jParameter> {
Neo4jParameters(Method method, TypeInformation<?> domainType) {
super(method, it -> new Neo4jParameter(it, domainType));
Neo4jParameters(ParametersSource parametersSource) {
super(parametersSource, it -> new Neo4jParameter(it, parametersSource.getDomainTypeInformation()));
}
private Neo4jParameters(List<Neo4jParameter> originals) {
@@ -131,6 +140,15 @@ class Neo4jQueryMethod extends QueryMethod {
}
}
@Override
public Class<?> getReturnedObjectType() {
Class<?> returnedObjectType = super.getReturnedObjectType();
if (returnedObjectType.equals(GeoResult.class)) {
return getDomainClass();
}
return returnedObjectType;
}
static class Neo4jParameter extends Parameter {
private static final String NAMED_PARAMETER_TEMPLATE = "$%s";
@@ -149,15 +167,11 @@ class Neo4jQueryMethod extends QueryMethod {
public String getPlaceholder() {
if (isNamedParameter()) {
return String.format(NAMED_PARAMETER_TEMPLATE, getName().get());
return String.format(NAMED_PARAMETER_TEMPLATE, getName().orElseThrow());
} else {
return String.format(POSITION_PARAMETER_TEMPLATE, getIndex());
}
}
public String getNameOrIndex() {
return this.getName().orElseGet(() -> Integer.toString(this.getIndex()));
}
}
boolean incrementLimit() {
@@ -165,6 +179,11 @@ class Neo4jQueryMethod extends QueryMethod {
}
boolean asCollectionQuery() {
return this.isCollectionLikeQuery() || this.isPageQuery() || this.isSliceQuery() || this.isScrollQuery();
return this.isCollectionLikeQuery() || this.isPageQuery() || this.isSliceQuery() || this.isScrollQuery() ||
GeoResults.class.isAssignableFrom(this.method.getReturnType());
}
Method getMethod() {
return method;
}
}

View File

@@ -30,6 +30,7 @@ import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import org.apache.commons.logging.LogFactory;
import org.neo4j.driver.Values;
@@ -46,6 +47,8 @@ import org.springframework.data.domain.Window;
import org.springframework.data.geo.Box;
import org.springframework.data.geo.Circle;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResult;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.geo.Metrics;
import org.springframework.data.neo4j.core.TemplateSupport;
import org.springframework.data.neo4j.core.convert.Neo4jPersistentPropertyConverter;
@@ -110,7 +113,7 @@ abstract class Neo4jQuerySupport {
this.queryType = queryType;
}
protected final Supplier<BiFunction<TypeSystem, MapAccessor, ?>> getMappingFunction(final ResultProcessor resultProcessor) {
protected final Supplier<BiFunction<TypeSystem, MapAccessor, ?>> getMappingFunction(final ResultProcessor resultProcessor, boolean isGeoNearQuery) {
return () -> {
final ReturnedType returnedTypeMetadata = resultProcessor.getReturnedType();
@@ -125,7 +128,9 @@ abstract class Neo4jQuerySupport {
mappingFunction = null;
} else if (returnedTypeMetadata.isProjecting()) {
mappingFunction = EntityInstanceWithSource.decorateMappingFunction(
this.mappingContext.getRequiredMappingFunctionFor(domainType));
this.mappingContext.getRequiredMappingFunctionFor(domainType));
} else if (isGeoNearQuery) {
mappingFunction = decorateAsGeoResult(this.mappingContext.getRequiredMappingFunctionFor(domainType));
} else {
mappingFunction = this.mappingContext.getRequiredMappingFunctionFor(domainType);
}
@@ -133,6 +138,20 @@ abstract class Neo4jQuerySupport {
};
}
public static BiFunction<TypeSystem, MapAccessor, ?> decorateAsGeoResult(BiFunction<TypeSystem, MapAccessor, ?> target) {
return (t, r) -> {
Object intermediateResult = target.apply(t, r);
var distances = StreamSupport.stream(r.keys().spliterator(), false).filter(k -> k.startsWith("__distance_")).toList();
if (distances.isEmpty()) {
throw new RuntimeException("No distance has been returned by the query, cannot create `GeoResult`");
} else if (distances.size() > 1) {
throw new RuntimeException("More than one distance has been returned by the query, cannot create `GeoResult`; avoid using multiple near operations when returning `GeoResult`");
}
var distance = new Distance(r.get(distances.get(0)).asDouble() / 1000.0, Metrics.KILOMETERS);
return new GeoResult<>(intermediateResult, distance);
};
}
private static boolean hasValidReturnTypeForDelete(Neo4jQueryMethod queryMethod) {
return VALID_RETURN_TYPES_FOR_DELETE.contains(queryMethod.getResultProcessor().getReturnedType().getReturnedType());
}
@@ -312,6 +331,11 @@ abstract class Neo4jQuerySupport {
}, hasMoreElements(rawResult, limit));
}
@SuppressWarnings("unchecked")
static GeoResults<Object> newGeoResults(Object rawResult) {
return new GeoResults<>((List<GeoResult<Object>>) rawResult, Metrics.KILOMETERS);
}
private static boolean hasMoreElements(List<?> result, int limit) {
return !result.isEmpty() && result.size() > limit;
}

View File

@@ -56,7 +56,6 @@ public final class QueryFragments {
private Long skip;
private ReturnTuple returnTuple;
private boolean scalarValueReturn = false;
private boolean renderConstantsAsParameters = false;
private Expression deleteExpression;
/**
* This flag becomes {@literal true} for backward scrolling keyset pagination. Any {@code AbstractNeo4jQuery} will in turn reverse the result list.
@@ -119,26 +118,18 @@ public final class QueryFragments {
}
public void setReturnBasedOn(NodeDescription<?> nodeDescription, Collection<PropertyFilter.ProjectedPath> includedProperties,
boolean isDistinct) {
this.returnTuple = new ReturnTuple(nodeDescription, includedProperties, isDistinct);
boolean isDistinct, List<Expression> additionalExpressions) {
this.returnTuple = new ReturnTuple(nodeDescription, includedProperties, isDistinct, additionalExpressions);
}
public boolean isScalarValueReturn() {
return scalarValueReturn;
}
public boolean requiresReverseSort() {
return requiresReverseSort;
}
public void setRequiresReverseSort(boolean requiresReverseSort) {
this.requiresReverseSort = requiresReverseSort;
}
public void setRenderConstantsAsParameters(boolean renderConstantsAsParameters) {
this.renderConstantsAsParameters = renderConstantsAsParameters;
}
public Statement toStatement() {
StatementBuilder.OngoingReadingWithoutWhere match = null;
@@ -166,15 +157,17 @@ public final class QueryFragments {
.skip(skip)
.limit(limit).build();
statement.setRenderConstantsAsParameters(renderConstantsAsParameters);
statement.setRenderConstantsAsParameters(false);
return statement;
}
private Collection<Expression> getReturnExpressions() {
return returnExpressions.size() > 0
? returnExpressions
: CypherGenerator.INSTANCE.createReturnStatementForMatch((Neo4jPersistentEntity<?>) returnTuple.nodeDescription,
this::includeField);
return returnExpressions.isEmpty() ? CypherGenerator.INSTANCE.createReturnStatementForMatch((Neo4jPersistentEntity<?>) returnTuple.nodeDescription,
this::includeField, returnTuple.additionalExpressions.toArray(Expression[]::new)) : returnExpressions;
}
public Collection<Expression> getAdditionalReturnExpressions() {
return this.returnTuple == null ? List.of() : returnTuple.additionalExpressions;
}
private boolean isDistinctReturn() {
@@ -219,6 +212,7 @@ public final class QueryFragments {
return skip;
}
/**
* Describes which fields of an entity needs to get returned.
*/
@@ -226,11 +220,13 @@ public final class QueryFragments {
final NodeDescription<?> nodeDescription;
final PropertyFilter filteredProperties;
final boolean isDistinct;
final List<Expression> additionalExpressions;
private ReturnTuple(NodeDescription<?> nodeDescription, Collection<PropertyFilter.ProjectedPath> filteredProperties, boolean isDistinct) {
private ReturnTuple(NodeDescription<?> nodeDescription, Collection<PropertyFilter.ProjectedPath> filteredProperties, boolean isDistinct, List<Expression> additionalExpressions) {
this.nodeDescription = nodeDescription;
this.filteredProperties = PropertyFilter.from(filteredProperties, nodeDescription);
this.isDistinct = isDistinct;
this.additionalExpressions = List.copyOf(additionalExpressions);
}
boolean include(PropertyFilter.RelaxedPropertyPath fieldName) {

View File

@@ -3161,11 +3161,11 @@ class RepositoryIT {
Record record = doWithSession(session -> session
.run("""
CREATE
(n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}),
(n)-[:Has]->(p1:Pet{name: 'Jerry'}),
(n)-[:Has]->(p2:Pet{name: 'Tom'}),
(p1)-[:Has]->(p3:Pet{name: 'Silvester'})-[:Has]->(h2:Hobby{name: 'Hunt Tweety'})
CREATE
(n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}),
(n)-[:Has]->(p1:Pet{name: 'Jerry'}),
(n)-[:Has]->(p2:Pet{name: 'Tom'}),
(p1)-[:Has]->(p3:Pet{name: 'Silvester'})-[:Has]->(h2:Hobby{name: 'Hunt Tweety'})
RETURN n, h1, p1, p2
""").single());
@@ -3212,10 +3212,10 @@ class RepositoryIT {
Record record = doWithSession(session -> session
.run("""
CREATE
(n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}),
(n)-[:Has]->(p1:Pet{name: 'Jerry'}),
(n)-[:Has]->(p2:Pet{name: 'Tom'})
CREATE
(n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}),
(n)-[:Has]->(p1:Pet{name: 'Jerry'}),
(n)-[:Has]->(p2:Pet{name: 'Tom'})
RETURN n, h1, p1, p2
""").single());
@@ -3251,10 +3251,10 @@ class RepositoryIT {
Record record = doWithSession(session -> session
.run("""
CREATE
(n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}),
(n)-[:Has]->(p1:Pet{name: 'Jerry'}),
(n)-[:Has]->(p2:Pet{name: 'Tom'})
CREATE
(n:PersonWithRelationship{name:'Freddie'})-[:Has]->(h1:Hobby{name:'Music'}),
(n)-[:Has]->(p1:Pet{name: 'Jerry'}),
(n)-[:Has]->(p2:Pet{name: 'Tom'})
RETURN n, h1, p1, p2
""").single());

View File

@@ -34,6 +34,8 @@ import java.util.stream.Collectors;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.assertj.core.api.SoftAssertions;
import org.assertj.core.api.ThrowingConsumer;
import org.assertj.core.data.Percentage;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayNameGeneration;
@@ -60,7 +62,14 @@ import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Sort;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoPage;
import org.springframework.data.geo.GeoResult;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.geo.Metrics;
import org.springframework.data.mapping.MappingException;
import org.springframework.data.mapping.PersistentPropertyAccessor;
import org.springframework.data.neo4j.core.DatabaseSelectionProvider;
@@ -152,6 +161,11 @@ import org.springframework.data.neo4j.integration.issues.gh2858.GH2858Repository
import org.springframework.data.neo4j.integration.issues.gh2886.Apple;
import org.springframework.data.neo4j.integration.issues.gh2886.FruitRepository;
import org.springframework.data.neo4j.integration.issues.gh2886.Orange;
import org.springframework.data.neo4j.integration.issues.gh2908.HasNameAndPlace;
import org.springframework.data.neo4j.integration.issues.gh2908.HasNameAndPlaceRepository;
import org.springframework.data.neo4j.integration.issues.gh2908.LocatedNodeRepository;
import org.springframework.data.neo4j.integration.issues.gh2908.LocatedNodeWithSelfRefRepository;
import org.springframework.data.neo4j.integration.issues.gh2908.Place;
import org.springframework.data.neo4j.integration.issues.gh2918.ConditionNode;
import org.springframework.data.neo4j.integration.issues.gh2918.ConditionRepository;
import org.springframework.data.neo4j.integration.issues.qbe.A;
@@ -212,6 +226,7 @@ class IssuesIT extends TestBase {
setupGH2459(transaction);
setupGH2572(transaction);
setupGH2583(transaction);
setupGH2908(transaction);
transaction.run("CREATE (:A {name: 'A name', id: randomUUID()}) -[:HAS] ->(:B {anotherName: 'Whatever', id: randomUUID()})");
@@ -1232,6 +1247,75 @@ class IssuesIT extends TestBase {
assertThatNoException().isThrownBy(() -> conditionRepository.findById(conditionSaved.uuid));
}
private void assertSupportedGeoResultBehavior(HasNameAndPlaceRepository<? extends HasNameAndPlace> repository) {
ThrowingConsumer<GeoResult<? extends HasNameAndPlace>> neo4jFoundInTheNearDistance = gr -> {
assertThat(gr.getContent().getName()).isEqualTo("NEO4J_HQ");
assertThat(gr.getDistance().getValue()).isCloseTo(90 / 1000.0, Percentage.withPercentage(5));
};
GeoResults<? extends HasNameAndPlace> nodes = repository.findAllAsGeoResultsByPlaceNear(Place.SFO.getValue());
assertThat(nodes).hasSize(2);
var distanceBetweenSFOAndNeo4jHQ = 8830.306;
assertThat(nodes.getAverageDistance()).satisfies(d -> {
assertThat(d.getValue()).isCloseTo(distanceBetweenSFOAndNeo4jHQ / 2.0, Percentage.withPercentage(1));
assertThat(d.getMetric()).isEqualTo(Metrics.KILOMETERS);
});
var zeroTo9k = Distance.between(0, Metrics.KILOMETERS, 90000, Metrics.KILOMETERS);
GeoPage<? extends HasNameAndPlace> pagedNodes = repository.findAllByPlaceNear(Place.SFO.getValue(), zeroTo9k, Pageable.ofSize(1));
assertThat(pagedNodes).hasSize(1);
assertThat(pagedNodes.getAverageDistance().getValue()).isCloseTo(0, Percentage.withPercentage(1));
assertThat(pagedNodes.getContent().get(0).getContent().getName()).isEqualTo("SFO");
pagedNodes = repository.findAllByPlaceNear(Place.SFO.getValue(), zeroTo9k, pagedNodes.nextPageable());
assertThat(pagedNodes).hasSize(1);
assertThat(pagedNodes.getAverageDistance().getValue()).isCloseTo(distanceBetweenSFOAndNeo4jHQ, Percentage.withPercentage(1));
assertThat(pagedNodes.getContent().get(0).getContent().getName()).isEqualTo("NEO4J_HQ");
var distance = new Distance(200.0 / 1000.0, Metrics.KILOMETERS);
nodes = repository.findAllByPlaceNear(Place.MINC.getValue(), distance);
assertThat(nodes).hasSize(1)
.first()
.satisfies(neo4jFoundInTheNearDistance);
nodes = repository.findAllByPlaceNear(Place.CLARION.getValue(), distance);
assertThat(nodes).isEmpty();
nodes = repository.findAllByPlaceNear(Place.MINC.getValue(),
Distance.between(60.0 / 1000.0, Metrics.KILOMETERS, 200.0 / 1000.0, Metrics.KILOMETERS));
assertThat(nodes).hasSize(1).first()
.satisfies(neo4jFoundInTheNearDistance);
nodes = repository.findAllByPlaceNear(Place.MINC.getValue(),
Distance.between(100.0 / 1000.0, Metrics.KILOMETERS, 200.0 / 1000.0, Metrics.KILOMETERS));
assertThat(nodes).isEmpty();
final Range<Distance> distanceRange = Range.of(Range.Bound.inclusive(new Distance(100.0 / 1000.0, Metrics.KILOMETERS)),
Range.Bound.unbounded());
nodes = repository.findAllByPlaceNear(Place.MINC.getValue(), distanceRange);
assertThat(nodes).hasSize(1).first().satisfies(gr -> {
var d = gr.getDistance();
assertThat(d.getValue()).isCloseTo(8800, Percentage.withPercentage(1));
assertThat(d.getMetric()).isEqualTo(Metrics.KILOMETERS);
assertThat(gr.getContent().getName()).isEqualTo("SFO");
});
}
@Test
@Tag("GH-2908")
void shouldSupportGeoResult(@Autowired LocatedNodeRepository repository) {
assertSupportedGeoResultBehavior(repository);
}
@Test
@Tag("GH-2908")
void shouldSupportGeoResultWithSelfRef(@Autowired LocatedNodeWithSelfRefRepository repository) {
assertSupportedGeoResultBehavior(repository);
}
@Configuration
@EnableTransactionManagement
@EnableNeo4jRepositories(namedQueriesLocation = "more-custom-queries.properties")

View File

@@ -18,6 +18,12 @@ package org.springframework.data.neo4j.integration.issues;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.assertj.core.api.ThrowingConsumer;
import org.assertj.core.data.Percentage;
import org.springframework.data.domain.Range;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResult;
import org.springframework.data.geo.Metrics;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@@ -65,6 +71,9 @@ import org.springframework.data.neo4j.integration.issues.gh2533.EntitiesAndProje
import org.springframework.data.neo4j.integration.issues.gh2533.ReactiveGH2533Repository;
import org.springframework.data.neo4j.integration.issues.gh2572.GH2572Child;
import org.springframework.data.neo4j.integration.issues.gh2572.ReactiveGH2572Repository;
import org.springframework.data.neo4j.integration.issues.gh2908.LocatedNode;
import org.springframework.data.neo4j.integration.issues.gh2908.Place;
import org.springframework.data.neo4j.integration.issues.gh2908.ReactiveLocatedNodeRepository;
import org.springframework.data.neo4j.repository.config.EnableReactiveNeo4jRepositories;
import org.springframework.data.neo4j.test.BookmarkCapture;
import org.springframework.data.neo4j.test.Neo4jIntegrationTest;
@@ -89,6 +98,7 @@ class ReactiveIssuesIT extends TestBase {
setupGH2328(transaction);
setupGH2572(transaction);
setupGH2908(transaction);
transaction.commit();
}
@@ -425,6 +435,47 @@ class ReactiveIssuesIT extends TestBase {
.verifyComplete();
}
@Test
@Tag("GH-2908")
void shouldSupportGeoResult(@Autowired ReactiveLocatedNodeRepository repository) {
ThrowingConsumer<GeoResult<LocatedNode>> neo4jFoundInTheNearDistance = gr -> {
assertThat(gr.getContent().getName()).isEqualTo("NEO4J_HQ");
assertThat(gr.getDistance().getValue()).isCloseTo(90 / 1000.0, Percentage.withPercentage(5));
};
List<GeoResult<LocatedNode>> nodes = repository.findAllAsGeoResultsByPlaceNear(Place.SFO.getValue()).collectList().block();
assertThat(nodes).hasSize(2);
var distance = new Distance(200.0 / 1000.0, Metrics.KILOMETERS);
nodes = repository.findAllByPlaceNear(Place.MINC.getValue(), distance).collectList().block();
assertThat(nodes).hasSize(1)
.first()
.satisfies(neo4jFoundInTheNearDistance);
nodes = repository.findAllByPlaceNear(Place.CLARION.getValue(), distance).collectList().block();
assertThat(nodes).isEmpty();
nodes = repository.findAllByPlaceNear(Place.MINC.getValue(),
Distance.between(60.0 / 1000.0, Metrics.KILOMETERS, 200.0 / 1000.0, Metrics.KILOMETERS)).collectList().block();
assertThat(nodes).hasSize(1).first()
.satisfies(neo4jFoundInTheNearDistance);
nodes = repository.findAllByPlaceNear(Place.MINC.getValue(),
Distance.between(100.0 / 1000.0, Metrics.KILOMETERS, 200.0 / 1000.0, Metrics.KILOMETERS)).collectList().block();
assertThat(nodes).isEmpty();
final Range<Distance> distanceRange = Range.of(Range.Bound.inclusive(new Distance(100.0 / 1000.0, Metrics.KILOMETERS)),
Range.Bound.unbounded());
nodes = repository.findAllByPlaceNear(Place.MINC.getValue(), distanceRange).collectList().block();
assertThat(nodes).hasSize(1).first().satisfies(gr -> {
var d = gr.getDistance();
assertThat(d.getValue()).isCloseTo(8800, Percentage.withPercentage(1));
assertThat(d.getMetric()).isEqualTo(Metrics.KILOMETERS);
assertThat(gr.getContent().getName()).isEqualTo("SFO");
});
}
@Configuration
@EnableTransactionManagement
@EnableReactiveNeo4jRepositories(namedQueriesLocation = "more-custom-queries.properties")

View File

@@ -19,6 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -36,6 +37,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.neo4j.integration.issues.gh2328.Entity2328;
import org.springframework.data.neo4j.integration.issues.gh2347.Application;
import org.springframework.data.neo4j.integration.issues.gh2347.Workflow;
import org.springframework.data.neo4j.integration.issues.gh2908.Place;
import org.springframework.data.neo4j.test.BookmarkCapture;
import org.springframework.data.neo4j.test.Neo4jExtension;
@@ -119,6 +121,14 @@ abstract class TestBase {
queryRunner.run("MATCH (p:GH2572Parent {id: 'GH2572Parent-2'}) CREATE (p) <-[:IS_PET]- (:GH2572Child {id: 'GH2572Child-4', name: 'another-pet'})");
}
protected static void setupGH2908(QueryRunner queryRunner) {
EnumSet<Place> places = EnumSet.of(Place.NEO4J_HQ, Place.SFO);
for (Place value : places) {
queryRunner.run("CREATE (l:LocatedNode {name: $name, place: $place})", Map.of("name", value.name(), "place", value.getValue()));
queryRunner.run("CREATE (l:LocatedNodeWithSelfRef {name: $name, place: $place})-[:NEXT]->(n:LocatedNodeWithSelfRef {name: $name + 'next'})", Map.of("name", value.name(), "place", value.getValue()));
}
}
protected static void assertLabels(BookmarkCapture bookmarkCapture, List<String> ids) {
try (Session session = neo4jConnectionSupport.getDriver().session(bookmarkCapture.createSessionConfig())) {
for (String id : ids) {

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* 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.integration.issues.gh2908;
import org.neo4j.driver.types.Point;
/**
* Introduced for unifying tests
*
* @author Michael J. Simons
*/
public interface HasNameAndPlace {
String getName();
Point getPlace();
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* 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.integration.issues.gh2908;
import org.neo4j.driver.types.Point;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoPage;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.neo4j.repository.Neo4jRepository;
/**
* Just extending {@link Neo4jRepository} here did not work, so it's split in the concrete interface.
* @author Michael J. Simons
* @param <T> Concrete type of the entity
*/
public interface HasNameAndPlaceRepository<T extends HasNameAndPlace> {
GeoResults<T> findAllAsGeoResultsByPlaceNear(Point point);
GeoResults<T> findAllByPlaceNear(Point p, Distance max);
GeoResults<T> findAllByPlaceNear(Point p, Range<Distance> between);
GeoPage<T> findAllByPlaceNear(Point p, Range<Distance> between, Pageable pageable);
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* 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.integration.issues.gh2908;
import org.neo4j.driver.types.Point;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
/**
* A located node without circles.
* @author Michael J. Simons
*/
@Node
public class LocatedNode implements HasNameAndPlace {
@Id
@GeneratedValue
private String id;
private final String name;
private final Point place;
public LocatedNode(String name, Point place) {
this.name = name;
this.place = place;
}
public String getId() {
return id;
}
@Override
public String getName() {
return name;
}
@Override
public Point getPlace() {
return place;
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* 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.integration.issues.gh2908;
import org.springframework.data.neo4j.repository.Neo4jRepository;
/**
* Repository spotting all supported Geo* results.
* @author Michael J. Simons
*/
public interface LocatedNodeRepository extends HasNameAndPlaceRepository<LocatedNode>, Neo4jRepository<LocatedNode, String> {
}

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* 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.integration.issues.gh2908;
import org.neo4j.driver.types.Point;
import org.springframework.data.neo4j.core.schema.GeneratedValue;
import org.springframework.data.neo4j.core.schema.Id;
import org.springframework.data.neo4j.core.schema.Node;
import org.springframework.data.neo4j.core.schema.Relationship;
/**
* A located node with circles.
* @author Michael J. Simons
*/
@Node
public class LocatedNodeWithSelfRef implements HasNameAndPlace {
@Id
@GeneratedValue
private String id;
private final String name;
private final Point place;
@Relationship
private LocatedNodeWithSelfRef next;
public LocatedNodeWithSelfRef(String name, Point place) {
this.name = name;
this.place = place;
}
public String getId() {
return id;
}
@Override
public String getName() {
return name;
}
@Override
public Point getPlace() {
return place;
}
public LocatedNodeWithSelfRef getNext() {
return next;
}
public void setNext(LocatedNodeWithSelfRef next) {
this.next = next;
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* 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.integration.issues.gh2908;
import org.springframework.data.neo4j.repository.Neo4jRepository;
/**
* Repository spotting all supported Geo* results.
* @author Michael J. Simons
*/
public interface LocatedNodeWithSelfRefRepository extends HasNameAndPlaceRepository<LocatedNodeWithSelfRef>, Neo4jRepository<LocatedNodeWithSelfRef, String> {
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* 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.integration.issues.gh2908;
import org.neo4j.driver.Values;
import org.neo4j.driver.types.Point;
/**
* Cool places to be.
* @author Michael J. Simons
*/
public enum Place {
NEO4J_HQ(Values.point(4326, 12.994823, 55.612191).asPoint()),
SFO(Values.point(4326, -122.38681, 37.61649).asPoint()),
CLARION(Values.point(4326, 12.994243, 55.607726).asPoint()),
MINC(Values.point(4326, 12.994039, 55.611496).asPoint());
private final Point value;
Place(Point value) {
this.value = value;
}
public Point getValue() {
return value;
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2011-2024 the original author or authors.
*
* 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.integration.issues.gh2908;
import org.neo4j.driver.types.Point;
import org.springframework.data.domain.Range;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResult;
import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository;
import reactor.core.publisher.Flux;
/**
* Repository spotting all supported Geo* results.
* @author Michael J. Simons
*/
public interface ReactiveLocatedNodeRepository extends ReactiveNeo4jRepository<LocatedNode, String> {
Flux<GeoResult<LocatedNode>> findAllAsGeoResultsByPlaceNear(Point point);
Flux<GeoResult<LocatedNode>> findAllByPlaceNear(Point p, Distance max);
Flux<GeoResult<LocatedNode>> findAllByPlaceNear(Point p, Range<Distance> between);
}