From 9cc5a85b9803e9da29d78723713e4efe86464753 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Fri, 16 Aug 2024 15:03:30 +0200 Subject: [PATCH] feat: Add support for `GeoResults`, `GeoPage` and `Flux`. (#2939) This adds support for returning `GeoResults` and `GeoPage` from any imperative repository on "near" queries, with and without maximum or range distance. These iterates will contain `GeoResult`, with each including the distance to the required reference point. For reactive only `Flux>` 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 --- .../data/neo4j/core/Neo4jTemplate.java | 11 ++- .../neo4j/core/ReactiveNeo4jTemplate.java | 16 ++-- .../data/neo4j/core/TemplateSupport.java | 29 +++---- .../neo4j/core/mapping/CypherGenerator.java | 29 ++++--- .../repository/query/AbstractNeo4jQuery.java | 35 +++++++- .../query/AbstractReactiveNeo4jQuery.java | 26 +++++- .../repository/query/CypherQueryCreator.java | 52 ++++++------ .../repository/query/Neo4jQueryMethod.java | 45 +++++++--- .../repository/query/Neo4jQuerySupport.java | 28 ++++++- .../repository/query/QueryFragments.java | 30 +++---- .../integration/imperative/RepositoryIT.java | 26 +++--- .../neo4j/integration/issues/IssuesIT.java | 84 +++++++++++++++++++ .../integration/issues/ReactiveIssuesIT.java | 51 +++++++++++ .../neo4j/integration/issues/TestBase.java | 10 +++ .../issues/gh2908/HasNameAndPlace.java | 30 +++++++ .../gh2908/HasNameAndPlaceRepository.java | 40 +++++++++ .../issues/gh2908/LocatedNode.java | 56 +++++++++++++ .../issues/gh2908/LocatedNodeRepository.java | 25 ++++++ .../issues/gh2908/LocatedNodeWithSelfRef.java | 68 +++++++++++++++ .../LocatedNodeWithSelfRefRepository.java | 25 ++++++ .../integration/issues/gh2908/Place.java | 41 +++++++++ .../gh2908/ReactiveLocatedNodeRepository.java | 37 ++++++++ 22 files changed, 681 insertions(+), 113 deletions(-) create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/HasNameAndPlace.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/HasNameAndPlaceRepository.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNode.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeRepository.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeWithSelfRef.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeWithSelfRefRepository.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/Place.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/ReactiveLocatedNodeRepository.java diff --git a/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java b/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java index 635326c15..4c96d4ba1 100644 --- a/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java +++ b/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java @@ -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 newMappingSpec = neo4jClient.query(cypherQuery) diff --git a/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java b/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java index 4b38a3ead..0a5757438 100644 --- a/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java +++ b/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java @@ -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 mappingSpec = this.neo4jClient.query(renderer.render( - nodesAndRelationshipsById.toStatement(entityMetaData))) - .bindAll(nodesAndRelationshipsById.getParameters()).fetchAs(resultType); + var statement = nodesAndRelationshipsById.toStatement(entityMetaData); + ReactiveNeo4jClient.MappingSpec mappingSpec = this.neo4jClient + .query(renderer.render(statement)) + .bindAll(statement.getCatalog().getParameters()) + .fetchAs(resultType); ReactiveNeo4jClient.RecordFetchSpec fetchSpec = preparedQuery.getOptionalMappingFunction() .map(mappingSpec::mappedBy).orElse(mappingSpec); diff --git a/src/main/java/org/springframework/data/neo4j/core/TemplateSupport.java b/src/main/java/org/springframework/data/neo4j/core/TemplateSupport.java index 95113e4bc..e474c4032 100644 --- a/src/main/java/org/springframework/data/neo4j/core/TemplateSupport.java +++ b/src/main/java/org/springframework/data/neo4j/core/TemplateSupport.java @@ -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 getParameters() { - Map 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 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(); } diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java b/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java index 81c0f98c5..14adae230 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java @@ -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 createReturnStatementForMatch(Neo4jPersistentEntity nodeDescription, - Predicate includeField) { - + Predicate includeField, Expression... additionalExpressions) { List 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 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 createGenericReturnStatement() { + public Collection createGenericReturnStatement(Expression... additionalExpressions) { List 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 includedProperties, @Nullable RelationshipDescription relationshipDescription, List processedRelationships) { + Predicate includedProperties, @Nullable RelationshipDescription relationshipDescription, List processedRelationships, Expression... additionalExpressions) { Collection relationships = ((DefaultNeo4jPersistentEntity) nodeDescription).getRelationshipsInHierarchy(includedProperties, parentPath); relationships.removeIf(r -> !includedProperties.test(parentPath.append(r.getFieldName()))); diff --git a/src/main/java/org/springframework/data/neo4j/repository/query/AbstractNeo4jQuery.java b/src/main/java/org/springframework/data/neo4j/repository/query/AbstractNeo4jQuery.java index 70bed2d5e..706e27893 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/query/AbstractNeo4jQuery.java +++ b/src/main/java/org/springframework/data/neo4j/repository/query/AbstractNeo4jQuery.java @@ -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); } diff --git a/src/main/java/org/springframework/data/neo4j/repository/query/AbstractReactiveNeo4jQuery.java b/src/main/java/org/springframework/data/neo4j/repository/query/AbstractReactiveNeo4jQuery.java index 917e5a8e7..51bc52d33 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/query/AbstractReactiveNeo4jQuery.java +++ b/src/main/java/org/springframework/data/neo4j/repository/query/AbstractReactiveNeo4jQuery.java @@ -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()); diff --git a/src/main/java/org/springframework/data/neo4j/repository/query/CypherQueryCreator.java b/src/main/java/org/springframework/data/neo4j/repository/query/CypherQueryCreator.java index 924e5eec7..8410355ee 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/query/CypherQueryCreator.java +++ b/src/main/java/org/springframework/data/neo4j/repository/query/CypherQueryCreator.java @@ -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 { 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 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(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 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 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; } diff --git a/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryMethod.java b/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryMethod.java index 4d738f430..e4f5e7733 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryMethod.java +++ b/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQueryMethod.java @@ -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> 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 createParameters(Method method, TypeInformation domainType) { - return new Neo4jParameters(method, domainType); + protected Parameters createParameters(ParametersSource parametersSource) { + return new Neo4jParameters(parametersSource); } static class Neo4jParameters extends Parameters { - 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 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; } } diff --git a/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQuerySupport.java b/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQuerySupport.java index 93929982d..bfff0889f 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQuerySupport.java +++ b/src/main/java/org/springframework/data/neo4j/repository/query/Neo4jQuerySupport.java @@ -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> getMappingFunction(final ResultProcessor resultProcessor) { + protected final Supplier> 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 decorateAsGeoResult(BiFunction 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 newGeoResults(Object rawResult) { + return new GeoResults<>((List>) rawResult, Metrics.KILOMETERS); + } + private static boolean hasMoreElements(List result, int limit) { return !result.isEmpty() && result.size() > limit; } diff --git a/src/main/java/org/springframework/data/neo4j/repository/query/QueryFragments.java b/src/main/java/org/springframework/data/neo4j/repository/query/QueryFragments.java index 914cdd675..752890251 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/query/QueryFragments.java +++ b/src/main/java/org/springframework/data/neo4j/repository/query/QueryFragments.java @@ -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 includedProperties, - boolean isDistinct) { - this.returnTuple = new ReturnTuple(nodeDescription, includedProperties, isDistinct); + boolean isDistinct, List 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 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 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 additionalExpressions; - private ReturnTuple(NodeDescription nodeDescription, Collection filteredProperties, boolean isDistinct) { + private ReturnTuple(NodeDescription nodeDescription, Collection filteredProperties, boolean isDistinct, List additionalExpressions) { this.nodeDescription = nodeDescription; this.filteredProperties = PropertyFilter.from(filteredProperties, nodeDescription); this.isDistinct = isDistinct; + this.additionalExpressions = List.copyOf(additionalExpressions); } boolean include(PropertyFilter.RelaxedPropertyPath fieldName) { diff --git a/src/test/java/org/springframework/data/neo4j/integration/imperative/RepositoryIT.java b/src/test/java/org/springframework/data/neo4j/integration/imperative/RepositoryIT.java index 8b87a8f2f..78a8ce70e 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/imperative/RepositoryIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/imperative/RepositoryIT.java @@ -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()); diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java b/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java index f3151c781..654fd649d 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java @@ -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 repository) { + + ThrowingConsumer> neo4jFoundInTheNearDistance = gr -> { + assertThat(gr.getContent().getName()).isEqualTo("NEO4J_HQ"); + assertThat(gr.getDistance().getValue()).isCloseTo(90 / 1000.0, Percentage.withPercentage(5)); + }; + + GeoResults 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 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 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") diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/ReactiveIssuesIT.java b/src/test/java/org/springframework/data/neo4j/integration/issues/ReactiveIssuesIT.java index be0266366..31892044f 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/ReactiveIssuesIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/ReactiveIssuesIT.java @@ -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> neo4jFoundInTheNearDistance = gr -> { + assertThat(gr.getContent().getName()).isEqualTo("NEO4J_HQ"); + assertThat(gr.getDistance().getValue()).isCloseTo(90 / 1000.0, Percentage.withPercentage(5)); + }; + + List> 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 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") diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/TestBase.java b/src/test/java/org/springframework/data/neo4j/integration/issues/TestBase.java index 7c633b061..8dac8fd1a 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/TestBase.java +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/TestBase.java @@ -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 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 ids) { try (Session session = neo4jConnectionSupport.getDriver().session(bookmarkCapture.createSessionConfig())) { for (String id : ids) { diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/HasNameAndPlace.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/HasNameAndPlace.java new file mode 100644 index 000000000..e4d262111 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/HasNameAndPlace.java @@ -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(); +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/HasNameAndPlaceRepository.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/HasNameAndPlaceRepository.java new file mode 100644 index 000000000..448207ae0 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/HasNameAndPlaceRepository.java @@ -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 Concrete type of the entity + */ +public interface HasNameAndPlaceRepository { + + GeoResults findAllAsGeoResultsByPlaceNear(Point point); + + GeoResults findAllByPlaceNear(Point p, Distance max); + + GeoResults findAllByPlaceNear(Point p, Range between); + + GeoPage findAllByPlaceNear(Point p, Range between, Pageable pageable); +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNode.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNode.java new file mode 100644 index 000000000..aaa5e1d6c --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNode.java @@ -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; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeRepository.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeRepository.java new file mode 100644 index 000000000..0e0d19c54 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeRepository.java @@ -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, Neo4jRepository { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeWithSelfRef.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeWithSelfRef.java new file mode 100644 index 000000000..48e22b507 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeWithSelfRef.java @@ -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; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeWithSelfRefRepository.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeWithSelfRefRepository.java new file mode 100644 index 000000000..aee9b5c52 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/LocatedNodeWithSelfRefRepository.java @@ -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, Neo4jRepository { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/Place.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/Place.java new file mode 100644 index 000000000..6797eacfe --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/Place.java @@ -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; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/ReactiveLocatedNodeRepository.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/ReactiveLocatedNodeRepository.java new file mode 100644 index 000000000..e421c3936 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2908/ReactiveLocatedNodeRepository.java @@ -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 { + + Flux> findAllAsGeoResultsByPlaceNear(Point point); + + Flux> findAllByPlaceNear(Point p, Distance max); + + Flux> findAllByPlaceNear(Point p, Range between); +}