Skip to content

liberation-data/drivine4j

Repository files navigation

Drivine4j

CI License

A Neo4j client library for Java and Kotlin with two approaches to graph mapping:

  1. PersistenceManager - Low-level API with manual Cypher queries (classic Drivine approach)
  2. GraphObjectManager - High-level API with annotated models and type-safe DSL (new in 4.0)

Philosophy

Composition Over Inheritance

A typical ORM defines a reusable object model. From this model statements are generated to hydrate to and from the model to the database. This addresses the so-called impedance mismatch between the object model and the database. However, there are drawbacks:

  • Generated queries work well for simple cases, but can get out of hand and degrade performance when it's more complex. Debugging these generated statements can be painful.
  • One model for many use cases is a big ask - the original CRUD cases work well, but more complex cases mean the model gets in the way more than it helps.

These trade-offs might be acceptable for relational databases, but with graph databases this mismatch doesn't really exist.

Just as we favor composition over inheritance in software development, we prefer composition when mapping results from complex queries. A person can play many roles: sometimes we're here to help them have a great holiday, other times to manage a team, in others they're a person of interest. With Drivine, you compose views as needed:

@GraphView
data class HolidayingPerson(
    @Root val person: Person,
    @GraphRelationship(type = "BOOKED_HOLIDAY")
    val holidays: List<Holiday>
)

Behind the scenes, Drivine generates efficient Cypher:

MATCH (person:Person {firstName: $firstName})
WITH person, [(person)-[:BOOKED_HOLIDAY]->(holiday:Holiday) | holiday {.*}] AS holidays
RETURN {
         person:   properties(person),
         holidays: holidays
       }

Composition lets us mix and match as needed.

Requirements

  • Java 21+
  • Kotlin:
    • For PersistenceManager API: Any Kotlin version
    • For GraphObjectManager API: Kotlin 2.2.0+ (requires context parameters feature)

Installation

Core Library

Gradle (Kotlin DSL)

dependencies {
    implementation("org.drivine:drivine4j:0.0.1-SNAPSHOT")
}

Gradle (Groovy)

dependencies {
    implementation 'org.drivine:drivine4j:0.0.1-SNAPSHOT'
}

Maven

<dependency>
    <groupId>org.drivine</groupId>
    <artifactId>drivine4j</artifactId>
    <version>0.0.1-SNAPSHOT</version>
</dependency>

Code Generation (For GraphObjectManager with Type-Safe DSL)

If you want to use GraphObjectManager with the type-safe query DSL, you need to add the code generation processor.

Note for Java Projects: The code generator (KSP) only processes Kotlin source files. For the best experience:

  • Define your @GraphView classes in Kotlin to get the generated type-safe DSL
  • Your @NodeFragment classes can be in Java or Kotlin
  • At runtime, both Java and Kotlin classes work fully with GraphObjectManager

See the Java Interoperability section for details and examples.

Gradle (Kotlin DSL)

plugins {
    id("com.google.devtools.ksp") version "2.2.20-2.0.4"
    kotlin("jvm") version "2.2.0"
}

kotlin {
    compilerOptions {
        // Required for context parameters DSL
        freeCompilerArgs.addAll("-Xcontext-parameters")
    }
}

dependencies {
    implementation("org.drivine:drivine4j:0.0.1-SNAPSHOT")
    ksp("org.drivine:drivine4j-codegen:0.0.1-SNAPSHOT")
}

Maven

<build>
    <plugins>
        <plugin>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-plugin</artifactId>
            <version>2.2.0</version>
            <configuration>
                <compilerPlugins>
                    <compilerPlugin>ksp</compilerPlugin>
                </compilerPlugins>
                <args>
                    <!-- Required for context parameters DSL -->
                    <arg>-Xcontext-parameters</arg>
                </args>
            </configuration>
            <dependencies>
                <!-- KSP extension for Maven -->
                <dependency>
                    <groupId>com.dyescape</groupId>
                    <artifactId>kotlin-maven-symbol-processing</artifactId>
                    <version>1.6</version>
                </dependency>

                <!-- Drivine code generator -->
                <dependency>
                    <groupId>org.drivine</groupId>
                    <artifactId>drivine4j-codegen</artifactId>
                    <version>0.0.1-SNAPSHOT</version>
                </dependency>
            </dependencies>
        </plugin>
    </plugins>
