Spring Data LadybugDB is a Spring Data-like integration framework for LadybugDB, providing familiar Spring patterns for graph database operations.

1. Overview

1.1. Architecture

The framework consists of several core components:

+------------------+     +--------------------+     +-------------------+
|   Application    | --> | LadybugDBTemplate  | --> | ConnectionFactory |
+------------------+     +--------------------+     +-------------------+
         |                       |                          |
         v                       v                          v
+------------------+     +--------------------+     +-------------------+
|   Repositories   |     |   EntityRegistry   |     |    Database       |
+------------------+     +--------------------+     +-------------------+

1.2. Core Components

1.2.1. LadybugDBTemplate

The central class for executing Cypher queries. Provides methods for:

  • execute() - Execute write operations

  • query() - Execute queries returning lists

  • queryForObject() - Execute queries returning single results

  • stream() - Memory-efficient streaming for large result sets

1.2.2. NodeRepository

A Spring Data-style repository interface providing CRUD operations:

  • save(), saveAll() - Persist entities

  • findById(), findAll(), findAllById() - Retrieve entities

  • delete(), deleteById(), deleteAll() - Remove entities

  • count(), existsById() - Utility methods

1.2.3. Connection Factories

Two implementations for connection management:

  • SimpleConnectionFactory - Creates new connections per request

  • PooledConnectionFactory - Connection pooling with Apache Commons Pool2

1.2.4. Entity Mapping

Annotation-based mapping for graph entities:

  • @NodeEntity - Marks a class as a node entity

  • @RelationshipEntity - Marks a class as a relationship entity

  • @Id - Marks the primary key field

1.3. Dependencies

The framework requires:

  • Java 21+

  • Spring Framework 6.x

  • LadybugDB Native Library (com.ladybugdb:lbug)

  • Neo4j Cypher DSL (org.neo4j:neo4j-cypher-dsl)

  • Apache Commons Pool2 (org.apache.commons:commons-pool2)

2. Getting Started

2.1. Installation

This section covers how to add Spring Data LadybugDB to your project. The framework requires the main library dependency, the LadybugDB native library, and optionally the Cypher DSL and connection pooling libraries.

Maven Dependency

Add the following dependency to your pom.xml:

<dependency>
    <groupId>com.thecookiezen</groupId>
    <artifactId>spring-data-ladybugdb</artifactId>
    <version>0.0.2</version>
</dependency>
Required Dependencies

Ensure you have the following dependencies in your project:

LadybugDB Native Library
<dependency>
    <groupId>com.ladybugdb</groupId>
    <artifactId>lbug</artifactId>
    <version>0.15.1</version>
</dependency>
Neo4j Cypher DSL (Optional)

For type-safe query building:

<dependency>
    <groupId>org.neo4j</groupId>
    <artifactId>neo4j-cypher-dsl</artifactId>
    <version>2025.2.4</version>
</dependency>
Apache Commons Pool2 (Optional)

For connection pooling:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.13.1</version>
</dependency>
System Requirements
  • Java: 21 or higher

  • Spring Framework: 6.x

  • LadybugDB: 0.15.0 or higher

2.2. Quick Start

This guide will get you started with Spring Data LadybugDB in 5 minutes. By the end, you’ll be able to:

  • Create and configure a connection to LadybugDB

  • Define entity classes with annotations

  • Execute Cypher queries using the template

  • Perform CRUD operations with repositories

The framework follows familiar Spring patterns - if you’ve used Spring Data JPA or Spring Data Neo4j, you’ll feel right at home.

Create a Database Instance

First, create a LadybugDB database instance:

import com.ladybugdb.Database;

Database database = new Database(":memory:");  // In-memory database
// Or: new Database("/path/to/database");      // Persistent database
Create a Connection Factory

For development, use SimpleConnectionFactory:

import com.thecookiezen.ladybugdb.spring.connection.SimpleConnectionFactory;

SimpleConnectionFactory connectionFactory = new SimpleConnectionFactory(database);

For production, use PooledConnectionFactory:

import com.thecookiezen.ladybugdb.spring.connection.PooledConnectionFactory;

PooledConnectionFactory connectionFactory = new PooledConnectionFactory(database);
Create the Template

Create a LadybugDBTemplate with an EntityRegistry:

import com.thecookiezen.ladybugdb.spring.core.LadybugDBTemplate;
import com.thecookiezen.ladybugdb.spring.repository.support.EntityRegistry;

EntityRegistry registry = new EntityRegistry();
LadybugDBTemplate template = new LadybugDBTemplate(connectionFactory, registry);
Define Your Entity

Create an entity class with the @NodeEntity annotation:

import com.thecookiezen.ladybugdb.spring.annotation.NodeEntity;
import org.springframework.data.annotation.Id;

@NodeEntity(label = "Person")
public class Person {
    @Id
    private String name;
    private int age;

    // constructors, getters, setters
    public Person() {}

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // getters and setters...
}
Register Entity Mappers

Register the entity descriptor with mappers for reading and writing:

import com.thecookiezen.ladybugdb.spring.mapper.RowMapper;
import com.thecookiezen.ladybugdb.spring.mapper.EntityWriter;
import com.thecookiezen.ladybugdb.spring.mapper.ValueMappers;
import com.thecookiezen.ladybugdb.spring.repository.support.EntityDescriptor;
import java.util.Map;

RowMapper<Person> reader = (row) -> {
    var p = row.getNode("n");
    return new Person(
        ValueMappers.asString(p.get("name")),
        ValueMappers.asInteger(p.get("age"))
    );
};

EntityWriter<Person> writer = (entity) -> Map.of("age", entity.getAge());

registry.registerDescriptor(Person.class, new EntityDescriptor<>(Person.class, reader, writer));
Create the Node Table

Execute DDL to create the node table:

template.execute("CREATE NODE TABLE Person(name STRING PRIMARY KEY, age INT64)");
Use the Template

Execute raw Cypher queries:

// Create a person
template.execute("CREATE (p:Person {name: $name, age: $age})",
    Map.of("name", "Alice", "age", 30));

// Query persons
List<Person> people = template.query(
    "MATCH (n:Person) RETURN n",
    Person.class
);
Use the Repository

Create a repository for CRUD operations:

import com.thecookiezen.ladybugdb.spring.repository.support.SimpleNodeRepository;

SimpleNodeRepository<Person, Void, String> repository = new SimpleNodeRepository<>(
    template, Person.class, Void.class,
    registry.getDescriptor(Person.class), null
);

// CRUD operations
Person saved = repository.save(new Person("Bob", 25));
Optional<Person> found = repository.findById("Bob");
repository.deleteById("Bob");
Complete Example

The following example consolidates all the steps above into a single runnable class. It demonstrates the complete workflow from database creation to query execution:

import com.ladybugdb.Database;
import com.thecookiezen.ladybugdb.spring.connection.SimpleConnectionFactory;
import com.thecookiezen.ladybugdb.spring.core.LadybugDBTemplate;
import com.thecookiezen.ladybugdb.spring.repository.support.*;

public class QuickStart {
    public static void main(String[] args) {
        try (Database database = new Database(":memory:");
             SimpleConnectionFactory factory = new SimpleConnectionFactory(database)) {
            EntityRegistry registry = new EntityRegistry();

            // Register mappers (see step 5)
            registry.registerDescriptor(Person.class, personDescriptor);

            LadybugDBTemplate template = new LadybugDBTemplate(factory, registry);

            template.execute("CREATE NODE TABLE Person(name STRING PRIMARY KEY, age INT64)");

            template.execute("CREATE (p:Person {name: 'Alice', age: 30})");

            List<Person> people = template.query("MATCH (n:Person) RETURN n", Person.class);
            System.out.println("Found " + people.size() + " people");
        }
    }
}

