From 3ed5c70d501e2b32e3c16f2a7a2803c9c4e10d37 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Tue, 25 Jun 2024 06:48:39 +0200 Subject: [PATCH] feat: Allow bidirectional mapping of relationship with properties. (#2914) This will allow for the mapper to have only *one* physical relationship plus the original behaviour staying intact (creating two independent). Required mapping is shown in the test. Basic idea is to check if a relationship in the opposite direction with the actual *same* source and target entities has already been seen. If so, no batch update on the imperative path is scheduled. Thus however will leave generated ids on the mapping classes unpopulated. Those will be retrieved after the fact. --------- Co-authored-by: Gerrit Meier --- .../data/neo4j/core/Neo4jTemplate.java | 47 ++- .../neo4j/core/ReactiveNeo4jTemplate.java | 124 +++++-- .../neo4j/core/mapping/CypherGenerator.java | 9 +- .../core/mapping/Neo4jMappingContext.java | 2 +- ...tedRelationshipProcessingStateMachine.java | 120 +++++++ .../imperative/OptimisticLockingIT.java | 2 +- .../neo4j/integration/issues/IssuesIT.java | 340 ++++++++++++++++++ .../integration/issues/ReactiveIssuesIT.java | 340 ++++++++++++++++++ .../gh2905/{BugFrom.java => BugFromV1.java} | 21 +- ...lationship.java => BugRelationshipV1.java} | 17 +- ...ugTargetBase.java => BugTargetBaseV1.java} | 8 +- .../{BugTarget.java => BugTargetV1.java} | 15 +- .../issues/gh2905/FromRepositoryV1.java | 24 ++ .../integration/issues/gh2905/Gh2905IT.java | 148 -------- .../gh2905/ReactiveFromRepositoryV1.java | 24 ++ .../issues/gh2905/ReactiveToRepositoryV1.java | 24 ++ .../issues/gh2905/ToRepositoryV1.java | 24 ++ .../integration/issues/gh2906/BugFrom.java | 57 +++ .../issues/gh2906/BugRelationship.java | 47 +++ .../integration/issues/gh2906/BugTarget.java | 33 ++ .../issues/gh2906/BugTargetBase.java | 51 +++ .../issues/gh2906/BugTargetContainer.java | 36 ++ .../issues/gh2906/FromRepository.java | 24 ++ .../gh2906/IncomingBugRelationship.java | 26 ++ .../gh2906/OutgoingBugRelationship.java | 26 ++ .../issues/gh2906/ReactiveFromRepository.java | 24 ++ .../issues/gh2906/ReactiveToRepository.java | 24 ++ .../issues/gh2906/ToRepository.java | 24 ++ 28 files changed, 1436 insertions(+), 225 deletions(-) rename src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/{BugFrom.java => BugFromV1.java} (83%) rename src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/{BugRelationship.java => BugRelationshipV1.java} (83%) rename src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/{BugTargetBase.java => BugTargetBaseV1.java} (85%) rename src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/{BugTarget.java => BugTargetV1.java} (81%) create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/FromRepositoryV1.java delete mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/Gh2905IT.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ReactiveFromRepositoryV1.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ReactiveToRepositoryV1.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ToRepositoryV1.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugFrom.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugRelationship.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTarget.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTargetBase.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTargetContainer.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/FromRepository.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/IncomingBugRelationship.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/OutgoingBugRelationship.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ReactiveFromRepository.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ReactiveToRepository.java create mode 100644 src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ToRepository.java diff --git a/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java b/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java index 9b54d6e50..57ca3bd0d 100644 --- a/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java +++ b/src/main/java/org/springframework/data/neo4j/core/Neo4jTemplate.java @@ -883,7 +883,7 @@ public final class Neo4jTemplate implements List> relationshipPropertiesRows = new ArrayList<>(); List> newRelationshipPropertiesRows = new ArrayList<>(); List updateRelatedValuesToStore = new ArrayList<>(); - List newRelatedValuesToStore = new ArrayList<>(); + List newRelationshipPropertiesToStore = new ArrayList<>(); for (Object relatedValueToStore : relatedValuesToStore) { @@ -977,15 +977,22 @@ public final class Neo4jTemplate implements .bindAll(statementHolder.getProperties()) .run(); } - } else if (relationshipDescription.hasRelationshipProperties() && isNewRelationship && idProperty != null) { - newRelationshipPropertiesRows.add(properties); - newRelatedValuesToStore.add(relatedValueToStore); } else if (relationshipDescription.hasRelationshipProperties()) { - neo4jMappingContext.getEntityConverter().write( - ((MappingSupport.RelationshipPropertiesWithEntityHolder) relatedValueToStore).getRelationshipProperties(), - properties); - - relationshipPropertiesRows.add(properties); + // check if bidi mapped already + var hlp = ((MappingSupport.RelationshipPropertiesWithEntityHolder) relatedValueToStore); + var hasProcessedRelationshipEntity = stateMachine.hasProcessedRelationshipEntity(propertyAccessor.getBean(), hlp.getRelatedEntity(), relationshipContext.getRelationship()); + if (hasProcessedRelationshipEntity) { + stateMachine.requireIdUpdate(sourceEntity, relationshipDescription, canUseElementId, fromId, relatedInternalId, relationshipContext, relatedValueToStore, idProperty); + } else { + if (isNewRelationship && idProperty != null) { + newRelationshipPropertiesRows.add(properties); + newRelationshipPropertiesToStore.add(relatedValueToStore); + } else { + neo4jMappingContext.getEntityConverter().write(hlp.getRelationshipProperties(), properties); + relationshipPropertiesRows.add(properties); + } + stateMachine.storeProcessRelationshipEntity(hlp, propertyAccessor.getBean(), hlp.getRelatedEntity(), relationshipContext.getRelationship()); + } } else { // non-dynamic relationship or relationship with properties plainRelationshipRows.add(properties); @@ -1020,9 +1027,9 @@ public final class Neo4jTemplate implements .bindAll(statementHolder.getProperties()) .run(); } - if (!newRelatedValuesToStore.isEmpty()) { + if (!newRelationshipPropertiesToStore.isEmpty()) { CreateRelationshipStatementHolder statementHolder = neo4jMappingContext.createStatementForImperativeRelationshipsWithPropertiesBatch(true, - sourceEntity, relationshipDescription, newRelatedValuesToStore, newRelationshipPropertiesRows, canUseElementId); + sourceEntity, relationshipDescription, newRelationshipPropertiesToStore, newRelationshipPropertiesRows, canUseElementId); List all = new ArrayList<>(neo4jClient.query(renderer.render(statementHolder.getStatement())) .bindAll(statementHolder.getProperties()) .fetchAs(Object.class) @@ -1031,11 +1038,14 @@ public final class Neo4jTemplate implements // assign new ids for (int i = 0; i < all.size(); i++) { Object anId = all.get(i); - assignIdToRelationshipProperties(relationshipContext, newRelatedValuesToStore.get(i), idProperty, anId); + assignIdToRelationshipProperties(relationshipContext, newRelationshipPropertiesToStore.get(i), idProperty, anId); } } } + // Possible grab missing relationship ids now for bidirectional ones, with properties, mapped in opposite directions + stateMachine.updateRelationshipIds(this::getRelationshipId); + relationshipHandler.applyFinalResultToOwner(propertyAccessor); }); @@ -1044,6 +1054,19 @@ public final class Neo4jTemplate implements return finalSubgraphRoot; } + private Optional getRelationshipId(Statement statement, Neo4jPersistentProperty idProperty, Object fromId, Object toId) { + + return neo4jClient.query(renderer.render(statement)) + .bind(convertIdValues(idProperty, fromId)) // + .to(Constants.FROM_ID_PARAMETER_NAME) // + .bind(toId) // + .to(Constants.TO_ID_PARAMETER_NAME) // + .fetchAs(Object.class) + .mappedBy((t, r) -> IdentitySupport.mapperForRelatedIdValues(idProperty).apply(r)) + .one(); + } + + // The pendant to {@link #saveRelatedNode(Object, NodeDescription, PropertyFilter, PropertyFilter.RelaxedPropertyPath)} // We can't do without a query, as we need to refresh the internal id private Entity loadRelatedNode(NodeDescription targetNodeDescription, Object relatedInternalId) { diff --git a/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java b/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java index f9b661924..3e69ef2d9 100644 --- a/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java +++ b/src/main/java/org/springframework/data/neo4j/core/ReactiveNeo4jTemplate.java @@ -418,19 +418,25 @@ public final class ReactiveNeo4jTemplate implements getProjectionFactory(), neo4jMappingContext); NestedRelationshipProcessingStateMachine stateMachine = new NestedRelationshipProcessingStateMachine(neo4jMappingContext); + Collection knownRelationshipsIds = new HashSet<>(); EntityFromDtoInstantiatingConverter converter = new EntityFromDtoInstantiatingConverter<>(domainType, neo4jMappingContext); return Flux.fromIterable(instances) .concatMap(instance -> { T domainObject = converter.convert(instance); @SuppressWarnings("unchecked") - Mono result = transactionalOperator.transactional(saveImpl(domainObject, pps, stateMachine) + Mono result = transactionalOperator.transactional(saveImpl(domainObject, pps, stateMachine, knownRelationshipsIds) .map(savedEntity -> (R) new DtoInstantiatingConverter(resultType, neo4jMappingContext).convertDirectly(savedEntity))); return result; }); } + private Mono saveImpl(T instance, @Nullable Collection includedProperties, @Nullable NestedRelationshipProcessingStateMachine stateMachine) { + return saveImpl(instance, includedProperties, stateMachine, new HashSet<>()); + } + + private Mono saveImpl(T instance, @Nullable Collection includedProperties, @Nullable NestedRelationshipProcessingStateMachine stateMachine, Collection knownRelationshipsIds) { if (stateMachine != null && stateMachine.hasProcessedValue(instance)) { return Mono.just(instance); @@ -479,7 +485,7 @@ public final class ReactiveNeo4jTemplate implements TemplateSupport.updateVersionPropertyIfPossible(entityMetaData, propertyAccessor, newOrUpdatedNode); finalStateMachine.markEntityAsProcessed(instance, elementId); }).map(IdentitySupport::getElementId) - .flatMap(internalId -> processRelations(entityMetaData, propertyAccessor, isNewEntity, finalStateMachine, binderFunction.filter)); + .flatMap(internalId -> processRelations(entityMetaData, propertyAccessor, isNewEntity, finalStateMachine, knownRelationshipsIds, binderFunction.filter)); }); } @@ -594,7 +600,7 @@ public final class ReactiveNeo4jTemplate implements Function> binderFunction = TemplateSupport.createAndApplyPropertyFilter( pps, entityMetaData, neo4jMappingContext.getRequiredBinderFunctionFor((Class) domainClass)); - return Flux.fromIterable(entities) + return (Flux) Flux.deferContextual((ctx) -> Flux.fromIterable(entities) // Map all entities into a tuple .map(e -> Tuples.of(e, entityMetaData.isNew(e))) // Map that tuple into a tuple <, PotentiallyModified> @@ -618,11 +624,16 @@ public final class ReactiveNeo4jTemplate implements .concatMap(t -> { PersistentPropertyAccessor propertyAccessor = entityMetaData.getPropertyAccessor(t.getT3()); Neo4jPersistentProperty idProperty = entityMetaData.getRequiredIdProperty(); - Object id = convertIdValues(idProperty, propertyAccessor.getProperty(idProperty)); - String internalId = idToInternalIdMapping.get(id); - return processRelations(entityMetaData, propertyAccessor, t.getT2(), new NestedRelationshipProcessingStateMachine(neo4jMappingContext, t.getT1(), internalId), + return processRelations(entityMetaData, propertyAccessor, t.getT2(), + ctx.get("stateMachine"), + ctx.get("knownRelIds"), TemplateSupport.computeIncludePropertyPredicate(pps, entityMetaData)); })) + )) + .contextWrite(ctx -> + ctx + .put("stateMachine", new NestedRelationshipProcessingStateMachine(neo4jMappingContext, null, null)) + .put("knownRelIds", new HashSet<>()) ); } @@ -878,16 +889,19 @@ public final class ReactiveNeo4jTemplate implements PersistentPropertyAccessor parentPropertyAccessor, boolean isParentObjectNew, NestedRelationshipProcessingStateMachine stateMachine, + Collection knownRelationshipsIds, PropertyFilter includeProperty ) { PropertyFilter.RelaxedPropertyPath startingPropertyPath = PropertyFilter.RelaxedPropertyPath.withRootType(neo4jPersistentEntity.getUnderlyingClass()); return processNestedRelations(neo4jPersistentEntity, parentPropertyAccessor, isParentObjectNew, - stateMachine, includeProperty, startingPropertyPath); + stateMachine, knownRelationshipsIds, includeProperty, startingPropertyPath); } private Mono processNestedRelations(Neo4jPersistentEntity sourceEntity, PersistentPropertyAccessor parentPropertyAccessor, - boolean isParentObjectNew, NestedRelationshipProcessingStateMachine stateMachine, PropertyFilter includeProperty, PropertyFilter.RelaxedPropertyPath previousPath) { + boolean isParentObjectNew, NestedRelationshipProcessingStateMachine stateMachine, + Collection knownRelationshipsIds, + PropertyFilter includeProperty, PropertyFilter.RelaxedPropertyPath previousPath) { Object fromId = parentPropertyAccessor.getProperty(sourceEntity.getRequiredIdProperty()); List> relationshipDeleteMonos = new ArrayList<>(); @@ -932,7 +946,6 @@ public final class ReactiveNeo4jTemplate implements boolean canUseElementId = TemplateSupport.rendererRendersElementId(renderer); if (!isParentObjectNew && !stateMachine.hasProcessedRelationship(fromId, relationshipDescription)) { - List knownRelationshipsIds = new ArrayList<>(); if (idProperty != null) { for (Object relatedValueToStore : relatedValuesToStore) { if (relatedValueToStore == null) { @@ -1021,7 +1034,7 @@ public final class ReactiveNeo4jTemplate implements TemplateSupport.updateVersionPropertyIfPossible(targetEntity, targetPropertyAccessor, savedEntity); } stateMachine.markAsAliased(relatedObjectBeforeCallbacksApplied, targetPropertyAccessor.getBean()); - stateMachine.markRelationshipAsProcessed(possibleInternalLongId == null ? relatedInternalId : possibleInternalLongId, + stateMachine.markRelationshipAsProcessed(possibleInternalLongId == null ? relatedInternalId : possibleInternalLongId, relationshipDescription.getRelationshipObverse()); Object idValue = idProperty != null @@ -1037,43 +1050,63 @@ public final class ReactiveNeo4jTemplate implements properties.put(Constants.FROM_ID_PARAMETER_NAME, convertIdValues(sourceEntity.getRequiredIdProperty(), fromId)); properties.put(Constants.TO_ID_PARAMETER_NAME, relatedInternalId); properties.put(Constants.NAME_OF_KNOWN_RELATIONSHIP_PARAM, idValue); + var update = true; + if (!relationshipDescription.isDynamic() && relationshipDescription.hasRelationshipProperties()) { + var hlp = ((MappingSupport.RelationshipPropertiesWithEntityHolder) relatedValueToStore); + var hasProcessedRelationshipEntity = stateMachine.hasProcessedRelationshipEntity(parentPropertyAccessor.getBean(), hlp.getRelatedEntity(), relationshipContext.getRelationship()); + if (hasProcessedRelationshipEntity) { + stateMachine.requireIdUpdate(sourceEntity, relationshipDescription, canUseElementId, fromId, relatedInternalId, relationshipContext, relatedValueToStore, idProperty); + update = false; + } else { + stateMachine.storeProcessRelationshipEntity(hlp, parentPropertyAccessor.getBean(), hlp.getRelatedEntity(), relationshipContext.getRelationship()); + } + } List rows = new ArrayList<>(); rows.add(properties); statementHolder = statementHolder.addProperty(Constants.NAME_OF_RELATIONSHIP_LIST_PARAM, rows); // in case of no properties the bind will just return an empty map - return neo4jClient - .query(renderer.render(statementHolder.getStatement())) - .bind(convertIdValues(sourceEntity.getRequiredIdProperty(), fromId)) // + if (update) { + return neo4jClient + .query(renderer.render(statementHolder.getStatement())) + .bind(convertIdValues(sourceEntity.getRequiredIdProperty(), fromId)) // .to(Constants.FROM_ID_PARAMETER_NAME) // - .bind(relatedInternalId) // + .bind(relatedInternalId) // .to(Constants.TO_ID_PARAMETER_NAME) // - .bind(idValue) // - .to(Constants.NAME_OF_KNOWN_RELATIONSHIP_PARAM) // - .bindAll(statementHolder.getProperties()) - .fetchAs(Object.class) - .mappedBy((t, r) -> IdentitySupport.mapperForRelatedIdValues(idProperty).apply(r)) - .one() - .flatMap(relationshipInternalId -> { - if (idProperty != null && isNewRelationship) { - relationshipContext - .getRelationshipPropertiesPropertyAccessor(relatedValueToStore) - .setProperty(idProperty, relationshipInternalId); - } + .bind(idValue) // + .to(Constants.NAME_OF_KNOWN_RELATIONSHIP_PARAM) // + .bindAll(statementHolder.getProperties()) + .fetchAs(Object.class) + .mappedBy((t, r) -> IdentitySupport.mapperForRelatedIdValues(idProperty).apply(r)) + .one() + .flatMap(relationshipInternalId -> { + if (idProperty != null && isNewRelationship) { + relationshipContext + .getRelationshipPropertiesPropertyAccessor(relatedValueToStore) + .setProperty(idProperty, relationshipInternalId); + knownRelationshipsIds.add(relationshipInternalId); + } - Mono nestedRelationshipsSignal = null; - if (processState != ProcessState.PROCESSED_ALL_VALUES) { - nestedRelationshipsSignal = processNestedRelations(targetEntity, targetPropertyAccessor, targetEntity.isNew(newRelatedObject), stateMachine, includeProperty, currentPropertyPath); - } + Mono nestedRelationshipsSignal = null; + if (processState != ProcessState.PROCESSED_ALL_VALUES) { + nestedRelationshipsSignal = processNestedRelations(targetEntity, targetPropertyAccessor, targetEntity.isNew(newRelatedObject), stateMachine, knownRelationshipsIds, includeProperty, currentPropertyPath); + } - Mono getRelationshipOrRelationshipPropertiesObject = Mono.fromSupplier(() -> MappingSupport.getRelationshipOrRelationshipPropertiesObject( - neo4jMappingContext, - relationshipDescription.hasRelationshipProperties(), - relationshipProperty.isDynamicAssociation(), - relatedValueToStore, - targetPropertyAccessor)); - return nestedRelationshipsSignal == null ? getRelationshipOrRelationshipPropertiesObject : - nestedRelationshipsSignal.then(getRelationshipOrRelationshipPropertiesObject); - }); + Mono getRelationshipOrRelationshipPropertiesObject = Mono.fromSupplier(() -> MappingSupport.getRelationshipOrRelationshipPropertiesObject( + neo4jMappingContext, + relationshipDescription.hasRelationshipProperties(), + relationshipProperty.isDynamicAssociation(), + relatedValueToStore, + targetPropertyAccessor)); + return nestedRelationshipsSignal == null ? getRelationshipOrRelationshipPropertiesObject : + nestedRelationshipsSignal.then(getRelationshipOrRelationshipPropertiesObject); + }); + } + return Mono.fromSupplier(() -> MappingSupport.getRelationshipOrRelationshipPropertiesObject( + neo4jMappingContext, + relationshipDescription.hasRelationshipProperties(), + relationshipProperty.isDynamicAssociation(), + relatedValueToStore, + targetPropertyAccessor)); }) .doOnNext(potentiallyRecreatedRelatedObject -> { RelationshipHandler handler = ctx.get(CONTEXT_RELATIONSHIP_HANDLER); @@ -1095,11 +1128,24 @@ public final class ReactiveNeo4jTemplate implements .thenMany(Flux.concat(relationshipCreationCreations)) .doOnNext(objects -> objects.applyFinalResultToOwner(parentPropertyAccessor)) .checkpoint() + .then(stateMachine.updateRelationshipIds(this::getRelationshipId)) .then(Mono.fromSupplier(parentPropertyAccessor::getBean)); return deleteAndThanCreateANew; } + private Mono getRelationshipId(Statement statement, Neo4jPersistentProperty idProperty, Object fromId, Object toId) { + + return neo4jClient.query(renderer.render(statement)) + .bind(convertIdValues(idProperty, fromId)) // + .to(Constants.FROM_ID_PARAMETER_NAME) // + .bind(toId) // + .to(Constants.TO_ID_PARAMETER_NAME) // + .fetchAs(Object.class) + .mappedBy((t, r) -> IdentitySupport.mapperForRelatedIdValues(idProperty).apply(r)) + .one(); + } + // The pendant to {@link #saveRelatedNode(Object, Neo4jPersistentEntity, PropertyFilter, PropertyFilter.RelaxedPropertyPath)} // We can't do without a query, as we need to refresh the internal id private Mono loadRelatedNode(NodeDescription targetNodeDescription, Object relatedInternalId) { diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java b/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java index d7cc246a4..80bdbad4e 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/CypherGenerator.java @@ -541,7 +541,8 @@ public enum CypherGenerator { RelationshipDescription relationship, boolean isNew, @Nullable String dynamicRelationshipType, - boolean canUseElementId) { + boolean canUseElementId, + boolean matchOnly) { Assert.isTrue(relationship.hasRelationshipProperties(), "Properties required to create a relationship with properties"); @@ -567,6 +568,12 @@ public enum CypherGenerator { .match(endNode) .where(getEndNodeIdFunction((Neo4jPersistentEntity) relationship.getTarget(), canUseElementId).apply(endNode).isEqualTo(parameter(Constants.TO_ID_PARAMETER_NAME))); + if (matchOnly) { + return startAndEndNodeMatch.match(relationshipFragment) + .returning(getReturnedIdExpressionsForRelationship(relationship, relationshipFragment)) + .build(); + } + StatementBuilder.ExposesSet createOrMatch = isNew ? startAndEndNodeMatch.create(relationshipFragment) : startAndEndNodeMatch.match(relationshipFragment) diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/Neo4jMappingContext.java b/src/main/java/org/springframework/data/neo4j/core/mapping/Neo4jMappingContext.java index b46fadd0f..d4930c592 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/Neo4jMappingContext.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/Neo4jMappingContext.java @@ -603,7 +603,7 @@ public final class Neo4jMappingContext extends AbstractMappingContext propMap = new HashMap<>(); // write relationship properties diff --git a/src/main/java/org/springframework/data/neo4j/core/mapping/NestedRelationshipProcessingStateMachine.java b/src/main/java/org/springframework/data/neo4j/core/mapping/NestedRelationshipProcessingStateMachine.java index f8ed7a23d..dc4ab2cdf 100644 --- a/src/main/java/org/springframework/data/neo4j/core/mapping/NestedRelationshipProcessingStateMachine.java +++ b/src/main/java/org/springframework/data/neo4j/core/mapping/NestedRelationshipProcessingStateMachine.java @@ -24,10 +24,14 @@ import java.util.Set; import java.util.concurrent.locks.StampedLock; import org.apiguardian.api.API; +import org.neo4j.cypherdsl.core.Statement; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + /** * This stores all processed nested relations and objects during save of objects so that the recursive descent can be * stopped accordingly. @@ -66,6 +70,10 @@ public final class NestedRelationshipProcessingStateMachine { */ private final Map processedObjectsIds = new HashMap<>(); + private final Set processedRelationshipEntities = new HashSet<>(); + + private final Set requiresIdUpdate = new HashSet<>(); + public NestedRelationshipProcessingStateMachine(final Neo4jMappingContext mappingContext) { Assert.notNull(mappingContext, "Mapping context is required"); @@ -125,6 +133,31 @@ public final class NestedRelationshipProcessingStateMachine { private record RelationshipDescriptionWithSourceId(Object id, RelationshipDescription relationshipDescription) { } + private record ProcessedRelationshipEntity(MappingSupport.RelationshipPropertiesWithEntityHolder entityHolder, + Object source, Object target, RelationshipDescription relationshipDescription) { + } + + private record RelationshipIdUpdateContext(Statement cypher, Object fromId, Object toId, + NestedRelationshipContext relationshipContext, + Object relatedValueToStore, Neo4jPersistentProperty idProperty) { + } + + /** + * Supplier for arbitrary relationship ids + */ + @FunctionalInterface + public interface RelationshipIdSupplier { + Optional getId(Statement statement, Neo4jPersistentProperty idProperty, Object fromId, Object toId); + } + + /** + * Reactive Supplier for arbitrary relationship ids + */ + @FunctionalInterface + public interface ReactiveRelationshipIdSupplier { + Mono getId(Statement statement, Neo4jPersistentProperty idProperty, Object fromId, Object toId); + } + /** * Marks the passed objects as processed * @@ -228,6 +261,93 @@ public final class NestedRelationshipProcessingStateMachine { return false; } + public void storeProcessRelationshipEntity(MappingSupport.RelationshipPropertiesWithEntityHolder id, Object source, Object target, RelationshipDescription type) { + final long stamp = lock.writeLock(); + try { + this.processedRelationshipEntities.add(new ProcessedRelationshipEntity(id, source, target, type)); + } finally { + lock.unlock(stamp); + } + } + + public boolean hasProcessedRelationshipEntity(Object source, Object target, RelationshipDescription type) { + final long stamp = lock.readLock(); + try { + return this.processedRelationshipEntities.stream() + .anyMatch(r -> r.relationshipDescription().getType().equals(type.getType()) && r.relationshipDescription().getDirection().opposite() == type.getDirection() && ( + r.source() == source && r.target() == target || + r.target() == source && r.source() == target + )); + } finally { + lock.unlock(stamp); + } + } + + public void requireIdUpdate(Neo4jPersistentEntity sourceEntity, RelationshipDescription relationshipDescription, boolean canUseElementId, + Object fromId, Object toId, NestedRelationshipContext relationshipContext, Object relatedValueToStore, Neo4jPersistentProperty idProperty) { + + Statement relationshipCreationQuery = CypherGenerator.INSTANCE.prepareSaveOfRelationshipWithProperties( + sourceEntity, relationshipDescription, false, + null, canUseElementId, true); + final long stamp = lock.writeLock(); + try { + this.requiresIdUpdate.add(new RelationshipIdUpdateContext(relationshipCreationQuery, fromId, toId, relationshipContext, relatedValueToStore, idProperty)); + } finally { + lock.unlock(stamp); + } + } + + public Collection getRequiresIdUpdate() { + final long stamp = lock.readLock(); + try { + return Set.copyOf(requiresIdUpdate); + } finally { + lock.unlock(stamp); + } + } + + public void markAsUpdated(RelationshipIdUpdateContext context) { + final long stamp = lock.writeLock(); + try { + requiresIdUpdate.remove(context); + } finally { + lock.unlock(stamp); + } + } + + public void updateRelationshipIds(RelationshipIdSupplier idSupplier) { + final long stamp = lock.writeLock(); + try { + var it = requiresIdUpdate.iterator(); + while (it.hasNext()) { + var requiredIdUpdate = it.next(); + idSupplier.getId(requiredIdUpdate.cypher(), requiredIdUpdate.idProperty(), requiredIdUpdate.fromId(), requiredIdUpdate.toId()).ifPresent(anId -> { + requiredIdUpdate.relationshipContext() + .getRelationshipPropertiesPropertyAccessor(requiredIdUpdate.relatedValueToStore()) + .setProperty(requiredIdUpdate.idProperty(), anId); + it.remove(); + }); + } + } finally { + lock.unlock(stamp); + } + } + + public Mono updateRelationshipIds(ReactiveRelationshipIdSupplier idSupplier) { + return Flux.defer(() -> { + final long stamp = lock.writeLock(); + return Flux.fromIterable(requiresIdUpdate) + .flatMap(requiredIdUpdate -> Mono.just(requiredIdUpdate).zipWith(idSupplier.getId(requiredIdUpdate.cypher(), requiredIdUpdate.idProperty(), requiredIdUpdate.fromId(), requiredIdUpdate.toId()))) + .doOnNext(t -> { + var requiredIdUpdate = t.getT1(); + requiredIdUpdate.relationshipContext() + .getRelationshipPropertiesPropertyAccessor(requiredIdUpdate.relatedValueToStore()) + .setProperty(requiredIdUpdate.idProperty(), t.getT2()); + requiresIdUpdate.remove(requiredIdUpdate); + }).doOnTerminate(() -> lock.unlock(stamp)); + }).then(); + } + public void markAsAliased(Object aliasEntity, Object entityOrId) { final long stamp = lock.writeLock(); try { diff --git a/src/test/java/org/springframework/data/neo4j/integration/imperative/OptimisticLockingIT.java b/src/test/java/org/springframework/data/neo4j/integration/imperative/OptimisticLockingIT.java index e749af49f..883d7a0a8 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/imperative/OptimisticLockingIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/imperative/OptimisticLockingIT.java @@ -399,7 +399,7 @@ class OptimisticLockingIT { try { Thread.sleep(sleep); } catch (InterruptedException e) { - e.printStackTrace(); + Thread.currentThread().interrupt(); } }); }); diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java b/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java index f25f4b2c5..6f782eb89 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/IssuesIT.java @@ -25,14 +25,17 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.assertj.core.api.InstanceOfAssertFactories; import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; @@ -43,6 +46,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.neo4j.cypherdsl.core.Condition; import org.neo4j.cypherdsl.core.Cypher; +import org.neo4j.cypherdsl.core.LabelExpression; import org.neo4j.cypherdsl.core.Node; import org.neo4j.cypherdsl.core.Parameter; import org.neo4j.cypherdsl.core.Property; @@ -50,7 +54,10 @@ import org.neo4j.driver.Driver; import org.neo4j.driver.QueryRunner; import org.neo4j.driver.Session; import org.neo4j.driver.Transaction; +import org.neo4j.driver.Value; import org.neo4j.driver.Values; +import org.neo4j.driver.types.Relationship; +import org.neo4j.driver.types.TypeSystem; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -151,6 +158,17 @@ import org.springframework.data.neo4j.integration.issues.gh2858.GH2858Repository import org.springframework.data.neo4j.integration.issues.gh2886.Apple; import org.springframework.data.neo4j.integration.issues.gh2886.FruitRepository; import org.springframework.data.neo4j.integration.issues.gh2886.Orange; +import org.springframework.data.neo4j.integration.issues.gh2905.BugFromV1; +import org.springframework.data.neo4j.integration.issues.gh2905.BugRelationshipV1; +import org.springframework.data.neo4j.integration.issues.gh2905.BugTargetV1; +import org.springframework.data.neo4j.integration.issues.gh2905.FromRepositoryV1; +import org.springframework.data.neo4j.integration.issues.gh2905.ToRepositoryV1; +import org.springframework.data.neo4j.integration.issues.gh2906.BugFrom; +import org.springframework.data.neo4j.integration.issues.gh2906.BugTarget; +import org.springframework.data.neo4j.integration.issues.gh2906.BugTargetContainer; +import org.springframework.data.neo4j.integration.issues.gh2906.FromRepository; +import org.springframework.data.neo4j.integration.issues.gh2906.OutgoingBugRelationship; +import org.springframework.data.neo4j.integration.issues.gh2906.ToRepository; import org.springframework.data.neo4j.integration.issues.qbe.A; import org.springframework.data.neo4j.integration.issues.qbe.ARepository; import org.springframework.data.neo4j.integration.issues.qbe.B; @@ -218,6 +236,24 @@ class IssuesIT extends TestBase { } } + // clean up known throw-away nodes / rels + @AfterEach + void cleanup(@Autowired BookmarkCapture bookmarkCapture) { + List labelsToBeRemoved = List.of("BugFromV1", "BugFrom", "BugTargetV1", "BugTarget", "BugTargetBaseV1", "BugTargetBase", "BugTargetContainer"); + var labelExpression = new LabelExpression(labelsToBeRemoved.get(0)); + for (int i = 1; i < labelsToBeRemoved.size(); i++) { + labelExpression = labelExpression.or(new LabelExpression(labelsToBeRemoved.get(i))); + } + try (Session session = neo4jConnectionSupport.getDriver().session(bookmarkCapture.createSessionConfig()); + Transaction transaction = session.beginTransaction()) { + Node nodes = Cypher.node(labelExpression); + String cypher = Cypher.match(nodes).detachDelete(nodes).build().getCypher(); + transaction.run(cypher).consume(); + transaction.commit(); + bookmarkCapture.seedWith(session.lastBookmarks()); + } + } + private static void setupGH2168(QueryRunner queryRunner) { queryRunner.run("CREATE (:DomainObject{id: 'A'})").consume(); } @@ -1217,6 +1253,310 @@ class IssuesIT extends TestBase { assertThat(fruits).allMatch(f -> f instanceof Apple || f instanceof Orange); } + @Test + @Tag("GH-2905") + void storeFromRootAggregate(@Autowired ToRepositoryV1 toRepositoryV1, @Autowired Driver driver) { + var to1 = BugTargetV1.builder().name("T1").type("BUG").build(); + + var from1 = BugFromV1.builder() + .name("F1") + .reli(BugRelationshipV1.builder().target(to1).comment("F1<-T1").build()) + .build(); + var from2 = BugFromV1.builder() + .name("F2") + .reli(BugRelationshipV1.builder().target(to1).comment("F2<-T1").build()) + .build(); + var from3 = BugFromV1.builder() + .name("F3") + .reli(BugRelationshipV1.builder().target(to1).comment("F3<-T1").build()) + .build(); + + to1.relatedBugs = Set.of(from1, from2, from3); + toRepositoryV1.save(to1); + + assertGH2905Graph(driver); + } + + @Test + @Tag("GH-2905") + void saveSingleEntities(@Autowired FromRepositoryV1 fromRepositoryV1, @Autowired ToRepositoryV1 toRepositoryV1, @Autowired Driver driver) { + var to1 = BugTargetV1.builder().name("T1").type("BUG").build(); + to1.relatedBugs = new HashSet<>(); + to1 = toRepositoryV1.save(to1); + + var from1 = BugFromV1.builder() + .name("F1") + .reli(BugRelationshipV1.builder().target(to1).comment("F1<-T1").build()) + .build(); + // This is the key to solve 2905 when you had the annotation previously, you must maintain both ends of the bidirectional relationship. + // SDN does not do this for you. + to1.relatedBugs.add(from1); + from1 = fromRepositoryV1.save(from1); + + var from2 = BugFromV1.builder() + .name("F2") + .reli(BugRelationshipV1.builder().target(to1).comment("F2<-T1").build()) + .build(); + // See above + to1.relatedBugs.add(from2); + + var from3 = BugFromV1.builder() + .name("F3") + .reli(BugRelationshipV1.builder().target(to1).comment("F3<-T1").build()) + .build(); + to1.relatedBugs.add(from3); + + // See above + fromRepositoryV1.saveAll(List.of(from1, from2, from3)); + + assertGH2905Graph(driver); + } + + private static void assertGH2905Graph(Driver driver) { + var result = driver.executableQuery("MATCH (t:BugTargetV1) -[:RELI] ->(f:BugFromV1) RETURN t, collect(f) AS f").execute().records(); + assertThat(result) + .hasSize(1) + .element(0).satisfies(r -> { + assertThat(r.get("t")).matches(TypeSystem.getDefault().NODE()::isTypeOf); + assertThat(r.get("f")) + .matches(TypeSystem.getDefault().LIST()::isTypeOf) + .extracting(Value::asList, as(InstanceOfAssertFactories.LIST)) + .hasSize(3); + }); + } + + @Test + @Tag("GH-2906") + void storeFromRootAggregateToLeaf(@Autowired ToRepository toRepository, @Autowired Driver driver) { + var to1 = new BugTarget("T1", "BUG"); + + var from1 = new BugFrom("F1", "F1<-T1", to1); + var from2 = new BugFrom("F2", "F2<-T1", to1); + var from3 = new BugFrom("F3", "F3<-T1", to1); + + to1.relatedBugs = Set.of( + new OutgoingBugRelationship(from1.reli.comment, from1), + new OutgoingBugRelationship(from2.reli.comment, from2), + new OutgoingBugRelationship(from3.reli.comment, from3) + ); + toRepository.save(to1); + + assertGH2906Graph(driver); + } + + + @Test + @Tag("GH-2906") + void storeFromRootAggregateToContainer(@Autowired ToRepository toRepository, @Autowired Driver driver) { + + var t1 = new BugTarget("T1", "BUG"); + var t2 = new BugTarget("T2", "BUG"); + + var to1 = new BugTargetContainer("C1"); + to1.items.add(t1); + to1.items.add(t2); + + var from1 = new BugFrom("F1", "F1<-T1", to1); + var from2 = new BugFrom("F2", "F2<-T1", to1); + var from3 = new BugFrom("F3", "F3<-T1", to1); + + to1.relatedBugs = Set.of( + new OutgoingBugRelationship(from1.reli.comment, from1), + new OutgoingBugRelationship(from2.reli.comment, from2), + new OutgoingBugRelationship(from3.reli.comment, from3) + ); + toRepository.save(to1); + + assertGH2906Graph(driver); + } + + @Test + @Tag("GH-2906") + void saveSingleEntitiesToLeaf(@Autowired FromRepository fromRepository, @Autowired ToRepository toRepository, @Autowired Driver driver) { + + var to1 = new BugTarget("T1", "BUG"); + to1 = toRepository.save(to1); + + var from1 = new BugFrom("F1", "F1<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from1.reli.comment, from1)); + from1 = fromRepository.save(from1); + + assertThat(from1.reli.id).isNotNull(); + assertThat(from1.reli.target.relatedBugs).first().extracting(r -> r.id).isNotNull(); + + var from2 = new BugFrom("F2", "F2<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from2.reli.comment, from2)); + + var from3 = new BugFrom("F3", "F3<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from3.reli.comment, from3)); + + // See above + var bugs = fromRepository.saveAll(List.of(from1, from2, from3)); + for (BugFrom from : bugs) { + assertThat(from.reli.id).isNotNull(); + assertThat(from.reli.target.relatedBugs).first().extracting(r -> r.id).isNotNull(); + } + + assertGH2906Graph(driver); + + var from1Loaded = fromRepository.findById(from1.uuid).orElseThrow(); + assertThat(from1Loaded.reli).isNotNull(); + } + + @Test + @Tag("GH-2906") + void saveSingleEntitiesToContainer(@Autowired FromRepository fromRepository, @Autowired ToRepository toRepository, @Autowired Driver driver) { + + var t1 = new BugTarget("T1", "BUG"); + var t2 = new BugTarget("T2", "BUG"); + + var to1 = new BugTargetContainer("C1"); + to1.items.add(t1); + to1.items.add(t2); + + to1 = toRepository.save(to1); + + var from1 = new BugFrom("F1", "F1<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from1.reli.comment, from1)); + + var from2 = new BugFrom("F2", "F2<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from2.reli.comment, from2)); + + var from3 = new BugFrom("F3", "F3<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from3.reli.comment, from3)); + + // See above + fromRepository.saveAll(List.of(from1, from2, from3)); + + assertGH2906Graph(driver); + } + + @Test + @Tag("GH-2906") + void saveSingleEntitiesViaServiceToContainer(@Autowired FromRepository fromRepository, @Autowired ToRepository toRepository, @Autowired Driver driver) { + + var t1 = new BugTarget("T1", "BUG"); + var t2 = new BugTarget("T2", "BUG"); + + var to1 = new BugTargetContainer("C1"); + to1.items.add(t1); + to1.items.add(t2); + + to1 = toRepository.save(to1); + var uuid = to1.uuid; + to1 = null; + + var from1 = new BugFrom("F1", "F1<-T1", null); + from1 = saveGH2906Entity(from1, uuid, fromRepository, toRepository); + + var from2 = new BugFrom("F2", "F2<-T1", null); + from2 = saveGH2906Entity(from2, uuid, fromRepository, toRepository); + + var from3 = new BugFrom("F3", "F3<-T1", null); + from3 = saveGH2906Entity(from3, uuid, fromRepository, toRepository); + + assertGH2906Graph(driver); + } + + @Test + @Tag("GH-2906") + void saveTwoSingleEntitiesViaServiceToContainer(@Autowired FromRepository fromRepository, @Autowired ToRepository toRepository, @Autowired Driver driver) { + + var t1 = new BugTarget("T1", "BUG"); + var t2 = new BugTarget("T2", "BUG"); + + var to1 = new BugTargetContainer("C1"); + to1.items.add(t1); + to1.items.add(t2); + + to1 = toRepository.save(to1); + var uuid = to1.uuid; + + var from1 = new BugFrom("F1", "F1<-T1", null); + saveGH2906Entity(from1, uuid, fromRepository, toRepository); + + var from2 = new BugFrom("F2", "F2<-T1", null); + saveGH2906Entity(from2, uuid, fromRepository, toRepository); + + assertGH2906Graph(driver, 2); + } + + @Test + @Tag("GH-2906") + void saveSingleEntitiesViaServiceToLeaf(@Autowired FromRepository fromRepository, @Autowired ToRepository toRepository, @Autowired Driver driver) { + + var uuid = toRepository.save(new BugTarget("T1", "BUG")).uuid; + + var e1 = saveGH2906Entity(new BugFrom("F1", "F1<-T1", null), uuid, fromRepository, toRepository); + + assertThat(e1.reli.id).isNotNull(); + assertThat(e1.reli.target.relatedBugs).first().extracting(r -> r.id).isNotNull(); + + e1 = saveGH2906Entity(new BugFrom("F2", "F2<-T1", null), uuid, fromRepository, toRepository); + assertThat(e1.reli.id).isNotNull(); + assertThat(e1.reli.target.relatedBugs).first().extracting(r -> r.id).isNotNull(); + + e1 = saveGH2906Entity(new BugFrom("F3", "F3<-T1", null), uuid, fromRepository, toRepository); + assertThat(e1.reli.id).isNotNull(); + assertThat(e1.reli.target.relatedBugs).first().extracting(r -> r.id).isNotNull(); + + assertGH2906Graph(driver); + } + + @Test + @Tag("GH-2906") + void saveTwoSingleEntitiesViaServiceToLeaf(@Autowired FromRepository fromRepository, @Autowired ToRepository toRepository, @Autowired Driver driver) { + + var to1 = new BugTarget("T1", "BUG"); + to1 = toRepository.save(to1); + var uuid = to1.uuid; + to1 = null; + + var from1 = new BugFrom("F1", "F1<-T1", null); + from1 = saveGH2906Entity(from1, uuid, fromRepository, toRepository); + + var from2 = new BugFrom("F2", "F2<-T1", null); + from2 = saveGH2906Entity(from2, uuid, fromRepository, toRepository); + + assertGH2906Graph(driver, 2); + } + + private BugFrom saveGH2906Entity(BugFrom from, String uuid, FromRepository fromRepository, ToRepository toRepository) { + var to = toRepository.findById(uuid).orElseThrow(); + + from.reli.target = to; + to.relatedBugs.add(new OutgoingBugRelationship(from.reli.comment, from)); + + return fromRepository.save(from); + } + + private static void assertGH2906Graph(Driver driver) { + assertGH2906Graph(driver, 3); + } + + private static void assertGH2906Graph(Driver driver, int cnt) { + + var expectedNodes = IntStream.rangeClosed(1, cnt).mapToObj(i -> String.format("F%d", i)).toArray(String[]::new); + var expectedRelationships = IntStream.rangeClosed(1, cnt).mapToObj(i -> String.format("F%d<-T1", i)).toArray(String[]::new); + + var result = driver.executableQuery("MATCH (t:BugTargetBase) -[r:RELI] ->(f:BugFrom) RETURN t, collect(f) AS f, collect(r) AS r").execute().records(); + assertThat(result) + .hasSize(1) + .element(0).satisfies(r -> { + assertThat(r.get("t")).matches(TypeSystem.getDefault().NODE()::isTypeOf); + assertThat(r.get("f")) + .matches(TypeSystem.getDefault().LIST()::isTypeOf) + .extracting(Value::asList, as(InstanceOfAssertFactories.LIST)) + .map(node -> ((org.neo4j.driver.types.Node) node).get("name").asString()) + .containsExactlyInAnyOrder(expectedNodes); + assertThat(r.get("r")) + .matches(TypeSystem.getDefault().LIST()::isTypeOf) + .extracting(Value::asList, as(InstanceOfAssertFactories.LIST)) + .map(rel -> ((Relationship) rel).get("comment").asString()) + .containsExactlyInAnyOrder(expectedRelationships); + }); + } + @Configuration @EnableTransactionManagement @EnableNeo4jRepositories(namedQueriesLocation = "more-custom-queries.properties") diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/ReactiveIssuesIT.java b/src/test/java/org/springframework/data/neo4j/integration/issues/ReactiveIssuesIT.java index be0266366..51909bf63 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/ReactiveIssuesIT.java +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/ReactiveIssuesIT.java @@ -15,9 +15,28 @@ */ package org.springframework.data.neo4j.integration.issues; +import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; +import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.BeforeEach; +import org.neo4j.cypherdsl.core.Cypher; +import org.neo4j.cypherdsl.core.LabelExpression; +import org.neo4j.cypherdsl.core.Node; +import org.neo4j.driver.Value; +import org.neo4j.driver.types.Relationship; +import org.neo4j.driver.types.TypeSystem; +import org.springframework.data.neo4j.integration.issues.gh2905.BugFromV1; +import org.springframework.data.neo4j.integration.issues.gh2905.BugRelationshipV1; +import org.springframework.data.neo4j.integration.issues.gh2905.BugTargetV1; +import org.springframework.data.neo4j.integration.issues.gh2905.ReactiveFromRepositoryV1; +import org.springframework.data.neo4j.integration.issues.gh2905.ReactiveToRepositoryV1; +import org.springframework.data.neo4j.integration.issues.gh2906.BugFrom; +import org.springframework.data.neo4j.integration.issues.gh2906.BugTarget; +import org.springframework.data.neo4j.integration.issues.gh2906.BugTargetContainer; +import org.springframework.data.neo4j.integration.issues.gh2906.OutgoingBugRelationship; +import org.springframework.data.neo4j.integration.issues.gh2906.ReactiveFromRepository; +import org.springframework.data.neo4j.integration.issues.gh2906.ReactiveToRepository; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -26,10 +45,14 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.stream.IntStream; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayNameGeneration; @@ -98,9 +121,17 @@ class ReactiveIssuesIT extends TestBase { @BeforeEach void setup(@Autowired BookmarkCapture bookmarkCapture) { + List labelsToBeRemoved = List.of("BugFromV1", "BugFrom", "BugTargetV1", "BugTarget", "BugTargetBaseV1", "BugTargetBase", "BugTargetContainer"); + var labelExpression = new LabelExpression(labelsToBeRemoved.get(0)); + for (int i = 1; i < labelsToBeRemoved.size(); i++) { + labelExpression = labelExpression.or(new LabelExpression(labelsToBeRemoved.get(i))); + } try (Session session = neo4jConnectionSupport.getDriver().session(bookmarkCapture.createSessionConfig())) { try (Transaction transaction = session.beginTransaction()) { setupGH2289(transaction); + Node nodes = Cypher.node(labelExpression); + String cypher = Cypher.match(nodes).detachDelete(nodes).build().getCypher(); + transaction.run(cypher).consume(); transaction.commit(); } bookmarkCapture.seedWith(session.lastBookmarks()); @@ -425,6 +456,315 @@ class ReactiveIssuesIT extends TestBase { .verifyComplete(); } + @Test + @Tag("GH-2905") + void storeFromRootAggregate(@Autowired ReactiveToRepositoryV1 toRepositoryV1, @Autowired Driver driver) { + var to1 = BugTargetV1.builder().name("T1").type("BUG").build(); + + var from1 = BugFromV1.builder() + .name("F1") + .reli(BugRelationshipV1.builder().target(to1).comment("F1<-T1").build()) + .build(); + var from2 = BugFromV1.builder() + .name("F2") + .reli(BugRelationshipV1.builder().target(to1).comment("F2<-T1").build()) + .build(); + var from3 = BugFromV1.builder() + .name("F3") + .reli(BugRelationshipV1.builder().target(to1).comment("F3<-T1").build()) + .build(); + + to1.relatedBugs = Set.of(from1, from2, from3); + toRepositoryV1.save(to1).then().as(StepVerifier::create).expectComplete().verify(); + + assertGH2905Graph(driver); + } + + @Test + @Tag("GH-2905") + void saveSingleEntities(@Autowired ReactiveFromRepositoryV1 fromRepositoryV1, @Autowired ReactiveToRepositoryV1 toRepositoryV1, @Autowired Driver driver) { + var bugTargetV1 = BugTargetV1.builder().name("T1").type("BUG").build(); + bugTargetV1.relatedBugs = new HashSet<>(); + toRepositoryV1.save(bugTargetV1).flatMapMany(to1 -> { + + var from1 = BugFromV1.builder() + .name("F1") + .reli(BugRelationshipV1.builder().target(to1).comment("F1<-T1").build()) + .build(); + // This is the key to solve 2905 when you had the annotation previously, you must maintain both ends of the bidirectional relationship. + // SDN does not do this for you. + to1.relatedBugs.add(from1); + + var from2 = BugFromV1.builder() + .name("F2") + .reli(BugRelationshipV1.builder().target(to1).comment("F2<-T1").build()) + .build(); + // See above + to1.relatedBugs.add(from2); + + var from3 = BugFromV1.builder() + .name("F3") + .reli(BugRelationshipV1.builder().target(to1).comment("F3<-T1").build()) + .build(); + to1.relatedBugs.add(from3); + + // See above + return fromRepositoryV1.saveAll(List.of(from1, from2, from3)); + }).then().as(StepVerifier::create).expectComplete().verify(); + + assertGH2905Graph(driver); + } + + private static void assertGH2905Graph(Driver driver) { + var result = driver.executableQuery("MATCH (t:BugTargetV1) -[:RELI] ->(f:BugFromV1) RETURN t, collect(f) AS f").execute().records(); + assertThat(result) + .hasSize(1) + .element(0).satisfies(r -> { + assertThat(r.get("t")).matches(TypeSystem.getDefault().NODE()::isTypeOf); + assertThat(r.get("f")) + .matches(TypeSystem.getDefault().LIST()::isTypeOf) + .extracting(Value::asList, as(InstanceOfAssertFactories.LIST)) + .hasSize(3); + }); + } + + @Test + @Tag("GH-2906") + void storeFromRootAggregateToLeaf(@Autowired ReactiveToRepository toRepository, @Autowired Driver driver) { + var to1 = new BugTarget("T1", "BUG"); + + var from1 = new BugFrom("F1", "F1<-T1", to1); + var from2 = new BugFrom("F2", "F2<-T1", to1); + var from3 = new BugFrom("F3", "F3<-T1", to1); + + to1.relatedBugs = Set.of( + new OutgoingBugRelationship(from1.reli.comment, from1), + new OutgoingBugRelationship(from2.reli.comment, from2), + new OutgoingBugRelationship(from3.reli.comment, from3) + ); + toRepository.save(to1).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + + assertGH2906Graph(driver); + } + + + @Test + @Tag("GH-2906") + void storeFromRootAggregateToContainer(@Autowired ReactiveToRepository toRepository, @Autowired Driver driver) { + + var t1 = new BugTarget("T1", "BUG"); + var t2 = new BugTarget("T2", "BUG"); + + var to1 = new BugTargetContainer("C1"); + to1.items.add(t1); + to1.items.add(t2); + + var from1 = new BugFrom("F1", "F1<-T1", to1); + var from2 = new BugFrom("F2", "F2<-T1", to1); + var from3 = new BugFrom("F3", "F3<-T1", to1); + + to1.relatedBugs = Set.of( + new OutgoingBugRelationship(from1.reli.comment, from1), + new OutgoingBugRelationship(from2.reli.comment, from2), + new OutgoingBugRelationship(from3.reli.comment, from3) + ); + toRepository.save(to1).as(StepVerifier::create).expectNextCount(1).verifyComplete(); + + assertGH2906Graph(driver); + } + + @Test + @Tag("GH-2906") + void saveSingleEntitiesToLeaf(@Autowired ReactiveFromRepository fromRepository, @Autowired ReactiveToRepository toRepository, @Autowired Driver driver) { + + var bt = new BugTarget("T1", "BUG"); + toRepository.save(bt).flatMapMany(to1 -> { + + var from1 = new BugFrom("F1", "F1<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from1.reli.comment, from1)); + + var from2 = new BugFrom("F2", "F2<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from2.reli.comment, from2)); + + var from3 = new BugFrom("F3", "F3<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from3.reli.comment, from3)); + + return fromRepository.saveAll(List.of(from1, from2, from3)).collectList().doOnNext(bugs -> { + for (BugFrom from : bugs) { + assertThat(from.reli.id).isNotNull(); + assertThat(from.reli.target.relatedBugs).first().extracting(r -> r.id).isNotNull(); + } + }); + }).then().as(StepVerifier::create).expectComplete().verify(); + + assertGH2906Graph(driver); + } + + @Test + @Tag("GH-2906") + void saveSingleEntitiesToContainer(@Autowired ReactiveFromRepository fromRepository, @Autowired ReactiveToRepository toRepository, @Autowired Driver driver) { + + var t1 = new BugTarget("T1", "BUG"); + var t2 = new BugTarget("T2", "BUG"); + + var to1 = new BugTargetContainer("C1"); + to1.items.add(t1); + to1.items.add(t2); + + to1 = toRepository.save(to1).block(); + + var from1 = new BugFrom("F1", "F1<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from1.reli.comment, from1)); + + var from2 = new BugFrom("F2", "F2<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from2.reli.comment, from2)); + + var from3 = new BugFrom("F3", "F3<-T1", to1); + to1.relatedBugs.add(new OutgoingBugRelationship(from3.reli.comment, from3)); + + // See above + fromRepository.saveAll(List.of(from1, from2, from3)).collectList().block(); + + assertGH2906Graph(driver); + } + + @Test + @Tag("GH-2906") + void saveSingleEntitiesViaServiceToContainer(@Autowired ReactiveFromRepository fromRepository, @Autowired ReactiveToRepository toRepository, @Autowired Driver driver) { + + var t1 = new BugTarget("T1", "BUG"); + var t2 = new BugTarget("T2", "BUG"); + + var to1 = new BugTargetContainer("C1"); + to1.items.add(t1); + to1.items.add(t2); + + toRepository.save(to1) + .flatMapMany(x -> { + var uuid = x.uuid; + var from1 = new BugFrom("F1", "F1<-T1", null); + var from2 = new BugFrom("F2", "F2<-T1", null); + var from3 = new BugFrom("F3", "F3<-T1", null); + + return Flux.concat(saveGH2906Entity(from1, uuid, fromRepository, toRepository), saveGH2906Entity(from2, uuid, fromRepository, toRepository), saveGH2906Entity(from3, uuid, fromRepository, toRepository)); + }) + .then() + .as(StepVerifier::create) + .expectComplete() + .verify(); + + assertGH2906Graph(driver); + } + + @Test + @Tag("GH-2906") + void saveTwoSingleEntitiesViaServiceToContainer(@Autowired ReactiveFromRepository fromRepository, @Autowired ReactiveToRepository toRepository, @Autowired Driver driver) { + + var t1 = new BugTarget("T1", "BUG"); + var t2 = new BugTarget("T2", "BUG"); + + var to1 = new BugTargetContainer("C1"); + to1.items.add(t1); + to1.items.add(t2); + + toRepository.save(to1).flatMapMany(x -> { + var uuid = x.uuid; + var from1 = new BugFrom("F1", "F1<-T1", null); + var from2 = new BugFrom("F2", "F2<-T1", null); + + return Flux.concat(saveGH2906Entity(from1, uuid, fromRepository, toRepository), saveGH2906Entity(from2, uuid, fromRepository, toRepository)); + }) + .then() + .as(StepVerifier::create) + .expectComplete() + .verify(); + + assertGH2906Graph(driver, 2); + } + + @Test + @Tag("GH-2906") + void saveSingleEntitiesViaServiceToLeaf(@Autowired ReactiveFromRepository fromRepository, @Autowired ReactiveToRepository toRepository, @Autowired Driver driver) { + + toRepository.save(new BugTarget("T1", "BUG")) + .map(x -> x.uuid) + .flatMapMany(uuid -> Flux.concat( + saveGH2906Entity(new BugFrom("F1", "F1<-T1", null), uuid, fromRepository, toRepository) + .doOnNext(assertRelations()), + saveGH2906Entity(new BugFrom("F2", "F2<-T1", null), uuid, fromRepository, toRepository) + .doOnNext(assertRelations()), + saveGH2906Entity(new BugFrom("F3", "F3<-T1", null), uuid, fromRepository, toRepository) + .doOnNext(assertRelations()) + + )).then() + .as(StepVerifier::create) + .expectComplete().verify(); + + assertGH2906Graph(driver); + } + + private static Consumer assertRelations() { + return e1 -> { + assertThat(e1.reli.id).isNotNull(); + assertThat(e1.reli.target.relatedBugs).first().extracting(r -> r.id).isNotNull(); + }; + } + + @Test + @Tag("GH-2906") + void saveTwoSingleEntitiesViaServiceToLeaf(@Autowired ReactiveFromRepository fromRepository, @Autowired ReactiveToRepository toRepository, @Autowired Driver driver) { + + var to1 = new BugTarget("T1", "BUG"); + toRepository.save(to1) + .map(x -> x.uuid) + .flatMapMany(uuid -> Flux.concat( + saveGH2906Entity(new BugFrom("F1", "F1<-T1", null), uuid, fromRepository, toRepository), + saveGH2906Entity(new BugFrom("F2", "F2<-T1", null), uuid, fromRepository, toRepository) + + )).then() + .as(StepVerifier::create) + .expectComplete().verify(); + + assertGH2906Graph(driver, 2); + } + + private Mono saveGH2906Entity(BugFrom from, String uuid, ReactiveFromRepository fromRepository, ReactiveToRepository toRepository) { + return toRepository.findById(uuid).flatMap(to -> { + + from.reli.target = to; + to.relatedBugs.add(new OutgoingBugRelationship(from.reli.comment, from)); + + return fromRepository.save(from); + }); + } + + private static void assertGH2906Graph(Driver driver) { + assertGH2906Graph(driver, 3); + } + + private static void assertGH2906Graph(Driver driver, int cnt) { + + var expectedNodes = IntStream.rangeClosed(1, cnt).mapToObj(i -> String.format("F%d", i)).toArray(String[]::new); + var expectedRelationships = IntStream.rangeClosed(1, cnt).mapToObj(i -> String.format("F%d<-T1", i)).toArray(String[]::new); + + var result = driver.executableQuery("MATCH (t:BugTargetBase) -[r:RELI] ->(f:BugFrom) RETURN t, collect(f) AS f, collect(r) AS r").execute().records(); + assertThat(result) + .hasSize(1) + .element(0).satisfies(r -> { + assertThat(r.get("t")).matches(TypeSystem.getDefault().NODE()::isTypeOf); + assertThat(r.get("f")) + .matches(TypeSystem.getDefault().LIST()::isTypeOf) + .extracting(Value::asList, as(InstanceOfAssertFactories.LIST)) + .map(node -> ((org.neo4j.driver.types.Node) node).get("name").asString()) + .containsExactlyInAnyOrder(expectedNodes); + assertThat(r.get("r")) + .matches(TypeSystem.getDefault().LIST()::isTypeOf) + .extracting(Value::asList, as(InstanceOfAssertFactories.LIST)) + .map(rel -> ((Relationship) rel).get("comment").asString()) + .containsExactlyInAnyOrder(expectedRelationships); + }); + } + @Configuration @EnableTransactionManagement @EnableReactiveNeo4jRepositories(namedQueriesLocation = "more-custom-queries.properties") diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugFrom.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugFromV1.java similarity index 83% rename from src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugFrom.java rename to src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugFromV1.java index aa195bbda..088181374 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugFrom.java +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugFromV1.java @@ -24,7 +24,7 @@ import org.springframework.data.neo4j.core.support.UUIDStringGenerator; * @author Mathias Kühn */ @SuppressWarnings("HiddenField") // Not worth cleaning up the Delomboked version -class BugFrom { +public class BugFromV1 { @Id @GeneratedValue(UUIDStringGenerator.class) protected String uuid; @@ -32,22 +32,28 @@ class BugFrom { private String name; @Relationship(type = "RELI", direction = Relationship.Direction.INCOMING) - private BugRelationship reli; + private BugRelationshipV1 reli; - BugFrom(String uuid, String name, BugRelationship reli) { + BugFromV1(String uuid, String name, BugRelationshipV1 reli) { this.uuid = uuid; this.name = name; this.reli = reli; } + /** + * Lombok builder + */ public static BugFromBuilder builder() { return new BugFromBuilder(); } + /** + * Lombok builder + */ public static class BugFromBuilder { private String uuid; private String name; - private BugRelationship reli; + private BugRelationshipV1 reli; BugFromBuilder() { } @@ -62,15 +68,16 @@ class BugFrom { return this; } - public BugFromBuilder reli(BugRelationship reli) { + public BugFromBuilder reli(BugRelationshipV1 reli) { this.reli = reli; return this; } - public BugFrom build() { - return new BugFrom(this.uuid, this.name, this.reli); + public BugFromV1 build() { + return new BugFromV1(this.uuid, this.name, this.reli); } + @Override public String toString() { return "BugFrom.BugFromBuilder(uuid=" + this.uuid + ", name=" + this.name + ", reli=" + this.reli + ")"; } diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugRelationship.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugRelationshipV1.java similarity index 83% rename from src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugRelationship.java rename to src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugRelationshipV1.java index 5c0af92d7..9933f9342 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugRelationship.java +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugRelationshipV1.java @@ -24,16 +24,16 @@ import org.springframework.data.neo4j.core.schema.TargetNode; */ @SuppressWarnings("HiddenField") // Not worth cleaning up the Delomboked version @RelationshipProperties -class BugRelationship { +public class BugRelationshipV1 { @RelationshipId protected Long id; protected String comment; @TargetNode - private BugTargetBase target; + private BugTargetBaseV1 target; - BugRelationship(Long id, String comment, BugTargetBase target) { + BugRelationshipV1(Long id, String comment, BugTargetBaseV1 target) { this.id = id; this.comment = comment; this.target = target; @@ -43,10 +43,13 @@ class BugRelationship { return new BugRelationshipBuilder(); } + /** + * Lombok builder + */ public static class BugRelationshipBuilder { private Long id; private String comment; - private BugTargetBase target; + private BugTargetBaseV1 target; BugRelationshipBuilder() { } @@ -61,13 +64,13 @@ class BugRelationship { return this; } - public BugRelationshipBuilder target(BugTargetBase target) { + public BugRelationshipBuilder target(BugTargetBaseV1 target) { this.target = target; return this; } - public BugRelationship build() { - return new BugRelationship(this.id, this.comment, this.target); + public BugRelationshipV1 build() { + return new BugRelationshipV1(this.id, this.comment, this.target); } public String toString() { diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTargetBase.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTargetBaseV1.java similarity index 85% rename from src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTargetBase.java rename to src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTargetBaseV1.java index 2a0c4c3ae..f252f2555 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTargetBase.java +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTargetBaseV1.java @@ -19,13 +19,15 @@ import java.util.Set; import org.springframework.data.neo4j.core.schema.GeneratedValue; import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; import org.springframework.data.neo4j.core.schema.Relationship; import org.springframework.data.neo4j.core.support.UUIDStringGenerator; /** * @author Mathias Kühn */ -abstract class BugTargetBase { +@Node +public abstract class BugTargetBaseV1 { @Id @GeneratedValue(UUIDStringGenerator.class) protected String uuid; @@ -33,9 +35,9 @@ abstract class BugTargetBase { private String name; @Relationship(type = "RELI", direction = Relationship.Direction.OUTGOING) - Set relatedBugs; + public Set relatedBugs; - BugTargetBase(String uuid, String name, Set relatedBugs) { + BugTargetBaseV1(String uuid, String name, Set relatedBugs) { this.uuid = uuid; this.name = name; this.relatedBugs = relatedBugs; diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTarget.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTargetV1.java similarity index 81% rename from src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTarget.java rename to src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTargetV1.java index 74dbeb723..4ada3c870 100644 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTarget.java +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/BugTargetV1.java @@ -21,10 +21,10 @@ import java.util.Set; * @author Mathias Kühn */ @SuppressWarnings("HiddenField") // Not worth cleaning up the Delomboked version -class BugTarget extends BugTargetBase { +public class BugTargetV1 extends BugTargetBaseV1 { private String type; - BugTarget(String uuid, String name, Set relatedBugs, String type) { + BugTargetV1(String uuid, String name, Set relatedBugs, String type) { super(uuid, name, relatedBugs); this.type = type; } @@ -33,10 +33,13 @@ class BugTarget extends BugTargetBase { return new BugTargetBuilder(); } + /** + * Builder + */ public static class BugTargetBuilder { private String uuid; private String name; - private Set relatedBugs; + private Set relatedBugs; private String type; BugTargetBuilder() { @@ -52,7 +55,7 @@ class BugTarget extends BugTargetBase { return this; } - public BugTargetBuilder relatedBugs(Set relatedBugs) { + public BugTargetBuilder relatedBugs(Set relatedBugs) { this.relatedBugs = relatedBugs; return this; } @@ -62,8 +65,8 @@ class BugTarget extends BugTargetBase { return this; } - public BugTarget build() { - return new BugTarget(this.uuid, this.name, this.relatedBugs, this.type); + public BugTargetV1 build() { + return new BugTargetV1(this.uuid, this.name, this.relatedBugs, this.type); } public String toString() { diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/FromRepositoryV1.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/FromRepositoryV1.java new file mode 100644 index 000000000..256882356 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/FromRepositoryV1.java @@ -0,0 +1,24 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2905; + +import org.springframework.data.neo4j.repository.Neo4jRepository; + +/** + * @author Gerrit Meier + */ +public interface FromRepositoryV1 extends Neo4jRepository { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/Gh2905IT.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/Gh2905IT.java deleted file mode 100644 index d6f061450..000000000 --- a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/Gh2905IT.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright 2011-2024 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.neo4j.integration.issues.gh2905; - -import static org.assertj.core.api.Assertions.as; -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import org.assertj.core.api.InstanceOfAssertFactories; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.neo4j.driver.Driver; -import org.neo4j.driver.Value; -import org.neo4j.driver.types.TypeSystem; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.neo4j.repository.Neo4jRepository; -import org.springframework.data.neo4j.repository.config.EnableNeo4jRepositories; -import org.springframework.data.neo4j.test.Neo4jExtension; -import org.springframework.data.neo4j.test.Neo4jImperativeTestConfiguration; -import org.springframework.data.neo4j.test.Neo4jIntegrationTest; -import org.springframework.transaction.annotation.EnableTransactionManagement; - -/** - * @author Michael J. Simons - */ -@Neo4jIntegrationTest -public class Gh2905IT { - - protected static Neo4jExtension.Neo4jConnectionSupport neo4jConnectionSupport; - - interface FromRepository extends Neo4jRepository { - - } - - interface ToRepository extends Neo4jRepository { - - } - - @BeforeEach - void clearDatabase(@Autowired Driver driver) { - driver.executableQuery("MATCH (n) DETACH DELETE n").execute(); - } - - @Test - void storeFromRootAggregate(@Autowired ToRepository toRepository, @Autowired Driver driver) { - var to1 = BugTarget.builder().name("T1").type("BUG").build(); - - var from1 = BugFrom.builder() - .name("F1") - .reli(BugRelationship.builder().target(to1).comment("F1<-T1").build()) - .build(); - var from2 = BugFrom.builder() - .name("F2") - .reli(BugRelationship.builder().target(to1).comment("F2<-T1").build()) - .build(); - var from3 = BugFrom.builder() - .name("F3") - .reli(BugRelationship.builder().target(to1).comment("F3<-T1").build()) - .build(); - - to1.relatedBugs = Set.of(from1, from2, from3); - toRepository.save(to1); - - assertGraph(driver); - } - - @Test - void saveSingleEntities(@Autowired FromRepository fromRepository, @Autowired ToRepository toRepository, @Autowired Driver driver) { - var to1 = BugTarget.builder().name("T1").type("BUG").build(); - to1.relatedBugs = new HashSet<>(); - to1 = toRepository.save(to1); - - var from1 = BugFrom.builder() - .name("F1") - .reli(BugRelationship.builder().target(to1).comment("F1<-T1").build()) - .build(); - // This is the key to solve 2905 when you had the annotation previously, you must maintain both ends of the bidirectional relationship. - // SDN does not do this for you. - to1.relatedBugs.add(from1); - from1 = fromRepository.save(from1); - - var from2 = BugFrom.builder() - .name("F2") - .reli(BugRelationship.builder().target(to1).comment("F2<-T1").build()) - .build(); - // See above - to1.relatedBugs.add(from2); - - var from3 = BugFrom.builder() - .name("F3") - .reli(BugRelationship.builder().target(to1).comment("F3<-T1").build()) - .build(); - to1.relatedBugs.add(from3); - // See above - fromRepository.saveAll(List.of(from1, from2, from3)); - - assertGraph(driver); - } - - private static void assertGraph(Driver driver) { - var result = driver.executableQuery("MATCH (t:BugTarget) -[:RELI] ->(f:BugFrom) RETURN t, collect(f) AS f").execute().records(); - assertThat(result) - .hasSize(1) - .element(0).satisfies(r -> { - assertThat(r.get("t")).matches(TypeSystem.getDefault().NODE()::isTypeOf); - assertThat(r.get("f")) - .matches(TypeSystem.getDefault().LIST()::isTypeOf) - .extracting(Value::asList, as(InstanceOfAssertFactories.LIST)) - .hasSize(3); - }); - } - - @Configuration - @EnableTransactionManagement - @EnableNeo4jRepositories(considerNestedRepositories = true) - static class Config extends Neo4jImperativeTestConfiguration { - - @Bean - public Driver driver() { - - return neo4jConnectionSupport.getDriver(); - } - - - @Override - public boolean isCypher5Compatible() { - return false; - } - } -} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ReactiveFromRepositoryV1.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ReactiveFromRepositoryV1.java new file mode 100644 index 000000000..09e7ff7cd --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ReactiveFromRepositoryV1.java @@ -0,0 +1,24 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2905; + +import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; + +/** + * @author Gerrit Meier + */ +public interface ReactiveFromRepositoryV1 extends ReactiveNeo4jRepository { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ReactiveToRepositoryV1.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ReactiveToRepositoryV1.java new file mode 100644 index 000000000..763f48820 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ReactiveToRepositoryV1.java @@ -0,0 +1,24 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2905; + +import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; + +/** + * @author Gerrit Meier + */ +public interface ReactiveToRepositoryV1 extends ReactiveNeo4jRepository { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ToRepositoryV1.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ToRepositoryV1.java new file mode 100644 index 000000000..6400eefde --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2905/ToRepositoryV1.java @@ -0,0 +1,24 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2905; + +import org.springframework.data.neo4j.repository.Neo4jRepository; + +/** + * @author Gerrit Meier + */ +public interface ToRepositoryV1 extends Neo4jRepository { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugFrom.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugFrom.java new file mode 100644 index 000000000..e114b4e24 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugFrom.java @@ -0,0 +1,57 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +import org.springframework.data.annotation.PersistenceCreator; +import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Relationship; +import org.springframework.data.neo4j.core.support.UUIDStringGenerator; + +/** + * @author Mathias Kühn + */ +@Node +public class BugFrom { + @Id + @GeneratedValue(UUIDStringGenerator.class) + public String uuid; + + String name; + + @Relationship(type = "RELI", direction = Relationship.Direction.INCOMING) + public IncomingBugRelationship reli; + + @PersistenceCreator // Due to the cyclic mapping you cannot have the relation as constructor parameter, how should this work? + BugFrom(String name, String uuid) { + this.name = name; + this.uuid = uuid; + } + + public BugFrom(String name, String comment, BugTargetBase target) { + this.name = name; + + this.reli = new IncomingBugRelationship(comment, target); + } + + + @Override + public String toString() { + return String.format(" {uuid: %s, name: %s}", uuid, name); + } +} + diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugRelationship.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugRelationship.java new file mode 100644 index 000000000..7ec9aa40d --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugRelationship.java @@ -0,0 +1,47 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +import org.springframework.data.neo4j.core.schema.RelationshipId; +import org.springframework.data.neo4j.core.schema.RelationshipProperties; +import org.springframework.data.neo4j.core.schema.TargetNode; + +/** + * @author Mathias Kühn + * @param The crux of this thing + */ +@RelationshipProperties +public abstract class BugRelationship { + + @RelationshipId + public Long id; + + public String comment; + + @TargetNode + public T target; + + BugRelationship(String comment, T target) { + this.comment = comment; + this.target = target; + } + + @Override + public String toString() { + return String.format("<%s> {id: %d, comment: %s}", this.getClass().getSimpleName(), id, comment); + } + +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTarget.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTarget.java new file mode 100644 index 000000000..f5ec0f1c2 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTarget.java @@ -0,0 +1,33 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +import org.springframework.data.neo4j.core.schema.Node; + +/** + * @author Mathias Kühn + */ +@Node +public class BugTarget extends BugTargetBase { + + String type; + + public BugTarget(String name, String type) { + super(name); + + this.type = type; + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTargetBase.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTargetBase.java new file mode 100644 index 000000000..a939b39b1 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTargetBase.java @@ -0,0 +1,51 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.data.neo4j.core.schema.GeneratedValue; +import org.springframework.data.neo4j.core.schema.Id; +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Relationship; +import org.springframework.data.neo4j.core.support.UUIDStringGenerator; + +/** + * @author Mathias Kühn + */ +@Node +public abstract class BugTargetBase { + + @Id + @GeneratedValue(UUIDStringGenerator.class) + public String uuid; + + public String name; + + @Relationship(type = "RELI", direction = Relationship.Direction.OUTGOING) + public Set relatedBugs = new HashSet<>(); + + BugTargetBase(String name) { + this.name = name; + } + + @Override + public String toString() { + return String.format("<%s> {uuid: %s, name: %s}", this.getClass().getSimpleName(), uuid, name); + } + +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTargetContainer.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTargetContainer.java new file mode 100644 index 000000000..2dbbb00b7 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/BugTargetContainer.java @@ -0,0 +1,36 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.data.neo4j.core.schema.Node; +import org.springframework.data.neo4j.core.schema.Relationship; + +/** + * @author Mathias Kühn + */ +@Node +public class BugTargetContainer extends BugTargetBase { + + @Relationship(type = "INCLUDE", direction = Relationship.Direction.OUTGOING) + public Set items = new HashSet<>(); + + public BugTargetContainer(String name) { + super(name); + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/FromRepository.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/FromRepository.java new file mode 100644 index 000000000..483469f2f --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/FromRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +import org.springframework.data.neo4j.repository.Neo4jRepository; + +/** + * @author Gerrit Meier + */ +public interface FromRepository extends Neo4jRepository { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/IncomingBugRelationship.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/IncomingBugRelationship.java new file mode 100644 index 000000000..fec452467 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/IncomingBugRelationship.java @@ -0,0 +1,26 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +/** + * @author Michael J. Simons + */ +public class IncomingBugRelationship extends BugRelationship { + + IncomingBugRelationship(String comment, BugTargetBase target) { + super(comment, target); + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/OutgoingBugRelationship.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/OutgoingBugRelationship.java new file mode 100644 index 000000000..98608b802 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/OutgoingBugRelationship.java @@ -0,0 +1,26 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +/** + * @author Michael J. Simons + */ +public class OutgoingBugRelationship extends BugRelationship { + + public OutgoingBugRelationship(String comment, BugFrom target) { + super(comment, target); + } +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ReactiveFromRepository.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ReactiveFromRepository.java new file mode 100644 index 000000000..6ea76c510 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ReactiveFromRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; + +/** + * @author Gerrit Meier + */ +public interface ReactiveFromRepository extends ReactiveNeo4jRepository { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ReactiveToRepository.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ReactiveToRepository.java new file mode 100644 index 000000000..6bdd4b462 --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ReactiveToRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +import org.springframework.data.neo4j.repository.ReactiveNeo4jRepository; + +/** + * @author Gerrit Meier + */ +public interface ReactiveToRepository extends ReactiveNeo4jRepository { +} diff --git a/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ToRepository.java b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ToRepository.java new file mode 100644 index 000000000..7fb618ccb --- /dev/null +++ b/src/test/java/org/springframework/data/neo4j/integration/issues/gh2906/ToRepository.java @@ -0,0 +1,24 @@ +/* + * Copyright 2011-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.neo4j.integration.issues.gh2906; + +import org.springframework.data.neo4j.repository.Neo4jRepository; + +/** + * @author Gerrit Meier + */ +public interface ToRepository extends Neo4jRepository { +}