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.
The simplified use case according the graph schema can be summarized as following:
ClassC
nodesClassB
(via ClassA
)ClassB
create a ClassD
including relationship to ClassB
and a rich relationship CDMapping
to ClassC
)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".
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".
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}
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!
@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);
}
}
}
@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
}
@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
}
@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
}
@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
}
@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
}
@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);
}
@Repository
public interface ClassADAO extends Neo4jRepository<ClassA, Long> {
}
Except for the first Neo4jRepository
type the ClassCDAO
and ClassDDAO
are identical.
@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);
}
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'}