2.3. Spring Integration

Spring Data LadybugDB integrates seamlessly with Spring Framework through annotation-based configuration.

Enable LadybugDB Repositories

Use the @EnableLadybugDBRepositories annotation to enable automatic repository detection:

import com.thecookiezen.ladybugdb.spring.config.EnableLadybugDBRepositories;
import com.thecookiezen.ladybugdb.spring.core.LadybugDBTemplate;
import com.thecookiezen.ladybugdb.spring.connection.LadybugDBConnectionFactory;
import com.thecookiezen.ladybugdb.spring.repository.support.EntityRegistry;
import com.thecookiezen.ladybugdb.spring.transaction.LadybugDBTransactionManager;
import org.springframework.context.annotation.*;

@Configuration
@EnableLadybugDBRepositories(basePackages = "com.example.repositories")
public class LadybugDBConfig {

    @Bean
    public Database database() {
        return new Database("/path/to/database");
    }

    @Bean
    public LadybugDBConnectionFactory connectionFactory(Database database) {
        return new PooledConnectionFactory(database);
    }

    @Bean
    public EntityRegistry entityRegistry() {
        EntityRegistry registry = new EntityRegistry();
        // Register entity descriptors
        return registry;
    }

    @Bean
    public LadybugDBTemplate ladybugDBTemplate(
            LadybugDBConnectionFactory connectionFactory,
            EntityRegistry entityRegistry) {
        return new LadybugDBTemplate(connectionFactory, entityRegistry);
    }

    @Bean
    public LadybugDBTransactionManager transactionManager(
            LadybugDBConnectionFactory connectionFactory) {
        return new LadybugDBTransactionManager(connectionFactory);
    }
}
Repository Interface

Define your repository interface extending NodeRepository:

import com.thecookiezen.ladybugdb.spring.annotation.NodeEntity;
import com.thecookiezen.ladybugdb.spring.annotation.Query;
import com.thecookiezen.ladybugdb.spring.repository.NodeRepository;
import org.springframework.data.annotation.Id;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;

@NodeEntity(label = "Person")
public class Person {
    @Id
    private String name;
    private int age;
    // constructors, getters, setters
}

public interface PersonRepository extends NodeRepository<Person, String, Void, Person> {

    @Query("MATCH (n:Person) WHERE n.age > $minAge RETURN n")
    List<Person> findByAgeGreaterThan(@Param("minAge") int minAge);

    @Query("MATCH (n:Person) WHERE n.name = $name RETURN n")
    Optional<Person> findByName(@Param("name") String name);
}
Using Repositories

Inject repositories into your services:

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;

@Service
public class PersonService {

    private final PersonRepository repository;

    public PersonService(PersonRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public Person createPerson(String name, int age) {
        return repository.save(new Person(name, age));
    }

    public Optional<Person> findPerson(String name) {
        return repository.findById(name);
    }

    public List<Person> findAdults() {
        return repository.findByAgeGreaterThan(17);
    }

    @Transactional
    public void deletePerson(String name) {
        repository.deleteById(name);
    }
}
@EnableLadybugDBRepositories Attributes
Attribute Description

basePackages

Packages to scan for repository interfaces

basePackageClasses

Type-safe alternative to basePackages

ladybugDBTemplateRef

Name of the LadybugDBTemplate bean (default: "ladybugDBTemplate")

repositoryImplementationPostfix

Postfix for custom repository implementations (default: "Impl")

queryLookupStrategy

Strategy for query creation (default: CREATE_IF_NOT_FOUND)

Transaction Management

The LadybugDBTransactionManager binds a connection to the current thread for the transaction duration:

import org.springframework.transaction.annotation.Transactional;

@Service
public class PersonService {

    @Transactional
    public void updateMultiplePersons(List<Person> persons) {
        // All operations use the same connection
        for (Person p : persons) {
            repository.save(p);
        }
    }
}
LadybugDB auto-commits each command. The transaction manager provides connection binding only, not atomic transactions. See Transactions and LadybugDB Transaction docs for details.
Connection Pool Configuration

Configure the connection pool with custom settings:

import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import java.time.Duration;

@Bean
public LadybugDBConnectionFactory connectionFactory(Database database) {
    GenericObjectPoolConfig<Connection> config = new GenericObjectPoolConfig<>();
    config.setMaxTotal(20);
    config.setMaxIdle(10);
    config.setMinIdle(5);
    config.setMaxWait(Duration.ofSeconds(60));
    config.setTestOnBorrow(true);
    config.setTestWhileIdle(true);
    config.setTimeBetweenEvictionRuns(Duration.ofMinutes(2));
    config.setMinEvictableIdleDuration(Duration.ofMinutes(10));

    return new PooledConnectionFactory(database, config);
}

3. Core Concepts

3.1. Connection Factories

Connection factories manage connections to the LadybugDB database. The framework provides two implementations:

SimpleConnectionFactory

Creates a new connection for each request and closes it when released. Best for:

  • Development and testing

  • Single-threaded applications

  • Low-traffic scenarios

import com.ladybugdb.Database;
import com.thecookiezen.ladybugdb.spring.connection.SimpleConnectionFactory;

try (Database database = new Database(":memory:");
     SimpleConnectionFactory factory = new SimpleConnectionFactory(database)) {

    // Get a connection
    Connection connection = factory.getConnection();

    try {
        // Use the connection...
        connection.query("MATCH (n) RETURN n");
    } finally {
        // Release the connection (closes it)
        factory.releaseConnection(connection);
    }
}
PooledConnectionFactory

Maintains a pool of connections for efficient reuse. Best for:

  • Production applications

  • Multi-threaded environments

  • High-traffic scenarios

import com.ladybugdb.Database;
import com.thecookiezen.ladybugdb.spring.connection.PooledConnectionFactory;

try (Database database = new Database("/path/to/database");
     PooledConnectionFactory factory = new PooledConnectionFactory(database)) {

    // Get a connection from the pool
    Connection connection = factory.getConnection();

    try {
        // Use the connection...
    } finally {
        // Return the connection to the pool
        factory.releaseConnection(connection);
    }

    // Monitor pool statistics
    int active = factory.getNumActive();  // Currently in use
    int idle = factory.getNumIdle();      // Available in pool
}
Pool Configuration

Customize pool behavior with GenericObjectPoolConfig:

import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import java.time.Duration;

GenericObjectPoolConfig<Connection> config = new GenericObjectPoolConfig<>();
config.setMaxTotal(10);                              // Max connections
config.setMaxIdle(5);                                // Max idle connections
config.setMinIdle(2);                                // Min idle connections
config.setMaxWait(Duration.ofSeconds(30));           // Max wait time
config.setTestOnBorrow(true);                        // Validate on borrow
config.setTestOnReturn(false);                       // Validate on return
config.setTestWhileIdle(true);                       // Validate idle connections
config.setTimeBetweenEvictionRuns(Duration.ofMinutes(1)); // Eviction check interval
config.setMinEvictableIdleDuration(Duration.ofMinutes(5)); // Min idle time before eviction

PooledConnectionFactory factory = new PooledConnectionFactory(database, config);
Default Configuration
Setting Default Description

maxTotal

10

Maximum number of connections

maxIdle

5

Maximum idle connections in pool

minIdle

2

Minimum idle connections maintained

maxWait

30 seconds

Maximum wait time when pool is exhausted

testOnBorrow

true

Validate connections when borrowed

testWhileIdle

true

Validate idle connections periodically

timeBetweenEvictionRuns

1 minute

Interval between eviction runs

minEvictableIdleDuration

5 minutes

Minimum idle time before eviction

LadybugDBConnectionFactory Interface

Both implementations implement LadybugDBConnectionFactory:

public interface LadybugDBConnectionFactory {

