Search code examples
neo4jrelationshipspring-data-neo4jneo4j-ogm

Update of RelationshipEntity resets values of various other rich relationships in Neo4j


1. Problem summary

When I update an attribute of a single, existing Neo4j RelationshipEntity, the actual values of various other rich relationships of same type are suddenly replaced by their old, former status of values. It seems as if a transaction is rolled back. My expectation is that only the attribute of the RelationshipEntity under inspection is updated and all other relationships are untouched.

2. Initial situation

  • running with Neo4j 3.4.7, Spring Boot v2.0.5.RELEASE, Spring v5.0.9.RELEASE
  • no explicit usage of transactions
  • reduced graph schema:

reduced graph schema

3. Target-actual comparison

3.1. Chronological sequence

The simplified use case according the graph schema can be summarized as following:

  • Identify all ClassC nodes
  • For them find various related ClassB (via ClassA)
  • For each identified ClassB create a ClassD including relationship to ClassB and a rich relationship CDMapping to ClassC)

3.2 Expected / positive result

The complete described block works fine for the first run. Various RelationshipEntitys between ClassC and ClassD with the attribute "Default Value" are setup, the last rich relationship receives intended the value "Special Value".

after first run

3.3 Detailed problem

When it comes to the second block cycle – exactly by saving the first new RelationshipEntity between ClassC and ClassD - the attributes of the previous run are unexpected set back to "Default Value", replacing the original "Special Value".

during second run

3.4 Neo4j generated queries

The following queries are executed by Neo4j in this moment, triggered by the relevant cdMappingDAO.save(cdMapping);:

UNWIND {rows} as row MATCH (startNode) WHERE ID(startNode) = row.startNodeId WITH row,startNode MATCH (endNode) WHERE ID(endNode) = row.endNodeId CREATE (startNode)-[rel:`MAPS_TO`]->(endNode) SET rel += row.props RETURN row.relRef as ref, ID(rel) as id, {type} as type with params {type=rel, rows=[{startNodeId=91, relRef=-45, endNodeId=115, props={attribute=Default Value}}]}
UNWIND {rows} as row MATCH (startNode) WHERE ID(startNode) = row.startNodeId WITH row,startNode MATCH (endNode) WHERE ID(endNode) = row.endNodeId MERGE (startNode)-[rel:`CONTAINS`]->(endNode) RETURN row.relRef as ref, ID(rel) as id, {type} as type with params {type=rel, rows=[{startNodeId=88, relRef=-49, endNodeId=115, props={}}, {startNodeId=92, relRef=-51, endNodeId=91, props={}}, {startNodeId=88, relRef=-54, endNodeId=93, props={}}, {startNodeId=89, relRef=-56, endNodeId=94, props={}}, {startNodeId=92, relRef=-57, endNodeId=90, props={}}]}
UNWIND {rows} as row MATCH (n) WHERE ID(n)=row.nodeId SET n:`ClassA`:`Entity` SET n += row.props RETURN row.nodeId as ref, ID(n) as id, {type} as type with params {type=node, rows=[{nodeId=92, props={name=Class A}}]}
UNWIND {rows} as row MATCH (n) WHERE ID(n)=row.nodeId SET n:`ClassB`:`Entity` SET n += row.props RETURN row.nodeId as ref, ID(n) as id, {type} as type with params {type=node, rows=[{nodeId=88, props={name=Class B1}}, {nodeId=89, props={name=Class B2}}]}
UNWIND {rows} as row MATCH (n) WHERE ID(n)=row.nodeId SET n:`ClassD`:`Entity` SET n += row.props RETURN row.nodeId as ref, ID(n) as id, {type} as type with params {type=node, rows=[{nodeId=115, props={}}, {nodeId=93, props={}}, {nodeId=94, props={}}]}
UNWIND {rows} as row MATCH (n) WHERE ID(n)=row.nodeId SET n:`ClassC`:`Entity` SET n += row.props RETURN row.nodeId as ref, ID(n) as id, {type} as type with params {type=node, rows=[{nodeId=90, props={name=Class C1}}, {nodeId=91, props={name=Class C2}}]}
UNWIND {rows} AS row MATCH ()-[r]-() WHERE ID(r) = row.relId SET r += row.props RETURN ID(r) as ref, ID(r) as id, {type} as type with params {rows=[{relId=104, props={attribute=Default Value}}, {relId=106, props={attribute=Default Value}}], type=rel}

