diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/PropertyFilter.java b/src/main/java/org/springframework/data/neo4j/core/mapping/PropertyFilter.java index 5102c3309..b310cd219 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/PropertyFilter.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/PropertyFilter.java @@ -15,16 +15,18 @@ */ package org.springframework.data.neo4j.core.mapping; -import org.apiguardian.api.API; -import org.springframework.data.mapping.PropertyPath; -import org.springframework.util.StringUtils; - import java.util.HashSet; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import org.apiguardian.api.API; +import org.springframework.data.mapping.PropertyPath; +import org.springframework.data.neo4j.core.schema.Property; +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + /** * Something that makes sense of propertyPaths by having an understanding of projection classes. */ @@ -45,6 +47,24 @@ public abstract class PropertyFilter { public abstract boolean isNotFiltering(); + static String toDotPath(PropertyPath propertyPath, @Nullable String lastSegment) { + + if (lastSegment == null) { + return propertyPath.toDotPath(); + } + StringBuilder dotPath = new StringBuilder(); + while (propertyPath != null) { + if (propertyPath.hasNext()) { + dotPath.append(propertyPath.getSegment()).append("."); + propertyPath = propertyPath.next(); + } else { + break; + } + } + dotPath.append(lastSegment); + return dotPath.toString(); + } + private static class FilteringPropertyFilter extends PropertyFilter { private final Set> rootClasses; private final Map projectingPropertyPaths; @@ -64,10 +84,23 @@ public abstract class PropertyFilter { .map(NodeDescription::getUnderlyingClass) .forEach(rootClasses::add); + Neo4jPersistentEntity entity = (Neo4jPersistentEntity) nodeDescription; projectingPropertyPaths = new ConcurrentHashMap<>(); propertiesMap.keySet() - .forEach(propertyPath -> - projectingPropertyPaths.put(propertyPath.toDotPath(), propertiesMap.get(propertyPath))); + .forEach(propertyPath -> { + String lastSegment = null; + if (!propertyPath.hasNext()) { + Neo4jPersistentProperty property = entity.getPersistentProperty( + propertyPath.getLeafProperty().getSegment()); + if (property != null && property.findAnnotation(Property.class) != null) { + lastSegment = property.getPropertyName(); + } + } + projectingPropertyPaths.put( + PropertyFilter.toDotPath(propertyPath, lastSegment), + propertiesMap.get(propertyPath) + ); + }); } @Override @@ -87,7 +120,8 @@ public abstract class PropertyFilter { return Integer.compare(depth2, depth1); }) - .filter(d -> dotPath.contains(d) && dotPath.startsWith(d)).findFirst(); + .filter(d -> dotPath.contains(d) && dotPath.startsWith(d)) + .findFirst(); return projectingPropertyPaths.containsKey(dotPath) || (dotPath.contains(".") && candidate.isPresent() && projectingPropertyPaths.get(candidate.get())); @@ -141,6 +175,19 @@ public abstract class PropertyFilter { return dotPath; } + public String toDotPath(@Nullable String lastSegment) { + + if (lastSegment == null) { + return this.toDotPath(); + } + + int idx = dotPath.lastIndexOf('.'); + if (idx < 0) { + return lastSegment; + } + return dotPath.substring(0, idx + 1) + lastSegment; + } + public Class getType() { return type; } @@ -165,6 +212,24 @@ public abstract class PropertyFilter { private String prependDotPathWith(String pathPart) { return dotPath.isEmpty() ? pathPart : pathPart + "." + dotPath; } - } + public String getSegment() { + + int idx = dotPath.indexOf("."); + if (idx < 0) { + idx = dotPath.length(); + } + return dotPath.substring(0, idx); + } + + public RelaxedPropertyPath getLeafProperty() { + + int idx = dotPath.lastIndexOf('.'); + if (idx < 0) { + return this; + } + + return new RelaxedPropertyPath(dotPath.substring(idx + 1), this.type); + } + } } 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 07dc26caa..f79f140dc 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 @@ -33,9 +33,11 @@ import org.neo4j.cypherdsl.core.Statement; import org.neo4j.cypherdsl.core.StatementBuilder; import org.springframework.data.mapping.PropertyPath; import org.springframework.data.neo4j.core.mapping.CypherGenerator; +import org.springframework.data.neo4j.core.mapping.Neo4jPersistentProperty; import org.springframework.data.neo4j.core.mapping.PropertyFilter; import org.springframework.data.neo4j.core.mapping.Neo4jPersistentEntity; import org.springframework.data.neo4j.core.mapping.NodeDescription; +import org.springframework.data.neo4j.core.schema.Property; import org.springframework.lang.Nullable; /** @@ -86,9 +88,7 @@ public final class QueryFragments { } public boolean includeField(PropertyFilter.RelaxedPropertyPath fieldName) { - return this.returnTuple == null - ? PropertyFilter.acceptAll().contains(fieldName.toDotPath(), fieldName.getType()) - : this.returnTuple.filteredProperties.contains(fieldName.toDotPath(), fieldName.getType()); + return this.returnTuple == null || this.returnTuple.include(fieldName); } public void setOrderBy(Collection orderBy) { @@ -179,5 +179,15 @@ public final class QueryFragments { this.filteredProperties = PropertyFilter.from(filteredProperties, nodeDescription); this.isDistinct = isDistinct; } + + boolean include(PropertyFilter.RelaxedPropertyPath fieldName) { + String dotPath = nodeDescription.getGraphProperty(fieldName.getSegment()) + .filter(Neo4jPersistentProperty.class::isInstance) + .map(Neo4jPersistentProperty.class::cast) + .filter(p -> p.findAnnotation(Property.class) != null) + .map(p -> fieldName.toDotPath(p.getPropertyName())) + .orElseGet(fieldName::toDotPath); + return this.filteredProperties.contains(dotPath, fieldName.getType()); + } } } diff --git a/src/main/java/org/springframework/data/neo4j/repository/support/EntityAndGraphPropertyAccessingMethodInterceptor.java b/src/main/java/org/springframework/data/neo4j/repository/support/EntityAndGraphPropertyAccessingMethodInterceptor.java new file mode 100644 index 000000000..e76acf896 --- /dev/null +++ b/src/main/java/org/springframework/data/neo4j/repository/support/EntityAndGraphPropertyAccessingMethodInterceptor.java @@ -0,0 +1,139 @@ +/* + * Copyright 2011-2022 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.repository.support; + +import java.beans.PropertyDescriptor; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicReference; + +import org.aopalliance.intercept.MethodInterceptor; +import org.aopalliance.intercept.MethodInvocation; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.NotReadablePropertyException; +import org.springframework.data.neo4j.core.mapping.Neo4jMappingContext; +import org.springframework.data.neo4j.core.mapping.Neo4jPersistentEntity; +import org.springframework.data.neo4j.core.mapping.Neo4jPersistentProperty; +import org.springframework.data.neo4j.core.schema.Property; +import org.springframework.data.projection.MethodInterceptorFactory; +import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +/** + * Basically a lenient property accessing method interceptor, first trying the entity property (or attribute), than + * a potentially renamed attribute via {@link Property}. + * + * @author Michael J. Simons + */ +final class EntityAndGraphPropertyAccessingMethodInterceptor implements MethodInterceptor { + + static MethodInterceptorFactory createMethodInterceptorFactory(Neo4jMappingContext mappingContext) { + return new MethodInterceptorFactory() { + @Override + public MethodInterceptor createMethodInterceptor(Object source, Class targetType) { + return new EntityAndGraphPropertyAccessingMethodInterceptor(source, mappingContext); + } + + @Override public boolean supports(Object source, Class targetType) { + return true; + } + }; + } + + private final BeanWrapper target; + + private EntityAndGraphPropertyAccessingMethodInterceptor(Object target, Neo4jMappingContext ctx) { + + Assert.notNull(target, "Proxy target must not be null!"); + this.target = new GraphPropertyAndDirectFieldAccessFallbackBeanWrapper(target, ctx); + } + + @Nullable + @Override + public Object invoke(@SuppressWarnings("null") MethodInvocation invocation) throws Throwable { + + Method method = invocation.getMethod(); + + if (ReflectionUtils.isObjectMethod(method)) { + return invocation.proceed(); + } + + PropertyDescriptor descriptor = BeanUtils.findPropertyForMethod(method); + + if (descriptor == null) { + throw new IllegalStateException("Invoked method is not a property accessor!"); + } + + if (!isSetterMethod(method, descriptor)) { + return target.getPropertyValue(descriptor.getName()); + } + + if (invocation.getArguments().length != 1) { + throw new IllegalStateException("Invoked setter method requires exactly one argument!"); + } + + target.setPropertyValue(descriptor.getName(), invocation.getArguments()[0]); + return null; + } + + private static boolean isSetterMethod(Method method, PropertyDescriptor descriptor) { + return method.equals(descriptor.getWriteMethod()); + } + + /** + * this version of the {@link DirectFieldAccessFallbackBeanWrapper} checks if there's an attribute on the entity + * annotated with {@link Property} mapping it to a different graph property when it fails to access the original + * attribute If so, that property is accessed. If not, the original exception is rethrown. + * This helps in projections such as described here + * https://stackoverflow.com/questions/68938823/sdn6-projection-interfaces-with-property-mapping + * that could have been used as workaround prior to fixing 2371. + */ + static class GraphPropertyAndDirectFieldAccessFallbackBeanWrapper extends DirectFieldAccessFallbackBeanWrapper { + + private final Neo4jMappingContext ctx; + + GraphPropertyAndDirectFieldAccessFallbackBeanWrapper(Object target, Neo4jMappingContext ctx) { + super(target); + this.ctx = ctx; + } + + @Override + public Object getPropertyValue(String propertyName) { + try { + return super.getPropertyValue(propertyName); + } catch (NotReadablePropertyException e) { + Neo4jPersistentEntity entity = ctx.getPersistentEntity(super.getRootClass()); + + AtomicReference value = new AtomicReference<>(); + if (entity != null) { + entity.doWithProperties( + (org.springframework.data.mapping.PropertyHandler) p -> { + if (p.findAnnotation(Property.class) != null && p.getPropertyName() + .equals(propertyName)) { + value.compareAndSet(null, p.getFieldName()); + } + }); + if (value.get() != null) { + return super.getPropertyValue(value.get()); + } + } + throw e; + } + } + } +} diff --git a/src/main/java/org/springframework/data/neo4j/repository/support/Neo4jRepositoryFactory.java b/src/main/java/org/springframework/data/neo4j/repository/support/Neo4jRepositoryFactory.java index 2bd2a6cf9..2c53b0a96 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/support/Neo4jRepositoryFactory.java +++ b/src/main/java/org/springframework/data/neo4j/repository/support/Neo4jRepositoryFactory.java @@ -25,6 +25,8 @@ import org.springframework.data.neo4j.repository.query.CypherdslConditionExecuto import org.springframework.data.neo4j.repository.query.Neo4jQueryLookupStrategy; import org.springframework.data.neo4j.repository.query.QuerydslNeo4jPredicateExecutor; import org.springframework.data.neo4j.repository.query.SimpleQueryByExampleExecutor; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.querydsl.QuerydslPredicateExecutor; import org.springframework.data.querydsl.QuerydslUtils; import org.springframework.data.repository.core.RepositoryInformation; @@ -122,4 +124,15 @@ final class Neo4jRepositoryFactory extends RepositoryFactorySupport { return Optional.of(new Neo4jQueryLookupStrategy(neo4jOperations, mappingContext, evaluationContextProvider)); } + + @Override + protected ProjectionFactory getProjectionFactory() { + + ProjectionFactory projectionFactory = super.getProjectionFactory(); + if (projectionFactory instanceof SpelAwareProxyProjectionFactory) { + ((SpelAwareProxyProjectionFactory) projectionFactory).registerMethodInvokerFactory( + EntityAndGraphPropertyAccessingMethodInterceptor.createMethodInterceptorFactory(mappingContext)); + } + return projectionFactory; + } } diff --git a/src/main/java/org/springframework/data/neo4j/repository/support/ReactiveNeo4jRepositoryFactory.java b/src/main/java/org/springframework/data/neo4j/repository/support/ReactiveNeo4jRepositoryFactory.java index dc1ce32d5..757386580 100644 --- a/src/main/java/org/springframework/data/neo4j/repository/support/ReactiveNeo4jRepositoryFactory.java +++ b/src/main/java/org/springframework/data/neo4j/repository/support/ReactiveNeo4jRepositoryFactory.java @@ -27,6 +27,8 @@ import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; import org.springframework.data.neo4j.repository.query.ReactiveNeo4jQueryLookupStrategy; import org.springframework.data.neo4j.repository.query.ReactiveQuerydslNeo4jPredicateExecutor; import org.springframework.data.neo4j.repository.query.SimpleReactiveQueryByExampleExecutor; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.querydsl.QuerydslUtils; import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor; import org.springframework.data.repository.core.RepositoryInformation; @@ -132,4 +134,15 @@ final class ReactiveNeo4jRepositoryFactory extends ReactiveRepositoryFactorySupp }); } } + + @Override + protected ProjectionFactory getProjectionFactory() { + + ProjectionFactory projectionFactory = super.getProjectionFactory(); + if (projectionFactory instanceof SpelAwareProxyProjectionFactory) { + ((SpelAwareProxyProjectionFactory) projectionFactory).registerMethodInvokerFactory( + EntityAndGraphPropertyAccessingMethodInterceptor.createMethodInterceptorFactory(mappingContext)); + } + return projectionFactory; + } } diff --git a/src/test/java/org/springframework/data/neo4j/core/mapping/PropertyFilterTest.java b/src/test/java/org/springframework/data/neo4j/core/mapping/PropertyFilterTest.java new file mode 100644 index 000000000..40b506dde --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/core/mapping/PropertyFilterTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2011-2022 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.core.mapping; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.data.mapping.PropertyPath; +import org.springframework.data.neo4j.integration.shared.common.PersonWithRelationship; + +/** + * @author Michael J. Simons + */ +class PropertyFilterTest { + + @ParameterizedTest + @CsvSource({ "id, foo", "hobbies, foo", "hobbies.name, hobbies.foo" }) + void toDotPathShouldWork(String value, String newDotPath) { + + PropertyPath path = PropertyPath.from(value, PersonWithRelationship.class); + String dotPath; + dotPath = PropertyFilter.toDotPath(path, null); + assertThat(dotPath).isEqualTo(path.toDotPath()); + dotPath = PropertyFilter.toDotPath(path, "foo"); + assertThat(dotPath).isEqualTo(newDotPath); + } + + @Nested + class RelaxedPropertyPathTest { + + @ParameterizedTest + @ValueSource(strings = { "s1", "s1.s2" }) + void toDotPathShouldWork(String value) { + + PropertyFilter.RelaxedPropertyPath path = PropertyFilter.RelaxedPropertyPath.withRootType(Object.class) + .append(value); + assertThat(path.toDotPath()).isEqualTo(value); + } + + @ParameterizedTest + @CsvSource({ "id, foo", "hobbies, foo", "hobbies.name, hobbies.foo" }) + void toDotPathWithReplacementShouldWork(String value, String newDotPath) { + + PropertyFilter.RelaxedPropertyPath path = PropertyFilter.RelaxedPropertyPath.withRootType(Object.class) + .append(value); + String dotPath; + dotPath = path.toDotPath(null); + assertThat(dotPath).isEqualTo(path.toDotPath()); + dotPath = path.toDotPath("foo"); + assertThat(dotPath).isEqualTo(newDotPath); + } + + @ParameterizedTest + @CsvSource({ "s1, s1", "s1.s2, s1" }) + void getSegmentShouldWork(String value, String segment) { + + PropertyFilter.RelaxedPropertyPath path = PropertyFilter.RelaxedPropertyPath.withRootType(Object.class) + .append(value); + assertThat(path.getSegment()).isEqualTo(segment); + } + + @ParameterizedTest + @CsvSource({ "s1, s1", "s1.s2, s2", "a.b.c, c" }) + void leafSegmentShouldWork(String value, String segment) { + + PropertyFilter.RelaxedPropertyPath path = PropertyFilter.RelaxedPropertyPath.withRootType(Object.class) + .append(value); + PropertyFilter.RelaxedPropertyPath leafProperty = path.getLeafProperty(); + assertThat(leafProperty.getType()).isEqualTo(path.getType()); + assertThat(leafProperty.getSegment()).isEqualTo(segment); + } + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/imperative/ProjectionIT.java b/src/test/java/org/springframework/data/neo4j/integration/imperative/ProjectionIT.java index aa5894afb..37fe2a24b 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/imperative/ProjectionIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/imperative/ProjectionIT.java @@ -20,6 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -34,6 +35,7 @@ import org.neo4j.driver.Record; import org.neo4j.driver.Session; import org.neo4j.driver.Transaction; import org.neo4j.driver.Values; +import org.neo4j.driver.types.MapAccessor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -59,6 +61,7 @@ import org.springframework.data.neo4j.integration.shared.common.Person; import org.springframework.data.neo4j.integration.shared.common.PersonDepartmentQueryResult; import org.springframework.data.neo4j.integration.shared.common.PersonEntity; import org.springframework.data.neo4j.integration.shared.common.PersonSummary; +import org.springframework.data.neo4j.integration.shared.common.PersonWithNoConstructor; import org.springframework.data.neo4j.integration.shared.common.ProjectionTest1O1; import org.springframework.data.neo4j.integration.shared.common.ProjectionTestLevel1; import org.springframework.data.neo4j.integration.shared.common.ProjectionTestRoot; @@ -107,6 +110,7 @@ class ProjectionIT { transaction.run("MATCH (n) detach delete n"); transaction.run("CREATE (p:PersonEntity {id: 'p1', email: 'p1@dep1.org'}) -[:MEMBER_OF]->(department:DepartmentEntity {id: 'd1', name: 'Dep1'}) RETURN p"); + transaction.run("CREATE (p:PersonWithNoConstructor {name: 'meistermeier', first_name: 'Gerrit', mittlererName: 'unknown'}) RETURN p"); for (Map.Entry person : new Map.Entry[] { new AbstractMap.SimpleEntry(FIRST_NAME, LAST_NAME), @@ -387,6 +391,38 @@ class ProjectionIT { assertThat(window.getAdditionalFields()).containsEntry("key1", "value1"); } + @Test // GH-2371 + void findWithCustomPropertyNameWorks(@Autowired PersonWithNoConstructorRepository repository) { + + assertThat(repository.findAll()).hasSize(1); + + ProjectedPersonWithNoConstructor person = repository.findByName("meistermeier"); + assertThat(person.getFirstName()).isEqualTo("Gerrit"); + assertThat(person.getMittlererName()).isEqualTo("unknown"); + } + + @Test // GH-2371 + void saveWithCustomPropertyNameWorks(@Autowired Neo4jTemplate neo4jTemplate) { + PersonWithNoConstructor person = neo4jTemplate.findOne("MATCH (p:PersonWithNoConstructor {name: 'meistermeier'}) RETURN p", Collections.emptyMap(), + PersonWithNoConstructor.class).get(); + + person.setName("rotnroll666"); + person.setFirstName("Michael"); + person.setMiddleName("foo"); + + neo4jTemplate.saveAs(person, ProjectedPersonWithNoConstructor.class); + + try (Session session = driver.session(bookmarkCapture.createSessionConfig())) { + Record record = session + .run("MATCH (p:PersonWithNoConstructor {name: 'rotnroll666'}) RETURN p") + .single(); + + MapAccessor p = record.get("p").asNode(); + assertThat(p.get("first_name").asString()).isEqualTo("Michael"); + assertThat(p.get("mittlererName").asString()).isEqualTo("foo"); + } + } + private static void projectedEntities(PersonDepartmentQueryResult personAndDepartment) { assertThat(personAndDepartment.getPerson()).extracting(PersonEntity::getId).isEqualTo("p1"); assertThat(personAndDepartment.getPerson()).extracting(PersonEntity::getEmail).isEqualTo("p1@dep1.org"); @@ -404,6 +440,20 @@ class ProjectionIT { .build(); } + interface ProjectedPersonWithNoConstructor { + + String getName(); + + String getFirstName(); + + String getMittlererName(); + } + + interface PersonWithNoConstructorRepository extends Neo4jRepository { + + ProjectedPersonWithNoConstructor findByName(String name); + } + interface ProjectionPersonRepository extends Neo4jRepository, CypherdslStatementExecutor { Collection findByLastName(String lastName); diff --git a/src/test/java/org/springframework/data/neo4j/integration/reactive/ReactiveProjectionIT.java b/src/test/java/org/springframework/data/neo4j/integration/reactive/ReactiveProjectionIT.java index cf6e7480e..446866221 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/reactive/ReactiveProjectionIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/reactive/ReactiveProjectionIT.java @@ -17,48 +17,52 @@ package org.springframework.data.neo4j.integration.reactive; import static org.assertj.core.api.Assertions.assertThat; -import org.neo4j.cypherdsl.core.Cypher; -import org.neo4j.cypherdsl.core.Node; -import org.neo4j.cypherdsl.core.Statement; -import org.neo4j.driver.Record; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.neo4j.core.ReactiveDatabaseSelectionProvider; -import org.springframework.data.neo4j.core.transaction.Neo4jBookmarkManager; -import org.springframework.data.neo4j.core.transaction.ReactiveNeo4jTransactionManager; -import org.springframework.data.neo4j.integration.shared.common.ProjectionTest1O1; -import org.springframework.data.neo4j.integration.shared.common.ProjectionTestLevel1; -import org.springframework.data.neo4j.integration.shared.common.ProjectionTestRoot; -import org.springframework.data.neo4j.repository.query.Query; -import org.springframework.data.neo4j.repository.support.ReactiveCypherdslStatementExecutor; -import org.springframework.data.neo4j.test.BookmarkCapture; -import org.springframework.data.repository.query.Param; -import org.springframework.transaction.ReactiveTransactionManager; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; +import java.util.Collections; +import java.util.List; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.neo4j.cypherdsl.core.Cypher; +import org.neo4j.cypherdsl.core.Node; +import org.neo4j.cypherdsl.core.Statement; import org.neo4j.driver.Driver; +import org.neo4j.driver.Record; import org.neo4j.driver.Session; import org.neo4j.driver.Transaction; +import org.neo4j.driver.types.MapAccessor; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.neo4j.config.AbstractReactiveNeo4jConfig; +import org.springframework.data.neo4j.core.ReactiveDatabaseSelectionProvider; +import org.springframework.data.neo4j.core.ReactiveNeo4jTemplate; +import org.springframework.data.neo4j.core.transaction.Neo4jBookmarkManager; +import org.springframework.data.neo4j.core.transaction.ReactiveNeo4jTransactionManager; import org.springframework.data.neo4j.integration.shared.common.NamesOnly; import org.springframework.data.neo4j.integration.shared.common.NamesOnlyDto; import org.springframework.data.neo4j.integration.shared.common.Person; import org.springframework.data.neo4j.integration.shared.common.PersonSummary; +import org.springframework.data.neo4j.integration.shared.common.PersonWithNoConstructor; +import org.springframework.data.neo4j.integration.shared.common.ProjectionTest1O1; +import org.springframework.data.neo4j.integration.shared.common.ProjectionTestLevel1; +import org.springframework.data.neo4j.integration.shared.common.ProjectionTestRoot; import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; import org.springframework.data.neo4j.repository.config.EnableReactiveNeo4jRepositories; +import org.springframework.data.neo4j.repository.query.Query; +import org.springframework.data.neo4j.repository.support.ReactiveCypherdslStatementExecutor; +import org.springframework.data.neo4j.test.BookmarkCapture; import org.springframework.data.neo4j.test.Neo4jExtension; import org.springframework.data.neo4j.test.Neo4jIntegrationTest; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.ReactiveTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; -import java.util.List; - /** * @author Gerrit Meier */ @@ -90,6 +94,7 @@ class ReactiveProjectionIT { transaction.run("MATCH (n) detach delete n"); transaction.run("CREATE (:Person{firstName:'%s', lastName:'%s'})-[:LIVES_AT]->(:Address{city:'%s'})" .formatted(FIRST_NAME, LAST_NAME, CITY)); + transaction.run("CREATE (p:PersonWithNoConstructor {name: 'meistermeier', first_name: 'Gerrit', mittlererName: 'unknown'}) RETURN p"); Record result = transaction.run(""" create (r:ProjectionTestRoot {name: 'root'}) @@ -303,6 +308,62 @@ class ReactiveProjectionIT { .verifyComplete(); } + @Test // GH-2371 + void findWithCustomPropertyNameWorks(@Autowired PersonWithNoConstructorRepository repository) { + + repository.findAll().as(StepVerifier::create).expectNextCount(1L); + + repository.findByName("meistermeier") + .as(StepVerifier::create) + .assertNext(person -> { + assertThat(person.getFirstName()).isEqualTo("Gerrit"); + assertThat(person.getMittlererName()).isEqualTo("unknown"); + }) + .verifyComplete(); + } + + @Test // GH-2371 + void saveWithCustomPropertyNameWorks(@Autowired BookmarkCapture bookmarkCapture, @Autowired ReactiveNeo4jTemplate neo4jTemplate) { + + neo4jTemplate + .findOne("MATCH (p:PersonWithNoConstructor {name: 'meistermeier'}) RETURN p", Collections.emptyMap(), PersonWithNoConstructor.class) + .doOnNext(person -> { + person.setName("rotnroll666"); + person.setFirstName("Michael"); + person.setMiddleName("foo"); + }).flatMap(p -> neo4jTemplate.saveAs(p, ProjectedPersonWithNoConstructor.class)) + .as(StepVerifier::create) + .expectNextCount(1L) + .verifyComplete(); + + + + try (Session session = driver.session(bookmarkCapture.createSessionConfig())) { + Record record = session + .run("MATCH (p:PersonWithNoConstructor {name: 'rotnroll666'}) RETURN p") + .single(); + + MapAccessor p = record.get("p").asNode(); + assertThat(p.get("first_name").asString()).isEqualTo("Michael"); + assertThat(p.get("mittlererName").asString()).isEqualTo("foo"); + } + } + + interface ProjectedPersonWithNoConstructor { + + String getName(); + + String getFirstName(); + + String getMittlererName(); + } + + interface PersonWithNoConstructorRepository extends ReactiveNeo4jRepository { + + Mono findByName(String name); + } + + interface ReactiveProjectionPersonRepository extends ReactiveNeo4jRepository, ReactiveCypherdslStatementExecutor { diff --git a/src/test/java/org/springframework/data/neo4j/integration/shared/common/PersonWithNoConstructor.java b/src/test/java/org/springframework/data/neo4j/integration/shared/common/PersonWithNoConstructor.java index f53d9055a..e07614bce 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/shared/common/PersonWithNoConstructor.java +++ b/src/test/java/org/springframework/data/neo4j/integration/shared/common/PersonWithNoConstructor.java @@ -38,4 +38,6 @@ public class PersonWithNoConstructor { private String name; @Property("first_name") private String firstName; + + @Property("mittlererName") private String middleName; }