    // Get a connection
    Connection getConnection();

    // Release a connection back to factory
    void releaseConnection(Connection connection);

    // Get the underlying database
    Database getDatabase();

    // Close the factory and release resources
    void close();
}
Resource Management

Always close connections and factories when done:

try (Database database = new Database(":memory:");
     SimpleConnectionFactory factory = new SimpleConnectionFactory(database)) {
    LadybugDBTemplate template = new LadybugDBTemplate(factory, registry);
    // Use template...
}

Or with Spring’s @PreDestroy:

@Bean(destroyMethod = "close")
public PooledConnectionFactory connectionFactory(Database database) {
    return new PooledConnectionFactory(database);
}

3.2. LadybugDBTemplate

The LadybugDBTemplate is the central class for executing Cypher queries. It provides:

  • Automatic connection management

  • Parameterized query support

  • Result mapping through RowMapper

  • Memory-efficient streaming for large results

  • Transaction-aware connection binding

Creating a Template
import com.ladybugdb.Database;
import com.thecookiezen.ladybugdb.spring.connection.SimpleConnectionFactory;
import com.thecookiezen.ladybugdb.spring.core.LadybugDBTemplate;
import com.thecookiezen.ladybugdb.spring.repository.support.EntityRegistry;

Database database = new Database(":memory:");
SimpleConnectionFactory factory = new SimpleConnectionFactory(database);
EntityRegistry registry = new EntityRegistry();

LadybugDBTemplate template = new LadybugDBTemplate(factory, registry);
Execute Operations

Execute write operations (CREATE, MERGE, DELETE, SET):

Execute without parameters
template.execute("CREATE (p:Person {name: 'Alice', age: 30})");
Execute with parameters
import java.util.Map;

template.execute(
    "CREATE (p:Person {name: $name, age: $age})",
    Map.of("name", "Bob", "age", 25)
);
Execute with Statement (Cypher DSL)
import org.neo4j.cypherdsl.core.Cypher;
import org.neo4j.cypherdsl.core.Node;
import org.neo4j.cypherdsl.core.Statement;

Node person = Cypher.node("Person").named("p")
    .withProperties("name", Cypher.parameter("name"));
Statement statement = Cypher.merge(person).returning(person).build();

template.execute(statement, Map.of("name", "Charlie"));
Query Operations
Query returning a List
import java.util.List;

List<Person> people = template.query(
    "MATCH (n:Person) WHERE n.age > $minAge RETURN n",
    Map.of("minAge", 20),
    Person.class
);
Query with custom RowMapper
import com.thecookiezen.ladybugdb.spring.mapper.RowMapper;
import com.thecookiezen.ladybugdb.spring.mapper.ValueMappers;
import java.util.List;

List<Person> people = template.query(
    "MATCH (n:Person) RETURN n ORDER BY n.name",
    (row) -> {
        var node = row.getNode("n");
        return new Person(
            ValueMappers.asString(node.get("name")),
            ValueMappers.asInteger(node.get("age"))
        );
    }
);
Query returning single result
import java.util.Optional;

Optional<Person> person = template.queryForObject(
    "MATCH (n:Person) WHERE n.name = $name RETURN n",
    Map.of("name", "Alice"),
    Person.class
);

if (person.isPresent()) {
    System.out.println("Found: " + person.get());
}
Query with Cypher DSL Statement
import org.neo4j.cypherdsl.core.Cypher;
import org.neo4j.cypherdsl.core.Node;
import org.neo4j.cypherdsl.core.Statement;

Node n = Cypher.node("Person").named("n");
Statement statement = Cypher.match(n)
    .where(n.property("age").gt(Cypher.parameter("minAge")))
    .returning(n)
    .build();

List<Person> people = template.query(statement, Map.of("minAge", 20), Person.class);
Streaming Results

For large result sets, use stream() for memory efficiency:

import java.util.stream.Stream;

try (Stream<Person> stream = template.stream(
        "MATCH (n:Person) RETURN n",
        Map.of(),
        Person.class)) {

    stream.forEach(person -> {
        // Process each person
        System.out.println(person.getName());
    });
}
Always use try-with-resources to ensure proper resource cleanup. The stream holds database resources until closed.
Callback Execution

Execute custom logic with direct connection access:

import com.thecookiezen.ladybugdb.spring.core.LadybugDBCallback;

Long count = template.execute((connection) -> {
    try (var result = connection.query("MATCH (n:Person) RETURN count(n) AS count")) {
        if (result.hasNext()) {
            return result.getNext().getValue(0).getValue();
        }
        return 0L;
    }
});
Query For String List

Convenience method for single-column string results:

import java.util.List;

List<String> names = template.queryForStringList(
    "MATCH (n:Person) RETURN n.name AS name ORDER BY name",
    "name"
);
Extension Loading

Load extensions before executing queries:

import java.util.List;

List<Note> similarNotes = template.query(
    new String[]{"vector"},  // Extensions to load
    "MATCH (n:Note) WHERE vector_search(n.embedding, $query) < 0.5 RETURN n",
    Map.of("query", queryVector),
    Note.class
);
Parameter Types

The template automatically converts Java types to LadybugDB Values:

Java Type LadybugDB Type

String

STRING

Integer, int

INT64

Long, long

INT64

Double, double

DOUBLE

Boolean, boolean

BOOLEAN

float[]

LIST (of FLOAT)

List<T>

LIST

Map<String, Object>

MAP

Value

(passed through)

Array Parameters
template.execute(
    "CREATE (p:Person {name: $name, scores: $scores})",
    Map.of(
        "name", "Alice",
        "scores", new float[]{0.1f, 0.2f, 0.3f}
    )
);
List Parameters
template.query(
    "MATCH (n:Person) WHERE n.name IN $names RETURN n",
    Map.of("names", List.of("Alice", "Bob", "Charlie")),
    Person.class
);
CypherMappingException

Thrown when row mapping fails:

try {
    List<Person> people = template.query("MATCH (n:Person) RETURN n", Person.class);
} catch (LadybugDBTemplate.CypherMappingException e) {
    // Handle mapping error
    logger.error("Failed to map result", e);
}

3.3. Transactions

Spring Data LadybugDB provides a transaction manager for connection binding, with important limitations due to LadybugDB’s architecture.

LadybugDBTransactionManager

The transaction manager binds a connection to the current thread:

import com.thecookiezen.ladybugdb.spring.transaction.LadybugDBTransactionManager;
import com.thecookiezen.ladybugdb.spring.connection.PooledConnectionFactory;

@Bean
public LadybugDBTransactionManager transactionManager(PooledConnectionFactory factory) {
    return new LadybugDBTransactionManager(factory);
}
Important Limitations
LadybugDB auto-commits each command immediately. The transaction manager provides connection binding only, not atomic transactions.
No Rollback Support
@Transactional
public void updatePerson(Person person) {
    template.execute("SET p.age = 30");  // Immediately committed
    throw new RuntimeException("Error!"); // Cannot roll back previous operation
}
Single Writer Constraint

LadybugDB allows only one write transaction at a time:

"At any point in time, there can be multiple read transactions but only one write transaction"

Transaction Type Behavior

Read-only

Multiple can run in parallel

Write

Only one at a time, others block

Implications
  • Each query auto-commits immediately

