Passing domain object as argument to custom Cypher query in Spring Data Neo4j

125 views Asked by At

I'm writing Spring Data Neo4j webapp. I have this domain object:

@Node("Person")
public class Person {
    
    @Id
    @GeneratedValue
    private String id;

and a (:Image) - [:DEPICTS] -> (:Person) relationship.

I need to count images depicting specific person. This was my attempt:

public interface ImageRepositoryNeo4j extends Neo4jRepository<Image, String> {
@Query("""
        MATCH (i:Image) -[:DEPICTS] -> (p:Person)
        WHERE p = $person
        RETURN count(i)
        """)
    // DSNT WORK
    public Integer countByPeopleContains(@Param("person") Person person);

but it always returns 0.

Other failed attempts

@Query("""
        MATCH (i:Image)-[:DEPICTS]->(p:Person)
        WHERE p IN $people
        RETURN count(i)
        """)
    // DOESN'T WORK, 0 RESULTS
    public Integer countByPeopleContains(@Param("people") Collection<Person> people);

@Query("""
        MATCH (i:Image) -[:DEPICTS] -> (p:Person)
        WHERE ID(p) = $person.__id__
        RETURN count(i)
        """)
    // DSNT WORK
    public Integer countByPeopleContains(@Param("person") Person person);

@Query("""
        MATCH (i:Image) -[:DEPICTS] -> (p:Person)
        WHERE id(p)=$person
        RETURN count(i)
        """)
    // DSNT WORK
    public Integer countByPeopleContains(@Param("person") String personId);

@Query("""
        MATCH (i:Image) -[:DEPICTS] -> (p:Person {id: $person.__id__})
        RETURN count(i)
        """)
    // DSNT WORK, ALWAYS 0
    public Integer countByPeopleContains(@Param("person") Person person);

All of them return 0.

And this one works, but since fullName isn't unique property, it is not suitable by domain logic:

@Query("""
        MATCH (i:Image) -[:DEPICTS] -> (p:Person {fullName: $person})
        RETURN count(i)
        """)
    // TODO: personFullname is not unique, use smth else
    public Integer countByPeopleContains(@Param("person") String personFullName);

How do I use domain object in custom Cypher query in Spring Data Neo4j Repository?

Clue

In Spring Data Neo4j documentation closest what I've found to what I need is this:

@Node
public final class Movie {

    @Id
    private final String title;
...
interface MovieRepository extends Neo4jRepository<Movie, String> {

    @Query("MATCH (m:Movie {title: $movie.__id__})\n"
           + "MATCH (m) <- [r:DIRECTED|REVIEWED|ACTED_IN] - (p:Person)\n"
           + "return m, collect(r), collect(p)")
    Movie findByMovie(@Param("movie") Movie movie);
}

but for some reason it doesn't work in my case. Maybe that's because my id is @GeneratedValue.

Another clue

In Neo4j embedded docs there is an example on how to pass an object to Cypher without Spring Data Neo4j:

Map<String,Object> params = new HashMap<>();
params.put( "node", bobNode );

String query =
    "MATCH (n:Person)" + "\n" +
    "WHERE n = $node" + "\n" +
    "RETURN n.name";

Result result = transaction.execute( query, params );

It works wif $node is org.neo4j.graphdb.Node:

@Bean
CommandLineRunner countForGregWithoutSDN(GraphDatabaseService graphDatabaseService) {
    return args -> {
        try (var tx = graphDatabaseService.beginTx()) {
            String cypherGetGreg = "MATCH (n:Person {birthday: date('1993-03-03')}) RETURN n LIMIT 5;";
            Result result = tx.execute(cypherGetGreg);
            var greg1993 = (Node) result.next().get("n");
            if (!greg1993.getProperty("name").equals("Greg")) 
                throw new RuntimeException("Should be Greg1993 but actually "+greg1993);
            result.close();

            Map<String,Object> params = new HashMap<>();
            params.put( "node", greg1993 );

            String cypherCountForGreg = """
                MATCH (i:Image) -[:DEPICTS] -> (p:Person)
                WHERE p = $node
                RETURN count(i)
                """;
            result = tx.execute( cypherCountForGreg, params );
            var count = result.next().values().iterator().next();
            System.out.println("Count for Greg1993 is "+count); // 1
            result.close();
        }
    };
}

but it results in 0 if $node is SDN Entity.

3

There are 3 answers

5
Vincent Rupp On

I suspect the problem lies in evaluating node equality.

Instead of WHERE p = $person try WHERE id(p) = id($person)

UPDATE: Based on this post, I could be mistaken: Should Nodes be compared directly or they must be going through ID() first?

4
Cugomastik On

There are 2 ways of passing fields.

Note: I used Spring Boot 3.2, SDN v7.2.2 and Neo4j v5.