</build>

Note: Maven support for KSP uses the third-party kotlin-maven-symbol-processing extension.

Quick Start

1. Configuration

@Configuration
@ComponentScan("org.drivine")
class AppConfig {
    @Bean
    fun dataSourceMap(): DataSourceMap {
        val props = ConnectionProperties(
            host = "localhost",
            port = 7687,
            username = "neo4j",
            password = "password",
            database = "neo4j"
        )
        return DataSourceMap(mapOf("neo" to props))
    }
}

2. Domain Model

data class Person(
    val uuid: String,
    val firstName: String,
    val lastName: String,
    val email: String?,
    val age: Int
)

3. Repository Pattern

@Component
class PersonRepository @Autowired constructor(
    @Qualifier("neoManager") val manager: PersistenceManager
) {
    @Transactional
    fun findByCity(city: String): List<Person> {
        return manager.query(
            QuerySpecification
                .withStatement<Any>("MATCH (p:Person {city: \$city}) RETURN properties(p)")
                .bind(mapOf("city" to city))
                .transform(Person::class.java)
        )
    }

    @Transactional
    fun findById(id: String): Person? {
        return manager.maybeGetOne(
            QuerySpecification
                .withStatement<Any>("MATCH (p:Person {uuid: \$id}) RETURN properties(p)")
                .bind(mapOf("id" to id))
                .transform(Person::class.java)
        )
    }

    @Transactional
    fun create(person: Person): Person {
        return manager.getOne(
            QuerySpecification
                .withStatement<Any>("CREATE (p:Person) SET p = \$props RETURN properties(p)")
                .bindObject("props", person)
                .transform(Person::class.java)
        )
    }

    @Transactional
    fun update(uuid: String, patch: Partial<Person>): Person {
        val props = patch.toMap()
        return manager.getOne(
            QuerySpecification
                .withStatement<Any>(
                    "MATCH (p:Person {uuid: \$uuid}) SET p += \$props RETURN properties(p)"
                )
                .bind(mapOf("uuid" to uuid, "props" to props))
                .transform(Person::class.java)
        )
    }
}

GraphObjectManager - Type-Safe Graph Mapping

GraphObjectManager provides a high-level API for working with graph-mapped objects using annotated models. It generates efficient Cypher queries automatically and provides a type-safe DSL for filtering and ordering.

Key Concepts

1. NodeFragment - Mapping Nodes

A @NodeFragment represents a single node in the graph:

@NodeFragment
data class Person(
    @NodeId val uuid: String,
    val name: String,
    val bio: String?
)

@NodeFragment
data class Organization(
    @NodeId val uuid: String,
    val name: String
)

2. RelationshipFragment - Capturing Relationship Properties

A @RelationshipFragment captures properties on relationship edges, not just the target node:

@RelationshipFragment
data class WorkHistory(
    val startDate: LocalDate,  // Property on the edge
    val role: String,           // Property on the edge
    val target: Organization    // Target node
)

This is useful for modeling:

  • Employment history (start date, role, organization)
  • Transaction records (timestamp, amount, target account)
  • Audit trails (timestamp, action, target entity)
  • Any relationship with metadata

3. GraphView - Composing Views

A @GraphView composes multiple fragments and relationships into a single query result:

@GraphView
data class PersonCareer(
    @Root val person: Person,  // Root fragment

    @GraphRelationship(type = "WORKS_FOR")
    val employmentHistory: List<WorkHistory>  // Relationship with properties
)

The @Root annotation marks which fragment is the query's starting point.

Loading Data