  • Rollback is not supported - once executed, changes are permanent

  • Concurrent write operations will block waiting for the write lock

3.4. Entity Registry

The EntityRegistry manages entity descriptors that define how to read and write entities to/from the database.

EntityDescriptor

An EntityDescriptor<T> combines a RowMapper for reading and an EntityWriter for writing:

import com.thecookiezen.ladybugdb.spring.repository.support.EntityDescriptor;
import com.thecookiezen.ladybugdb.spring.mapper.RowMapper;
import com.thecookiezen.ladybugdb.spring.mapper.EntityWriter;

public record EntityDescriptor<T>(
    Class<T> entityType,
    RowMapper<T> reader,
    EntityWriter<T> writer
) {}
Registering Descriptors
import com.thecookiezen.ladybugdb.spring.repository.support.EntityRegistry;
import com.thecookiezen.ladybugdb.spring.mapper.RowMapper;
import com.thecookiezen.ladybugdb.spring.mapper.EntityWriter;
import java.util.Map;

EntityRegistry registry = new EntityRegistry();

// Create reader (RowMapper)
RowMapper<Person> reader = (row) -> {
    var node = row.getNode("n");
    return new Person(
        ValueMappers.asString(node.get("name")),
        ValueMappers.asInteger(node.get("age"))
    );
};

// Create writer (EntityWriter)
EntityWriter<Person> writer = (entity) -> Map.of(
    "age", entity.getAge()
    // Note: ID is handled separately
);

// Register the descriptor
registry.registerDescriptor(Person.class, new EntityDescriptor<>(Person.class, reader, writer));
Using Registered Descriptors
With LadybugDBTemplate

Once registered, use the entity class directly:

// Query using registered mapper
List<Person> people = template.query("MATCH (n:Person) RETURN n", Person.class);

// Query for single result
Optional<Person> person = template.queryForObject(
    "MATCH (n:Person) WHERE n.name = $name RETURN n",
    Map.of("name", "Alice"),
    Person.class
);
With SimpleNodeRepository

Pass the descriptor to the repository constructor:

import com.thecookiezen.ladybugdb.spring.repository.support.SimpleNodeRepository;

SimpleNodeRepository<Person, Void, String> repository = new SimpleNodeRepository<>(
    template,
    Person.class,
    Void.class,
    registry.getDescriptor(Person.class),
    null  // No relationship descriptor
);
RowMapper Interface

The RowMapper<T> functional interface maps a QueryRow to an entity:

@FunctionalInterface
public interface RowMapper<T> {
    T mapRow(QueryRow row) throws Exception;
}
Mapping Nodes
RowMapper<Person> personMapper = (row) -> {
    // Get node properties
    Map<String, Value> node = row.getNode("n");

    String name = ValueMappers.asString(node.get("name"));
    Integer age = ValueMappers.asInteger(node.get("age"));

    return new Person(name, age);
};
Mapping Relationships
RowMapper<Follows> followsMapper = (row) -> {
    // Get relationship data
    RelationshipData rel = row.getRelationship("rel");
    String id = rel.id().toString();
    int since = ValueMappers.asInteger(rel.properties().get("since"));

    // Get connected nodes
    Map<String, Value> sourceNode = row.getNode("s");
    Map<String, Value> targetNode = row.getNode("t");

    Person from = new Person(
        ValueMappers.asString(sourceNode.get("name")),
        ValueMappers.asInteger(sourceNode.get("age"))
    );

    Person to = new Person(
        ValueMappers.asString(targetNode.get("name")),
        ValueMappers.asInteger(targetNode.get("age"))
    );

    return new Follows(id, from, to, since);
};
EntityWriter Interface

The EntityWriter<T> functional interface decomposes an entity into a property map:

@FunctionalInterface
public interface EntityWriter<T> {
    Map<String, Object> decompose(T entity);
}
The ID property is handled separately by the repository and should not be included in the writer output.
Simple Writer
EntityWriter<Person> personWriter = (entity) -> Map.of(
    "age", entity.getAge()
);
Writer with Multiple Properties
EntityWriter<Document> documentWriter = (entity) -> {
    Map<String, Object> props = new HashMap<>();
    props.put("title", entity.getTitle());
    props.put("content", entity.getContent());
    props.put("createdAt", entity.getCreatedAt().toEpochMilli());
    props.put("tags", entity.getTags());
    props.put("embedding", entity.getEmbedment());  // float[]
    return props;
};
QueryRow Interface

The QueryRow provides access to row data:

public interface QueryRow {
    // Get value by column name
    Value getValue(String column);

    // Get value by column index
    Value getValue(int index);

    // Check if column exists
    boolean containsKey(String column);

    // Check value type
    boolean isNode(String column);
    boolean isRelationship(String column);

    // Get structured data
    Map<String, Value> getNode(String column);
    RelationshipData getRelationship(String column);

    // Get all column names
    Set<String> keySet();
}
Registering Multiple Entities
EntityRegistry registry = new EntityRegistry();

registry.registerDescriptor(Person.class, personDescriptor);
registry.registerDescriptor(Company.class, companyDescriptor);
registry.registerDescriptor(Follows.class, followsDescriptor);
registry.registerDescriptor(WorksAt.class, worksAtDescriptor);

// Retrieve descriptors
EntityDescriptor<Person> personDesc = registry.getDescriptor(Person.class);
Error Handling

Attempting to query with an unregistered entity class:

try {
    List<Unregistered> results = template.query("MATCH (n) RETURN n", Unregistered.class);
} catch (IllegalArgumentException e) {
    // "No entity descriptor registered for com.example.Unregistered"
}
Best Practices
Column Naming

Use consistent column names in your queries and mappers:

// Query uses "n" for node
template.query("MATCH (n:Person) RETURN n", Person.class);

// Mapper expects "n"
RowMapper<Person> mapper = (row) -> {
    var node = row.getNode("n");  // Must match query
    // ...
};
Null Safety

Handle null values in mappers:

RowMapper<Document> mapper = (row) -> {
    var node = row.getNode("n");

    String title = ValueMappers.asString(node.get("title"));  // Returns null if missing
    Integer views = ValueMappers.asInteger(node.get("views")); // Returns null if missing

    return new Document(
        title,
        views != null ? views : 0  // Default value
    );
};
Complex Types

Use ValueMappers for collections and arrays:

EntityWriter<Document> writer = (entity) -> {
    Map<String, Object> props = new HashMap<>();
    props.put("tags", entity.getTags());           // List<String>
    props.put("scores", entity.getScores());       // List<Integer>
    props.put("embedding", entity.getEmbedment()); // float[]
    return props;
};

RowMapper<Document> reader = (row) -> {
    var node = row.getNode("n");
    return new Document(
        ValueMappers.asString(node.get("title")),
        ValueMappers.asStringList(node.get("tags")),
        ValueMappers.asFloatArray(node.get("embedding"))
    );
};

4. Querying

4.1. Raw Cypher Queries

Execute Cypher queries directly using string-based query syntax with parameterized values.

Parameterized Queries

Always use parameters ($paramName) instead of string interpolation to prevent Cypher injection:

import java.util.Map;

// Execute a write operation
template.execute(
    "CREATE (p:Person {name: $name, age: $age})",
    Map.of("name", "Alice", "age", 30)
);

// Query for a list of results
List<Person> people = template.query(
    "MATCH (n:Person) WHERE n.name IN $names RETURN n",
    Map.of("names", List.of("Alice", "Bob", "Charlie")),
    Person.class
);
Query Methods
execute() - Write Operations

For CREATE, MERGE, DELETE, SET operations and extensions:

// With parameters
template.execute(
    "CREATE (p:Person {name: $name, age: $age})",
    Map.of("name", "Bob", "age", 25)
);

// With extensions (e.g., vector search)
template.execute(
    new String[]{"vector"},
    "INSTALL vector",
    Map.of()
);
query() - Return List

For queries returning multiple results:

import java.util.List;

List<Person> people = template.query(
    "MATCH (n:Person) WHERE n.age > $minAge RETURN n ORDER BY n.name",
    Map.of("minAge", 18),
    Person.class
);

// With custom RowMapper
List<String> names = template.query(
    "MATCH (n:Person) RETURN n.name AS name ORDER BY name",
    (row) -> ValueMappers.asString(row.getValue("name"))
);
queryForObject() - Return Single

For queries expecting zero or one result:

import java.util.Optional;

Optional<Person> person = template.queryForObject(
    "MATCH (n:Person) WHERE n.name = $name RETURN n",
    Map.of("name", "Alice"),
    Person.class
);

if (person.isPresent()) {
    System.out.println("Found: " + person.get().getName());
}
queryForStringList() - Return String Column

For single-column string results:

import java.util.List;

List<String> names = template.queryForStringList(
    "MATCH (n:Person) RETURN n.name AS name ORDER BY name",
    "name"
);
Security
Cypher Injection Prevention
Never concatenate user input into Cypher strings.
// DANGEROUS - Vulnerable to injection
String unsafe = "Alice' OR 1=1 //";
template.execute("CREATE (p:Person {name: '" + unsafe + "'})");

// SAFE - Use parameters
template.execute(
    "CREATE (p:Person {name: $name})",
    Map.of("name", unsafe)  // Safely escaped
);
Security Warning Log

The template logs a warning when detecting potentially unsafe queries:

SECURITY: Detected Cypher query with multiple literals and no parameters. Query: MATCH (n:Person {name: 'Alice', age: 30}) RETURN n
Advanced Cypher Queries

For more complex queries (Pattern Matching, Aggregations, Subqueries, Updates), please consult the Neo4j Cypher Manual:

👉 Neo4j Cypher Manual

4.2. Cypher DSL

Neo4j’s Cypher DSL allows you to build queries programmatically instead of writing raw Cypher strings. This approach provides several benefits:

  • Type safety - Compiler catches errors before runtime

  • IDE support - Auto-completion and refactoring work naturally

  • Composability - Build complex queries from reusable parts

  • Readability - Fluent API mirrors Cypher syntax

Use the DSL when building dynamic queries or when you want compile-time validation. For simple, static queries, raw Cypher may be more concise.

Dependency
<dependency>
    <groupId>org.neo4j</groupId>
    <artifactId>neo4j-cypher-dsl</artifactId>
    <version>2025.2.4</version>
</dependency>
Basic Usage

Here is a basic example of creating and executing a query using the Cypher DSL:

import org.neo4j.cypherdsl.core.Cypher;
import org.neo4j.cypherdsl.core.Node;
import org.neo4j.cypherdsl.core.Statement;
import java.util.Map;
import java.util.List;

// 1. Define the node
Node person = Cypher.node("Person").named("p");

// 2. Build the statement
Statement statement = Cypher.match(person)
    .where(person.property("age").gt(Cypher.parameter("minAge")))
    .returning(person)
    .orderBy(person.property("name").ascending())
    .build();

// 3. Execute with LadybugTemplate
List<Person> adults = template.query(
    statement,
    Map.of("minAge", 18),
    Person.class
);
Getting Cypher String

For debugging or logging, you can easily get the generated Cypher string:

String cypher = statement.getCypher();
System.out.println("Generated Cypher: " + cypher);
Further Reading

For comprehensive documentation, advanced query building (including relationships, aggregations, and complex conditions), and more details on how to use the Neo4j Cypher DSL, please refer to the official documentation:

4.3. Row Mappers

Row mappers convert query result rows to domain objects.

RowMapper Interface
@FunctionalInterface
public interface RowMapper<T> {
    T mapRow(QueryRow row) throws Exception;
}
QueryRow Interface

The QueryRow provides access to row data:

public interface QueryRow {
    // Access by column name
    Value getValue(String column);
    Value getValue(int index);

    // Type checking
    boolean containsKey(String column);
    boolean isNode(String column);
    boolean isRelationship(String column);

    // Structured access
    Map<String, Value> getNode(String column);
    RelationshipData getRelationship(String column);

