Search code examples
javaneo4jspring-data-neo4j-4neo4j-ogm

Modelling tree/hierarchy structure in Spring Data Neo4j 4.1


In order to model a tree/hierarchy (where the parent-child relationship can be traversed both ways) with Spring Data Neo4j 4.1, I wrote the following entity class

@NodeEntity(label = "node")
public class Node {

    @GraphId
    @SuppressWarnings("unused")
    private Long graphId;

    private String name;

    @Relationship(type = "PARENT", direction = Relationship.OUTGOING)
    private Node parent;

    @Relationship(type = "PARENT", direction = Relationship.INCOMING)
    private Iterable<Node> children;

    @SuppressWarnings("unused")
    protected Node() {
        // For SDN.
    }

    public Node(String name, Node parent) {
        this.name = Objects.requireNonNull(name);
        this.parent = parent;
    }

    public String getName() {
        return name;
    }

    public Node getParent() {
        return parent;
    }
}

The problem is that, apparently, the presence of the children field screws up the PARENT relation such that there can be only one such incoming relation for a node. That is, as demonstrated by the following test case, a node cannot have more than one child - "conflicting" relations are automatically deleted:

@RunWith(SpringRunner.class)
@SpringBootTest(
        classes = GraphDomainTestConfig.class,
        webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@SuppressWarnings("SpringJavaAutowiredMembersInspection")
public class NodeTest {

    @Autowired
    private NodeRepository repository;

    @Test
    public void test() {
        // Breakpoint 0

        Node A = new Node("A", null);

        A = repository.save(A);
        // Breakpoint 1

        Node B = new Node("B", A);
        Node C = new Node("C", A);

        B = repository.save(B);
        // Breakpoint 2

        C = repository.save(C);
        // Breakpoint 3

        A = repository.findByName("A");
        B = repository.findByName("B");
        C = repository.findByName("C");
        // Breakpoint 4

        assertNull(A.getParent()); // OK
        assertEquals(B.getParent().getName(), "A"); // FAILS (null pointer exception)! 
        assertEquals(C.getParent().getName(), "A"); // OK
    }
}

The test is set up to use the embedded driver. The log output at the "breakpoints" are as follows:

In order to keep the noise down, I've limited myself to include the log output that I think could be related to the problem. Please ask for more output in the comments if you need it. The same thing goes with configuration etc.

Breakpoint 0: Strange warning.

WARN: No identity field found for class of type: com.example.NodeTest when creating persistent property for field: private com.example.NodeRepository com.example.NodeTest.repository

Breakpoint 1: Node A is created.

INFO: Request: UNWIND {rows} as row CREATE (n:`node`) SET n=row.props RETURN row.nodeRef as ref, ID(n) as id, row.type as type with params {rows=[{nodeRef=-1965998569, type=node, props={name=A}}]}

Breakpoint 2: Node B and its relationship to A is created.

INFO: Request: UNWIND {rows} as row CREATE (n:`node`) SET n=row.props RETURN row.nodeRef as ref, ID(n) as id, row.type as type with params {rows=[{nodeRef=-1715570484, type=node, props={name=B}}]}
INFO: Request: UNWIND {rows} as row MATCH (startNode) WHERE ID(startNode) = row.startNodeId MATCH (endNode) WHERE ID(endNode) = row.endNodeId MERGE (startNode)-[rel:`PARENT`]->(endNode) RETURN row.relRef as ref, ID(rel) as id, row.type as type with params {rows=[{startNodeId=1, relRef=-1978848273, type=rel, endNodeId=0}]}

Breakpoint 3: Node C and its relationship to A is created. But B's relationship to A is also deleted!

INFO: Request: UNWIND {rows} as row CREATE (n:`node`) SET n=row.props RETURN row.nodeRef as ref, ID(n) as id, row.type as type with params {rows=[{nodeRef=-215596349, type=node, props={name=C}}]}
INFO: Request: UNWIND {rows} as row MATCH (startNode) WHERE ID(startNode) = row.startNodeId MATCH (endNode) WHERE ID(endNode) = row.endNodeId MERGE (startNode)-[rel:`PARENT`]->(endNode) RETURN row.relRef as ref, ID(rel) as id, row.type as type with params {rows=[{startNodeId=2, relRef=-2003500348, type=rel, endNodeId=0}]}
INFO: Request: UNWIND {rows} as row MATCH (startNode) WHERE ID(startNode) = row.startNodeId MATCH (endNode) WHERE ID(endNode) = row.endNodeId MATCH (startNode)-[rel:`PARENT`]->(endNode) DELETE rel with params {rows=[{startNodeId=1, endNodeId=0}]}

Breakpoint 4: Querying the repository.

INFO: Request: MATCH (n:`node`) WHERE n.`name` = { `name` } WITH n MATCH p=(n)-[*0..1]-(m) RETURN p, ID(n) with params {name=A}
WARN: Cannot map iterable of class com.example.Node to instance of com.example.Node. More than one potential matching field found.
INFO: Request: MATCH (n:`node`) WHERE n.`name` = { `name` } WITH n MATCH p=(n)-[*0..1]-(m) RETURN p, ID(n) with params {name=B}
INFO: Request: MATCH (n:`node`) WHERE n.`name` = { `name` } WITH n MATCH p=(n)-[*0..1]-(m) RETURN p, ID(n) with params {name=C}

I suspect the problem to be connected to the warning in the second line (of "breakpoint 4"), but I don't understand the reason/solution for it.

Why can't the field be mapped? Why does this cause the semantics shown above? How do you correctly model a tree where you can traverse the parent-child relationship both ways?

Additional information:

If I remove the children field, the test passes. Reversing the direction of the relationship or making the field type (or subtype of) Collection doesn't make any difference.

The relevant project dependencies are org.springframework.boot:spring-boot-starter-data-neo4j:jar:1.4.0.RELEASE:compile, org.neo4j:neo4j-ogm-test:jar:2.0.4:test, and org.neo4j.test:neo4j-harness:jar:3.0.4:test.


Solution

  • When you have an incoming @Relationship, you must annotate the field, accessor and mutator methods with the @Relationship with type and direction INCOMING.

    Secondly, I believe Iterable for children will not work with the OGM mapping process- implementations of List,Vector,Set,SortedSet will.

    We have an example of a Tree here: https://github.com/neo4j/neo4j-ogm/blob/2.0/core/src/test/java/org/neo4j/ogm/domain/tree/Entity.java and the test https://github.com/neo4j/neo4j-ogm/blob/2.0/core/src/test/java/org/neo4j/ogm/persistence/examples/tree/TreeIntegrationTest.java

    Edit:

    So I've taken a look at the code again- Iterable will probably work. Might actually be a Set. Regarding your comment on parent.children.add(this), it is required because without it, your object model is not in sync with what you expect the graph model to be. When the OGM maps this, it could find that the child has a parent, but the parent does not include the child- and so it will pick one or the other as the source of truth.