Docs
View as markdown

Embed relate in a Java app

A first Java program on the com.relate:relate client, which opens a store, passes parameters, runs transactions, reads result metadata, and catches errors.

java

By the end of this page, you'll have a Java program that keeps a small graph of people and projects in one file. It reads the graph with parameters and reports errors by their class and code. Along the way we write inside a transaction A unit of work that commits as a whole or not at all; each statement is its own transaction unless an explicit begin … commit groups several. Read more in the glossary, roll one back, and read what a result says about itself. It takes about fifteen minutes. relate runs inside your JVM, so there is no server to start, and every step writes to a store it creates.

Prerequisites

  • Java 17 or later, and Maven or Gradle to add the client to a build. On Java 24 or later, pass --enable-native-access=ALL-UNNAMED to java, or the first call prints a restricted-method warning.
  • A terminal and a text editor. The relate CLI is not needed: the jar carries the engine.

Install the client

The Java client A language package that runs the relate engine inside a program's process: the Python, Node.js, Rust, Java, Go and Swift clients. Read more in the glossary is the Maven artifact com.relate:relate, with the package com.relate. Its jar bundles the native engine for macOS (arm64 and x86-64), Linux (arm64 and x86-64, glibc), and Windows (x86-64), and loads the right one through JNA (Java Native Access) with no library path to set. The jars are attached to each release on the releases page. The client needs two libraries that the jar does not include, JNA (net.java.dev.jna:jna) and Jackson (com.fasterxml.jackson.core:jackson-databind). In a Maven or Gradle build, the coordinate brings both with it (<release> is the release version). In Maven:

<dependency>
  <groupId>com.relate</groupId>
  <artifactId>relate</artifactId>
  <version><release></version>
</dependency>

In Gradle (Kotlin DSL):

dependencies { implementation("com.relate:relate:<release>") }

