diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/Neo4jTemplate.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/Neo4jTemplate.java index ef50d268..1cbd58da 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/Neo4jTemplate.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/Neo4jTemplate.java @@ -303,7 +303,7 @@ private ExecutableQuery createExecutableQuery(Class domainType, Statem PreparedQuery preparedQuery = PreparedQuery.queryFor(domainType) .withCypherQuery(renderer.render(statement)) .withParameters(parameters) - .usingMappingFunction(this.neo4jMappingContext.getRequiredMappingFunctionFor(domainType)) + .usingMappingFunction(neo4jMappingContext.getRequiredMappingFunctionFor(domainType)) .build(); return toExecutableQuery(preparedQuery); } diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java index a9c715cd..362c9a19 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/convert/Neo4jConverter.java @@ -18,12 +18,13 @@ */ package org.neo4j.springframework.data.core.convert; +import java.util.Map; + +import org.neo4j.driver.Record; import org.neo4j.driver.Value; -import org.neo4j.driver.types.TypeSystem; import org.springframework.dao.TypeMismatchDataAccessException; -import org.springframework.data.mapping.PersistentProperty; -import org.springframework.data.mapping.PersistentPropertyAccessor; -import org.springframework.data.mapping.model.ParameterValueProvider; +import org.springframework.data.convert.EntityReader; +import org.springframework.data.convert.EntityWriter; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; @@ -34,7 +35,7 @@ * @soundtrack The Kleptones - A Night At The Hip-Hopera * @since 1.0 */ -public interface Neo4jConverter { +public interface Neo4jConverter extends EntityReader, EntityWriter> { /** * Reads a {@link Value} returned by the driver and converts it into a {@link Neo4jSimpleTypes simple type} supported @@ -43,34 +44,21 @@ public interface Neo4jConverter { * the failed conversion. * * @param value The value to be read, may be null. - * @param type The type information describing the target type + * @param type The type information describing the target type. * @return A simple type or null, if the value was {@literal null} or {@link org.neo4j.driver.Values#NULL}. * @throws TypeMismatchDataAccessException In case the value cannot be converted to the target type */ @Nullable - Object readValue(@Nullable Value value, TypeInformation type); - - @Nullable - Value writeValue(@Nullable Object value, TypeInformation type); + Object readValueForProperty(@Nullable Value value, TypeInformation type); /** - * Returns a {@link PersistentPropertyAccessor} that delegates to {@code targetPropertyAccessor} and applies - * all known conversions before returning a value. + * Converts an {@link Object} to a driver's value object. * - * @param targetPropertyAccessor The property accessor to delegate to, must not be {@code null}. - * @param The type of the entity to operate on. - * @return A {@link PersistentPropertyAccessor} guaranteed to be not {@code null}. + * @param value The value to get written, may be null. + * @param type The type information describing the target type. + * @return A driver compatible value object. */ - PersistentPropertyAccessor decoratePropertyAccessor(TypeSystem typeSystem, PersistentPropertyAccessor targetPropertyAccessor); + @Nullable + Value writeValueFromProperty(@Nullable Object value, TypeInformation type); - /** - * Returns a {@link ParameterValueProvider} that delegates to {@code targetParameterValueProvider} and applies - * all known conversions before returning a value. - * - * @param targetParameterValueProvider The parameter value provider to delegate to, must not be {@code null}. - * @param The type of the entity to operate on. - * @return A {@link ParameterValueProvider} guaranteed to be not {@code null}. - */ - > ParameterValueProvider decorateParameterValueProvider( - ParameterValueProvider targetParameterValueProvider); } diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/MapExpression.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/MapExpression.java index 8507351f..1683933f 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/MapExpression.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/MapExpression.java @@ -18,6 +18,8 @@ */ package org.neo4j.springframework.data.core.cypher; +import static org.neo4j.springframework.data.core.cypher.Expressions.*; + import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -25,6 +27,7 @@ import org.apiguardian.api.API; import org.neo4j.springframework.data.core.cypher.support.TypedSubtree; +import org.neo4j.springframework.data.core.cypher.support.Visitable; import org.springframework.util.Assert; /** @@ -74,4 +77,9 @@ MapExpression addEntries(List entries) { newContent.addAll(entries); return new MapExpression<>(newContent); } + + @Override + protected Visitable prepareVisit(MapEntry child) { + return child instanceof Expression ? nameOrExpression((Expression) child) : child; + } } diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/MapProjection.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/MapProjection.java index 7eb4c2bc..fcb33d51 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/MapProjection.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/MapProjection.java @@ -111,8 +111,8 @@ private static List createNewContent(Object... content) { Assert.isTrue(!knownKeys.contains(lastKey), "Duplicate key '" + lastKey + "'"); entry = new KeyValueMapEntry(lastKey, lastExpression); knownKeys.add(lastKey); - } else if (lastExpression instanceof PropertyLookup) { - entry = (PropertyLookup) lastExpression; + } else if (lastExpression instanceof MapEntry) { + entry = (MapEntry) lastExpression; } else { throw new IllegalArgumentException(lastExpression + " of type " + lastExpression.getClass() + " cannot be used with an implicit name as map entry."); diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/Relationship.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/Relationship.java index a14fde6f..2d32682e 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/Relationship.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/cypher/Relationship.java @@ -37,7 +37,7 @@ */ @API(status = API.Status.INTERNAL, since = "1.0") public final class Relationship implements - PatternElement, Named, Expression, + PatternElement, Named, Expression, MapEntry, ExposesRelationships { /** diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jBinderFunction.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jBinderFunction.java deleted file mode 100644 index 27d4ad4f..00000000 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jBinderFunction.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2019 "Neo4j," - * Neo4j Sweden AB [https://neo4j.com] - * - * This file is part of Neo4j. - * - * 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.neo4j.springframework.data.core.mapping; - -import static org.neo4j.springframework.data.core.schema.NodeDescription.*; - -import java.util.HashMap; -import java.util.Map; -import java.util.function.Function; - -import org.neo4j.springframework.data.core.convert.Neo4jConverter; -import org.springframework.data.mapping.PersistentPropertyAccessor; - -/** - * @author Michael J. Simons - * @param type that should get mapped by the binder function - * @since 1.0 - */ -final class DefaultNeo4jBinderFunction implements Function> { - - private final Neo4jPersistentEntity nodeDescription; - - private final Neo4jConverter converter; - - DefaultNeo4jBinderFunction(Neo4jPersistentEntity nodeDescription, Neo4jConverter converter) { - this.nodeDescription = nodeDescription; - this.converter = converter; - } - - @Override - public Map apply(T entity) { - Map properties = new HashMap<>(); - - PersistentPropertyAccessor propertyAccessor = nodeDescription.getPropertyAccessor(entity); - nodeDescription.doWithProperties((Neo4jPersistentProperty p) -> { - - // Skip the internal properties, we don't want them to end up stored as properties - if (p.isInternalIdProperty()) { - return; - } - - final Object value = converter.writeValue(propertyAccessor.getProperty(p), p.getTypeInformation()); - properties.put(p.getPropertyName(), value); - }); - - Map parameters = new HashMap<>(); - parameters.put(NAME_OF_PROPERTIES_PARAM, properties); - parameters.put(NAME_OF_ID_PARAM, propertyAccessor.getProperty(nodeDescription.getRequiredIdProperty())); - - return parameters; - } -} diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverter.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverter.java index 21e76ad6..5475231f 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverter.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverter.java @@ -18,25 +18,47 @@ */ package org.neo4j.springframework.data.core.mapping; +import static java.util.stream.Collectors.*; +import static org.neo4j.springframework.data.core.schema.NodeDescription.*; +import static org.neo4j.springframework.data.core.schema.RelationshipDescription.*; + +import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.StreamSupport; +import org.apache.commons.logging.LogFactory; +import org.neo4j.driver.Record; import org.neo4j.driver.Value; import org.neo4j.driver.Values; +import org.neo4j.driver.types.MapAccessor; +import org.neo4j.driver.types.Node; +import org.neo4j.driver.types.Relationship; import org.neo4j.driver.types.TypeSystem; import org.neo4j.springframework.data.core.convert.Neo4jConversions; import org.neo4j.springframework.data.core.convert.Neo4jConverter; +import org.neo4j.springframework.data.core.schema.RelationshipDescription; import org.springframework.core.CollectionFactory; import org.springframework.core.convert.ConversionService; -import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.support.ConfigurableConversionService; import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.core.log.LogAccessor; import org.springframework.dao.TypeMismatchDataAccessException; -import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.convert.EntityInstantiators; +import org.springframework.data.mapping.AssociationHandler; +import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.PreferredConstructor; -import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.data.mapping.PropertyHandler; import org.springframework.data.mapping.model.ParameterValueProvider; -import org.springframework.data.util.ClassTypeInformation; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -48,9 +70,21 @@ */ final class DefaultNeo4jConverter implements Neo4jConverter { + private static final LogAccessor log = new LogAccessor(LogFactory.getLog(DefaultNeo4jConverter.class)); + + /** + * The shared entity instantiators of this context. Those should not be recreated for each entity or even not for + * each query, as otherwise the cache of Spring's org.springframework.data.convert.ClassGeneratingEntityInstantiator + * won't apply + */ + private static final EntityInstantiators INSTANTIATORS = new EntityInstantiators(); + + private final NodeDescriptionStore nodeDescriptionStore; private final ConversionService conversionService; - DefaultNeo4jConverter(Neo4jConversions neo4jConversions) { + private TypeSystem typeSystem; + + DefaultNeo4jConverter(Neo4jConversions neo4jConversions, NodeDescriptionStore nodeDescriptionStore) { Assert.notNull(neo4jConversions, "Neo4jConversions must not be null!"); @@ -58,10 +92,54 @@ final class DefaultNeo4jConverter implements Neo4jConverter { neo4jConversions.registerConvertersIn(configurableConversionService); this.conversionService = configurableConversionService; + this.nodeDescriptionStore = nodeDescriptionStore; + } + + @Override + public R read(Class targetType, Record record) { + + Neo4jPersistentEntity rootNodeDescription = + (Neo4jPersistentEntity) nodeDescriptionStore.getNodeDescription(targetType); + + try { + List recordValues = record.values(); + String nodeLabel = rootNodeDescription.getPrimaryLabel(); + MapAccessor queryRoot = null; + for (Value value : recordValues) { + if (value.hasType(typeSystem.NODE()) && value.asNode().hasLabel(nodeLabel)) { + if (recordValues.size() > 1) { + queryRoot = mergeRootNodeWithRecord(value.asNode(), record); + } else { + queryRoot = value.asNode(); + } + break; + } + } + if (queryRoot == null) { + for (Value value : recordValues) { + if (value.hasType(typeSystem.MAP())) { + queryRoot = value; + break; + } + } + } + + if (queryRoot == null) { + log.warn(() -> String.format("Could not find mappable nodes or relationships inside %s for %s", record, + rootNodeDescription)); + return null; // todo should not be null because of the @nonnullapi annotation in the EntityReader. Fail? + } else { + Map knownObjects = new ConcurrentHashMap<>(); + return map(queryRoot, rootNodeDescription, knownObjects); + } + } catch (Exception e) { + throw new MappingException("Error mapping " + record.toString(), e); + } } + @Override @Nullable - public Object readValue(@Nullable Value value, TypeInformation type) { + public Object readValueForProperty(@Nullable Value value, TypeInformation type) { if (value == null || value == Values.NULL) { return null; @@ -86,7 +164,31 @@ public Object readValue(@Nullable Value value, TypeInformation type) { } @Override - public Value writeValue(@Nullable Object value, TypeInformation type) { + public void write(Object source, Map parameters) { + Map properties = new HashMap<>(); + + Neo4jPersistentEntity nodeDescription = + (Neo4jPersistentEntity) nodeDescriptionStore.getNodeDescription(source.getClass()); + + PersistentPropertyAccessor propertyAccessor = nodeDescription.getPropertyAccessor(source); + nodeDescription.doWithProperties((Neo4jPersistentProperty p) -> { + + // Skip the internal properties, we don't want them to end up stored as properties + if (p.isInternalIdProperty()) { + return; + } + + final Object value = writeValueFromProperty(propertyAccessor.getProperty(p), p.getTypeInformation()); + properties.put(p.getPropertyName(), value); + }); + + parameters.put(NAME_OF_PROPERTIES_PARAM, properties); + parameters.put(NAME_OF_ID_PARAM, propertyAccessor.getProperty(nodeDescription.getRequiredIdProperty())); + + } + + @Override + public Value writeValueFromProperty(@Nullable Object value, TypeInformation type) { if (value == null) { return Values.NULL; @@ -106,50 +208,221 @@ private static boolean isCollection(TypeInformation type) { return Collection.class.isAssignableFrom(type.getType()); } - @Override - public PersistentPropertyAccessor decoratePropertyAccessor(TypeSystem typeSystem, - PersistentPropertyAccessor targetPropertyAccessor) { + void setTypeSystem(TypeSystem typeSystem) { + this.typeSystem = typeSystem; + } + + /** + * Merges the root node of a query and the remaining record into one map, adding the internal ID of the node, too. + * Merge happens only when the record contains additional values. + * + * @param node Node whose attributes are about to be merged + * @param record Record that should be merged + * @return + */ + private static MapAccessor mergeRootNodeWithRecord(Node node, Record record) { + Map mergedAttributes = new HashMap<>(node.size() + record.size() + 1); + + mergedAttributes.put(NAME_OF_INTERNAL_ID, node.id()); + mergedAttributes.putAll(node.asMap(Function.identity())); + mergedAttributes.putAll(record.asMap(Function.identity())); - return new ConvertingPropertyAccessor<>(targetPropertyAccessor, new DelegatingConversionService()); + return Values.value(mergedAttributes); } - @Override - public > ParameterValueProvider decorateParameterValueProvider( - ParameterValueProvider targetParameterValueProvider) { + /** + * @param queryResult The original query result + * @param nodeDescription The node description of the current entity to be mapped from the result + * @param knownObjects The current list of known objects + * @param As in entity type + * @return + */ + private ET map(MapAccessor queryResult, + Neo4jPersistentEntity nodeDescription, + Map knownObjects) { + + ET instance = instantiate(nodeDescription, queryResult); + + PersistentPropertyAccessor propertyAccessor = nodeDescription.getPropertyAccessor(instance); + + if (nodeDescription.requiresPropertyPopulation()) { - return new ParameterValueProvider() { + // Fill simple properties + Predicate isConstructorParameter = nodeDescription + .getPersistenceConstructor()::isConstructorParameter; + nodeDescription.doWithProperties(populateFrom(queryResult, propertyAccessor, isConstructorParameter)); + + // Fill associations + Collection relationships = nodeDescription.getRelationships(); + nodeDescription.doWithAssociations( + populateFrom(queryResult, propertyAccessor, relationships, knownObjects)); + } + return instance; + } + + private ET instantiate(Neo4jPersistentEntity anotherNodeDescription, MapAccessor values) { + + ParameterValueProvider parameterValueProvider = new ParameterValueProvider() { @Override public Object getParameterValue(PreferredConstructor.Parameter parameter) { - Object originalValue = targetParameterValueProvider.getParameterValue(parameter); - Assert.isInstanceOf(Value.class, originalValue, "Decorated parameters other than of type Value are not supported."); - return readValue((Value) originalValue, parameter.getType()); + Neo4jPersistentProperty matchingProperty = anotherNodeDescription + .getRequiredPersistentProperty(parameter.getName()); + return readValueForProperty(extractValueOf(matchingProperty, values), parameter.getType()); } }; + + return INSTANTIATORS.getInstantiatorFor(anotherNodeDescription) + .createInstance(anotherNodeDescription, parameterValueProvider); } - class DelegatingConversionService implements ConversionService { + private PropertyHandler populateFrom( + MapAccessor queryResult, + PersistentPropertyAccessor propertyAccessor, + Predicate isConstructorParameter + ) { + return property -> { + if (isConstructorParameter.test(property)) { + return; + } - @Override - public boolean canConvert(Class sourceType, Class targetType) { - return sourceType == Value.class; - } + propertyAccessor.setProperty(property, + readValueForProperty(extractValueOf(property, queryResult), property.getTypeInformation())); + }; + } - @Override - public boolean canConvert(TypeDescriptor sourceType, TypeDescriptor targetType) { - return sourceType.isAssignableTo(TypeDescriptor.valueOf(Value.class)); - } + private AssociationHandler populateFrom( + MapAccessor queryResult, + PersistentPropertyAccessor propertyAccessor, + Collection relationships, + Map knownObjects + ) { + return association -> { + Neo4jPersistentProperty inverse = association.getInverse(); - @Override @Nullable - public T convert(Object source, Class targetType) { + RelationshipDescription relationship = relationships.stream() + .filter(r -> r.getFieldName().equals(inverse.getName())) + .findFirst().get(); - return (T) readValue((Value) source, ClassTypeInformation.from(targetType)); - } + String relationshipType = relationship.getType(); + String targetLabel = relationship.getTarget().getPrimaryLabel(); + + Neo4jPersistentEntity targetNodeDescription = (Neo4jPersistentEntity) relationship.getTarget(); + + List value = new ArrayList<>(); + Map dynamicValue = new HashMap<>(); + + BiConsumer mappedObjectHandler = relationship.isDynamic() ? + dynamicValue::put : (type, mappedObject) -> value.add(mappedObject); + + Value list = queryResult.get(relationship.generateRelatedNodesCollectionName()); + + Map relationshipsAndProperties = new HashMap<>(); + + // if the list is null the mapping is based on a custom query + if (list == Values.NULL) { - @Override @Nullable - public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { + Predicate isList = entry -> entry instanceof Value && typeSystem.LIST().isTypeOf(entry); + + Predicate containsOnlyRelationships = entry -> entry.asList(Function.identity()) + .stream() + .allMatch(listEntry -> typeSystem.RELATIONSHIP().isTypeOf(listEntry)); + + Predicate containsOnlyNodes = entry -> entry.asList(Function.identity()) + .stream() + .allMatch(listEntry -> typeSystem.NODE().isTypeOf(listEntry)); + + // find relationships in the result + List allMatchingTypeRelationshipsInResult = StreamSupport + .stream(queryResult.values().spliterator(), false) + .filter(isList.and(containsOnlyRelationships)) + .flatMap(entry -> entry.asList(Value::asRelationship).stream()) + .filter(r -> r.type().equals(relationshipType)) + .collect(toList()); + + List allNodesWithMatchingLabelInResult = StreamSupport + .stream(queryResult.values().spliterator(), false) + .filter(isList.and(containsOnlyNodes)) + .flatMap(entry -> entry.asList(Value::asNode).stream()) + .filter(n -> n.hasLabel(targetLabel)) + .collect(toList()); + + if (allNodesWithMatchingLabelInResult.isEmpty() && allMatchingTypeRelationshipsInResult.isEmpty()) { + return; + } + + for (Node possibleValueNode : allNodesWithMatchingLabelInResult) { + long nodeId = possibleValueNode.id(); + + for (Relationship possibleRelationship : allMatchingTypeRelationshipsInResult) { + if (possibleRelationship.endNodeId() == nodeId) { + Object mappedObject = map(possibleValueNode, targetNodeDescription, knownObjects); + if (relationship.hasRelationshipProperties()) { + + Class propertiesClass = relationship.getRelationshipPropertiesClass(); + + Object relationshipProperties = map(possibleRelationship, + (Neo4jPersistentEntity) nodeDescriptionStore.getNodeDescription(propertiesClass), + knownObjects); + relationshipsAndProperties.put(mappedObject, relationshipProperties); + } else { + mappedObjectHandler.accept(possibleRelationship.type(), mappedObject); + } + break; + } + } + } + } else { + for (Value relatedEntity : list.asList(Function.identity())) { + Neo4jPersistentProperty idProperty = targetNodeDescription.getRequiredIdProperty(); + + // internal (generated) id or external set + Object idValue = idProperty.isInternalIdProperty() + ? relatedEntity.get(NAME_OF_INTERNAL_ID) + : relatedEntity.get(idProperty.getName()); + Object valueEntry = knownObjects.computeIfAbsent(idValue, + (id) -> map(relatedEntity, targetNodeDescription, knownObjects)); + + if (relationship.hasRelationshipProperties()) { + Relationship relatedEntityRelationship = relatedEntity.get(NAME_OF_RELATIONSHIP).asRelationship(); + Class propertiesClass = relationship.getRelationshipPropertiesClass(); + + Object relationshipProperties = map(relatedEntityRelationship, + (Neo4jPersistentEntity) nodeDescriptionStore.getNodeDescription(propertiesClass), + knownObjects); + relationshipsAndProperties.put(valueEntry, relationshipProperties); + } else { + mappedObjectHandler.accept(relatedEntity.get(NAME_OF_RELATIONSHIP_TYPE).asString(), valueEntry); + } + } + } + + if (inverse.getTypeInformation().isCollectionLike()) { + if (inverse.getType().equals(Set.class)) { + propertyAccessor.setProperty(inverse, new HashSet(value)); + } else { + propertyAccessor.setProperty(inverse, value); + } + } else { + if (relationship.isDynamic()) { + propertyAccessor.setProperty(inverse, dynamicValue.isEmpty() ? null : dynamicValue); + } else if (relationship.hasRelationshipProperties()) { + propertyAccessor.setProperty(inverse, relationshipsAndProperties); + } else { + propertyAccessor.setProperty(inverse, value.isEmpty() ? null : value.get(0)); + } + } + }; + } - return readValue((Value) source, ClassTypeInformation.from(targetType.getType())); + private static Value extractValueOf(Neo4jPersistentProperty property, MapAccessor propertyContainer) { + if (property.isInternalIdProperty()) { + return propertyContainer instanceof Node ? + Values.value(((Node) propertyContainer).id()) : + propertyContainer.get(NAME_OF_INTERNAL_ID); + } else { + String graphPropertyName = property.getPropertyName(); + return propertyContainer.get(graphPropertyName); } } } diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jMappingFunction.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jMappingFunction.java deleted file mode 100644 index f234069a..00000000 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jMappingFunction.java +++ /dev/null @@ -1,321 +0,0 @@ -/* - * Copyright (c) 2019 "Neo4j," - * Neo4j Sweden AB [https://neo4j.com] - * - * This file is part of Neo4j. - * - * 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.neo4j.springframework.data.core.mapping; - -import static java.util.stream.Collectors.*; -import static org.neo4j.springframework.data.core.schema.NodeDescription.*; -import static org.neo4j.springframework.data.core.schema.RelationshipDescription.*; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.stream.StreamSupport; - -import org.apache.commons.logging.LogFactory; -import org.neo4j.driver.Record; -import org.neo4j.driver.Value; -import org.neo4j.driver.Values; -import org.neo4j.driver.types.MapAccessor; -import org.neo4j.driver.types.Node; -import org.neo4j.driver.types.Relationship; -import org.neo4j.driver.types.TypeSystem; -import org.neo4j.springframework.data.core.convert.Neo4jConverter; -import org.neo4j.springframework.data.core.schema.RelationshipDescription; -import org.springframework.core.log.LogAccessor; -import org.springframework.data.convert.EntityInstantiators; -import org.springframework.data.mapping.AssociationHandler; -import org.springframework.data.mapping.MappingException; -import org.springframework.data.mapping.PersistentPropertyAccessor; -import org.springframework.data.mapping.PreferredConstructor; -import org.springframework.data.mapping.PropertyHandler; -import org.springframework.data.mapping.model.ParameterValueProvider; - -/** - * The central logic of mapping Neo4j's {@link org.neo4j.driver.Record records} to entities based on the Spring - * implementation of the {@link org.neo4j.springframework.data.core.schema.Schema}, - * represented by the {@link Neo4jMappingContext}. - * - * @author Gerrit Meier - * @author Michael J. Simons - * @since 1.0 - */ -final class DefaultNeo4jMappingFunction implements BiFunction { - - private static final LogAccessor log = new LogAccessor(LogFactory.getLog(DefaultNeo4jMappingFunction.class)); - - /** - * The shared entity instantiators of this context. Those should not be recreated for each entity or even not for - * each query, as otherwise the cache of Spring's org.springframework.data.convert.ClassGeneratingEntityInstantiator - * won't apply - */ - private static final EntityInstantiators INSTANTIATORS = new EntityInstantiators(); - - /** - * The description of the possible root node from where the mapping should start. - */ - private final Neo4jPersistentEntity rootNodeDescription; - - private final Neo4jConverter converter; - - DefaultNeo4jMappingFunction(Neo4jPersistentEntity rootNodeDescription, Neo4jConverter converter) { - - this.rootNodeDescription = rootNodeDescription; - this.converter = converter; - } - - @Override - public T apply(TypeSystem typeSystem, Record record) { - // That would be the place to call a custom converter for the whole object, if any such thing would be - // available (Converter - try { - List recordValues = record.values(); - String nodeLabel = rootNodeDescription.getPrimaryLabel(); - MapAccessor queryRoot = null; - for (Value value : recordValues) { - if (value.hasType(typeSystem.NODE()) && value.asNode().hasLabel(nodeLabel)) { - if (recordValues.size() > 1) { - queryRoot = mergeRootNodeWithRecord(value.asNode(), record); - } else { - queryRoot = value.asNode(); - } - break; - } - } - if (queryRoot == null) { - for (Value value : recordValues) { - if (value.hasType(typeSystem.MAP())) { - queryRoot = value; - break; - } - } - } - - if (queryRoot == null) { - log.warn(() -> String.format("Could not find mappable nodes or relationships inside %s for %s", record, - rootNodeDescription)); - return null; - } else { - Map knownObjects = new ConcurrentHashMap<>(); - return map(typeSystem, queryRoot, rootNodeDescription, knownObjects); - } - } catch (Exception e) { - throw new MappingException("Error mapping " + record.toString(), e); - } - } - - /** - * Merges the root node of a query and the remaining record into one map, adding the internal ID of the node, too. - * Merge happens only when the record contains additional values. - * - * @param node Node whose attributes are about to be merged - * @param record Record that should be merged - * @return - */ - private static MapAccessor mergeRootNodeWithRecord(Node node, Record record) { - Map mergedAttributes = new HashMap<>(node.size() + record.size() + 1); - - mergedAttributes.put(NAME_OF_INTERNAL_ID, node.id()); - mergedAttributes.putAll(node.asMap(Function.identity())); - mergedAttributes.putAll(record.asMap(Function.identity())); - - return Values.value(mergedAttributes); - } - - /** - * @param queryResult The original query result - * @param nodeDescription The node description of the current entity to be mapped from the result - * @param knownObjects The current list of known objects - * @param As in entity type - * @return - */ - private ET map(TypeSystem typeSystem, MapAccessor queryResult, - Neo4jPersistentEntity nodeDescription, - Map knownObjects) { - - ET instance = instantiate(nodeDescription, queryResult); - - PersistentPropertyAccessor propertyAccessor = converter - .decoratePropertyAccessor(typeSystem, nodeDescription.getPropertyAccessor(instance)); - if (nodeDescription.requiresPropertyPopulation()) { - - // Fill simple properties - Predicate isConstructorParameter = nodeDescription - .getPersistenceConstructor()::isConstructorParameter; - nodeDescription.doWithProperties(populateFrom(queryResult, propertyAccessor, isConstructorParameter)); - - // Fill associations - Collection relationships = nodeDescription.getRelationships(); - nodeDescription.doWithAssociations( - populateFrom(typeSystem, queryResult, propertyAccessor, relationships, knownObjects)); - } - return instance; - } - - private ET instantiate(Neo4jPersistentEntity anotherNodeDescription, MapAccessor values) { - - ParameterValueProvider parameterValueProvider = new ParameterValueProvider() { - @Override - public Value getParameterValue(PreferredConstructor.Parameter parameter) { - - Neo4jPersistentProperty matchingProperty = anotherNodeDescription - .getRequiredPersistentProperty(parameter.getName()); - return extractValueOf(matchingProperty, values); - } - }; - parameterValueProvider = converter.decorateParameterValueProvider(parameterValueProvider); - return INSTANTIATORS.getInstantiatorFor(anotherNodeDescription) - .createInstance(anotherNodeDescription, parameterValueProvider); - } - - private static PropertyHandler populateFrom( - MapAccessor queryResult, - PersistentPropertyAccessor propertyAccessor, - Predicate isConstructorParameter - ) { - return property -> { - if (isConstructorParameter.test(property)) { - return; - } - - Value value = extractValueOf(property, queryResult); - propertyAccessor.setProperty(property, value); - }; - } - - private AssociationHandler populateFrom( - TypeSystem typeSystem, - MapAccessor queryResult, - PersistentPropertyAccessor propertyAccessor, - Collection relationships, - Map knownObjects - ) { - return association -> { - Neo4jPersistentProperty inverse = association.getInverse(); - - RelationshipDescription relationship = relationships.stream() - .filter(r -> r.getFieldName().equals(inverse.getName())) - .findFirst().get(); - - String relationshipType = relationship.getType(); - String targetLabel = relationship.getTarget().getPrimaryLabel(); - - Neo4jPersistentEntity targetNodeDescription = (Neo4jPersistentEntity) relationship.getTarget(); - - List value = new ArrayList<>(); - Map dynamicValue = new HashMap<>(); - - BiConsumer mappedObjectHandler = relationship.isDynamic() ? - dynamicValue::put : (type, mappedObject) -> value.add(mappedObject); - - Value list = queryResult.get(relationship.generateRelatedNodesCollectionName()); - - // if the list is null the mapping is based on a custom query - if (list == Values.NULL) { - - Predicate isList = entry -> entry instanceof Value && typeSystem.LIST().isTypeOf(entry); - - Predicate containsOnlyRelationships = entry -> entry.asList(Function.identity()) - .stream() - .allMatch(listEntry -> typeSystem.RELATIONSHIP().isTypeOf(listEntry)); - - Predicate containsOnlyNodes = entry -> entry.asList(Function.identity()) - .stream() - .allMatch(listEntry -> typeSystem.NODE().isTypeOf(listEntry)); - - // find relationships in the result - List allMatchingTypeRelationshipsInResult = StreamSupport - .stream(queryResult.values().spliterator(), false) - .filter(isList.and(containsOnlyRelationships)) - .flatMap(entry -> entry.asList(Value::asRelationship).stream()) - .filter(r -> r.type().equals(relationshipType)) - .collect(toList()); - - List allNodesWithMatchingLabelInResult = StreamSupport - .stream(queryResult.values().spliterator(), false) - .filter(isList.and(containsOnlyNodes)) - .flatMap(entry -> entry.asList(Value::asNode).stream()) - .filter(n -> n.hasLabel(targetLabel)) - .collect(toList()); - - if (allNodesWithMatchingLabelInResult.isEmpty() && allMatchingTypeRelationshipsInResult.isEmpty()) { - return; - } - - for (Node possibleValueNode : allNodesWithMatchingLabelInResult) { - long nodeId = possibleValueNode.id(); - - for (Relationship possibleRelationship : allMatchingTypeRelationshipsInResult) { - if (possibleRelationship.endNodeId() == nodeId) { - Object mappedObject = map(typeSystem, possibleValueNode, targetNodeDescription, knownObjects); - mappedObjectHandler.accept(possibleRelationship.type(), mappedObject); - break; - } - } - } - } else { - for (Value relatedEntity : list.asList(Function.identity())) { - Neo4jPersistentProperty idProperty = targetNodeDescription.getRequiredIdProperty(); - - // internal (generated) id or external set - Object idValue = idProperty.isInternalIdProperty() - ? relatedEntity.get(NAME_OF_INTERNAL_ID) - : relatedEntity.get(idProperty.getName()); - Object valueEntry = knownObjects.computeIfAbsent(idValue, - (id) -> map(typeSystem, relatedEntity, targetNodeDescription, knownObjects)); - - mappedObjectHandler.accept(relatedEntity.get(NAME_OF_RELATIONSHIP_TYPE).asString(), valueEntry); - } - } - - if (inverse.getTypeInformation().isCollectionLike()) { - if (inverse.getType().equals(Set.class)) { - propertyAccessor.setProperty(inverse, new HashSet(value)); - } else { - propertyAccessor.setProperty(inverse, value); - } - } else { - if (relationship.isDynamic()) { - propertyAccessor.setProperty(inverse, dynamicValue.isEmpty() ? null : dynamicValue); - } else { - propertyAccessor.setProperty(inverse, value.isEmpty() ? null : value.get(0)); - } - } - }; - } - - private static Value extractValueOf(Neo4jPersistentProperty property, MapAccessor propertyContainer) { - if (property.isInternalIdProperty()) { - return propertyContainer instanceof Node ? - Values.value(((Node) propertyContainer).id()) : - propertyContainer.get(NAME_OF_INTERNAL_ID); - } else { - String graphPropertyName = property.getPropertyName(); - return propertyContainer.get(graphPropertyName); - } - } -} diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentProperty.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentProperty.java index 29db298a..2f81e2ca 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentProperty.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jPersistentProperty.java @@ -20,6 +20,7 @@ import org.neo4j.springframework.data.core.schema.NodeDescription; import org.neo4j.springframework.data.core.schema.Relationship; +import org.neo4j.springframework.data.core.schema.RelationshipProperties; import org.springframework.data.mapping.Association; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PersistentEntity; @@ -68,8 +69,14 @@ class DefaultNeo4jPersistentProperty extends AnnotationBasedPersistentProperty createAssociation() { - Neo4jPersistentEntity obverseOwner = this.mappingContext - .getPersistentEntity(this.getAssociationTargetType()); + Neo4jPersistentEntity obverseOwner; + + // if the target is a relationship property always take the key type from the map instead of the value type. + if (this.hasActualTypeAnnotation(RelationshipProperties.class)) { + obverseOwner = this.mappingContext.getPersistentEntity(this.getComponentType()); + } else { + obverseOwner = this.mappingContext.getPersistentEntity(this.getAssociationTargetType()); + } Relationship outgoingRelationship = this.findAnnotation(Relationship.class); @@ -86,8 +93,14 @@ protected Association createAssociation() { } Neo4jPersistentProperty obverse = null; - return new DefaultRelationshipDescription(this, obverse, type, this.isDynamicAssociation(), - (NodeDescription) getOwner(), this.getName(), obverseOwner, direction); + boolean dynamicAssociation = this.isDynamicAssociation(); + + // Because a dynamic association is also represented as a Map, this ensures that the + // relationship properties class will only have a value if it's not a dynamic association. + Class relationshipPropertiesClass = dynamicAssociation ? null : getMapValueType(); + + return new DefaultRelationshipDescription(this, obverse, type, dynamicAssociation, (NodeDescription) getOwner(), + this.getName(), obverseOwner, direction, relationshipPropertiesClass); } @Override diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultRelationshipDescription.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultRelationshipDescription.java index 9ddcb1d0..ac5a3afe 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultRelationshipDescription.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/DefaultRelationshipDescription.java @@ -24,6 +24,7 @@ import org.neo4j.springframework.data.core.schema.Relationship; import org.neo4j.springframework.data.core.schema.RelationshipDescription; import org.springframework.data.mapping.Association; +import org.springframework.lang.Nullable; /** * @author Michael J. Simons @@ -44,10 +45,12 @@ class DefaultRelationshipDescription extends Association relationshipPropertiesClass; + DefaultRelationshipDescription(Neo4jPersistentProperty inverse, Neo4jPersistentProperty obverse, String type, boolean dynamic, NodeDescription source, String fieldName, NodeDescription target, - Relationship.Direction direction) { + Relationship.Direction direction, @Nullable Class relationshipPropertiesClass) { super(inverse, obverse); @@ -57,6 +60,7 @@ class DefaultRelationshipDescription extends Association getRelationshipPropertiesClass() { + return relationshipPropertiesClass; + } + + @Override + public boolean hasRelationshipProperties() { + return getRelationshipPropertiesClass() != null; + } @Override public String toString() { diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java index a1530f81..1662767b 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContext.java @@ -18,43 +18,25 @@ */ package org.neo4j.springframework.data.core.mapping; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.function.Supplier; import org.apiguardian.api.API; -import org.neo4j.driver.Record; -import org.neo4j.driver.types.TypeSystem; +import org.neo4j.driver.Driver; import org.neo4j.springframework.data.core.convert.Neo4jConversions; import org.neo4j.springframework.data.core.convert.Neo4jConverter; import org.neo4j.springframework.data.core.convert.Neo4jSimpleTypes; -import org.neo4j.springframework.data.core.schema.IdDescription; import org.neo4j.springframework.data.core.schema.IdGenerator; import org.neo4j.springframework.data.core.schema.NodeDescription; -import org.neo4j.springframework.data.core.schema.RelationshipDescription; import org.neo4j.springframework.data.core.schema.Schema; -import org.neo4j.springframework.data.core.schema.UnknownEntityException; import org.springframework.beans.BeanUtils; import org.springframework.beans.BeansException; -import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.NoSuchBeanDefinitionException; -import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; -import org.springframework.beans.factory.support.BeanDefinitionReaderUtils; -import org.springframework.beans.factory.support.BeanDefinitionRegistry; -import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; -import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.context.ApplicationContext; -import org.springframework.data.mapping.Association; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.context.AbstractMappingContext; import org.springframework.data.mapping.model.Property; @@ -72,26 +54,25 @@ */ @API(status = API.Status.INTERNAL, since = "1.0") public final class Neo4jMappingContext - extends AbstractMappingContext, Neo4jPersistentProperty> implements Schema, - BeanDefinitionRegistryPostProcessor { + extends AbstractMappingContext, Neo4jPersistentProperty> implements Schema { /** - * A lookup of entities based on their primary label. We depend on the locking mechanism provided by the - * {@link AbstractMappingContext}, so this lookup is not synchronized further. + * A map of fallback id generators, that have not been added to the application context */ - private final Map> nodeDescriptionsByPrimaryLabel = new HashMap<>(); + private final Map>, IdGenerator> idGenerators = new ConcurrentHashMap<>(); /** - * A map of fallback id generators, that have not been added to the application context + * The {@link NodeDescriptionStore} is basically a {@link Map} and it is used to break the dependency + * cycle between this class and the {@link DefaultNeo4jConverter}. */ - private final Map>, IdGenerator> fallbackIdGenerators = new ConcurrentHashMap<>(); + private final NodeDescriptionStore nodeDescriptionStore = new NodeDescriptionStore(); /** * The converter used in this mapping context. */ private final Neo4jConverter converter; - private @Nullable ListableBeanFactory beanFactory; + private @Nullable AutowireCapableBeanFactory beanFactory; public Neo4jMappingContext() { @@ -101,7 +82,7 @@ public Neo4jMappingContext() { public Neo4jMappingContext(Neo4jConversions neo4jConversions) { super.setSimpleTypeHolder(Neo4jSimpleTypes.HOLDER); - this.converter = new DefaultNeo4jConverter(neo4jConversions); + this.converter = new DefaultNeo4jConverter(neo4jConversions, nodeDescriptionStore); } public Neo4jConverter getConverter() { @@ -118,7 +99,7 @@ protected Neo4jPersistentEntity createPersistentEntity(TypeInformation final DefaultNeo4jPersistentEntity newEntity = new DefaultNeo4jPersistentEntity<>(typeInformation); String primaryLabel = newEntity.getPrimaryLabel(); - if (this.nodeDescriptionsByPrimaryLabel.containsKey(primaryLabel)) { + if (this.nodeDescriptionStore.containsKey(primaryLabel)) { // @formatter:off throw new MappingException( String.format(Locale.ENGLISH, "The schema already contains a node description under the primary label %s", @@ -126,8 +107,8 @@ protected Neo4jPersistentEntity createPersistentEntity(TypeInformation // @formatter:on } - if (this.nodeDescriptionsByPrimaryLabel.containsValue(newEntity)) { - Optional label = this.nodeDescriptionsByPrimaryLabel.entrySet().stream() + if (this.nodeDescriptionStore.containsValue(newEntity)) { + Optional label = this.nodeDescriptionStore.entrySet().stream() .filter(e -> e.getValue().equals(newEntity)).map( Map.Entry::getKey).findFirst(); @@ -144,7 +125,7 @@ protected Neo4jPersistentEntity createPersistentEntity(TypeInformation newEntity.getUnderlyingClass().getName(), existingDescription.getPrimaryLabel())); } - this.nodeDescriptionsByPrimaryLabel.put(primaryLabel, newEntity); + this.nodeDescriptionStore.put(primaryLabel, newEntity); return newEntity; } @@ -163,39 +144,12 @@ protected Neo4jPersistentProperty createPersistentProperty(Property property, @Override @Nullable public NodeDescription getNodeDescription(String primaryLabel) { - return this.nodeDescriptionsByPrimaryLabel.get(primaryLabel); - } - - NodeDescription getRequiredNodeDescription(String primaryLabel) { - - NodeDescription nodeDescription = this.getNodeDescription(primaryLabel); - if (nodeDescription == null) { - throw new MappingException( - String.format("Required node description not found with primary label '%s'", primaryLabel)); - } - return nodeDescription; + return this.nodeDescriptionStore.get(primaryLabel); } @Override public NodeDescription getNodeDescription(Class underlyingClass) { - - for (NodeDescription nodeDescription : this.nodeDescriptionsByPrimaryLabel.values()) { - if (nodeDescription.getUnderlyingClass().equals(underlyingClass)) { - return nodeDescription; - } - } - return null; - } - - @Override - @Nullable - public BiFunction getMappingFunctionFor(Class targetClass) { - if (this.hasPersistentEntityFor(targetClass)) { - Neo4jPersistentEntity neo4jPersistentEntity = this.getPersistentEntity(targetClass); - return new DefaultNeo4jMappingFunction<>(neo4jPersistentEntity, this.converter); - } - - return null; + return this.nodeDescriptionStore.getNodeDescription(underlyingClass); } @Override @@ -203,42 +157,21 @@ public Optional> addPersistentEntity(Class type) { return super.addPersistentEntity(type); } - @Override - public Function> getRequiredBinderFunctionFor(Class sourceClass) { - - if (!this.hasPersistentEntityFor(sourceClass)) { - throw new UnknownEntityException(sourceClass); - } - - Neo4jPersistentEntity neo4jPersistentEntity = this.getPersistentEntity(sourceClass); - return new DefaultNeo4jBinderFunction(neo4jPersistentEntity, converter); - } - - private Collection computeRelationshipsOf(String primaryLabel) { - - NodeDescription nodeDescription = getRequiredNodeDescription(primaryLabel); - - final List relationships = new ArrayList<>(); - - Neo4jPersistentEntity entity = this.getPersistentEntity(nodeDescription.getUnderlyingClass()); - entity.doWithAssociations((Association association) -> - relationships.add((RelationshipDescription) association) - ); - - return Collections.unmodifiableCollection(relationships); - } - @Override public > T getOrCreateIdGeneratorOfType(Class idGeneratorType) { - Supplier fallbackSupplier = () -> (T) this.fallbackIdGenerators - .computeIfAbsent(idGeneratorType, BeanUtils::instantiateClass); - if (this.beanFactory == null) { - return fallbackSupplier.get(); + if (this.idGenerators.containsKey(idGeneratorType)) { + return (T) this.idGenerators.get(idGeneratorType); } else { - return this.beanFactory - .getBeanProvider(idGeneratorType) - .getIfUnique(fallbackSupplier); + T idGenerator; + if (this.beanFactory == null) { + idGenerator = BeanUtils.instantiateClass(idGeneratorType); + } else { + idGenerator = this.beanFactory.getBeanProvider(idGeneratorType) + .getIfUnique(() -> this.beanFactory.createBean(idGeneratorType)); + } + this.idGenerators.put(idGeneratorType, idGenerator); + return idGenerator; } } @@ -255,30 +188,8 @@ public > Optional getIdGenerator(String reference) { public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { super.setApplicationContext(applicationContext); - this.beanFactory = applicationContext; - } - - @Override - public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { - - // Register all the known id generators - this.getPersistentEntities().stream() - .map(Neo4jPersistentEntity::getIdDescription) - .filter(IdDescription::isExternallyGeneratedId).map(IdDescription::getIdGeneratorClass) - .filter(Optional::isPresent).map(Optional::get) - .distinct() - .map(generatorClass -> { - RootBeanDefinition definition = new RootBeanDefinition(generatorClass); - definition.setRole(RootBeanDefinition.ROLE_INFRASTRUCTURE); - return definition; - }) - .forEach(definition -> { - String beanName = BeanDefinitionReaderUtils.generateBeanName(definition, registry); - registry.registerBeanDefinition(beanName, definition); - }); - } - - @Override - public void postProcessBeanFactory(@SuppressWarnings({ "HiddenField" }) ConfigurableListableBeanFactory beanFactory) throws BeansException { + this.beanFactory = applicationContext.getAutowireCapableBeanFactory(); + Driver driver = this.beanFactory.getBean(Driver.class); + ((DefaultNeo4jConverter) this.converter).setTypeSystem(driver.defaultTypeSystem()); } } diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/NodeDescriptionStore.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/NodeDescriptionStore.java new file mode 100644 index 00000000..8832289a --- /dev/null +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/mapping/NodeDescriptionStore.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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.neo4j.springframework.data.core.mapping; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.neo4j.springframework.data.core.schema.NodeDescription; +import org.springframework.data.mapping.context.AbstractMappingContext; +import org.springframework.lang.Nullable; + +/** + * This class is more or less just a wrapper around the node description lookup map. + * It ensures that there is no cyclic dependency between {@link Neo4jMappingContext} and {@link DefaultNeo4jConverter}. + * + * @author Gerrit Meier + */ +class NodeDescriptionStore { + + /** + * A lookup of entities based on their primary label. We depend on the locking mechanism provided by the + * {@link AbstractMappingContext}, so this lookup is not synchronized further. + */ + private final Map> nodeDescriptionsByPrimaryLabel = new HashMap<>(); + + public boolean containsKey(String primaryLabel) { + return nodeDescriptionsByPrimaryLabel.containsKey(primaryLabel); + } + + public boolean containsValue(DefaultNeo4jPersistentEntity newEntity) { + return nodeDescriptionsByPrimaryLabel.containsValue(newEntity); + } + + public void put(String primaryLabel, DefaultNeo4jPersistentEntity newEntity) { + nodeDescriptionsByPrimaryLabel.put(primaryLabel, newEntity); + } + + public Set>> entrySet() { + return nodeDescriptionsByPrimaryLabel.entrySet(); + } + + public Collection> values() { + return nodeDescriptionsByPrimaryLabel.values(); + } + + @Nullable + public NodeDescription get(String primaryLabel) { + return nodeDescriptionsByPrimaryLabel.get(primaryLabel); + } + + @Nullable + public NodeDescription getNodeDescription(Class targetType) { + for (NodeDescription nodeDescription : values()) { + if (nodeDescription.getUnderlyingClass().equals(targetType)) { + return nodeDescription; + } + } + return null; + } +} diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/CypherGenerator.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/CypherGenerator.java index 0052dd4f..acd0b15d 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/CypherGenerator.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/CypherGenerator.java @@ -29,9 +29,9 @@ import org.apiguardian.api.API; import org.jetbrains.annotations.NotNull; +import org.neo4j.springframework.data.core.cypher.*; import org.neo4j.springframework.data.core.cypher.Node; import org.neo4j.springframework.data.core.cypher.Relationship; -import org.neo4j.springframework.data.core.cypher.*; import org.neo4j.springframework.data.core.mapping.Neo4jPersistentEntity; import org.springframework.data.mapping.MappingException; import org.springframework.lang.Nullable; @@ -198,8 +198,8 @@ public Statement createRelationshipRemoveQuery(Neo4jPersistentEntity neo4jPer boolean outgoing = relationshipDescription.isOutgoing(); String relationshipType = relationshipDescription.isDynamic() ? null : relationshipDescription.getType(); - String relationshipToRemoveName = "rel"; + String relationshipToRemoveName = "rel"; Relationship relationship = outgoing ? startNode.relationshipTo(endNode, relationshipType).named(relationshipToRemoveName) : startNode.relationshipFrom(endNode, relationshipType).named(relationshipToRemoveName); @@ -311,9 +311,15 @@ private List generateListsOf(Collection relatio ? startNode.relationshipTo(endNode, relationshipType) : startNode.relationshipFrom(endNode, relationshipType); + MapProjection mapProjection = projectAllPropertiesAndRelationships(endNodeDescription, fieldName); + if (relationshipDescription.hasRelationshipProperties()) { + relationship = relationship.named(RelationshipDescription.NAME_OF_RELATIONSHIP); + mapProjection = mapProjection.and(relationship); + } + generatedLists.add(relationshipTargetName); generatedLists.add(listBasedOn(relationship) - .returning(projectAllPropertiesAndRelationships(endNodeDescription, fieldName))); + .returning(mapProjection)); } } diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipDescription.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipDescription.java index 4741cf30..fd7a60cc 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipDescription.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipDescription.java @@ -21,6 +21,7 @@ import org.apiguardian.api.API; import org.jetbrains.annotations.NotNull; import org.neo4j.springframework.data.core.schema.Relationship.Direction; +import org.springframework.lang.Nullable; /** * Description of a relationship. Those descriptions always describe outgoing relationships. The inverse direction @@ -33,6 +34,8 @@ @API(status = API.Status.INTERNAL, since = "1.0") public interface RelationshipDescription { + String NAME_OF_RELATIONSHIP = "__relationship__"; + String NAME_OF_RELATIONSHIP_TYPE = "__relationshipType__"; /** @@ -79,6 +82,23 @@ public interface RelationshipDescription { */ Direction getDirection(); + /** + * If this is a relationship with properties, the properties-defining class will get returned, + * otherwise {@literal null}. + * + * @return The type of the relationship property class for relationship with properties, otherwise {@literal null} + */ + @Nullable + Class getRelationshipPropertiesClass(); + + /** + * Tells if this relationship is a relationship with additional properties. + * In such cases {@code getRelationshipPropertiesClass} will return the type of the properties holding class. + * + * @return {@literal true} if an additional properties are available, otherwise {@literal false} + */ + boolean hasRelationshipProperties(); + default boolean isOutgoing() { return Direction.OUTGOING.equals(this.getDirection()); } diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipProperties.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipProperties.java new file mode 100644 index 00000000..5bf3f61c --- /dev/null +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/RelationshipProperties.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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.neo4j.springframework.data.core.schema; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * This marker interface is used on classes to mark that they represent additional relationship properties. + * A class that implements this interface must not be used as a or annotated with {@link Node}. + * + * @author Gerrit Meier + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@Documented +@Inherited +@API(status = API.Status.STABLE, since = "1.0") +public @interface RelationshipProperties { +} diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/Schema.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/Schema.java index d8bdbc7f..1390ce66 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/Schema.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/core/schema/Schema.java @@ -18,6 +18,7 @@ */ package org.neo4j.springframework.data.core.schema; +import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -27,6 +28,8 @@ import org.apiguardian.api.API; import org.neo4j.driver.Record; import org.neo4j.driver.types.TypeSystem; +import org.neo4j.springframework.data.core.convert.Neo4jConverter; +import org.springframework.data.mapping.MappingException; import org.springframework.lang.Nullable; /** @@ -74,6 +77,16 @@ default NodeDescription getRequiredNodeDescription(Class underlyingClass) return nodeDescription; } + default NodeDescription getRequiredNodeDescription(String primaryLabel) { + NodeDescription nodeDescription = getNodeDescription(primaryLabel); + if (nodeDescription == null) { + throw new MappingException( + String.format("Required node description not found with primary label '%s'", primaryLabel)); + } + return nodeDescription; + } + + /** * Retrieves a schema based mapping function for the {@code targetClass}. The mapping function will expect a * record containing all the nodes and relationships necessary to fully populate an instance of the given class. @@ -90,16 +103,27 @@ default NodeDescription getRequiredNodeDescription(Class underlyingClass) * @throws UnknownEntityException When {@code targetClass} is not a managed class */ default BiFunction getRequiredMappingFunctionFor(Class targetClass) { - BiFunction mappingFunction = getMappingFunctionFor(targetClass); - if (mappingFunction == null) { + NodeDescription nodeDescription = getNodeDescription(targetClass); + if (nodeDescription == null) { throw new UnknownEntityException(targetClass); } - return mappingFunction; + return (typeSystem, record) -> getConverter().read(targetClass, record); } - @Nullable BiFunction getMappingFunctionFor(Class targetClass); + Neo4jConverter getConverter(); + + default Function> getRequiredBinderFunctionFor(Class sourceClass) { - Function> getRequiredBinderFunctionFor(Class sourceClass); + if (getNodeDescription(sourceClass) == null) { + throw new UnknownEntityException(sourceClass); + } + + return t -> { + Map parameters = new HashMap<>(); + getConverter().write(t, parameters); + return parameters; + }; + } /** * Creates or retrieves an instance of the given id generator class. During the lifetime of the schema, diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQuerySupport.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQuerySupport.java index 71369f3f..298a3519 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQuerySupport.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/repository/query/Neo4jQuerySupport.java @@ -89,15 +89,15 @@ protected final ResultProcessor getResultProcessor(ParameterAccessor parameterAc } else if (resultProcessor.getReturnedType().isProjecting()) { if (returnedType.isInterface()) { - mappingFunction = this.mappingContext.getMappingFunctionFor(domainType); + mappingFunction = this.mappingContext.getRequiredMappingFunctionFor(domainType); } else if (this.mappingContext.hasPersistentEntityFor(returnedType)) { - mappingFunction = this.mappingContext.getMappingFunctionFor(returnedType); + mappingFunction = this.mappingContext.getRequiredMappingFunctionFor(returnedType); } else { this.mappingContext.addPersistentEntity(returnedType); - mappingFunction = this.mappingContext.getMappingFunctionFor(returnedType); + mappingFunction = this.mappingContext.getRequiredMappingFunctionFor(returnedType); } } else { - mappingFunction = this.mappingContext.getMappingFunctionFor(domainType); + mappingFunction = this.mappingContext.getRequiredMappingFunctionFor(domainType); } return mappingFunction; } diff --git a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/repository/support/Predicate.java b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/repository/support/Predicate.java index 5162a05e..f21ed068 100644 --- a/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/repository/support/Predicate.java +++ b/spring-data-neo4j-rx/src/main/java/org/neo4j/springframework/data/repository/support/Predicate.java @@ -141,7 +141,7 @@ static Predicate create(Neo4jMappingContext mappingContext, Example examp } predicate.add(mode, condition); predicate.parameters.put(propertyName, optionalValue - .map(v -> converter.writeValue(v, ((Neo4jPersistentProperty) graphProperty).getTypeInformation())) + .map(v -> converter.writeValueFromProperty(v, ((Neo4jPersistentProperty) graphProperty).getTypeInformation())) .get()); } } diff --git a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverterTest.java b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverterTest.java index 4557375a..9d9e3a97 100644 --- a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverterTest.java +++ b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/core/mapping/DefaultNeo4jConverterTest.java @@ -42,7 +42,7 @@ */ class DefaultNeo4jConverterTest { - private final DefaultNeo4jConverter defaultNeo4jConverter = new DefaultNeo4jConverter(new Neo4jConversions()); + private final DefaultNeo4jConverter defaultNeo4jConverter = new DefaultNeo4jConverter(new Neo4jConversions(), null); @Nested class Reads { @@ -51,7 +51,7 @@ void shouldCatchConversionErrors() { Value value = Values.value("Das funktioniert nicht."); assertThatExceptionOfType(TypeMismatchDataAccessException.class) - .isThrownBy(() -> defaultNeo4jConverter.readValue(value, ClassTypeInformation.from(Date.class))) + .isThrownBy(() -> defaultNeo4jConverter.readValueForProperty(value, ClassTypeInformation.from(Date.class))) .withMessageStartingWith("Could not convert \"Das funktioniert nicht.\" into java.util.Date;") .withCauseInstanceOf(ConversionFailedException.class) .withRootCauseInstanceOf(DateTimeParseException.class); @@ -62,7 +62,7 @@ void shouldCatchUncoercibleErrors() { Value value = Values.value("Das funktioniert nicht."); assertThatExceptionOfType(TypeMismatchDataAccessException.class) - .isThrownBy(() -> defaultNeo4jConverter.readValue(value, ClassTypeInformation.from(LocalDate.class))) + .isThrownBy(() -> defaultNeo4jConverter.readValueForProperty(value, ClassTypeInformation.from(LocalDate.class))) .withMessageStartingWith("Could not convert \"Das funktioniert nicht.\" into java.time.LocalDate;") .withCauseInstanceOf(ConversionFailedException.class) .withRootCauseInstanceOf(Uncoercible.class); @@ -74,7 +74,7 @@ void shouldCatchUncoerfcibleErrors() { assertThatExceptionOfType(TypeMismatchDataAccessException.class) .isThrownBy( - () -> defaultNeo4jConverter.readValue(value, ClassTypeInformation.from(ReactiveNeo4jClient.class))) + () -> defaultNeo4jConverter.readValueForProperty(value, ClassTypeInformation.from(ReactiveNeo4jClient.class))) .withMessageStartingWith( "Could not convert \"Das funktioniert nicht.\" into org.neo4j.springframework.data.core.ReactiveNeo4jClient;") .withRootCauseInstanceOf(ConverterNotFoundException.class); diff --git a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContextTest.java b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContextTest.java index b1f36375..a2b45747 100644 --- a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContextTest.java +++ b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/core/mapping/Neo4jMappingContextTest.java @@ -119,20 +119,13 @@ void shouldPreventIllegalIdTypes() { .withMessageMatching("Internally generated ids can only be assigned to one of .*"); } - @Test - void shouldNotProvideMappingForUnknownClasses() { - - Neo4jMappingContext schema = new Neo4jMappingContext(); - assertThat(schema.getMappingFunctionFor(UserNode.class)).isNull(); - } - @Test void targetTypeOfAssociationsShouldBeKnownToTheMappingContext() { Neo4jMappingContext schema = new Neo4jMappingContext(); Neo4jPersistentEntity bikeNodeEntity = schema.getPersistentEntity(BikeNode.class); bikeNodeEntity.doWithAssociations((Association association) -> - assertThat(schema.getMappingFunctionFor(association.getInverse().getAssociationTargetType())).isNotNull()); + assertThat(schema.getRequiredMappingFunctionFor(association.getInverse().getAssociationTargetType())).isNotNull()); } @Test diff --git a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/imperative/PersonWithRelationshipWithPropertiesRepository.java b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/imperative/PersonWithRelationshipWithPropertiesRepository.java new file mode 100644 index 00000000..52e31637 --- /dev/null +++ b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/imperative/PersonWithRelationshipWithPropertiesRepository.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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.neo4j.springframework.data.integration.imperative; + +import org.neo4j.springframework.data.integration.shared.PersonWithRelationshipWithProperties; +import org.neo4j.springframework.data.repository.Neo4jRepository; +import org.neo4j.springframework.data.repository.query.Query; +import org.springframework.data.repository.query.Param; + +/** + * @author Gerrit Meier + */ +public interface PersonWithRelationshipWithPropertiesRepository extends Neo4jRepository { + + @Query("MATCH (p:PersonWithRelationshipWithProperties)-[l:LIKES]->(h:Hobby) return p, collect(l), collect(h)") + PersonWithRelationshipWithProperties loadFromCustomQuery(@Param("id") Long id); +} + diff --git a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java index 715df017..47768ae8 100644 --- a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java +++ b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/imperative/RepositoryIT.java @@ -36,6 +36,7 @@ import java.util.stream.IntStream; import java.util.stream.StreamSupport; +import org.assertj.core.data.MapEntry; import org.assertj.core.groups.Tuple; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -53,6 +54,7 @@ import org.neo4j.springframework.data.repository.config.EnableNeo4jRepositories; import org.neo4j.springframework.data.test.Neo4jExtension.Neo4jConnectionSupport; import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.neo4j.springframework.data.types.CartesianPoint2d; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -97,6 +99,7 @@ static PersonWithAllConstructor personExample(String sameValue) { private final PersonRepository repository; private final ThingRepository thingRepository; private final RelationshipRepository relationshipRepository; + private final PersonWithRelationshipWithPropertiesRepository relationshipWithPropertiesRepository; private final PetRepository petRepository; private final Driver driver; private Long id1; @@ -106,12 +109,14 @@ static PersonWithAllConstructor personExample(String sameValue) { @Autowired RepositoryIT(PersonRepository repository, ThingRepository thingRepository, - RelationshipRepository relationshipRepository, PetRepository petRepository, Driver driver) { + RelationshipRepository relationshipRepository, PetRepository petRepository, Driver driver, + PersonWithRelationshipWithPropertiesRepository relationshipWithPropertiesRepository) { this.repository = repository; this.relationshipRepository = relationshipRepository; this.thingRepository = thingRepository; this.petRepository = petRepository; + this.relationshipWithPropertiesRepository = relationshipWithPropertiesRepository; this.driver = driver; } @@ -408,6 +413,111 @@ void loadEntityWithRelationshipWithAssignedId() { assertThat(relatedThing.getName()).isEqualTo("Thing1"); } + @Test + void loadEntityWithRelationshipWithProperties() { + + long personId; + long hobbyNode1Id; + long hobbyNode2Id; + + try (Session session = driver.session()) { + Record record = session + .run("CREATE (n:PersonWithRelationshipWithProperties{name:'Freddie'})," + + " (n)-[l1:LIKES" + + "{since: 1995, active: true, localDate: date('1995-02-26'), myEnum: 'SOMETHING', point: point({x: 0, y: 1})}" + + "]->(h1:Hobby{name:'Music'})," + + " (n)-[l2:LIKES" + + "{since: 2000, active: false, localDate: date('2000-06-28'), myEnum: 'SOMETHING_DIFFERENT', point: point({x: 2, y: 3})}" + + "]->(h2:Hobby{name:'Something else'})" + + "RETURN n, h1, h2").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node hobbyNode2 = record.get("h2").asNode(); + + personId = personNode.id(); + hobbyNode1Id = hobbyNode1.id(); + hobbyNode2Id = hobbyNode2.id(); + } + + Optional optionalPerson = relationshipWithPropertiesRepository.findById(personId); + assertThat(optionalPerson).isPresent(); + PersonWithRelationshipWithProperties person = optionalPerson.get(); + assertThat(person.getName()).isEqualTo("Freddie"); + + Hobby hobby1 = new Hobby(); + hobby1.setName("Music"); + hobby1.setId(hobbyNode1Id); + LikesHobbyRelationship rel1 = new LikesHobbyRelationship(1995); + rel1.setActive(true); + rel1.setLocalDate(LocalDate.of(1995, 2, 26)); + rel1.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING); + rel1.setPoint(new CartesianPoint2d(0d, 1d)); + + Hobby hobby2 = new Hobby(); + hobby2.setName("Something else"); + hobby2.setId(hobbyNode2Id); + LikesHobbyRelationship rel2 = new LikesHobbyRelationship(2000); + rel2.setActive(false); + rel2.setLocalDate(LocalDate.of(2000, 6, 28)); + rel2.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING_DIFFERENT); + rel2.setPoint(new CartesianPoint2d(2d, 3d)); + + assertThat(person.getHobbies()).contains(MapEntry.entry(hobby1, rel1), MapEntry.entry(hobby2, rel2)); + } + + + @Test + void loadEntityWithRelationshipWithPropertiesFromCustomQuery() { + + long personId; + long hobbyNode1Id; + long hobbyNode2Id; + + try (Session session = driver.session()) { + Record record = session + .run("CREATE (n:PersonWithRelationshipWithProperties{name:'Freddie'})," + + " (n)-[l1:LIKES" + + "{since: 1995, active: true, localDate: date('1995-02-26'), myEnum: 'SOMETHING', point: point({x: 0, y: 1})}" + + "]->(h1:Hobby{name:'Music'})," + + " (n)-[l2:LIKES" + + "{since: 2000, active: false, localDate: date('2000-06-28'), myEnum: 'SOMETHING_DIFFERENT', point: point({x: 2, y: 3})}" + + "]->(h2:Hobby{name:'Something else'})" + + "RETURN n, h1, h2").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node hobbyNode2 = record.get("h2").asNode(); + + personId = personNode.id(); + hobbyNode1Id = hobbyNode1.id(); + hobbyNode2Id = hobbyNode2.id(); + } + + PersonWithRelationshipWithProperties person = relationshipWithPropertiesRepository.loadFromCustomQuery(personId); + assertThat(person.getName()).isEqualTo("Freddie"); + + Hobby hobby1 = new Hobby(); + hobby1.setName("Music"); + hobby1.setId(hobbyNode1Id); + LikesHobbyRelationship rel1 = new LikesHobbyRelationship(1995); + rel1.setActive(true); + rel1.setLocalDate(LocalDate.of(1995, 2, 26)); + rel1.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING); + rel1.setPoint(new CartesianPoint2d(0d, 1d)); + + Hobby hobby2 = new Hobby(); + hobby2.setName("Something else"); + hobby2.setId(hobbyNode2Id); + LikesHobbyRelationship rel2 = new LikesHobbyRelationship(2000); + rel2.setActive(false); + rel2.setLocalDate(LocalDate.of(2000, 6, 28)); + rel2.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING_DIFFERENT); + rel2.setPoint(new CartesianPoint2d(2d, 3d)); + + assertThat(person.getHobbies()).contains(MapEntry.entry(hobby1, rel1), MapEntry.entry(hobby2, rel2)); + } + @Test void existsById() { boolean exists = repository.existsById(id1); diff --git a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactivePersonWithRelationshipWithPropertiesRepository.java b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactivePersonWithRelationshipWithPropertiesRepository.java new file mode 100644 index 00000000..905fefdc --- /dev/null +++ b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactivePersonWithRelationshipWithPropertiesRepository.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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.neo4j.springframework.data.integration.reactive; + +import reactor.core.publisher.Mono; + +import org.neo4j.springframework.data.integration.shared.PersonWithRelationshipWithProperties; +import org.neo4j.springframework.data.repository.ReactiveNeo4jRepository; +import org.neo4j.springframework.data.repository.query.Query; +import org.springframework.data.repository.query.Param; + +/** + * @author Gerrit Meier + */ +public interface ReactivePersonWithRelationshipWithPropertiesRepository + extends ReactiveNeo4jRepository { + + @Query("MATCH (p:PersonWithRelationshipWithProperties)-[l:LIKES]->(h:Hobby) return p, collect(l), collect(h)") + Mono loadFromCustomQuery(@Param("id") Long id); +} diff --git a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRepositoryIT.java b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRepositoryIT.java index d5f2ec69..76dfea0f 100644 --- a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRepositoryIT.java +++ b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/reactive/ReactiveRepositoryIT.java @@ -38,6 +38,7 @@ import java.util.Map; import java.util.stream.IntStream; +import org.assertj.core.data.MapEntry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -54,13 +55,15 @@ import org.neo4j.springframework.data.integration.shared.AnotherThingWithAssignedId; import org.neo4j.springframework.data.integration.shared.Club; import org.neo4j.springframework.data.integration.shared.Hobby; +import org.neo4j.springframework.data.integration.shared.LikesHobbyRelationship; import org.neo4j.springframework.data.integration.shared.PersonWithAllConstructor; import org.neo4j.springframework.data.integration.shared.PersonWithRelationship; import org.neo4j.springframework.data.integration.shared.Pet; import org.neo4j.springframework.data.integration.shared.ThingWithAssignedId; import org.neo4j.springframework.data.repository.config.EnableReactiveNeo4jRepositories; -import org.neo4j.springframework.data.test.Neo4jExtension.*; import org.neo4j.springframework.data.test.Neo4jIntegrationTest; +import org.neo4j.springframework.data.test.Neo4jExtension.*; +import org.neo4j.springframework.data.types.CartesianPoint2d; import org.reactivestreams.Publisher; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; @@ -97,6 +100,7 @@ class ReactiveRepositoryIT { @Autowired private ReactiveThingRepository thingRepository; @Autowired private ReactiveRelationshipRepository relationshipRepository; @Autowired private ReactivePetRepository petRepository; + @Autowired private ReactivePersonWithRelationshipWithPropertiesRepository relationshipWithPropertiesRepository; @Autowired private Driver driver; @Autowired private ReactiveTransactionManager transactionManager; private long id1; @@ -379,6 +383,116 @@ void loadEntityWithRelationshipWithAssignedId() { .verifyComplete(); } + @Test + void loadEntityWithRelationshipWithProperties() { + + long personId; + long hobbyNode1Id; + long hobbyNode2Id; + + try (Session session = driver.session()) { + Record record = session + .run("CREATE (n:PersonWithRelationshipWithProperties{name:'Freddie'})," + + " (n)-[l1:LIKES" + + "{since: 1995, active: true, localDate: date('1995-02-26'), myEnum: 'SOMETHING', point: point({x: 0, y: 1})}" + + "]->(h1:Hobby{name:'Music'})," + + " (n)-[l2:LIKES" + + "{since: 2000, active: false, localDate: date('2000-06-28'), myEnum: 'SOMETHING_DIFFERENT', point: point({x: 2, y: 3})}" + + "]->(h2:Hobby{name:'Something else'})" + + "RETURN n, h1, h2").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node hobbyNode2 = record.get("h2").asNode(); + + personId = personNode.id(); + hobbyNode1Id = hobbyNode1.id(); + hobbyNode2Id = hobbyNode2.id(); + } + + StepVerifier.create(relationshipWithPropertiesRepository.findById(personId)) + .assertNext(person -> { + assertThat(person.getName()).isEqualTo("Freddie"); + + Hobby hobby1 = new Hobby(); + hobby1.setName("Music"); + hobby1.setId(hobbyNode1Id); + LikesHobbyRelationship rel1 = new LikesHobbyRelationship(1995); + rel1.setActive(true); + rel1.setLocalDate(LocalDate.of(1995, 2, 26)); + rel1.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING); + rel1.setPoint(new CartesianPoint2d(0d, 1d)); + + Hobby hobby2 = new Hobby(); + hobby2.setName("Something else"); + hobby2.setId(hobbyNode2Id); + LikesHobbyRelationship rel2 = new LikesHobbyRelationship(2000); + rel2.setActive(false); + rel2.setLocalDate(LocalDate.of(2000, 6, 28)); + rel2.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING_DIFFERENT); + rel2.setPoint(new CartesianPoint2d(2d, 3d)); + + assertThat(person.getHobbies()).contains(MapEntry.entry(hobby1, rel1), MapEntry.entry(hobby2, rel2)); + }) + .verifyComplete(); + + } + + @Test + void loadEntityWithRelationshipWithPropertiesFromCustomQuery() { + + long personId; + long hobbyNode1Id; + long hobbyNode2Id; + + try (Session session = driver.session()) { + Record record = session + .run("CREATE (n:PersonWithRelationshipWithProperties{name:'Freddie'})," + + " (n)-[l1:LIKES" + + "{since: 1995, active: true, localDate: date('1995-02-26'), myEnum: 'SOMETHING', point: point({x: 0, y: 1})}" + + "]->(h1:Hobby{name:'Music'})," + + " (n)-[l2:LIKES" + + "{since: 2000, active: false, localDate: date('2000-06-28'), myEnum: 'SOMETHING_DIFFERENT', point: point({x: 2, y: 3})}" + + "]->(h2:Hobby{name:'Something else'})" + + "RETURN n, h1, h2").single(); + + Node personNode = record.get("n").asNode(); + Node hobbyNode1 = record.get("h1").asNode(); + Node hobbyNode2 = record.get("h2").asNode(); + + personId = personNode.id(); + hobbyNode1Id = hobbyNode1.id(); + hobbyNode2Id = hobbyNode2.id(); + } + + StepVerifier.create(relationshipWithPropertiesRepository.loadFromCustomQuery(personId)) + .assertNext(person -> { + assertThat(person.getName()).isEqualTo("Freddie"); + + Hobby hobby1 = new Hobby(); + hobby1.setName("Music"); + hobby1.setId(hobbyNode1Id); + LikesHobbyRelationship rel1 = new LikesHobbyRelationship(1995); + rel1.setActive(true); + rel1.setLocalDate(LocalDate.of(1995, 2, 26)); + rel1.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING); + rel1.setPoint(new CartesianPoint2d(0d, 1d)); + + Hobby hobby2 = new Hobby(); + hobby2.setName("Something else"); + hobby2.setId(hobbyNode2Id); + LikesHobbyRelationship rel2 = new LikesHobbyRelationship(2000); + rel2.setActive(false); + rel2.setLocalDate(LocalDate.of(2000, 6, 28)); + rel2.setMyEnum(LikesHobbyRelationship.MyEnum.SOMETHING_DIFFERENT); + rel2.setPoint(new CartesianPoint2d(2d, 3d)); + + assertThat(person.getHobbies()).contains(MapEntry.entry(hobby1, rel1), MapEntry.entry(hobby2, rel2)); + }) + .verifyComplete(); + + } + @Test void findAllByIds() { List personList = Arrays.asList(person1, person2); diff --git a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/Hobby.java b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/Hobby.java index b614c23c..56412583 100644 --- a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/Hobby.java +++ b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/Hobby.java @@ -49,6 +49,13 @@ public void setName(String name) { this.name = name; } + @Override public String toString() { + return "Hobby{" + + "id=" + id + + ", name='" + name + '\'' + + '}'; + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/LikesHobbyRelationship.java b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/LikesHobbyRelationship.java new file mode 100644 index 00000000..6b74c103 --- /dev/null +++ b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/LikesHobbyRelationship.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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.neo4j.springframework.data.integration.shared; + +import java.time.LocalDate; +import java.util.Objects; + +import org.neo4j.springframework.data.core.schema.RelationshipProperties; +import org.neo4j.springframework.data.types.CartesianPoint2d; + +/** + * @author Gerrit Meier + */ +@RelationshipProperties +public class LikesHobbyRelationship { + + private final Integer since; + + private Boolean active; + + // use some properties that require conversion + // cypher type + private LocalDate localDate; + + // additional type + private MyEnum myEnum; + + // spatial type + private CartesianPoint2d point; + + + public LikesHobbyRelationship(Integer since) { + this.since = since; + } + + public void setActive(Boolean active) { + this.active = active; + } + + public void setLocalDate(LocalDate localDate) { + this.localDate = localDate; + } + + public void setMyEnum(MyEnum myEnum) { + this.myEnum = myEnum; + } + + public void setPoint(CartesianPoint2d point) { + this.point = point; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + LikesHobbyRelationship that = (LikesHobbyRelationship) o; + return since.equals(that.since) && + Objects.equals(active, that.active) && + Objects.equals(localDate, that.localDate) && + myEnum == that.myEnum && + Objects.equals(point, that.point); + } + + @Override + public int hashCode() { + return Objects.hash(since, active, localDate, myEnum, point); + } + + /** + * The missing javadoc + */ + public enum MyEnum { + SOMETHING, SOMETHING_DIFFERENT + } +} diff --git a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelationshipWithProperties.java b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelationshipWithProperties.java new file mode 100644 index 00000000..a42c10b4 --- /dev/null +++ b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/PersonWithRelationshipWithProperties.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2019 "Neo4j," + * Neo4j Sweden AB [https://neo4j.com] + * + * This file is part of Neo4j. + * + * 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.neo4j.springframework.data.integration.shared; + +import java.util.Map; + +import org.neo4j.springframework.data.core.schema.GeneratedValue; +import org.neo4j.springframework.data.core.schema.Id; +import org.neo4j.springframework.data.core.schema.Node; +import org.neo4j.springframework.data.core.schema.Relationship; + +/** + * @author Gerrit Meier + */ +@Node +public class PersonWithRelationshipWithProperties { + + @Id @GeneratedValue private Long id; + + private final String name; + + @Relationship("LIKES") + private Map hobbies; + + public PersonWithRelationshipWithProperties(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public Map getHobbies() { + return hobbies; + } +} diff --git a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/TestSequenceGenerator.java b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/TestSequenceGenerator.java index 166dc226..6f0435f8 100644 --- a/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/TestSequenceGenerator.java +++ b/spring-data-neo4j-rx/src/test/java/org/neo4j/springframework/data/integration/shared/TestSequenceGenerator.java @@ -20,6 +20,7 @@ import java.util.concurrent.atomic.AtomicInteger; +import org.neo4j.driver.Driver; import org.neo4j.springframework.data.core.schema.IdGenerator; import org.springframework.util.StringUtils; @@ -32,6 +33,16 @@ public class TestSequenceGenerator implements IdGenerator { private final AtomicInteger sequence = new AtomicInteger(0); + /** + * Use an instance of the {@link Driver} bean here to ensure that also injection works when the {@link IdGenerator} + * gets created. + **/ + private final Driver driver; + + public TestSequenceGenerator(Driver driver) { + this.driver = driver; + } + @Override public String generateId(String primaryLabel, Object entity) { return StringUtils.uncapitalize(primaryLabel) + "-" + sequence.incrementAndGet();