Load All Instances

@Component
class PersonService @Autowired constructor(
    private val graphObjectManager: GraphObjectManager
) {
    fun getAllPeople(): List<PersonCareer> {
        return graphObjectManager.loadAll(PersonCareer::class.java)
    }
}

Load by ID

fun getPerson(uuid: String): PersonCareer? {
    return graphObjectManager.load(uuid, PersonCareer::class.java)
}

Type-Safe Query DSL

The code generator creates a type-safe DSL for each @GraphView, giving you IntelliJ autocomplete and compile-time type checking.

Basic Filtering

// Load people whose bio contains "Lead"
val leads = graphObjectManager.loadAll<PersonCareer> {
    where {
        person.bio contains "Lead"  // Direct property access!
    }
}

Multiple Conditions (AND)

val results = graphObjectManager.loadAll<PersonCareer> {
    where {
        person.name eq "Alice Engineer"
        person.bio.isNotNull()
    }
}
// Generates: WHERE person.name = $p0 AND person.bio IS NOT NULL

OR Conditions

val results = graphObjectManager.loadAll<PersonCareer> {
    where {
        anyOf {
            person.name eq "Alice"
            person.name eq "Bob"
        }
    }
}
// Generates: WHERE (person.name = $p0 OR person.name = $p1)

Ordering

val results = graphObjectManager.loadAll<PersonCareer> {
    where {
        person.bio.isNotNull()
    }
    orderBy {
        person.name.asc()
    }
}

Available Operators

Comparison:

  • eq - equals (=)
  • neq - not equals (<>)
  • gt - greater than (>)
  • gte - greater than or equal (>=)
  • lt - less than (<)
  • lte - less than or equal (<=)
  • in - IN operator

String Operations:

  • contains - CONTAINS
  • startsWith - STARTS WITH
  • endsWith - ENDS WITH

Null Checking:

  • isNull() - IS NULL
  • isNotNull() - IS NOT NULL

Ordering:

  • asc() - ascending order
  • desc() - descending order

Saving Data

Simple Save (Dirty Tracking)

GraphObjectManager tracks loaded objects and only saves changed fields:

// Load an object
val person = graphObjectManager.load(uuid, PersonCareer::class.java)!!

// Modify it
val updated = person.copy(
    person = person.person.copy(bio = "Updated bio")
)

// Save - only dirty fields are written!
graphObjectManager.save(updated)

Save with Relationship Changes

val person = graphObjectManager.load(uuid, PersonCareer::class.java)!!

// Remove all employment history
val updated = person.copy(employmentHistory = emptyList())

graphObjectManager.save(updated, CascadeType.NONE)

Deleting Data

GraphObjectManager provides type-safe methods for deleting graph objects.

Delete by ID

// Delete a single node by UUID
val deleted = graphObjectManager.delete<Person>(uuid)

// Delete a GraphView's root node (relationships are detached)
graphObjectManager.delete<RaisedAndAssignedIssue>(issueUuid)

Delete with WHERE Clause

// Delete only if condition is met
graphObjectManager.delete<Issue>(uuid, "n.state = 'closed'")

// For GraphViews, use the root fragment alias
graphObjectManager.delete<RaisedAndAssignedIssue>(uuid, "issue.state = 'closed'")

Delete All with Filter

// Delete all matching a condition
graphObjectManager.deleteAll<Issue>("n.state = 'closed'")

// For GraphViews
graphObjectManager.deleteAll<RaisedAndAssignedIssue>("issue.locked = true")

Type-Safe DSL Delete

The most powerful way - uses generated DSL for compile-time type checking:

// Delete closed issues
graphObjectManager.deleteAll<RaisedAndAssignedIssue> {
    where {
        issue.state eq "closed"
    }
}

// Delete with multiple conditions
graphObjectManager.deleteAll<RaisedAndAssignedIssue> {
    where {
        issue.state eq "open"
        issue.locked eq true
    }
}