4. To be solved challenge

Can you please give me an advice why and by what component the attribute values are reset? How can I ensure, that the RelationshipEntity under inspection is updated only? Many thanks in advance for pointing me into the right direction!

5. Code snippets

5.1 GraphHandler

@Component
public class GraphHandler implements CommandLineRunner {
  private ClassADAO classADAO;
  private ClassBDAO classBDAO;
  private ClassCDAO classCDAO;
  private ClassDDAO classDDAO;
  private CDMappingDAO cdMappingDAO;
  private SessionFactory sessionFactory;


  @Autowired
  public GraphHandler(ClassADAO classADAO, ClassBDAO classBDAO, ClassCDAO classCDAO, ClassDDAO classDDAO, CDMappingDAO cdMappingDAO, SessionFactory sessionFactory) {
    this.classADAO = classADAO;
    this.classBDAO = classBDAO;
    this.classCDAO = classCDAO;
    this.classDDAO = classDDAO;
    this.cdMappingDAO = cdMappingDAO;
    this.sessionFactory = sessionFactory;
  }


  public void run(String... args) {
    createInitialModel();
    runUseCase();
  }


  private void createInitialModel() {
    ClassA classA = new ClassA("Class A");
    ClassB classB1 = new ClassB("Class B1");
    ClassB classB2 = new ClassB("Class B2");
    ClassC classC1 = new ClassC("Class C1");
    ClassC classC2 = new ClassC("Class C2");

    classA.getClassBs().addAll(Arrays.asList(classB1, classB2));
    classA.getClassCs().addAll(Arrays.asList(classC1, classC2));

    classADAO.save(classA);
    classBDAO.save(classB1);
    classBDAO.save(classB2);
    classCDAO.save(classC1);
    classCDAO.save(classC2);
  }


  private void runUseCase() {
    Iterable<ClassC> classCs = classCDAO.findAll();
    for (ClassC classC : classCs) {

      ClassD rememberedClassD = null;
      List<ClassB> classBs = classBDAO.findClassBSelection(classC.getId());
      for (ClassB classB : classBs) {

        ClassD classD = new ClassD();
        classD.setClassB(classB);
        classB.getClassDs().add(classD);
        classDDAO.save(classD);
        rememberedClassD = classD;

        CDMapping cdMapping = new CDMapping(classC, classD, "Default Value");
        cdMappingDAO.save(cdMapping); // <-- here the problem occurs
      }

      // choosing the last created relationship (ClassC-ClassD) and mark it
      CDMapping relationship = cdMappingDAO.getRelationshipByClassCAndClassD(classC.getId(), rememberedClassD.getId());
      relationship.setAttribute("Special Value");
      cdMappingDAO.save(relationship);
    }
  }
}

5.2 CDMapping

@RelationshipEntity(type = "MAPS_TO")
public class CDMapping {
  @Id
  @GeneratedValue
  private Long id;

  @StartNode
  private ClassC classC;

  @EndNode
  private ClassD classD;

  private String attribute;

  public CDMapping(ClassC classC, ClassD classD, String attribute) {
    this.classC = classC;
    this.classD = classD;
    this.attribute = attribute;

    classC.getCdMappings().add(this);
    classD.getCdMappings().add(this);
  }

  // default constructor, getter and setter here
}

5.3 ClassA

@NodeEntity
public class ClassA extends Entity {
  private String name;

  @Relationship(type = "CONTAINS")
  private List<ClassC> classCs = new ArrayList<>();

  @Relationship(type = "MAPS_TO")
  private List<ClassB> classBs = new ArrayList<>();

