diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jEntityConverter.java b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jEntityConverter.java index 1a608005d5..27448d1232 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jEntityConverter.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/DefaultNeo4jEntityConverter.java @@ -34,6 +34,7 @@ import java.util.stream.Collectors; import java.util.stream.StreamSupport; +import org.neo4j.driver.Record; import org.neo4j.driver.Value; import org.neo4j.driver.Values; import org.neo4j.driver.internal.value.NullValue; @@ -82,6 +83,8 @@ final class DefaultNeo4jEntityConverter implements Neo4jEntityConverter { private final Type relationshipType; private final Type mapType; private final Type listType; + private final Type pathType; + private final Map> labelNodeCache = new HashMap<>(); DefaultNeo4jEntityConverter(EntityInstantiators entityInstantiators, NodeDescriptionStore nodeDescriptionStore, @@ -101,6 +104,7 @@ final class DefaultNeo4jEntityConverter implements Neo4jEntityConverter { this.relationshipType = typeSystem.RELATIONSHIP(); this.mapType = typeSystem.MAP(); this.listType = typeSystem.LIST(); + this.pathType = typeSystem.PATH(); } @Override @@ -112,7 +116,7 @@ public R read(Class targetType, MapAccessor mapAccessor) { @SuppressWarnings("unchecked") // ¯\_(ツ)_/¯ Neo4jPersistentEntity rootNodeDescription = (Neo4jPersistentEntity) nodeDescriptionStore.getNodeDescription(targetType); - MapAccessor queryRoot = determineQueryRoot(mapAccessor, rootNodeDescription); + MapAccessor queryRoot = determineQueryRoot(mapAccessor, rootNodeDescription, true); try { return queryRoot == null ? null : map(queryRoot, queryRoot, rootNodeDescription); @@ -122,7 +126,7 @@ public R read(Class targetType, MapAccessor mapAccessor) { } @Nullable - private MapAccessor determineQueryRoot(MapAccessor mapAccessor, @Nullable Neo4jPersistentEntity rootNodeDescription) { + private MapAccessor determineQueryRoot(MapAccessor mapAccessor, @Nullable Neo4jPersistentEntity rootNodeDescription, boolean firstTry) { if (rootNodeDescription == null) { return null; @@ -183,9 +187,40 @@ private MapAccessor determineQueryRoot(MapAccessor mapAccessor, @Nullable Ne } } + // The aggregating mapping function synthesizes a bunch of things and we must not interfere with those + boolean isSynthesized = isSynthesized(mapAccessor); + if (!isSynthesized) { + // Check if the original record has been a map. Would have been probably sane to do this right from the start, + // but this would change original SDN 6.0 behaviour to much + if (mapAccessor instanceof Value && ((Value) mapAccessor).hasType(mapType)) { + return mapAccessor; + } + + // This is also due the aggregating mapping function: It will check on a NoRootNodeMappingException + // whether there's a nested, aggregatable path + if (firstTry && !canBeAggregated(mapAccessor)) { + Value value = Values.value(Collections.singletonMap("_", mapAccessor.asMap(Function.identity()))); + return determineQueryRoot(value, rootNodeDescription, false); + } + } + throw new NoRootNodeMappingException(mapAccessor, rootNodeDescription); } + private boolean canBeAggregated(MapAccessor mapAccessor) { + + if (mapAccessor instanceof Record r) { + return r.values().stream().anyMatch(pathType::isTypeOf); + } + return false; + } + + private boolean isSynthesized(MapAccessor mapAccessor) { + return mapAccessor.containsKey(Constants.NAME_OF_SYNTHESIZED_ROOT_NODE) && + mapAccessor.containsKey(Constants.NAME_OF_SYNTHESIZED_RELATIONS) && + mapAccessor.containsKey(Constants.NAME_OF_SYNTHESIZED_RELATED_NODES); + } + private Collection createDynamicLabelsProperty(TypeInformation type, Collection dynamicLabels) { Collection target = CollectionFactory.createCollection(type.getType(), String.class, dynamicLabels.size()); diff --git a/src/test/java/org/springframework/data/neo4j/integration/lite/A.java b/src/test/java/org/springframework/data/neo4j/integration/lite/A.java new file mode 100644 index 0000000000..b7afdfb1ef --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/lite/A.java @@ -0,0 +1,43 @@ +/* + * 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.integration.lite; + +/** + * DTO with nested DTO + * + * @author Michael J. Simons + */ +public class A { + private String outer; + + private B nested; + + public String getOuter() { + return outer; + } + + public void setOuter(String outer) { + this.outer = outer; + } + + public B getNested() { + return nested; + } + + public void setNested(B nested) { + this.nested = nested; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/lite/B.java b/src/test/java/org/springframework/data/neo4j/integration/lite/B.java new file mode 100644 index 0000000000..25ba96fb0c --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/lite/B.java @@ -0,0 +1,33 @@ +/* + * 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.integration.lite; + +/** + * Inner DTO + * + * @author Michael J. Simons + */ +public class B { + private String inner; + + public String getInner() { + return inner; + } + + public void setInner(String inner) { + this.inner = inner; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/lite/LightweightMappingIT.java b/src/test/java/org/springframework/data/neo4j/integration/lite/LightweightMappingIT.java new file mode 100644 index 0000000000..b6b7be3b02 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/lite/LightweightMappingIT.java @@ -0,0 +1,146 @@ +/* + * 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.integration.lite; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collection; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.neo4j.driver.Driver; +import org.neo4j.driver.Session; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.neo4j.core.DatabaseSelectionProvider; +import org.springframework.data.neo4j.core.transaction.Neo4jBookmarkManager; +import org.springframework.data.neo4j.core.transaction.Neo4jTransactionManager; +import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories; +import org.springframework.data.neo4j.test.BookmarkCapture; +import org.springframework.data.neo4j.test.Neo4jExtension; +import org.springframework.data.neo4j.test.Neo4jImperativeTestConfiguration; +import org.springframework.data.neo4j.test.Neo4jIntegrationTest; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +@Neo4jIntegrationTest +class LightweightMappingIT { + + protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; + + @BeforeAll + static void setupData(@Autowired Driver driver, @Autowired BookmarkCapture bookmarkCapture) { + + try (Session session = driver.session(bookmarkCapture.createSessionConfig())) { + session.run("MATCH (n) DETACH DELETE n").consume(); + // language=cypher + session.run( + """ + CREATE (u1:User {login: 'michael', id: randomUUID()}) + CREATE (u2:User {login: 'gerrit', id: randomUUID()}) + CREATE (so1:SomeDomainObject {name: 'name1', id: randomUUID()}) + CREATE (so2:SomeDomainObject {name: 'name2', id: randomUUID()}) + CREATE (so1)<-[:OWNS]-(u1)-[:OWNS]->(so2) + """ + ); + bookmarkCapture.seedWith(session.lastBookmarks()); + } + } + + @Test + void getAllFlatShouldWork(@Autowired SomeDomainRepository repository) { + + Collection dtos = repository.getAllFlat(); + assertThat(dtos).hasSize(10) + .allSatisfy(dto -> { + assertThat(dto.counter).isGreaterThan(0); + assertThat(dto.resyncId).isNotNull(); + }); + } + + @Test + void getOneFlatShouldWork(@Autowired SomeDomainRepository repository) { + + Optional dtos = repository.getOneFlat(); + assertThat(dtos).hasValueSatisfying(dto -> { + assertThat(dto.counter).isEqualTo(4711L); + assertThat(dto.resyncId).isNotNull(); + }); + } + + @Test + void getAllNestedShouldWork(@Autowired SomeDomainRepository repository) { + + Collection dtos = repository.getNestedStuff(); + assertThat(dtos).hasSize(1) + .first() + .satisfies(dto -> { + assertThat(dto.counter).isEqualTo(4711L); + assertThat(dto.resyncId).isNotNull(); + assertThat(dto.user) + .isNotNull() + .extracting(User::getLogin) + .isEqualTo("michael"); + assertThat(dto.user.getOwnedObjects()) + .hasSize(2); + + }); + } + + + @Test + void getTestedDTOsShouldWork(@Autowired SomeDomainRepository repository) { + + Optional dto = repository.getOneNestedDTO(); + assertThat(dto).hasValueSatisfying(v -> { + assertThat(v.getOuter()).isEqualTo("av"); + assertThat(v.getNested()).isNotNull() + .extracting(B::getInner).isEqualTo("bv"); + }); + + } + + @Configuration + @EnableTransactionManagement + @EnableNeo4jRepositories(considerNestedRepositories = true) + static class Config extends Neo4jImperativeTestConfiguration { + + @Bean + public Driver driver() { + return neo4jConnectionSupport.getDriver(); + } + + @Bean + public BookmarkCapture bookmarkCapture() { + return new BookmarkCapture(); + } + + @Override + public PlatformTransactionManager transactionManager(Driver driver, DatabaseSelectionProvider databaseNameProvider) { + + BookmarkCapture bookmarkCapture = bookmarkCapture(); + return new Neo4jTransactionManager(driver, databaseNameProvider, Neo4jBookmarkManager.create(bookmarkCapture)); + } + + @Override + public boolean isCypher5Compatible() { + return neo4jConnectionSupport.isCypher5SyntaxCompatible(); + } + } + +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/lite/MyDTO.java b/src/test/java/org/springframework/data/neo4j/integration/lite/MyDTO.java new file mode 100644 index 0000000000..b3b01d951a --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/lite/MyDTO.java @@ -0,0 +1,29 @@ +/* + * 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.integration.lite; + +/** + * DTO with optionally linked domain object + * + * @author Michael J. Simons + */ +public class MyDTO { + String resyncId; + + Long counter; + + User user; +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/lite/SomeDomainObject.java b/src/test/java/org/springframework/data/neo4j/integration/lite/SomeDomainObject.java new file mode 100644 index 0000000000..93d4a86511 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/lite/SomeDomainObject.java @@ -0,0 +1,49 @@ +/* + * 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.integration.lite; + +import java.util.UUID; + +import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; + +/** + * Irrelevant to the tests in this package, but needed for setting up a repository. + * + * @author Michael J. Simons + */ +@Node +public class SomeDomainObject { + + @Id + @GeneratedValue + private UUID id; + + private final String name; + + public SomeDomainObject(String name) { + this.name = name; + } + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/lite/SomeDomainRepository.java b/src/test/java/org/springframework/data/neo4j/integration/lite/SomeDomainRepository.java new file mode 100644 index 0000000000..fff8cbd448 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/lite/SomeDomainRepository.java @@ -0,0 +1,63 @@ +/* + * 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.integration.lite; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.neo4j.repository.Neo4jRepository; +import org.springframework.data.neo4j.repository.query.Query; + +/** + * @author Michael J. Simons + */ +public interface SomeDomainRepository extends Neo4jRepository { + + /** + * @return Mapping arbitrary, ungrouped results into a dto + */ + // language=cypher + @Query("UNWIND range(1,10) AS x RETURN randomUUID() AS resyncId, tointeger(x*rand()*10)+1 AS counter ORDER BY counter") + Collection getAllFlat(); + + /** + * @return Mapping a single ungrouped result + */ + // language=cypher + @Query("RETURN randomUUID() AS resyncId, 4711 AS counter") + Optional getOneFlat(); + + /** + * @return Mapping a dto plus known domain objects + */ + // language=cypher + @Query(""" + MATCH (u:User {login:'michael'}) -[r:OWNS] -> (s:SomeDomainObject) + WITH u, collect(r) AS r, collect(s) AS ownedObjects + RETURN + u{.*, __internalNeo4jId__: id(u), r, ownedObjects} AS user, + randomUUID() AS resyncId, 4711 AS counter,u + """) + Collection getNestedStuff(); + + /** + * @return Mapping nested dtos + */ + // language=cypher + @Query("RETURN 'av' AS outer, {inner: 'bv'} AS nested") + Optional getOneNestedDTO(); +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/lite/User.java b/src/test/java/org/springframework/data/neo4j/integration/lite/User.java new file mode 100644 index 0000000000..3352ee84cc --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/lite/User.java @@ -0,0 +1,66 @@ +/* + * 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.integration.lite; + +import java.util.List; +import java.util.UUID; + +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; + +/** + * Another known node. + * + * @author Michael J. Simons + */ +@Node +public class User { + + @Id + @GeneratedValue + private UUID id; + + private final String login; + + @Relationship(direction = Relationship.Direction.OUTGOING, type = "OWNS") + private List ownedObjects; + + public User(String login) { + this.login = login; + } + + public UUID getId() { + return id; + } + + public String getLogin() { + return login; + } + + public void setId(UUID id) { + this.id = id; + } + + public List getOwnedObjects() { + return ownedObjects; + } + + public void setOwnedObjects(List ownedObjects) { + this.ownedObjects = ownedObjects; + } +}