    // Column names
    Set<String> keySet();
}
Basic Mapping
Scalar Values
import com.thecookiezen.ladybugdb.spring.mapper.RowMapper;
import com.thecookiezen.ladybugdb.spring.mapper.ValueMappers;

RowMapper<String> nameMapper = (row) ->
    ValueMappers.asString(row.getValue("name"));

RowMapper<Integer> ageMapper = (row) ->
    ValueMappers.asInteger(row.getValue("age"));

RowMapper<Long> countMapper = (row) ->
    ValueMappers.asLong(row.getValue("count"));
Mapping Entities
RowMapper<Person> personMapper = (row) -> {
    Map<String, Value> node = row.getNode("n");

    return new Person(
        ValueMappers.asString(node.get("name")),
        ValueMappers.asInteger(node.get("age"))
    );
};

List<Person> people = template.query(
    "MATCH (n:Person) RETURN n",
    personMapper
);
Multiple Columns
RowMapper<PersonDto> mapper = (row) -> {
    return new PersonDto(
        ValueMappers.asString(row.getValue("name")),
        ValueMappers.asInteger(row.getValue("age")),
        ValueMappers.asString(row.getValue("city"))
    );
};

List<PersonDto> results = template.query(
    "MATCH (n:Person) RETURN n.name AS name, n.age AS age, n.city AS city",
    mapper
);
Mapping Relationships
RelationshipData
import com.thecookiezen.ladybugdb.spring.mapper.RelationshipData;

public record RelationshipData(
    InternalID id,           // Internal relationship ID
    String labelName,        // Relationship type (e.g., "KNOWS")
    InternalID sourceId,     // Source node ID
    InternalID targetId,     // Target node ID
    Map<String, Value> properties  // Custom properties
) {}
Mapping Relationship Entities
@RelationshipEntity(type = "FOLLOWS", nodeType = Person.class, sourceField = "from", targetField = "to")
public class Follows {
    @Id
    String name;
    Person from;
    Person to;
    int since;
}

RowMapper<Follows> followsMapper = (row) -> {
    // Get relationship data
    RelationshipData rel = row.getRelationship("rel");
    String name = ValueMappers.asString(rel.properties().get("name"));
    int since = ValueMappers.asInteger(rel.properties().get("since"));

    // Get connected nodes
    Map<String, Value> sourceNode = row.getNode("s");
    Map<String, Value> targetNode = row.getNode("t");

    Person from = new Person(
        ValueMappers.asString(sourceNode.get("name")),
        ValueMappers.asInteger(sourceNode.get("age"))
    );

    Person to = new Person(
        ValueMappers.asString(targetNode.get("name")),
        ValueMappers.asInteger(targetNode.get("age"))
    );

    return new Follows(name, from, to, since);
};

List<Follows> follows = template.query(
    "MATCH (s:Person)-[rel:FOLLOWS]->(t:Person) RETURN s, rel, t",
    followsMapper
);
Relationship with Multiple Types
RowMapper<Connection> connectionMapper = (row) -> {
    RelationshipData rel = row.getRelationship("r");
    String type = rel.labelName();  // "KNOWS", "WORKS_WITH", etc.

    Map<String, Value> source = row.getNode("a");
    Map<String, Value> target = row.getNode("b");

    return new Connection(
        type,
        ValueMappers.asString(source.get("name")),
        ValueMappers.asString(target.get("name"))
    );
};
Complex Mappings
Nested Objects
RowMapper<PersonWithCity> mapper = (row) -> {
    Map<String, Value> personNode = row.getNode("p");
    Map<String, Value> cityNode = row.getNode("c");

    City city = new City(
        ValueMappers.asString(cityNode.get("name")),
        ValueMappers.asString(cityNode.get("country"))
    );

    return new PersonWithCity(
        ValueMappers.asString(personNode.get("name")),
        ValueMappers.asInteger(personNode.get("age")),
        city
    );
};

List<PersonWithCity> results = template.query(
    "MATCH (p:Person)-[:LIVES_IN]->(c:City) RETURN p, c",
    mapper
);
Lists and Arrays
RowMapper<Document> documentMapper = (row) -> {
    Map<String, Value> node = row.getNode("n");

    return new Document(
        ValueMappers.asString(node.get("id")),
        ValueMappers.asString(node.get("title")),
        ValueMappers.asStringList(node.get("tags")),
        ValueMappers.asFloatArray(node.get("embedding"))
    );
};
Null Handling
RowMapper<Person> safeMapper = (row) -> {
    Map<String, Value> node = row.getNode("n");

    String name = ValueMappers.asString(node.get("name"));
    Integer age = ValueMappers.asInteger(node.get("age"));
    String city = ValueMappers.asString(node.get("city"));

    return new Person(
        name,
        age != null ? age : 0,        // Default value
        city != null ? city : "Unknown"  // Default value
    );
};
Optional Relationships
RowMapper<PersonWithOptionalCity> mapper = (row) -> {
    Map<String, Value> personNode = row.getNode("p");
    Map<String, Value> cityNode = row.getNode("c");  // May be null

    City city = null;
    if (cityNode != null && !cityNode.isEmpty()) {
        city = new City(
            ValueMappers.asString(cityNode.get("name")),
            ValueMappers.asString(cityNode.get("country"))
        );
    }

    return new PersonWithOptionalCity(
        ValueMappers.asString(personNode.get("name")),
        Optional.ofNullable(city)
    );
};

List<PersonWithOptionalCity> results = template.query(
    "MATCH (p:Person) OPTIONAL MATCH (p)-[:LIVES_IN]->(c:City) RETURN p, c",
    mapper
);
Type Checking

Use isNode() and isRelationship() for type-safe access:

RowMapper<Object> dynamicMapper = (row) -> {
    if (row.isNode("result")) {
        Map<String, Value> node = row.getNode("result");
        return "Node: " + ValueMappers.asString(node.get("name"));
    } else if (row.isRelationship("result")) {
        RelationshipData rel = row.getRelationship("result");
        return "Relationship: " + rel.labelName();
    } else {
        return "Value: " + row.getValue("result").getValue();
    }
};
Registering with EntityRegistry
import com.thecookiezen.ladybugdb.spring.repository.support.EntityDescriptor;
import com.thecookiezen.ladybugdb.spring.repository.support.EntityRegistry;

EntityRegistry registry = new EntityRegistry();

RowMapper<Person> reader = (row) -> {
    Map<String, Value> node = row.getNode("n");
    return new Person(
        ValueMappers.asString(node.get("name")),
        ValueMappers.asInteger(node.get("age"))
    );
};

EntityWriter<Person> writer = (entity) -> Map.of("age", entity.getAge());

registry.registerDescriptor(Person.class, new EntityDescriptor<>(Person.class, reader, writer));

// Now use with template
List<Person> people = template.query("MATCH (n:Person) RETURN n", Person.class);
Error Handling
RowMapper<Person> safeMapper = (row) -> {
    try {
        Map<String, Value> node = row.getNode("n");
        return new Person(
            ValueMappers.asString(node.get("name")),
            ValueMappers.asInteger(node.get("age"))
        );
    } catch (Exception e) {
        throw new RuntimeException("Failed to map Person row", e);
    }
};

The template wraps mapping exceptions in CypherMappingException:

try {
    List<Person> people = template.query("MATCH (n) RETURN n", Person.class);
} catch (LadybugDBTemplate.CypherMappingException e) {
    logger.error("Mapping failed", e.getCause());
}

4.4. Value Mappers

ValueMappers is a utility class for converting LadybugDB Value objects to Java types.

Scalar Types
asString

Converts a Value to String. Returns null for null values.

String name = ValueMappers.asString(value);
asInteger

Converts a Value to Integer. Handles Number types directly.

Integer age = ValueMappers.asInteger(node.get("age"));
int primitiveAge = ValueMappers.asInteger(node.get("age"));  // Auto-unboxing
asLong
Long timestamp = ValueMappers.asLong(node.get("createdAt"));
asDouble
Double score = ValueMappers.asDouble(node.get("score"));
asBoolean
Boolean active = ValueMappers.asBoolean(node.get("active"));
List Types
asList

Generic list mapping with element mapper:

List<Double> scores = ValueMappers.asList(value, ValueMappers::asDouble);

List<Person> people = ValueMappers.asList(value, v -> {
    // Custom element mapping
    return new Person(ValueMappers.asString(v));
});
asStringList
List<String> tags = ValueMappers.asStringList(node.get("tags"));
asIntegerList
List<Integer> years = ValueMappers.asIntegerList(node.get("years"));
Array Types
asFloatArray

Returns null for null values. Useful for vector embeddings.

float[] embedding = ValueMappers.asFloatArray(node.get("embedding"));

if (embedding != null) {
    System.out.println("Embedding dimension: " + embedding.length);
}
Null Safety

All mappers handle null gracefully:

Value nullValue = null;
String result = ValueMappers.asString(nullValue);  // Returns null

Value nodeValue = node.get("nonexistent");
Integer age = ValueMappers.asInteger(nodeValue);   // Returns null
Default Values

Handle nulls with defaults:

Integer age = ValueMappers.asInteger(node.get("age"));
int safeAge = age != null ? age : 0;

// Or with Optional
int safeAge = Optional.ofNullable(ValueMappers.asInteger(node.get("age")))
    .orElse(0);
Usage in RowMappers
Node Properties
RowMapper<Person> mapper = (row) -> {
    Map<String, Value> node = row.getNode("n");

    return new Person(
        ValueMappers.asString(node.get("name")),
        ValueMappers.asInteger(node.get("age")),
        ValueMappers.asBoolean(node.get("active")),
        ValueMappers.asDouble(node.get("score"))
    );
};
Direct Column Values
RowMapper<String> nameMapper = (row) ->
    ValueMappers.asString(row.getValue("name"));

RowMapper<Long> countMapper = (row) ->
    ValueMappers.asLong(row.getValue("count"));
Collections
RowMapper<Document> mapper = (row) -> {
    Map<String, Value> node = row.getNode("n");

    return new Document(
        ValueMappers.asString(node.get("id")),
        ValueMappers.asStringList(node.get("tags")),
        ValueMappers.asFloatArray(node.get("embedding"))
    );
};
Nested Lists
RowMapper<Matrix> mapper = (row) -> {
    Value matrixValue = row.getValue("matrix");

    List<List<Double>> matrix = ValueMappers.asList(matrixValue,
        innerValue -> ValueMappers.asDoubleList(innerValue)
    );

    return new Matrix(matrix);
};
Usage in EntityWriters

The template converts Java types to Values automatically. Common patterns:

EntityWriter<Document> writer = (entity) -> {
    Map<String, Object> props = new HashMap<>();
    props.put("title", entity.getTitle());           // String
    props.put("views", entity.getViews());           // Integer
    props.put("score", entity.getScore());           // Double
    props.put("active", entity.isActive());          // Boolean
    props.put("tags", entity.getTags());             // List<String>
    props.put("embedding", entity.getEmbedment());   // float[]
    return props;
};
Type Conversion Table
Java Type Mapper Method LadybugDB Type

String

asString()

STRING

Integer

asInteger()

INT64

Long

asLong()

INT64

Double

asDouble()

DOUBLE

Boolean

asBoolean()

BOOLEAN

List<String>

asStringList()

LIST

List<Integer>

asIntegerList()

LIST

List<Double>

asDoubleList()

LIST

float[]

asFloatArray()

LIST

List<T>

asList(mapper)

LIST

Performance Notes
  • Direct Value.getValue() is used internally for optimal performance