  1. Use a Map without saving the Node:

Model:

@Setter
@Getter
@Node
public class Car {

    @Id
    @GeneratedValue
    private Long id;

    @Property(name = "model")
    private String model;

    public Car(){} // have an empty constructor to save
}

Repository class:

public interface CarRepository extends Neo4jRepository<Car, Long> {
    
    @Query("WITH $car AS c " +
            "RETURN c['model'] AS model")
    String getCarModel(@Param("car") Map<String, Object> carMap); 
}

Testing:

 private void testCar() {
        Car car = new Car();
        car.setModel("Tesla Model S");

        Map<String, Object> carMap = new HashMap<>();
        carMap.put("model", car.getModel());
        String model = carRepository.getCarModel(carMap);
        System.out.println("\nModel: " + model);

    }
  1. Save the Node and use Id to access the parameters

Repository: The important part is to use __id__ here. ".id" did not work in tests.

 public interface CarRepository extends Neo4jRepository<Car, Long> {
        @Query("MATCH (c:car) WHERE id(c) = $car.__id__ " +
                "RETURN c.model as model")
        String getCarModel(@Param("car") Car car);
    }

Testing:

    private void testCar() {
        Car car = new Car();
        car.setModel("Tesla Model S");
    
        carRepository.save(car); //this will save it with an ID
        System.out.println("\nModel: " + 
        carRepository.getCarModel(car));
        carRepository.delete(car);//if you want
    }
0
Podbrushkin On

Solution

public interface ImageRepositoryNeo4j extends Neo4jRepository<Image, String> {
@Query("""
    MATCH (i:Image) -[:DEPICTS] -> (p:Person)
    WHERE id(p) = toInteger(split($person.__id__, ':')[-1])
    RETURN count(i)
    """)
public Integer countImagesDepictingPerson(@Param("person") Person person);

Ugly, but it works. Note: Person.id is of type String. Maybe you wouldn't need all this hassle if your Person.id is of type Integer or Long.

Explanation

When we have

@Node("Person")
public class Person {
    
    @Id
    @GeneratedValue
    private String id;

This is what exactly neo4j generates as an id:

var freshPerson = personRepository.findOneByBirthday(LocalDate.of(1993,3,3));
log.info("freshPerson.getId()={}", freshPerson.getId());
// freshPerson.getId()=4:a8bd1c80-0f61-4a2b-b094-7e7aed736caf:2

As you can see, freshPerson.id is a long string.

But if you will call this cypher:

@Query("""
    MATCH (p:Person {name: 'Roy'})
    RETURN id(p)
    """)
public Integer getRoyIdInt();

You will get an Integer (and a warning message about id() function being deprecated).

And if you will call this cypher:

@Query("""
    MATCH (p:Person {name: 'Roy'})
    RETURN p.id
    """)
public String getRoyId();

You will get null instead of String and this warning:

WARN 7164 --- [           main] o.s.data.neo4j.cypher.unrecognized       : Neo.ClientNotification.Statement.UnknownPropertyKeyWarning: The provided property key is not in the database
        MATCH (p:Person {name: 'Roy'})
        RETURN p.id
                 ^
    One of the property names in your query is not available in the database, make sure you didn't misspell it or that the label is available when you run this statement in your application (the missing property name is: id)

This is unexpected, because obviously we have id property in our Person class, even though it is annotated with @Id @GeneratedValue.

Integer in the end of 4:a8bd1c80-0f61-4a2b-b094-7e7aed736caf:2 is actually what id() function returns, so we can extract it and find a node by it. This is exactly what is happening in Solution section.

Also we can extract it in java, check out last section. I've made it verbose so it will be clear what is going on.

Solution 2

Declare neo4j repo method which takes Integer as an arg:

@Query("""
    MATCH (i:Image) -[:DEPICTS] -> (p:Person)
    WHERE id(p) = $id
    RETURN count(i)
    """)
public Integer countImagesDepictingPersonWithId(@Param("id") Integer id);

Use this method:

// Create Greg with birthday:
Person greg = new Person("Greg", LocalDate.of(1993,3,3));
greg = personRepository.save(greg);

// Create image depicting Greg:
var img2 = new Image("/blabla2.jpg");
img2.getPeople().add(greg);

// Find someone by birthday (we know it's Greg):
var freshPerson = personRepository.findOneByBirthday(LocalDate.of(1993,3,3));

// Extract Integer id from String id:
String[] tokens = freshPerson.getId().split(":");
String lastToken = tokens[tokens.length - 1];
Integer gregId = Integer.parseInt(lastToken);

// Count images depicting Greg:
var count = imageRepository.countImagesDepictingPersonWithId(gregId);
log.info("Greg1993 is depicted in {} images.", count); //1

We just moved String splitting logic from Cypher to Java.