User-defined procedures

A user-defined procedure is a mechanism that enables you to extend Neo4j by writing customized code, which can be invoked directly from Cypher. Procedures can take arguments, perform operations on the database, and return results. For a comparison between user-defined procedures, functions, and aggregation functions see Neo4j customized code.

User-defined procedures requiring execution on the system database need to include the annotation @SystemProcedure or they will be classed as a user database procedure.

Call a procedure

To call a user-defined procedure, use a Cypher CALL clause. The procedure name must be fully qualified, so a procedure named findDenseNodes defined in the package org.neo4j.examples could be called using:

CALL org.neo4j.examples.findDenseNodes(1000)

CALL may be the only clause within a Cypher statement or may be combined with other clauses. Arguments can be supplied directly within the query or taken from the associated parameter set. For full details, see the documentation in Cypher Manual → CALL procedure.

Create a procedure

Make sure you have read and followed the preparatory setup instructions in Setting up a plugin project.

The example discussed below is available as a repository on GitHub. To get started quickly you can fork the repository and work with the code as you follow along in the guide below.

First, decide what the procedure should do, then write a test that proves that it does it right. Finally, write a procedure that passes the test.

Integration tests

The test dependencies include Neo4j Harness and JUnit. These can be used to write integration tests for procedures. The tests should start a Neo4j instance, load the procedure, and execute queries against it.

An example using JUnit 5 for testing a procedure that returns relationship types found in the graph.
package example;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.neo4j.driver.Driver;
import org.neo4j.driver.GraphDatabase;
import org.neo4j.driver.Record;
import org.neo4j.driver.Result;
import org.neo4j.driver.Session;
import org.neo4j.driver.Value;
import org.neo4j.harness.Neo4j;
import org.neo4j.harness.Neo4jBuilders;

import static org.assertj.core.api.Assertions.assertThat;

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class GetRelationshipTypesTests {

    private Driver driver;
    private Neo4j embeddedDatabaseServer;

    @BeforeAll
    void initializeNeo4j() {
        this.embeddedDatabaseServer = Neo4jBuilders.newInProcessBuilder()
                .withDisabledServer()
                .withProcedure(GetRelationshipTypes.class)
                .build();

        this.driver = GraphDatabase.driver(embeddedDatabaseServer.boltURI());
    }

    @AfterAll
    void closeDriver(){
        this.driver.close();
        this.embeddedDatabaseServer.close();
    }

    @AfterEach
    void cleanDb(){
        try(Session session = driver.session()) {
            session.run("MATCH (n) DETACH DELETE n");
        }
    }

    /**
     * We should be getting the correct values when there is only one type in each direction
     */
    @Test
    public void shouldReturnTheTypesWhenThereIsOneEachWay() {
        final String expectedIncoming = "INCOMING";
        final String expectedOutgoing = "OUTGOING";

        // In a try-block, to make sure we close the session after the test
        try(Session session = driver.session()) {

            //Create our data in the database.
            session.run(String.format("CREATE (:Person)-[:%s]->(:Movie {id:1})-[:%s]->(:Person)", expectedIncoming, expectedOutgoing));

            //Execute our procedure against it.
            Record record = session.run("MATCH (u:Movie {id:1}) CALL example.getRelationshipTypes(u) YIELD outgoing, incoming RETURN outgoing, incoming").single();

            //Get the incoming / outgoing relationships from the result
            assertThat(record.get("incoming").asList(Value::asString)).containsOnly(expectedIncoming);
            assertThat(record.get("outgoing").asList(Value::asString)).containsOnly(expectedOutgoing);
        }
    }
}

The previous example uses JUnit 5, which requires the use of org.neo4j.harness.junit.extension.Neo4jExtension. If you want to use JUnit 4 with Neo4j 4.x or 5, use org.neo4j.harness.junit.rule.Neo4jRule instead.

Define a procedure

With the test in place, write a procedure that fulfills the expectations of the test. The full example is available in the Neo4j Procedure Template repository.

Particular things to note:

  • All procedures are annotated @Procedure.

  • The procedure annotation can take three optional arguments: name, mode, and eager.

    • name is used to specify a different name for the procedure than the default generated, which is class.path.nameOfMethod. If mode is specified, name must be specified as well.

    • mode is used to declare the types of interactions that the procedure performs. A procedure fails if it attempts to execute database operations that violate its mode. The default mode is READ. The following modes are available:

      • READ — This procedure only performs read operations against the graph.

      • WRITE — This procedure performs read and write operations against the graph.

      • SCHEMA — This procedure performs operations against the schema, i.e. create and drop indexes and constraints. A procedure with this mode can read graph data, but not write.

      • DBMS — This procedure performs system operations such as user management and query management. A procedure with this mode is not able to read or write graph data.

    • eager is a boolean setting defaulting to false. If it is set to true, the Cypher planner plans an extra eager operation before and after calling the procedure. This is useful in cases where the procedure makes changes to the database in a way that could interact with the operations preceding or following the procedure. For example:

      MATCH (n)
      WHERE n.key = 'value'
      WITH n
      CALL deleteNeighbours(n, 'FOLLOWS')

      This query can delete some of the nodes that are matched by the Cypher query, and the n.key lookup will fail. Marking this procedure as eager prevents this from causing an error in Cypher code. However, it is still possible for the procedure to interfere with itself by trying to read entities it has previously deleted. It is the responsibility of the procedure author to handle that case.

  • The context of the procedure, which is the same as each resource that the procedure wants to use, is annotated @Context.

The correct way to signal an error from within a procedure is to throw RuntimeException.

Injectable resources

When writing procedures, some resources can be injected into the procedure from the database. To inject these, use the @Context annotation. The classes that can be injected are:

  • Log

  • TerminationGuard

  • GraphDatabaseService

  • Transaction

All of the above classes are considered safe and future-proof and do not compromise the security of the database. Several unsupported (restricted) classes can also be injected and can be changed with little or no notice. Procedures written to use these restricted APIs are not loaded by default, and you need to use the dbms.security.procedures.unrestricted to load unsafe procedures. Read more about this config setting in Operations Manual → Securing extensions.