  • No intermediate string conversion for numeric types

  • List operations allocate new collections (not cached)

5. Repositories

5.1. Node Repository

SimpleNodeRepository provides CRUD operations for node entities, extending the standard Spring Data CrudRepository.

NodeRepository Interface

Your custom repository will typically extend NodeRepository, which gives you out-of-the-box CRUD and relationship management:

import org.springframework.data.repository.CrudRepository;

@NoRepositoryBean
public interface NodeRepository<T, ID, R, S> extends CrudRepository<T, ID> {

    // Relationship operations
    R createRelation(S source, T target, R relationship);
    List<R> findRelationsBySource(S source);
    List<R> findAllRelations();
    void deleteRelation(R relationship);
    void deleteRelationBySource(T source);
    Optional<R> findRelationById(ID id);
}
CRUD Operations

Since NodeRepository extends CrudRepository, all standard standard operations are supported seamlessly without writing boilerplate:

  • save(entity) / saveAll(entities): Inserts or updates entities using Cypher MERGE.

  • findById(id) / findAll(): Retrieves entities mapping their graph properties to your Java object.

  • count() / existsById(id): Lightweight aggregations to check entity presence.

  • delete(entity) / deleteById(id) / deleteAll(…​): Uses DETACH DELETE to safely remove nodes and their relationships.

Relationship Operations

In addition to CRUD, NodeRepository can handle custom relationship creation and retrieval. See Relationships for detailed documentation on entity relationships.

Person alice = repository.findById("Alice").orElseThrow();
Person bob = repository.findById("Bob").orElseThrow();

// Create a relationship
Follows mappedFollows = new Follows("alice_bob", alice, bob, 2020);
repository.createRelation(alice, bob, mappedFollows);

// Query relationships
List<Follows> relationsFromAlice = repository.findRelationsBySource(alice);
Entity Requirements and Registration

To properly manage an entity, it must: 1. Be annotated with @NodeEntity (label defaults to class name). 2. Have an @Id field mapped to the graph node’s primary key. 3. Define a no-argument constructor for instantiation.

If using LadybugDB programmatically outside of Spring’s component scan, you must register its EntityDescriptor with the EntityRegistry, providing a RowMapper and EntityWriter.

EntityRegistry registry = new EntityRegistry();
registry.registerDescriptor(Person.class,
    new EntityDescriptor<>(Person.class, personReader, personWriter));

SimpleNodeRepository<Person, Void, String> repo = new SimpleNodeRepository<>(
    template, Person.class, Void.class,
    registry.getDescriptor(Person.class), null
);

When using @EnableLadybugDBRepositories in a standard Spring context, this registration is handled automatically based on your @NodeEntity classes.

5.2. Custom Queries

Define custom queries on repository interfaces using the @Query annotation. Spring Data LadybugDB processes these queries at runtime.

@Query Annotation

You can use the @Query annotation to supply custom Cypher for repository methods:

import com.thecookiezen.ladybugdb.spring.annotation.Query;
import org.springframework.data.repository.query.Param;

public interface PersonRepository extends NodeRepository<Person, String, Void, Person> {

    @Query("MATCH (n:Person) WHERE n.age > $minAge RETURN n")
    List<Person> findByAgeGreaterThan(@Param("minAge") int minAge);

    @Query(value = "MATCH (n:Person {name: $name}) DETACH DELETE n", modifying = true)
    void deleteByName(@Param("name") String name);
}
Annotation Attributes
  • value: The Cypher query string

  • modifying: Whether the query modifies data (default: false)

  • loadExtensions: Extensions to load before execution (default: empty)

Parameter Binding

Always bind parameters with $paramName in Cypher and @Param("paramName") in your method signature.

@Query("MATCH (n:Person) WHERE n.name IN $names AND n.age = $age RETURN n")
List<Person> findByNamesAndAge(@Param("names") List<String> names, @Param("age") int age);
Return Types

The template automatically processes the results into common Java types:

  • List / Iterable: For multi-result sets (List<Person>).

  • Optional / Single Entity: Returns one instance, or Optional.empty()/null if not found.

  • Scalars: Extract single columns like List<String>, Integer, or Long.

  • Void: For modifying = true queries.

Extension Loading

If your query requires LadybugDB extensions (like vector), declare them in the query annotation:

@Query(
    value = "MATCH (n:Document) WHERE vector_search(n.embedding, $query, metric := 'cosine') < 0.5 RETURN n",
    loadExtensions = {"vector"}
)
List<Document> findSimilarDocuments(@Param("query") float[] query);
Complex Queries and Projections

You can map results to records for custom projections:

public record PersonSummary(String name, int age) {}

@Query("MATCH (n:Person) RETURN n.name AS name, n.age AS age")
List<PersonSummary> findAllSummaries();
Note that your projections should have matching field names with the returned Cypher aliases.
Query Lookup Strategy

Currently, Spring Data LadybugDB lookup heavily relies on explicitly declared queries via @Query. Derivation from method names is not fully supported in exactly the same fluent capability as JPA. Always define your Cypher string explicitly.

5.3. Entity Mapping

Map Java classes to LadybugDB nodes and relationships using annotations. Spring Data LadybugDB provides straightforward annotations to handle graph-to-object mapping.

@NodeEntity

Marks a class as a node entity stored in a node table.

import com.thecookiezen.ladybugdb.spring.annotation.NodeEntity;
import org.springframework.data.annotation.Id;

@NodeEntity(label = "Person")
public class Person {
    @Id
    private String name;
    private int age;

    // Default constructor is required
    public Person() {}
}
Attributes
  • label: The node table/label name. Defaults to simple class name (with Entity suffix removed if present).

@Id

Marks the primary key field for the entity.

LadybugDB node tables require a primary key. The @Id field must match the table’s primary key definition.
@RelationshipEntity

Marks a class as a relationship entity connecting two nodes.

import com.thecookiezen.ladybugdb.spring.annotation.RelationshipEntity;
import org.springframework.data.annotation.Id;

@RelationshipEntity(
    type = "KNOWS",
    nodeType = Person.class,
    sourceField = "from",
    targetField = "to"
)
public class Knows {
    @Id
    private String id;
    private Person from;
    private Person to;
    private int since;

    public Knows() {}
}
Attributes
  • type: The relationship type name (e.g., "KNOWS", "FOLLOWS").

  • nodeType: The node entity class for both source and target.

  • sourceField: Field name holding the source node.

  • targetField: Field name holding the target node.

Relationship Requirements
  • Source and target must be the exact same node type.

  • The @Id field stores the relationship’s primary key.