  // default constructor, getter and setter here
}

5.4 ClassB

@NodeEntity
public class ClassB extends Entity {
  private String name;

  @Relationship(type = "MAPS_TO", direction = Relationship.INCOMING)
  private List<ClassA> classAs = new ArrayList<>();


  @Relationship(type = "CONTAINS")
  private List<ClassD> classDs = new ArrayList<>();

  // default constructor, getter and setter here
}

5.5 ClassC

@NodeEntity
public class ClassC extends Entity {
  private String name;

  @Relationship(type = "CONTAINS", direction = Relationship.INCOMING)
  private ClassA classA;

  @Relationship(type = "MAPS_TO")
  private List<CDMapping> cdMappings = new ArrayList<>();

  // default constructor, getter and setter here
}

5.6 ClassD

@NodeEntity
public class ClassD extends Entity {
  @Relationship(type = "CONTAINS", direction = Relationship.INCOMING)
  private ClassB classB;

  @Relationship(type = "MAPS_TO", direction = Relationship.INCOMING)
  private List<CDMapping> cdMappings = new ArrayList<>();

  // default constructor, getter and setter here
}

Update

5.7 CDMappingDAO

@Repository
public interface CDMappingDAO extends Neo4jRepository<CDMapping, Long> {

  @Query("MATCH (classC:ClassC)-[relationship:MAPS_TO]-(classD:ClassD) WHERE id(classC)={classCId} AND id(classD)={classDId} RETURN classC, relationship, classD;")
  CDMapping getRelationshipByClassCAndClassD(@Param("classCId") Long classCId, @Param("classDId") Long classDId);
}

5.8 ClassADAO / ClassCDAO / ClassDDAO

@Repository
public interface ClassADAO extends Neo4jRepository<ClassA, Long> {
}

Except for the first Neo4jRepository type the ClassCDAO and ClassDDAO are identical.

5.9 ClassBDAO

@Repository
public interface ClassBDAO extends Neo4jRepository<ClassB, Long> {
  @Query("MATCH (classC:ClassC)<-[:CONTAINS]-(:ClassA)-[:MAPS_TO]->(classB:ClassB) WHERE id(classC)={classCId} RETURN classB;")
  List<ClassB> findClassBSelection(@Param("classCId") Long classCId);
}