Each program on this page is one source file, run with java on a classpath that holds the client jar, JNA, and jackson-databind (your build's runtime classpath). The client has no version constant of its own, so this program asks the engine which release it is:

import com.relate.Database;

public class Version {
    public static void main(String[] args) {
        try (Database db = Database.open(":memory:");
             Database.Connection conn = db.connect()) {
            var result = conn.query(
                "CALL dbms.components() YIELD name, versions "
                    + "WHERE name = 'Relate' RETURN versions[0] AS version");
            System.out.println(result.rows().get(0).get(0));
        }
    }
}
0.1.2

The first Database.open() loads the native engine, so a platform the jar doesn't cover fails at this step, with an error naming the platform it detected. Notice the version: the programs on this page were checked against that release, and their recorded output comes from it.

Open a store

A store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary is where relate keeps a graph. :memory: is a store that lives in the process and forgets everything when it closes. A path ending in .rl is a single-file store A store kept in one .rl file that holds the snapshot and the WAL together; the layout for a --database path ending in .rl. Read more in the glossary, which Database.open() creates when nothing is there yet.

import java.io.File;

import com.relate.Database;

public class Open {
    public static void main(String[] args) {
        try (Database memory = Database.open(":memory:");
             Database.Connection conn = memory.connect()) {
            var result = conn.query("RETURN 'hello' AS greeting");
            System.out.println(result.rows().get(0).get(0));
        }

        try (Database team = Database.open("./team.rl")) {
            System.out.println(new File("team.rl").exists());
        }
    }
}
hello
true

Database.open() opens a store, connect() returns a connection on it, and query() runs one statement and returns its whole result. Every call is synchronous. Database and Database.Connection are AutoCloseable, and closing them is what lets the same JVM open a file store again, so the programs on this page open them with try-with-resources. Notice that team.rl now exists.

Create the graph

Our graph is three people and two projects: who works on what, since when, and who maintains relate. One CREATE statement writes all of it. The rest of this page uses the same statement, as TEAM, on an in-memory store, so each program runs on its own.

import com.relate.Database;

public class Create {
    static final String TEAM = """
        CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
               (grace:Person {name: 'Grace', role: 'engineer'}),
               (noor:Person {name: 'Noor', role: 'maintainer'}),
               (relate:Project {name: 'relate', language: 'Rust'}),
               (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
               (ada)-[:WORKS_ON {since: 2024}]->(relate),
               (grace)-[:WORKS_ON {since: 2023}]->(relate),
               (grace)-[:WORKS_ON {since: 2022}]->(tck),
               (noor)-[:MAINTAINS]->(relate),
               (ada)-[:KNOWS]->(grace)
        """;

    public static void main(String[] args) {
        try (Database db = Database.open(":memory:");
             Database.Connection conn = db.connect()) {
            conn.query(TEAM);
            var nodes = conn.query("MATCH (n) RETURN count(n) AS n");
            var rels = conn.query("MATCH ()-[r]->() RETURN count(r) AS n");
            System.out.println("nodes: " + nodes.rows().get(0).get(0));
            System.out.println("relationships: " + rels.rows().get(0).get(0));
        }
    }
}
nodes: 5
relationships: 5

A write returns no rows, and a Java result carries no change counters, so we count what's there. Five nodes and five relationships: the whole team.

Read it back with parameters

A statement names each value it needs as $name, and query() takes the values as a Map<String, ?>. The statement text stays the same while the values change, and a value never becomes part of the Cypher.

import java.util.List;
import java.util.Map;

import com.relate.Database;

public class Params {
    static final String TEAM = """
        CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
               (grace:Person {name: 'Grace', role: 'engineer'}),
               (noor:Person {name: 'Noor', role: 'maintainer'}),
               (relate:Project {name: 'relate', language: 'Rust'}),
               (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
               (ada)-[:WORKS_ON {since: 2024}]->(relate),
               (grace)-[:WORKS_ON {since: 2023}]->(relate),
               (grace)-[:WORKS_ON {since: 2022}]->(tck),
               (noor)-[:MAINTAINS]->(relate),
               (ada)-[:KNOWS]->(grace)
        """;

    static final String TEAM_OF = """
        MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project})
        RETURN p.name AS name, w.since AS since
        ORDER BY since
        """;

    public static void main(String[] args) {
        try (Database db = Database.open(":memory:");
             Database.Connection conn = db.connect()) {
            conn.query(TEAM);
            for (String project : List.of("relate", "opencypher-tck")) {
                System.out.println(project);
                for (List<Object> row : conn.query(TEAM_OF, Map.of("project", project)).rows()) {
                    String name = (String) row.get(0);
                    long since = (Long) row.get(1);
                    System.out.println("  " + name + " since " + since);
                }
            }
        }
    }
}
relate
  Grace since 2023
  Ada since 2024
opencypher-tck
  Grace since 2022

Each row is a List<Object> in the order of the RETURN columns, and each value arrives as its Java type: a Cypher string as a String, an integer as a Long. Notice that Noor isn't listed: she maintains relate through a MAINTAINS relationship, and the pattern asks for WORKS_ON.

Change it in a transaction

A connection commits each statement on its own until you call begin(). From then on, every statement is part of one transaction, commit() keeps it, and rollback() throws it away. This program adds Lin to the team and commits, then deletes every project and rolls back.

import com.relate.Database;

public class Transaction {
    static final String TEAM = """
        CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
               (grace:Person {name: 'Grace', role: 'engineer'}),
               (noor:Person {name: 'Noor', role: 'maintainer'}),
               (relate:Project {name: 'relate', language: 'Rust'}),
               (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
               (ada)-[:WORKS_ON {since: 2024}]->(relate),
               (grace)-[:WORKS_ON {since: 2023}]->(relate),
               (grace)-[:WORKS_ON {since: 2022}]->(tck),
               (noor)-[:MAINTAINS]->(relate),
               (ada)-[:KNOWS]->(grace)
        """;

    static long count(Database.Connection conn, String label) {
        var result = conn.query("MATCH (n:" + label + ") RETURN count(n) AS n");
        return (Long) result.rows().get(0).get(0);
    }

    public static void main(String[] args) {
        try (Database db = Database.open(":memory:");
             Database.Connection conn = db.connect()) {
            conn.query(TEAM);

            conn.begin();
            conn.query("MATCH (tck:Project {name: 'opencypher-tck'}) "
                + "CREATE (:Person {name: 'Lin', role: 'engineer'})-[:WORKS_ON {since: 2026}]->(tck)");
            String bookmark = conn.commit();
            System.out.println("committed: " + (bookmark != null));
            System.out.println("people after commit: " + count(conn, "Person"));

            conn.begin();
            conn.query("MATCH (p:Project) DETACH DELETE p");
            System.out.println("projects inside the transaction: " + count(conn, "Project"));
            conn.rollback();
            System.out.println("projects after rollback: " + count(conn, "Project"));
        }
    }
}
committed: true
people after commit: 4
projects inside the transaction: 0
projects after rollback: 2

Inside the transaction, the delete is visible: zero projects. After rollback(), both are back, and Lin, who was committed, stays. commit() returns a bookmark The marker a commit returns, relate:<n>, naming its place in the store's commit sequence; returned by the clients and over Bolt. Read more in the glossary, the commit's place in the store's history as relate:<n>, or null when the transaction wrote nothing. Each transaction starts with its own begin().

Read result metadata

A Result describes itself as well as holding rows: the column names, what kind of statement it was, and, for a write outside a transaction, the bookmark of its commit.

import java.util.Map;

import com.relate.Database;

public class Metadata {
    static final String TEAM = """
        CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
               (grace:Person {name: 'Grace', role: 'engineer'}),
               (noor:Person {name: 'Noor', role: 'maintainer'}),
               (relate:Project {name: 'relate', language: 'Rust'}),
               (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
               (ada)-[:WORKS_ON {since: 2024}]->(relate),
               (grace)-[:WORKS_ON {since: 2023}]->(relate),
               (grace)-[:WORKS_ON {since: 2022}]->(tck),
               (noor)-[:MAINTAINS]->(relate),
               (ada)-[:KNOWS]->(grace)
        """;

    public static void main(String[] args) {
        try (Database db = Database.open(":memory:");
             Database.Connection conn = db.connect()) {
            conn.query(TEAM);

            var result = conn.query(
                "MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project}) "
                    + "RETURN p.name AS name, w.since AS since ORDER BY since",
                Map.of("project", "relate"));
            System.out.println(result.columns());
            var first = result.rows().get(0);
            for (int i = 0; i < first.size(); i++) {
                System.out.println(result.columns().get(i) + " " + first.get(i).getClass().getSimpleName());
            }
            System.out.println("query type: " + result.getQueryType());

            var write = conn.query("MATCH (n:Person {name: 'Noor'}) SET n.role = 'lead'");
            System.out.println("query type: " + write.getQueryType());
            System.out.println("bookmark: " + (write.getBookmark() != null));
        }
    }
}
[name, since]
name String
since Long
query type: READ
query type: WRITE
bookmark: true

A result has no list of column types: each value carries its own Java type. A node arrives as a com.relate.Node record with its labels and properties. getQueryType() is r for a read and w for a write that returns no columns. getBookmark() is set on a write that committed on its own, and null for a read.

Catch an error

Every error the engine reports throws a subclass of com.relate.RelateException, an unchecked exception, and the subclass says what kind of problem it is. A mistake in the statement throws ClientException. A statement that uses $name without a value is rejected before it runs:

import java.util.Map;

import com.relate.ClientException;
import com.relate.Database;

public class Errors {
    static final String TEAM = """
        CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
               (grace:Person {name: 'Grace', role: 'engineer'}),
               (noor:Person {name: 'Noor', role: 'maintainer'}),
               (relate:Project {name: 'relate', language: 'Rust'}),
               (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
               (ada)-[:WORKS_ON {since: 2024}]->(relate),
               (grace)-[:WORKS_ON {since: 2023}]->(relate),
               (grace)-[:WORKS_ON {since: 2022}]->(tck),
               (noor)-[:MAINTAINS]->(relate),
               (ada)-[:KNOWS]->(grace)
        """;

    static final String ROLE_OF = "MATCH (p:Person {name: $name}) RETURN p.role AS role";

    public static void main(String[] args) {
        try (Database db = Database.open(":memory:");
             Database.Connection conn = db.connect()) {
            conn.query(TEAM);

            try {
                conn.query(ROLE_OF);
            } catch (ClientException e) {
                System.out.println(e.getClass().getSimpleName());
                System.out.println(e.getMessage());
                System.out.println("gqlStatus: " + e.getGqlStatus());
                System.out.println("code: " + e.getCode());
                System.out.println("stage: " + e.getStage());
                System.out.println("retryable: " + e.isRetryable());
            }

            var result = conn.query(ROLE_OF, Map.of("name", "Noor"));
            System.out.println(result.rows().get(0).get(0));
        }
    }
}
ClientException
semantic error: ParameterMissing: Expected parameter(s): name
gqlStatus: 42N02
code: Neo.ClientError.Statement.ParameterMissing
stage: COMPILE
retryable: false
maintainer

The message says what's missing; the accessors are what code branches on:

AccessorThis errorMeaning
getGqlStatus()42N02The GQLSTATUS code; the Python and Node.js clients report the same code for the same error
getCode()Neo.ClientError.Statement.ParameterMissingThe Neo4j status code for the same error
getStage()COMPILEWhere it was found: COMPILE before any work, RUNTIME while producing rows, INTERNAL for a failure outside the statement, such as a locked store
isRetryable()falseWhether running it again can succeed; true for a write-write conflict, never for a mistake in the statement

The other subclasses are TransientException, thrown exactly when isRetryable() is true, and DatabaseException, for a failure that isn't the statement's fault. The errors reference lists this message with its cause and remedy. With the value bound, the same statement finds Noor's role.

Put it together

Team.java puts the steps together in one program. It opens team.rl, creates the graph only when the store has no people yet, and prints who works on each project. A relate error anywhere ends it with the error's code and a nonzero exit status.

Team.java
import java.util.List;
import java.util.Map;

import com.relate.Database;
import com.relate.RelateException;

public class Team {
    static final String TEAM = """
        CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
               (grace:Person {name: 'Grace', role: 'engineer'}),
               (noor:Person {name: 'Noor', role: 'maintainer'}),
               (relate:Project {name: 'relate', language: 'Rust'}),
               (tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
               (ada)-[:WORKS_ON {since: 2024}]->(relate),
               (grace)-[:WORKS_ON {since: 2023}]->(relate),
               (grace)-[:WORKS_ON {since: 2022}]->(tck),
               (noor)-[:MAINTAINS]->(relate),
               (ada)-[:KNOWS]->(grace)
        """;

    static final String TEAM_OF = """
        MATCH (p:Person)-[w:WORKS_ON]->(:Project {name: $project})
        RETURN p.name AS name, w.since AS since
        ORDER BY since
        """;

    static void ensureTeam(Database.Connection conn) {
        long people = (Long) conn.query("MATCH (p:Person) RETURN count(p) AS n").rows().get(0).get(0);
        if (people == 0) {
            conn.query(TEAM);
            System.out.println("created the team graph");
        } else {
            System.out.println("found " + people + " people");
        }
    }

    static void report(Database.Connection conn, String project) {
        System.out.println(project);
        for (List<Object> row : conn.query(TEAM_OF, Map.of("project", project)).rows()) {
            System.out.println("  " + row.get(0) + " since " + row.get(1));
        }
    }

    public static void main(String[] args) {
        try (Database db = Database.open("./team.rl");
             Database.Connection conn = db.connect()) {
            ensureTeam(conn);
            report(conn, "relate");
            report(conn, "opencypher-tck");
        } catch (RelateException e) {
            System.err.println("relate error " + e.getGqlStatus() + ": " + e.getMessage());
            System.exit(1);
        }
    }
}
created the team graph
relate
  Grace since 2023
  Ada since 2024
opencypher-tck
  Grace since 2022

Run it with java -cp <classpath> Team.java, where <classpath> holds the client jar, JNA, and jackson-databind, or as the main class of your build. Each statement is durable in team.rl when query() returns, and try-with-resources closes the connection and the store at the end. Run it a second time and it prints found 3 people instead of creating the graph again, then the same report. If another process has team.rl open for writing, Database.open() throws a DatabaseException, and the program prints relate error 42000: internal error and exits with status 1.

To read the file with no chance of changing it, open it read-only: Database.openReadOnly("./team.rl"). A write then throws a ClientException with GQLSTATUS 42N18, and a path with no store behind it throws instead of creating a new store.

What you built

You have a Java program that keeps a graph of people and projects in team.rl, one file you can copy or archive. It reads with parameters, writes inside transactions, knows a read from a write by its query type, and reports errors by class and GQLSTATUS code. Ada, Grace, and Noor are waiting in that file for the next run.

Next