// Delete by relationship property
graphObjectManager.deleteAll<RaisedAndAssignedIssue> {
    where {
        assignedTo.name eq "Former Employee"
    }
}

// Delete all (no filter)
graphObjectManager.deleteAll<RaisedAndAssignedIssue> { }

Delete Behavior

All delete operations use DETACH DELETE:

  • Removes the node and all its relationships
  • Related nodes are not deleted (only the relationships to them)
  • Returns the count of deleted nodes
// Delete an issue - persons remain, only ASSIGNED_TO/RAISED_BY relationships removed
graphObjectManager.delete<RaisedAndAssignedIssue>(issueUuid)

// Verify related nodes still exist
val person = graphObjectManager.load<Person>(personUuid)  // Still there!

CASCADE Policies

When saving @GraphView objects with modified relationships, CascadeType determines what happens to target nodes:

CascadeType.NONE (Default - Safest)

Only deletes the relationship, leaves target nodes intact:

graphObjectManager.save(updated, CascadeType.NONE)

Use when: Target nodes are shared or should persist independently.

CascadeType.DELETE_ORPHAN (Safe Deletion)

Deletes relationship and target only if no other relationships exist to the target:

graphObjectManager.save(updated, CascadeType.DELETE_ORPHAN)

Use when: You want to clean up orphaned nodes but preserve shared ones.

Example: Removing a person's employment at a solo startup deletes the startup (orphaned), but removing employment at a company with other employees keeps the company.

CascadeType.DELETE_ALL (Destructive)

Always deletes both the relationship and target nodes:

graphObjectManager.save(updated, CascadeType.DELETE_ALL)

⚠️ Warning: Permanently deletes data. Use with caution.

Use when: Target nodes are exclusively owned and should be deleted with the relationship.

Session and Dirty Tracking

GraphObjectManager maintains a session that tracks loaded objects:

  1. On Load: Takes a snapshot of the object's state
  2. On Save: Compares current state to snapshot
  3. Optimization: Only writes changed fields (dirty checking)

This means:

  • Loaded objects: Optimized saves (only dirty fields)
  • New objects: Full saves (all fields written)

Generated Cypher Examples

Simple Load All

graphObjectManager.loadAll(PersonCareer::class.java)

Generates:

MATCH (person:Person:Mapped)

WITH
    person {
        bio: person.bio,
        name: person.name,
        uuid: person.uuid
    } AS person,

    [(person)-[employmentHistory_rel:WORKS_FOR]->(employmentHistory_target:Organization) |
        {
            startDate: employmentHistory_rel.startDate,
            role: employmentHistory_rel.role,
            target: employmentHistory_target {
                name: employmentHistory_target.name,
                uuid: employmentHistory_target.uuid
            }
        }
    ] AS employmentHistory

RETURN {
    person: person,
    employmentHistory: employmentHistory
} AS result

Filtered Query

graphObjectManager.loadAll<PersonCareer> {
    where {
        person.bio contains "Lead"
    }
}

Generates:

MATCH (person:Person:Mapped)
WHERE person.bio CONTAINS $p0

WITH person { ... } AS person,
     [...] AS employmentHistory

RETURN { person: person, employmentHistory: employmentHistory } AS result

Polymorphic Relationships

Drivine supports polymorphic relationship targets using Kotlin sealed classes with label-based type discrimination. This allows a single relationship to point to different node types.

Defining Polymorphic Types

Use a sealed class hierarchy with @NodeFragment labels to define polymorphic types:

// Base sealed class - the "WebUser" label is shared by all subtypes
@NodeFragment(labels = ["WebUser"])
sealed class WebUser {
    abstract val uuid: UUID
    abstract val displayName: String
}

// Subtype with additional "Anonymous" label
@NodeFragment(labels = ["WebUser", "Anonymous"])
data class AnonymousWebUser(
    override val uuid: UUID,
    override val displayName: String,
    val anonymousToken: String  // Subtype-specific property
) : WebUser()