  • sourceField and targetField reference node entity fields.

5.4. Relationships

Create and manage relationships between nodes using the repository API.

Relationship Entity Definition
import com.thecookiezen.ladybugdb.spring.annotation.RelationshipEntity;
import org.springframework.data.annotation.Id;

@RelationshipEntity(
    type = "FOLLOWS",
    nodeType = Person.class,
    sourceField = "from",
    targetField = "to"
)
public class Follows {
    @Id
    String name;
    Person from;
    Person to;
    int since;

    public Follows() {}

    public Follows(String name, Person from, Person to, int since) {
        this.name = name;
        this.from = from;
        this.to = to;
        this.since = since;
    }
}
Creating Relationship Table

Before creating relationships, define the relationship table:

template.execute("CREATE NODE TABLE Person(name STRING PRIMARY KEY, age INT64)");
template.execute("CREATE REL TABLE FOLLOWS(FROM Person TO Person, name STRING PRIMARY KEY, since INT64)");
Registering Descriptors

Register both node and relationship descriptors:

EntityRegistry registry = new EntityRegistry();

registry.registerDescriptor(Person.class, personDescriptor);
registry.registerDescriptor(Follows.class, followsDescriptor);
Relationship Writer
EntityWriter<Follows> followsWriter = (entity) -> Map.of(
    "name", entity.name,
    "since", entity.since
    // from/to handled by repository
);
Relationship Reader
RowMapper<Follows> followsReader = (row) -> {
    RelationshipData rel = row.getRelationship("rel");
    Map<String, Value> sourceNode = row.getNode("s");
    Map<String, Value> targetNode = row.getNode("t");

    String name = ValueMappers.asString(rel.properties().get("name"));
    int since = ValueMappers.asInteger(rel.properties().get("since"));

    Person from = new Person(
        ValueMappers.asString(sourceNode.get("name")),
        ValueMappers.asInteger(sourceNode.get("age"))
    );

    Person to = new Person(
        ValueMappers.asString(targetNode.get("name")),
        ValueMappers.asInteger(targetNode.get("age"))
    );

    return new Follows(name, from, to, since);
};
CRUD Operations
Create Relationship
SimpleNodeRepository<Person, Follows, String> repository = new SimpleNodeRepository<>(
    template, Person.class, Follows.class,
    personDescriptor, followsDescriptor
);

// First create nodes
Person alice = repository.save(new Person("Alice", 30));
Person bob = repository.save(new Person("Bob", 25));

// Create relationship
Follows follows = new Follows("alice_bob", alice, bob, 2020);
Follows created = repository.createRelation(alice, bob, follows);

The generated Cypher:

MATCH (s:Person {name: $sourceId}), (t:Person {name: $targetId})
MERGE (s)-[rel:FOLLOWS {name: $name}]->(t)
SET rel.since = $since
RETURN s, t, rel
Find Relationship By ID
Optional<Follows> found = repository.findRelationById("alice_bob");
Find Relationships By Source

Find all relationships from a specific node:

Person alice = repository.findById("Alice").orElseThrow();
List<Follows> relationships = repository.findRelationsBySource(alice);

for (Follows f : relationships) {
    System.out.println("Alice follows " + f.to.name);
}
Find All Relationships
List<Follows> allRelationships = repository.findAllRelations();
Update Relationship

Update by creating a new relationship with the same ID:

Follows existing = repository.findRelationById("alice_bob").orElseThrow();
existing.since = 2021;  // Update year
Follows updated = repository.createRelation(existing.from, existing.to, existing);
Delete Relationship
Follows follows = repository.findRelationById("alice_bob").orElseThrow();
repository.deleteRelation(follows);
Delete Relationships By Source

Delete all relationships from a source node:

Person alice = repository.findById("Alice").orElseThrow();
repository.deleteRelationBySource(alice);
Querying Relationships
Using Template
List<Follows> follows = template.query(
    "MATCH (s:Person)-[rel:FOLLOWS]->(t:Person) RETURN s, rel, t",
    followsReader
);
With Conditions
List<Follows> recentFollows = template.query(
    "MATCH (s:Person)-[rel:FOLLOWS]->(t:Person) WHERE rel.since >= $year RETURN s, rel, t",
    Map.of("year", 2020),
    followsReader
);
Using Cypher DSL
import org.neo4j.cypherdsl.core.Cypher;
import org.neo4j.cypherdsl.core.Node;
import org.neo4j.cypherdsl.core.Relationship;

Node s = Cypher.node("Person").named("s");
Node t = Cypher.node("Person").named("t");
Relationship rel = s.relationshipTo(t, "FOLLOWS").named("rel");

Statement statement = Cypher.match(rel)
    .where(rel.property("since").gte(Cypher.parameter("year")))
    .returning(s, t, rel)
    .build();

List<Follows> follows = template.query(statement, Map.of("year", 2020), followsReader);
With Custom Query
public interface PersonRepository extends NodeRepository<Person, String, Follows, Person> {

    @Query("MATCH (s:Person)-[rel:FOLLOWS]->(t:Person) WHERE s.name = $name RETURN s, rel, t")
    List<Follows> findFollowsByPerson(@Param("name") String name);

    @Query("MATCH (s:Person)-[rel:FOLLOWS]->(t:Person) WHERE rel.since >= $year RETURN s, rel, t")
    List<Follows> findFollowsSinceYear(@Param("year") int year);
}

6. Reference

6.1. API Overview

This section provides a quick reference for the key packages and interfaces in Spring Data LadybugDB. Use this as a high-level guide to locate the right abstractions. For comprehensive method signatures, please consult the Javadocs and your IDE.

Core Package (com.thecookiezen.ladybugdb.spring.core)

Provides the primary template class (LadybugDBTemplate) for executing database operations. This is the main entry point for executing Cypher queries and managing core connections.

Connection Package (com.thecookiezen.ladybugdb.spring.connection)

Contains factory interfaces and implementations (LadybugDBConnectionFactory, SimpleConnectionFactory, PooledConnectionFactory) for managing database connections. Choose between simple (one connection per operation) or pooled (reusable connections) factories based on your performance requirements.

Mapper Package (com.thecookiezen.ladybugdb.spring.mapper)

Includes interfaces and utilities for converting between database rows and Java objects. Implement RowMapper for reading data and EntityWriter for writing data. The ValueMappers utility provides type-safe conversions for common data types.

Repository Package

The repository package provides Spring Data-style repository abstractions for CRUD operations.

  • com.thecookiezen.ladybugdb.spring.repository: Contains NodeRepository, the primary interface for entity persistence.

  • com.thecookiezen.ladybugdb.spring.repository.support: Contains implementation classes (SimpleNodeRepository) and infrastructure (EntityRegistry).

  • com.thecookiezen.ladybugdb.spring.repository.query: Handles custom queries defined with the @Query annotation.

Annotation Package (com.thecookiezen.ladybugdb.spring.annotation)

Defines custom annotations (@NodeEntity, @RelationshipEntity, @Query) for marking entity classes and defining custom queries. These annotations are processed at runtime to configure entity mapping behavior.

Transaction Package (com.thecookiezen.ladybugdb.spring.transaction)

Provides Spring’s PlatformTransactionManager integration via LadybugDBTransactionManager. Note that LadybugDB currently auto-commits each operation, so this manager primarily handles connection binding and context propagation.

Configuration Package (com.thecookiezen.ladybugdb.spring.config)

Provides Spring’s annotation-based configuration (@EnableLadybugDBRepositories) for enabling and registering LadybugDB repositories in your application context.