Solution

  • At the moment it seems there's a bug somewhere in OGM, but I have found two workarounds for your problem.

    Workaround one leaves your code pretty much as it is:

    Change runUseCase One is in your GraphHandler to save the CDMapping with depth 0:

    private void runUseCase() {
      Iterable<ClassC> classCs = classCDAO.findAll();
      for (ClassC classC : classCs) {
    
        ClassD rememberedClassD = null;
        List<ClassB> classBs = classBDAO.findClassBSelection(classC.getId());
        for (ClassB classB : classBs) {
    
          ClassD classD = new ClassD();
          classD.setClassB(classB);
          classB.getClassDs().add(classD);
          classDDAO.save(classD);
          rememberedClassD = classD;
    
          CDMapping cdMapping = new CDMapping(classC, classD, "Default Value");
          cdMappingDAO.save(cdMapping, 0); // <-- here the problem occurs
        }
    
        // choosing the last created relationship (ClassC-ClassD) and mark it
        CDMapping relationship = cdMappingDAO.getRelationshipByClassCAndClassD(classC.getId(), rememberedClassD.getId());
        relationship.setAttribute("Special Value");
        cdMappingDAO.save(relationship, 0);
      }
    }
    

    This leaves the "special" ones intact.

    I'd rather suggest the following change, adding some more correct transaction boundaries.

    Introduce a service class like SomeGraphBasedService. A dedicated class is necessary due to the fact how Springs declarative transactions work. Both methods, createInitialModel and runUseCase span now one transaction each and all DAO methods participate in it. Please take especially note on the comment, I only save the topmost parent class in createInitialModel:

    import java.util.Arrays;
    import java.util.List;
    
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    @Service
    public class SomeGraphBasedService {
        private final ClassADAO classADAO;
        private final ClassBDAO classBDAO;
        private final ClassCDAO classCDAO;
        private final ClassDDAO classDDAO;
        private final CDMappingDAO cdMappingDAO;
    
        public SomeGraphBasedService(ClassADAO classADAO, ClassBDAO classBDAO,
            ClassCDAO classCDAO, ClassDDAO classDDAO, CDMappingDAO cdMappingDAO) {
            this.classADAO = classADAO;
            this.classBDAO = classBDAO;
            this.classCDAO = classCDAO;
            this.classDDAO = classDDAO;
            this.cdMappingDAO = cdMappingDAO;
        }
    
        @Transactional
        public void createInitialModel() {
            ClassA classA = new ClassA("Class A");
            ClassB classB1 = new ClassB("Class B1");
            ClassB classB2 = new ClassB("Class B2");
            ClassC classC1 = new ClassC("Class C1");
            ClassC classC2 = new ClassC("Class C2");
    
            classA.getClassBs().addAll(Arrays.asList(classB1, classB2));
            classB1.getClassAs().add(classA);
            classB2.getClassAs().add(classA);
            classA.getClassCs().addAll(Arrays.asList(classC1, classC2));
    
            classADAO.save(classA);
            // No need to save them one by one, the releationships
            // take care of that while saving the parent class at the top
            /*
            classBDAO.save(classB1);
            classBDAO.save(classB2);
            classCDAO.save(classC1);
            classCDAO.save(classC2);
            */
        }
    
        @Transactional
        public void runUseCase() {
    
            Iterable<ClassC> classCs = classCDAO.findAll();
            for (ClassC classC : classCs) {
    
                ClassD rememberedClassD = null;
                List<ClassB> classBs = classBDAO.findClassBSelection(classC.getId());
                for (ClassB classB : classBs) {
    
                    ClassD classD = new ClassD();
                    classD.setClassB(classB);
                    classB.getClassDs().add(classD);
                    classDDAO.save(classD);
                    rememberedClassD = classD;
    
                    CDMapping cdMapping = new CDMapping(classC, classD, "Default Value");
                    cdMappingDAO.save(cdMapping); // <-- here the problem occurs
                }
    
                // choosing the last created relationship (ClassC-ClassD) and mark it
                CDMapping relationship = cdMappingDAO
                    .getRelationshipByClassCAndClassD(classC.getId(), rememberedClassD.getId());
                relationship.setAttribute("Special Value");
                cdMappingDAO.save(relationship);
            }
    
            Iterable<CDMapping> f = cdMappingDAO.findAll();
            for (CDMapping ff : f) {
                System.out.println(ff);
            }
        }
    }
    

    Then your command line runner becomes this:

    import org.springframework.boot.CommandLineRunner;
    import org.springframework.stereotype.Component;
    
    @Component
    public class GraphHandler implements CommandLineRunner {
        private final SomeGraphBasedService someGraphBasedService;
    
        public GraphHandler(SomeGraphBasedService someGraphBasedService) {
            this.someGraphBasedService = someGraphBasedService;
        }
    
        public void run(String... args) {
            this.someGraphBasedService.createInitialModel();
            this.someGraphBasedService.runUseCase();
    
        }
    }
    

    While the first one is obviously a workaround, the other solution is the one I'd prefer in a real world scenario.

    Anyway, the output of both is now as expected:

    CDMapping{id=21, classC=ClassC{name='Class C1'}, classD=ClassD{classB=ClassB{name='Class B1'}}, attribute='Default Value'}
    CDMapping{id=23, classC=ClassC{name='Class C1'}, classD=ClassD{classB=ClassB{name='Class B2'}}, attribute='Special Value'}
    CDMapping{id=25, classC=ClassC{name='Class C2'}, classD=ClassD{classB=ClassB{name='Class B1'}}, attribute='Default Value'}
    CDMapping{id=27, classC=ClassC{name='Class C2'}, classD=ClassD{classB=ClassB{name='Class B2'}}, attribute='Special Value'}