// Subtype with additional "Registered" label
@NodeFragment(labels = ["WebUser", "Registered"])
data class RegisteredWebUser(
    override val uuid: UUID,
    override val displayName: String,
    val email: String  // Subtype-specific property
) : WebUser()

In Neo4j, nodes have multiple labels:

  • (:WebUser:Anonymous {displayName: "Guest", anonymousToken: "abc123"})
  • (:WebUser:Registered {displayName: "Alice", email: "[email protected]"})

Using Polymorphic Relationships

Reference the sealed class in your @GraphView:

@GraphView
data class GuideUserWithPolymorphicWebUser(
    @Root val core: GuideUser,
    @GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING)
    val webUser: WebUser?  // Polymorphic - could be Anonymous or Registered
)

When loading, Drivine automatically deserializes to the correct subtype based on labels:

val results = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> { }

results.forEach { guide ->
    when (val user = guide.webUser) {
        is AnonymousWebUser -> println("Anonymous: ${user.anonymousToken}")
        is RegisteredWebUser -> println("Registered: ${user.email}")
        null -> println("No web user")
    }
}

Filtering Polymorphic Types

There are two approaches to filter by polymorphic subtype:

Approach 1: Type-Specific View (Compile-Time)

Create a view that uses the specific subtype:

@GraphView
data class AnonymousGuideUser(
    @Root val core: GuideUser,
    @GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING)
    val webUser: AnonymousWebUser  // Specific type, not WebUser
)

// Only returns guides with AnonymousWebUser
val anonymousGuides = graphObjectManager.loadAll<AnonymousGuideUser> { }

The generated query automatically filters by the subtype's labels.

Approach 2: instanceOf DSL (Runtime)

Use instanceOf<T>() to filter at query time while keeping the polymorphic view:

import org.drivine.query.dsl.instanceOf

// Filter to only anonymous users
val results = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> {
    where {
        webUser.instanceOf<AnonymousWebUser>()
    }
}

// Combine with other conditions
val activeAnonymous = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> {
    where {
        core.guideProgress gte 10
        webUser.instanceOf<AnonymousWebUser>()
    }
}

// Use in OR conditions
val anonymousOrRegistered = graphObjectManager.loadAll<GuideUserWithPolymorphicWebUser> {
    where {
        anyOf {
            webUser.instanceOf<AnonymousWebUser>()
            webUser.instanceOf<RegisteredWebUser>()
        }
    }
}

The instanceOf<T>() function:

  • Extracts labels from the @NodeFragment annotation on type T
  • Generates a Cypher label check: WHERE EXISTS { ... WHERE webUser:WebUser:Anonymous }
  • Works with anyOf for OR conditions
Approach When to Use
Type-specific view You always want a specific subtype; compile-time type safety
instanceOf<T>() Dynamic filtering; single view for multiple subtypes

Required vs Optional Relationships

Drivine distinguishes between required (non-nullable) and optional (nullable) relationships in @GraphView classes.

Optional Relationships (Nullable)

When a relationship property is nullable, Drivine returns all root nodes, even those without the relationship:

@GraphView
data class GuideUserWithOptionalWebUser(
    @Root val core: GuideUser,
    @GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING)
    val webUser: WebUser?  // Nullable - relationship is optional
)

// Returns ALL GuideUsers, even those without a WebUser
val results = graphObjectManager.loadAll<GuideUserWithOptionalWebUser> { }
results.forEach { guide ->
    if (guide.webUser != null) {
        println("Has web user: ${guide.webUser.displayName}")
    } else {
        println("No web user")
    }
}

Required Relationships (Non-Nullable)

When a relationship property is non-nullable, Drivine automatically filters out root nodes that don't have the relationship:

@GraphView
data class GuideUserWithRequiredWebUser(
    @Root val core: GuideUser,
    @GraphRelationship(type = "IS_WEB_USER", direction = Direction.OUTGOING)
    val webUser: WebUser  // Non-nullable - relationship is required!
)

