From 08d834dbd871f4166677d7d1af80ccb74c67f4e5 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Tue, 1 Feb 2022 14:54:02 +0100 Subject: [PATCH] GH-2371 - Respect `@Property` information in projection. Domain attributes can be renamed using the `@Property` annotation when being mapped to and read from graph properties. That renaming must also happening when using projections. As with #2451 in 582fd7de8471266b814cdc5788c7603c7287259b this is kind of a problem with the existing filter mechanism that works after the stack has dealt with the actual properties. After all properties and paths are computed, they will be translated as late as possible before checked for inclusions: - When writing just before it is checked whether to add them to the properties parameter or not - When reading after they have been map-projected from the database into the result set but before they are checked for inclusions. Also addressed here is the a workaround for writes: In case a user had an interface based projection and used the graph property name as accessor, a write would succeed, however a read would fail. A property accessor has now been registered with the default projection factory that translates a failure to access an entity property (attribute) once. This fixes #2371. # Conflicts: # src/test/java/org/springframework/data/neo4j/integration/reactive/ReactiveProjectionIT.java --- .../neo4j/core/mapping/PropertyFilter.java | 81 +++++++++- .../repository/query/QueryFragments.java | 16 +- ...aphPropertyAccessingMethodInterceptor.java | 139 ++++++++++++++++++ .../support/Neo4jRepositoryFactory.java | 13 ++ .../ReactiveNeo4jRepositoryFactory.java | 13 ++ .../core/mapping/PropertyFilterTest.java | 89 +++++++++++ .../integration/imperative/ProjectionIT.java | 50 +++++++ .../reactive/ReactiveProjectionIT.java | 97 +++++++++--- .../common/PersonWithNoConstructor.java | 2 + 9 files changed, 471 insertions(+), 29 deletions(-) create mode 100644 src/main/java/org/springframework/data/neo4j/repository/support/EntityAndGraphPropertyAccessingMethodInterceptor.java create mode 100644 src/test/java/org/springframework/data/neo4j/core/mapping/PropertyFilterTest.java 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; }