// Only returns GuideUsers that HAVE a WebUser
val results = graphObjectManager.loadAll<GuideUserWithRequiredWebUser> { }
// All results guaranteed to have webUser != null

The generated Cypher includes a WHERE EXISTS clause:

MATCH (core:GuideUser)
WHERE EXISTS { (core)-[:IS_WEB_USER]->(:WebUser) }  -- Filters out nodes without relationship
WITH core, ...
RETURN { ... }

This prevents MissingKotlinParameterException that would occur if a null value was deserialized into a non-nullable property.

Summary

Property Type Behavior Use Case
val webUser: WebUser? Returns all root nodes Optional relationship, handle null in code
val webUser: WebUser Filters to only nodes with relationship Required relationship, guaranteed non-null
val webUsers: List<WebUser> Returns all root nodes (empty list if none) Collection relationships are always safe

Core Features (PersistenceManager)

Fluent Query Building

val activeAdults = manager.query(
    QuerySpecification
        .withStatement<Any>("MATCH (p:Person) RETURN properties(p)")
        .transform(Person::class.java)
        .filter { it.age >= 18 }           // Client-side filtering
        .filter { it.email != null }
        .map { it.firstName }              // Transform to String
        .limit(10)
)

Chainable Transformations

val fullNames: List<String> = manager.query(
    QuerySpecification
        .withStatement<Any>("MATCH (p:Person) RETURN properties(p)")
        .transform(Person::class.java)    // Map to Person
        .filter { it.age > 25 }            // Filter
        .map { "${it.firstName} ${it.lastName}" }  // Transform to String
)

Transaction Management

@Component
class UserService @Autowired constructor(
    private val personRepo: PersonRepository,
    private val emailService: EmailService
) {
    @Transactional  // Spring's @Transactional works
    fun registerUser(person: Person) {
        val created = personRepo.create(person)
        emailService.sendWelcome(created.email)
        // Auto-commits on success, rolls back on exception
    }

    @DrivineTransactional  // Or use Drivine's annotation
    fun updateUserProfile(uuid: String, updates: Partial<Person>) {
        personRepo.update(uuid, updates)
    }
}

Partial Updates

val updates = partial<Person> {
    set(Person::email, "[email protected]")
    set(Person::age, 30)
}
personRepo.update(personId, updates)

External Query Files

Place .cypher files in src/main/resources/queries/:

// queries/findActiveUsers.cypher
MATCH (p:Person)
WHERE p.isActive = true
RETURN properties(p)

Load and use:

@Configuration
class QueryConfig @Autowired constructor(
    private val loader: QueryLoader
) {
    @Bean
    fun findActiveUsers() = CypherStatement(loader.load("findActiveUsers"))
}

@Component
class PersonRepository @Autowired constructor(
    @Qualifier("neoManager") val manager: PersistenceManager,
    val findActiveUsers: CypherStatement
) {
    fun getActive(): List<Person> {
        return manager.query(
            QuerySpecification
                .withStatement<Any>(findActiveUsers.statement)
                .transform(Person::class.java)
        )
    }
}

Multiple Query Results

// Expect exactly one result (throws if 0 or >1)
val person: Person = manager.getOne(spec)

// Expect 0 or 1 result (returns null if not found)
val maybePerson: Person? = manager.maybeGetOne(spec)

// Return all results
val people: List<Person> = manager.query(spec)

// Execute without returning results (for mutations)
manager.execute(spec)

API Reference

PersistenceManager

interface PersistenceManager {
    fun <T> query(spec: QuerySpecification<T>): List<T>
    fun <T> getOne(spec: QuerySpecification<T>): T
    fun <T> maybeGetOne(spec: QuerySpecification<T>): T?
    fun <T> execute(spec: QuerySpecification<T>)
}

QuerySpecification

QuerySpecification
    .withStatement<T>(cypherQuery)       // Start with Cypher query
    .bind(params)                        // Bind parameters
    .transform(TargetClass::class.java)  // Map to target type
    .filter { predicate }                // Client-side filtering
    .map { transformation }              // Transform results
    .limit(n)                            // Limit results
    .skip(n)                             // Skip first n results

ConnectionProperties

data class ConnectionProperties(
    val host: String = "localhost",
    val port: Int = 7687,
    val username: String? = null,
    val password: String? = null,
    val database: String? = null,
    val encrypted: Boolean = false
)

Binding Objects

Use bindObject() to serialize objects to Neo4j-compatible types using Jackson:

// Automatically converts Enums to String, UUID to String, Instant to ZonedDateTime
val task = Task(id = "1", priority = Priority.HIGH, status = Status.OPEN, dueDate = Instant.now())
manager.execute(
    QuerySpecification
        .withStatement("CREATE (t:Task) SET t = $props")
        .bindObject("props", task)
)

The Neo4j ObjectMapper automatically:

  • Converts Enum to String
  • Converts UUID to String
  • Converts Instant to ZonedDateTime
  • Converts Date to ZonedDateTime
  • Includes null values by default (allows explicit property removal)
  • Ignores unknown properties when deserializing

To exclude nulls on specific properties, use @JsonInclude(JsonInclude.Include.NON_NULL).

Multi-Database Support

@Configuration
class MultiDbConfig {
    @Bean
    fun dataSourceMap(): DataSourceMap {
        return DataSourceMap(mapOf(
            "analytics" to ConnectionProperties(
                host = "analytics.neo4j.com",
                database = "analytics"
            ),
            "users" to ConnectionProperties(
                host = "users.neo4j.com",
                database = "users"
            )
        ))
    }
}

@Component
class AnalyticsRepository @Autowired constructor(
    @Qualifier("analytics") val manager: PersistenceManager
) { /* ... */ }

@Component
class UserRepository @Autowired constructor(
    @Qualifier("users") val manager: PersistenceManager
) { /* ... */ }

Testing

@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PersonRepositoryTest @Autowired constructor(
    private val repository: PersonRepository
) {
    @Test
    fun `should find person by city`() {
        val results = repository.findByCity("New York")
        assertThat(results).isNotEmpty
    }
}

Automated Test Configuration (Testcontainers + Local Dev)

Drivine provides @EnableDrivineTestConfig for seamless test setup that works in both local development and CI:

1. Define datasource in application-test.yml:

database:
  datasources:
    neo:
      host: localhost
      port: 7687
      username: neo4j
      password: password
      type: NEO4J
      database-name: neo4j

2. Use @EnableDrivineTestConfig in your test configuration:

@Configuration
@EnableDrivine
@EnableDrivineTestConfig
class TestConfig

3. Control behavior with environment variable:

# Use local Neo4j (for development - fast, inspectable)
export USE_LOCAL_NEO4J=true
./gradlew test

# Use Testcontainers (for CI - isolated, default)
./gradlew test  # USE_LOCAL_NEO4J defaults to false

What happens automatically:

  • Local Mode (USE_LOCAL_NEO4J=true): Uses your application-test.yml settings as-is, connects to your local Neo4j
  • CI Mode (default): Starts a Neo4j Testcontainer automatically and overrides host/port/password from your properties

Benefits:

  • ✅ One configuration works for both local dev and CI
  • ✅ Zero boilerplate - no manual container setup
  • ✅ Fast local development with real Neo4j
  • ✅ Reliable CI with Testcontainers
  • ✅ Easy debugging - set @Rollback(false) and inspect your local DB

Manual TestContainers Setup

If you need more control, you can still configure Testcontainers manually:

@Configuration
@EnableDrivine
class TestConfig {
    @Bean
    fun dataSourceMap(): DataSourceMap {
        val props = ConnectionProperties(
            host = extractHost(DrivineTestContainer.getConnectionUrl()),
            port = extractPort(DrivineTestContainer.getConnectionUrl()),
            userName = DrivineTestContainer.getConnectionUsername(),
            password = DrivineTestContainer.getConnectionPassword(),
            type = DatabaseType.NEO4J,
            databaseName = "neo4j"
        )
        return DataSourceMap(mapOf("neo" to props))
    }

    private fun extractHost(boltUrl: String): String =
        boltUrl.substringAfter("bolt://").substringBefore(":")

    private fun extractPort(boltUrl: String): Int =
        boltUrl.substringAfter("bolt://").substringAfter(":").toIntOrNull() ?: 7687
}

Java Interoperability

Drivine4j provides full runtime support for Java, but with some considerations for the type-safe DSL.

What Works in Java

Full Runtime Support:

  • All @NodeFragment classes work in both Java and Kotlin
  • @GraphView classes work at runtime in both languages
  • @RelationshipFragment classes work in both languages
  • GraphObjectManager loading and saving works with Java classes
  • Polymorphic types work with Java classes (using @JsonSubTypes or manual registration)

Java Features:

  • Generic collections (List<Person>) are properly handled using Java reflection
  • Nested @GraphView relationships work
  • All annotations (@Root, @GraphRelationship, @NodeId, etc.) work on Java fields

Type-Safe DSL Limitation

DSL Generation (KSP limitation):

  • The code generator only processes Kotlin source files
  • Java @GraphView classes won't get generated DSL
  • You can still load them, just without the type-safe query builder

Recommended Pattern for Java Projects

Best Practice: Define @GraphView classes in Kotlin, everything else can be Java.

// Java fragments work great!
@NodeFragment(labels = {"Person"})
public class Person {
    @NodeId public UUID uuid;
    public String name;
    public String bio;
}

@NodeFragment(labels = {"Organization"})
public class Organization {
    @NodeId public UUID uuid;
    public String name;
}
// Define GraphViews in Kotlin to get DSL generation
@GraphView
data class PersonContext(
    @Root val person: Person,  // Java class!
    @GraphRelationship(type = "WORKS_FOR")
    val worksFor: List<Organization>  // Java class!
)
// Use from Java with full type-safe DSL support!
public class PersonService {
    @Autowired
    private GraphObjectManager graphObjectManager;

    public List<PersonContext> findByOrganization(String orgName) {
        return graphObjectManager.loadAll(
            PersonContext.class,
            spec -> {
                spec.where(ctx -> {
                    ctx.getQuery().getWorksFor().getName().eq(orgName);
                });
                return null;
            }
        );
    }
}

Alternative: Pure Java Without DSL

If you prefer pure Java, you can still use GraphObjectManager without the DSL:

@GraphView
public class JavaPersonContext {
    @Root public Person person;

    @GraphRelationship(type = "WORKS_FOR", direction = Direction.OUTGOING)
    public List<Organization> worksFor;
}

// Works at runtime - no DSL, but fully functional
List<JavaPersonContext> all = graphObjectManager.loadAll(JavaPersonContext.class);
JavaPersonContext person = graphObjectManager.load(uuid, JavaPersonContext.class);

Summary

Feature Java Support Notes
@NodeFragment ✅ Full Works identically in Java and Kotlin
@RelationshipFragment ✅ Full Works identically in Java and Kotlin
@GraphView runtime ✅ Full Loading, saving, polymorphism all work
@GraphView DSL generation ❌ Kotlin only KSP limitation
Generic collections ✅ Full Java reflection handles List<T>, Set<T>
Nested relationships ✅ Full Works with Java classes
Polymorphic types ✅ Full Works with @JsonSubTypes

Recommendation: Use Kotlin for @GraphView definitions to get the type-safe DSL, and use Java for everything else if preferred.

Building from Source

# Run tests
./gradlew test

# Build library
./gradlew build

# Publish to local Maven (~/.m2/repository)
./gradlew publishToMavenLocal

License

Apache License 2.0

Links

About

drivine4j

Resources

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published