A graph database client library for Java and Kotlin supporting Neo4j, FalkorDB, Amazon Neptune, and Memgraph with two approaches to graph mapping:
- PersistenceManager - Low-level API with manual Cypher queries (classic Drivine approach)
- GraphObjectManager - High-level API with annotated models and type-safe DSL.
Drivine4j is the graph database client library for Embabel - Agentic AI for the JVM.
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.
- Java 21+
- Kotlin:
- For PersistenceManager API: Any Kotlin version
- For GraphObjectManager API: Kotlin 2.2.0+ (requires context parameters feature)
dependencies {
implementation("org.drivine:drivine4j:0.0.77")
}dependencies {
implementation 'org.drivine:drivine4j:0.0.77'
}<dependency>
<groupId>org.drivine</groupId>
<artifactId>drivine4j</artifactId>
<version>0.0.77</version>
</dependency>If you want to use GraphObjectManager with the type-safe query DSL, you need to add the code generation processor.
Note for Java Projects: Both Java and Kotlin are fully supported at runtime. The code generator (KSP) produces Kotlin DSL extensions, but a Java-friendly query builder API is also available. Define your
@GraphViewand@NodeFragmentclasses in either language. See the Java Interoperability section for details.
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.77")
ksp("org.drivine:drivine4j-codegen:0.0.77")
}<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.77</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>Note: Maven support for KSP uses the third-party kotlin-maven-symbol-processing extension.
@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))
}
}data class Person(
val uuid: String,
val firstName: String,
val lastName: String,
val email: String?,
val age: Int
)@Component
class PersonRepository @Autowired constructor(
@Qualifier("neoManager") val manager: PersistenceManager
) {
@Transactional
fun findByCity(city: String): List<Person> {
return manager.query(
QuerySpecification
.withStatement("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("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("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(
"MATCH (p:Person {uuid: \$uuid}) SET p += \$props RETURN properties(p)"
)
.bind(mapOf("uuid" to uuid, "props" to props))
.transform(Person::class.java)
)
}
}When using PersistenceManager with Cypher queries, always return a single map or scalar value -- not multiple columns. This ensures correct mapping with .transform() and avoids issues with NULL values.
-- WRONG: Multiple columns -- hard to map, NULL values cause errors
RETURN a.name, a.age, b.title
-- CORRECT: Return a single map
RETURN { name: a.name, age: a.age, title: b.title } AS result
-- CORRECT: Return a single property map
RETURN properties(p)
-- CORRECT: Return a scalar value
RETURN count(p) AS totalIf you need to aggregate across multiple nodes, compose the result into a single map in your RETURN clause:
MATCH (p:Proposition)-[:HAS_MENTION]->(m:Mention)
WITH m.type AS entityType, m.name AS name, count(p) AS mentionCount
ORDER BY mentionCount DESC
LIMIT 30
RETURN {
entityType: entityType,
name: name,
mentionCount: mentionCount
} AS resultThis way .transform(MyDto::class.java) can map the result directly to a data class.
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.
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
)Defaults for missing properties. When a node is missing a property, the value loads as null — which fails for a non-nullable type. Two annotations handle that without any Jackson knowledge:
@NodeFragment(labels = ["User"])
data class UserNode(
@NodeId val id: String,
@Default val roles: List<String> = emptyList(), // missing/null → the declared default []
@Default val status: String = "active", // missing/null → "active"
@EmptyWhenAbsent val tags: List<String>, // missing/null → [] (no default needed)
)@Defaultfalls back to the property's declared default (a Kotlin constructor default, or a Java field initializer). Works for any type; a provided value always wins.@EmptyWhenAbsentmaps an absent/null collection or map to empty, with no declared default required — the right choice for Java records, whose components have no field initializer:public record UserNode(@NodeId String id, @EmptyWhenAbsent List<String> roles) {}
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
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.
Graph databases excel at recursive structures — ontologies, org charts, location hierarchies. Drivine supports self-referential @GraphView classes where a relationship targets its own type, expanding to a configurable depth using nested pattern comprehensions.
Define a recursive view:
@NodeFragment(labels = ["Location"])
data class Location(
@NodeId val uuid: UUID,
val name: String,
val type: String
)
@GraphView
data class LocationHierarchy(
val location: Location,
@GraphRelationship(type = "HAS_LOCATION", direction = Direction.OUTGOING, maxDepth = 3)
val subLocations: List<LocationHierarchy> // Self-referential!
)maxDepth = 3 means Drivine expands 3 levels deep. Loading a continent produces:
Europe (continent)
├── Western Europe (region)
│ ├── France (country) → subLocations: []
│ ├── Germany (country) → subLocations: []
│ └── ...
├── Northern Europe (region)
│ ├── Sweden (country) → subLocations: []
│ └── ...
└── ...
At the terminal depth, collections become [] and nullable singles become null.
Generated Cypher (abbreviated):
MATCH (location:Location)
WITH
location { name: location.name, type: location.type, uuid: location.uuid } AS location,
[(location)-[:HAS_LOCATION]->(sub_d1:Location) |
sub_d1 {
location: { name: sub_d1.name, type: sub_d1.type, uuid: sub_d1.uuid },
subLocations: [(sub_d1)-[:HAS_LOCATION]->(sub_d2:Location) |
sub_d2 {
location: { name: sub_d2.name, ... },
subLocations: [(sub_d2)-[:HAS_LOCATION]->(sub_d3:Location) |
sub_d3 { location: { ... }, subLocations: [] }
]
}
]
}
] AS subLocations
RETURN { location: location, subLocations: subLocations } AS resultTraversing upward — create a different view with Direction.INCOMING:
@GraphView
data class LocationAncestry(
val location: Location,
@GraphRelationship(type = "HAS_LOCATION", direction = Direction.INCOMING, maxDepth = 3)
val parent: LocationAncestry? // Nullable single — each location has at most one parent
)Loading "France" returns France → Western Europe → Europe → (terminated).
Query-time depth override:
graphObjectManager.loadAll<LocationHierarchy> {
depth("subLocations", 5) // Override annotation's maxDepth=3 to 5
where { query.location.type eq "continent" }
}Chain cycles (A → B → A) are also supported. When a relationship targets a @GraphView that forms a cycle through other types, Drivine tracks visit counts and terminates at maxDepth:
@GraphView
data class PersonOrgView(
val person: Person,
@GraphRelationship(type = "WORKS_FOR", direction = Direction.OUTGOING)
val employer: OrgPersonView?
)
@GraphView
data class OrgPersonView(
val org: Organization,
@GraphRelationship(type = "EMPLOYS", direction = Direction.OUTGOING, maxDepth = 2)
val employees: List<PersonOrgView>
)@GraphRelationship is a single hop. @GraphPath traverses several and maps only the final node, skipping the ones in between:
@GraphView
data class ActorDirectors(
@Root val actor: Actor,
@GraphPath([
Hop("ACTED_IN", Direction.OUTGOING, label = "Movie"), // through Movie — not mapped
Hop("DIRECTED_BY", Direction.OUTGOING), // to Director
])
val directors: List<Director>,
)The far node is de-duplicated (an actor who made two movies by the same director gets that director once). Field cardinality mirrors @GraphRelationship: List<T> is a collection, T? a single optional, T a required single (roots lacking the path are filtered out). Each Hop's label optionally constrains the node it reaches; maxDepth does not apply (a path is a fixed hop list, not variable-length recursion).
@Count and @Aggregate add per-root scalar fields computed in the query, so you don't load a collection just to size or summarize it:
@GraphView
data class ActorStats(
@Root val actor: Actor,
@Count("ACTED_IN") val movieCount: Long,
@Aggregate(AggregateFunction.AVG, type = "RATED", property = "score") val avgRating: Double,
@Aggregate(AggregateFunction.SUM, type = "RATED", property = "score") val totalRating: Double,
)@Count needs no property; SUM/AVG/MIN/MAX aggregate a numeric property of the related nodes. Aggregates are single-hop. For group-by ranking (top-N), use PersistenceManager + .transform<T>() with Cypher — that's not a node-rooted view.
All three — path traversal and aggregates — work identically across Neo4j, Memgraph, and FalkorDB.
@Component
class PersonService @Autowired constructor(
private val graphObjectManager: GraphObjectManager
) {
fun getAllPeople(): List<PersonCareer> {
return graphObjectManager.loadAll<PersonCareer>()
}
}fun getPerson(uuid: String): PersonCareer? {
return graphObjectManager.load<PersonCareer>(uuid)
}count returns a Long and is consistent with loadAll — it counts exactly the objects loadAll would return for the same type and filter. There are three overloads, mirroring loadAll/deleteAll:
// 1. Count everything of a type (reified, or pass Issue::class.java)
val total: Long = graphObjectManager.count<Issue>()
// 2. Count with a simple WHERE filter (aliases match loadAll: `n` for fragments,
// the root fragment field name for views)
graphObjectManager.count<Issue>("n.state = 'open'")
// 3. Count with the type-safe DSL (generated per-view extension injects the query object)
graphObjectManager.count<RaisedAndAssignedIssue> {
where { query.issue.state eq "open" }
}The codegen emits INSTANCE-injecting extensions for every DSL-spec method — loadAll<T> { },
deleteAll<T> { }, count<T> { }, and loadNearest<T>(…) { } (the last only for @VectorIndex-ed
views). The reified count<T>() / loadNearest<T>(…) and the explicit ::class.java overloads also
remain (the latter for Java).
Fragments are a straight node count of the fragment's labels.
Views count only roots that satisfy the view's required relationships — non-optional, non-collection @GraphRelationships — so the result equals loadAll(...).size, not a naive node count. For example, RaisedAndAssignedIssue requires raisedBy (a single, non-null RAISED_BY):
graphObjectManager.count(Issue::class.java) // 3 — every Issue node
graphObjectManager.count(RaisedAndAssignedIssue::class.java) // 2 — only Issues with a RAISED_BY edgeAn Issue with no RAISED_BY is a valid Issue node but is not a RaisedAndAssignedIssue, so it is excluded — just as loadAll would exclude it. Optional (nullable) and collection relationships place no such constraint.
From Java, the same three overloads apply (use .count(Issue.class) etc.); the DSL overload takes the generated query object and a lambda.
loadNearest runs an approximate nearest-neighbour search and returns the hits paired with a
normalized similarity score. Like loadAll, it works on both a @GraphView — searching the
root fragment's embedding and returning the fully-projected view — and a plain @NodeFragment,
searching and returning the bare nodes:
// Search a view: ranks PropositionViews by their root proposition's embedding
val views: List<Scored<PropositionView>> =
graphObjectManager.loadNearest(PropositionView::class.java, queryEmbedding, topK = 20)
// Search a fragment: ranks bare PropositionNodes
val nodes: List<Scored<PropositionNode>> =
graphObjectManager.loadNearest(PropositionNode::class.java, queryEmbedding, topK = 20)The embedding to search is identified by a @VectorIndex annotation on the searched fragment (the
view's root fragment, or the fragment itself) —
inferred when the fragment declares one embedding, or named explicitly to pick among
several. @VectorIndex is the query-side declaration of "this embedding is searchable" and is
independent of how the index is created: it works whether you create the index from the annotation
(SchemaCatalog.fromFragments(...)) or from an explicit spec (SchemaCatalog.of(VectorIndexSpec(...)));
in the latter case, add the annotation to the fragment as well — it carries no creation side effects
on its own.
@NodeFragment(labels = ["Proposition"])
data class PropositionNode(
@NodeId val id: String,
val text: String,
@VectorIndex(similarity = SimilarityFunction.COSINE)
val embedding: List<Float>? = null,
)
@GraphView
data class PropositionView(
@Root val proposition: PropositionNode,
@GraphRelationship(type = "HAS_MENTION", direction = Direction.OUTGOING)
val mentions: List<Mention>,
)// Single embedding on the root fragment → inferred, no property argument
val hits: List<Scored<PropositionView>> =
graphObjectManager.loadNearest(PropositionView::class.java, queryEmbedding, topK = 20)
hits.forEach { println("${it.score} → ${it.value.proposition.text}") }
// Disambiguate when a node carries several embeddings, and/or floor by similarity
graphObjectManager.loadNearest(PropositionView::class.java, "titleEmbedding", queryEmbedding, topK = 20, threshold = 0.8)Each result is a Scored<T>(value, score); the score is normalized to similarity, higher = more
similar on every engine, so ordering and threshold mean the same thing regardless of backend.
topK is the index's k, not a guaranteed result count. When searching a view, its
required relationships (and the optional threshold) are applied after the K-nearest search,
so a candidate that ranks in the top K but fails the filter is dropped — meaning loadNearest can
return fewer than topK results. A fragment search has no relationship filter, so it returns
the full top K (minus any threshold cut).
k is also the search beam width — this is the surprising part. On Lucene-backed engines the
result queue is the HNSW candidate queue, so k does not merely truncate a ranked list; it decides
how much of the graph the search explores. A small k can miss a vector that is genuinely nearest.
This is measurable, not theoretical: on a 9K-vector index, a vector verified as true global rank 3 was
not returned at any k ≤ 100, and came back at rank 3 at k = 200. Neo4j exposes no separate
ef_search, so raising k is the only query-time lever on recall.
Use searchK to widen the search without widening the result:
// search with a beam of 200, return the best 40 that survive the filter
graphObjectManager.loadNearest<PropositionView>(dsl, queryEmbedding, topK = 40, searchK = 200) {
where { proposition.contextId eq ctx }
}searchK is what the index is asked for; topK becomes a LIMIT applied after the filter, so
over-fetching actually recovers rows the filter would otherwise have thinned away. Omit it and the
emitted query is unchanged. searchK < topK throws — it can only lose results.
On Neo4j, over-fetching also re-ranks the beam by exact similarity before trimming: where the index quantizes, its own score is computed against quantized vectors and does not order identically to exact similarity, so trimming by it discards true matches the beam already found. Whether an index quantizes depends on the engine build unless you pin it.
Filtered searches dilute. Because predicates apply after the index yields, a scoped caller gets
roughly k × selectivity rows — at 22% selectivity, asking for 40 returns about 9 — and those
survivors are the globally-nearest that happen to be in scope, not the nearest within scope. searchK
mitigates this. See 0.0.79-vector-search-k.md.
Searching a partition (partitionLabel). When the same property is indexed per partition — one
vector index per corpus, tenant, or other scope — name the partition and the search runs inside it,
pre-filtered by construction, so k is no longer diluted by the filter:
persistenceManager.indexes.ensure(VectorIndexSpec("Corpus_abc", "embedding", 1536))
graphObjectManager.loadNearest(
PropositionNode::class.java, null, queryEmbedding,
topK = 40, threshold = null, searchK = null, partitionLabel = "Corpus_abc",
)The label changes only which index is located: the node still carries its own :Proposition label, so
where { } and the projection are unaffected. The index name re-derives as
${label}_${property}_vector, matching what VectorIndexSpec would create — so a fragment whose
@VectorIndex pins an explicit name cannot be partitioned, and says so.
See 0.0.79-vector-partitioning.md.
Backends without a native vector index (Amazon Neptune) throw UnsupportedOperationException.
Pinning the physical index. Only dimensions and similarity_function are always emitted;
everything else about the physical index is the engine's choice, and engine defaults change between
versions — so the same declaration can produce a different index on different servers. Pin the HNSW
graph shape portably on the annotation, and anything engine-specific on a VectorIndexSpec:
@VectorIndex(similarity = SimilarityFunction.COSINE, hnswM = 32, hnswEfConstruction = 200)
val embedding: List<Float>? = null
// engine-specific options — Neo4j quantization, FalkorDB efRuntime — in a catalog spec
SchemaCatalog.of(
VectorIndexSpec("Proposition", "embedding", 1536,
engineOptions = listOf(Neo4jVectorOptions(quantizationEnabled = false))),
)An unpinned parameter stays the engine's to choose and is never reported as drift; a pinned one the engine contradicts is. The effective configuration is logged after every index creation either way. See 0.0.79-vector-index-tuning.md.
Filtering with where { }. A where { } block AND-s caller predicates into the same
post-search filter, so you can combine vector similarity with property predicates and
relationship quantifiers in one statement:
// nearest propositions in this context that mention entity X (reified form generated per @VectorIndex view)
graphObjectManager.loadNearest<PropositionView>(queryVector, topK = 20) {
where {
proposition.contextId eq ctx
proposition.status eq "active"
mentions.any { resolvedId eq entityId } // any{} / none{} over a projected relationship
}
}The codegen emits this loadNearest<T>(vector, topK, threshold) { where { } } extension for each
@VectorIndex-bearing view (its root fragment) and bare fragment (mirroring the generated
loadAll { }). The filtered form works on a fragment too — filter its own properties directly:
// filtered vector search over a bare @NodeFragment
graphObjectManager.loadNearest(ChunkNode::class.java, ChunkNodeQueryDsl.INSTANCE, queryVector, topK = 20) {
where { query.containerSectionId eq "sec-1" }
}The predicate is applied after the K-nearest search, so it really does prune — a node that ranks
in the top K but fails the predicate is dropped, and topK remains the index's k (the result may
contain fewer rows). Property predicates filter the projected root; relationship quantifiers
(any{}/none{}) filter the projected relationship collection (any(m IN mentions WHERE …)).
Multiple any{} AND together — "mentions all of these entities" is one any{} per id. Referencing
a relationship the view does not project is an error.
loadMatching is the full-text mirror of loadNearest: it finds the topK nodes most relevant to a
text query and returns them as scored, typed results — normalized to a consistent [0, 1] similarity
across engines, with no consumer Cypher and no per-engine score wrangling.
// bare @NodeFragment
val hits: List<Scored<ChunkNode>> = graphObjectManager.loadMatching<ChunkNode>("graph databases", topK = 20)
// with a floor on the normalized relevance
graphObjectManager.loadMatching<ChunkNode>("graph databases", topK = 20, threshold = 0.5)
// a @GraphView searches its root fragment's text index and returns the projected view
graphObjectManager.loadMatching<ChunkView>("graph databases", topK = 20)The index is resolved from a @FullTextIndex on the searched fragment (property-level for a single
field, or class-level @FullTextIndex(properties = ["title", "body"]) for a multi-property index) — the
same annotation the schema feature uses to create it. threshold defaults to 0.0 (keep everything);
topK is applied as a trailing LIMIT. Each result is a Scored<T>(value, score), most relevant
first, with polymorphic dispatch (a loadMatching<SealedBase> returns each hit as its concrete subtype).
The query string is passed through to the engine's full-text language (Lucene syntax on Neo4j:
AND/OR/"phrase"/field:term); raw user input is not escaped for you. Backends without a native
full-text index throw UnsupportedOperationException.
Filtering with where { } works exactly like loadNearest — full-text relevance plus property
predicates in one query, on a view or a bare fragment:
graphObjectManager.loadMatching(ChunkNode::class.java, ChunkNodeQueryDsl.INSTANCE, "graph databases", topK = 20) {
where { query.containerSectionId eq "sec-1" }
}Engine note: full-text search runs on Neo4j (
db.index.fulltext), FalkorDB (db.idx.fulltext, by label), and Memgraph (text_search, GA — no experimental flag needed).
The code generator creates a type-safe DSL for each @GraphView, giving you IntelliJ autocomplete and compile-time type checking.
// Load people whose bio contains "Lead"
val leads = graphObjectManager.loadAll<PersonCareer> {
where {
person.bio contains "Lead" // Direct property access!
}
}val results = graphObjectManager.loadAll<PersonCareer> {
where {
person.name eq "Alice Engineer"
person.bio.isNotNull()
}
}
// Generates: WHERE person.name = $p0 AND person.bio IS NOT NULLval results = graphObjectManager.loadAll<PersonCareer> {
where {
anyOf {
person.name eq "Alice"
person.name eq "Bob"
}
}
}
// Generates: WHERE (person.name = $p0 OR person.name = $p1)The DSL isn't only for @GraphViews — the code generator also emits a <Fragment>QueryDsl for every
bare @NodeFragment, so you can loadAll / count / deleteAll a node type directly with a typed
where over its own properties. Import the query receiver:
import org.drivine.query.dsl.query
// reified form — filters ChunkNode's own properties (query.<kotlinFieldName>, not the on-disk name)
val chunks = graphObjectManager.loadAll<ChunkNode> {
where { query.containerSectionId eq "sec-1" }
}
graphObjectManager.count<ChunkNode> { where { query.containerSectionId eq "sec-1" } }
// a sealed fragment dispatches each row to its concrete subtype; narrow with instanceOf()
graphObjectManager.loadAll<ContentElementNode> { where { query.instanceOf<ChunkNode>() } }
// to filter a subtype by its OWN property, use the explicit 3-arg form with that subtype's DSL
graphObjectManager.loadAll(ChunkNode::class.java, ChunkNodeQueryDsl.INSTANCE) {
where { query.containerSectionId eq "sec-1" }
}Use query.<field> (the Kotlin field name); @GraphProperty on-disk names are mapped for you. The same
<Fragment>QueryDsl.INSTANCE powers the filtered loadNearest / loadMatching forms above.
Filter a root by a predicate over the elements of a List<>-typed @GraphRelationship. The block
is scoped to the target fragment's properties, and several conditions inside one block correlate
to a single related element.
// propositions that have at least one mention resolving to this entity
graphObjectManager.loadAll<PropositionView> {
where { mentions.any { resolvedId eq entityId } }
}
// ... or to any of several
graphObjectManager.loadAll<PropositionView> {
where { mentions.any { resolvedId inList entityIds } }
}
// propositions with NO mention resolving to this entity (also matches propositions with no mentions)
graphObjectManager.loadAll<PropositionView> {
where { mentions.none { resolvedId eq entityId } }
}
// correlated: a single mention that is BOTH the subject AND resolves to this entity
graphObjectManager.loadAll<PropositionView> {
where { mentions.any { role eq "SUBJECT"; resolvedId eq entityId } }
}Rendered as an existence subquery, per engine — EXISTS { (root)-[:HAS_MENTION]->(m) WHERE … } on
Neo4j, size([(root)-[:HAS_MENTION]->(m) WHERE … | 1]) > 0 on Memgraph, a CALL-subquery count on
FalkorDB — with none{} wrapping it in NOT (…). any{} is the explicit form of the flat
mentions.resolvedId eq id shorthand; prefer it for none, for correlated multi-condition blocks,
or for clarity. (Backends without subquery support throw, per the grammar default.)
Filter on a caller value being contained in a list-valued node property — the mirror of inList
(inList is property in caller-list; hasItem is caller-value in list-property):
// propositions whose `grounding: List<String>` contains this chunk id
graphObjectManager.loadAll<PropositionView> {
where { proposition.grounding hasItem "chunk-1" }
}
// -> WHERE 'chunk-1' IN proposition.groundingRenders as portable openCypher ($value IN node.listProp) on Neo4j, FalkorDB, and Memgraph, AND-s
with any other predicate, and works inside loadNearest { where { } }. (Named hasItem rather than
contains because Kotlin reserves operator fun contains for the in operator and requires it to
return Boolean, while the DSL operators register by side-effect.)
val results = graphObjectManager.loadAll<PersonCareer> {
where {
person.bio.isNotNull()
}
orderBy {
person.name.asc()
}
}limit(n) and skip(n) push a row bound into the generated Cypher (bound as $_limit / $_skip,
after ORDER BY), so "top 20 by recency" is done database-side instead of over-fetching:
// top 20
graphObjectManager.loadAll<PropositionView> {
orderBy { proposition.created.desc() }
limit(20)
}
// page 3 (20 per page)
graphObjectManager.loadAll<PropositionView> {
orderBy { proposition.created.desc() }
skip(40)
limit(20)
}For large or changing result sets, prefer keyset pagination with seek. Its properties must
match the root orderBy properties in the same order; Drivine derives each comparison from the
sort direction and builds the lexicographic continuation predicate. End with a unique key so ties
cannot be skipped or duplicated:
graphObjectManager.loadAll<SessionView> {
orderBy {
session.lastActivityAt.desc()
session.sessionId.desc()
}
seek {
session.lastActivityAt after cursor.lastActivityAt
session.sessionId after cursor.sessionId
}
limit(pageSize)
}Each cursor value is the corresponding property of the last row of the previous page. To tell the
caller whether a next page exists without a second query, ask for limit(pageSize + 1), return the
first pageSize results, and treat the presence of the extra row as hasMore.
The ordered properties must be non-null in the data. A row whose sort key is null satisfies no
comparison, so it is dropped from every page after the first — and engines disagree about where
nulls sort, so the shape of that loss is not even portable. Use non-null properties as keys, or
filter nulls out in where.
Index the cursor. Keyset pagination is only cheap if the engine can seek into an index and stop;
otherwise it scans the whole continuation and takes the top n, which is no better than skip. The
rule is that the index mirrors the cursor — a range index over exactly the orderBy properties, in
the same order:
@NodeFragment(labels = ["Session"])
@RangeIndex(properties = ["lastActivityAt", "sessionId"]) // matches the cursor below
data class SessionNode(
@NodeId val sessionId: String,
val lastActivityAt: Instant,
)orderBy {
session.lastActivityAt.desc()
session.sessionId.desc()
}
seek {
session.lastActivityAt after cursor.lastActivityAt
session.sessionId after cursor.sessionId
}A single-property cursor takes a single-property index (@RangeIndex on the field) — same rule, one
key. Profiled on Neo4j 25, 200k nodes, a 20-row page from the middle of the relation:
| Index | Database accesses |
|---|---|
| Composite over both cursor keys | 27 |
| Single-property on the leading key only | 200,020 |
| None | 500,010 |
With the matching index the plan is NodeIndexSeek → Limit — no sort at all, since the index
supplies the order, and it stops as soon as the page is full.
Why an index that covers only part of the cursor is so much worse: Drivine constrains every cursor
key with IS NOT NULL, because a Neo4j index excludes nodes that lack the property, so the planner
will not use a composite index unless the query provably excludes those same nodes. That conjunct is
what makes the composite index usable; against an index that doesn't contain the property, it is
just an extra property read per row. Hence: mirror the cursor, and neither half of that applies.
This applies to Neo4j. Memgraph and FalkorDB cannot satisfy an ORDER BY from an index — both
place a blocking sort between the scan and the limit, measured even in the simplest case of one
indexed property and no predicate. So on those engines keyset pagination costs O(remaining) rather
than O(page), no index will change that, and Drivine neither emits the IS NOT NULL conjuncts (which
cost Memgraph its range bound) nor gives index advice there. seek is still worth using on them for
its stability — pages that don't shift under concurrent writes — just not for its cost.
Drivine tells you when the index is missing. Any root-level orderBy — with or without seek —
is checked against the database's indexes, and reports when nothing mirrors it:
seek on Session(lastActivityAt, sessionId) has no matching range index, so the query scans and
sorts instead of seeking. Declare @RangeIndex(properties = ["lastActivityAt", "sessionId"]) on the
fragment, or call indexes.ensure(RangeIndexSpec("Session", listOf("lastActivityAt", "sessionId"))).
The index must cover exactly these properties, in this order.
Ordering without an index is correct, just unindexed — perfectly reasonable on a small collection — so the default only warns, once per label/property combination. Turn it up in development or CI so an unindexed page fails the build instead of quietly scanning in production:
drivine:
query:
index-advice: FAIL # WARN (default) | OFFThe property applies to every manager the factory creates. Without Spring, or to override a single manager after it has been handed out:
graphObjectManager.indexAdvice = IndexAdvicePolicy.FAILA query already pinned to a single root — a top-level where equality on the @NodeId, or on a
property carrying a single-property uniqueness constraint — is never advised on. Its ordering is over
one row, so no index could change the plan. An equality inside anyOf doesn't count, since it
constrains only one branch of the OR.
The index list is read once and cached per manager, so the check costs one round trip per process,
not one per query. If your application ensures indexes lazily, after the first ordered query has
already run, that cache will be stale — construct the manager after schema setup, or use OFF.
See docs/0.0.75-index-aware-keyset-pagination.md for the planner reasoning behind the mirror rule, the full measurements, and the cross-engine caveats.
seek rejects missing/misaligned order keys, null cursor values, use together with skip, and
use on any operation that would ignore it (count, deleteAll, loadNearest, loadMatching) —
those fail loudly rather than quietly returning an unpaginated result. Drivine intentionally owns
query planning but not cursor serialization; applications can expose an opaque cursor format
appropriate to their API. Java callers use .seek(q -> List.of(...)) and the Java-friendly
PropertyReference.after(value) method.
For a @GraphView, limit(n) bounds root entities — each returned view keeps its relationships
fully populated (relationships are pattern comprehensions, so one root is one row). Pair limit with
orderBy for a deterministic top-N; without ordering the subset is an arbitrary ≤ n. count(…)
ignores limit/skip. (loadNearest doesn't take a limit — topK is already its bound.) From Java,
.limit(n) / .skip(n) are on the query builder alongside .where() / .orderBy().
The DSL supports sorting nested relationship collections directly in the database. The Cypher emitted depends on the engine's dialect:
| Engine | Strategy | Nested Sort |
|---|---|---|
| Neo4j (default) | apoc.coll.sortMaps() |
Supported |
| Neo4j (CALL) | CALL { ORDER BY + collect } |
Supported |
| FalkorDB | CALL { ORDER BY + collect } |
Supported (via CALL prolog) |
| Neptune | CALL { ORDER BY + collect } |
Supported (via CALL prolog) |
| Memgraph | CALL { ORDER BY + collect } |
Supported (no APOC, uses CALL) |
Direct Relationship Sorting:
// Sort assignees by name within each issue
val results = graphObjectManager.loadAll<RaisedAndAssignedIssue> {
where {
issue.state eq "open"
}
orderBy {
issue.id.desc() // Root ordering (uses index)
assignedTo.name.asc() // Collection sorting
}
}Nested Relationship Sorting:
// Sort nested worksFor organizations within raisedBy
val results = graphObjectManager.loadAll<RaisedAndAssignedIssue> {
orderBy {
raisedBy.worksFor.name.desc() // Sort organizations by name descending
}
}How it works:
- Root-level ordering (e.g.,
issue.id.desc()) uses Cypher'sORDER BY, which can utilize indexes - Collection sorting strategy is selected automatically by the Cypher dialect
- On Neo4j, the default uses APOC Extended's
apoc.coll.sortMaps()— requires APOC Extended matching your Neo4j version - On FalkorDB, Neptune, and Memgraph, CALL subquery prologs are used — no plugins required
- To use CALL subqueries on Neo4j instead of APOC, set the Cypher dialect in your datasource config:
database:
datasources:
graph:
type: NEO4J
cypher-dialect: FALKORDB # Uses CALL subquery sort, no APOC neededThe dialect controls all engine-specific Cypher generation — existence checks, collection sorting, and nested view projections. Available dialects: NEO4J_5 (default for Neo4j), NEO4J_4, FALKORDB, NEPTUNE, MEMGRAPH.
For declarative client-side sorting without APOC, use the @SortedBy annotation on relationship fields:
@GraphView
data class ProjectWithContributors(
@Root val project: Project,
@GraphRelationship(type = "HAS_CONTRIBUTOR", direction = Direction.OUTGOING)
@SortedBy("name") // Sort by contributor name ascending
val contributors: List<Contributor>
)
// Descending order
@GraphView
data class ProjectWithContributorsSortedDesc(
@Root val project: Project,
@GraphRelationship(type = "HAS_CONTRIBUTOR", direction = Direction.OUTGOING)
@SortedBy("name", ascending = false) // Sort descending
val contributors: List<Contributor>
)
// Nested property paths (for nested GraphView relationships)
@GraphView
data class ProjectWithNestedSort(
@Root val project: Project,
@GraphRelationship(type = "HAS_CONTRIBUTOR", direction = Direction.OUTGOING)
@SortedBy("contributor.name") // Sort by nested property
val contributors: List<ContributorWithTasks>
)The @SortedBy annotation:
- Sorts the collection automatically after deserialization
- Supports dot notation for nested property paths (e.g.,
"person.name") - Works with any
Comparableproperty type - Handles nulls gracefully (sorted to end)
Comparison:
eq- equals (=)neq- not equals (<>)gt- greater than (>)gte- greater than or equal (>=)lt- less than (<)lte- less than or equal (<=)in/inList- IN operator: a property value is in a caller list (property IN $list)notIn- NOT IN (NOT property IN $list)hasItem- list membership: a caller value is in a list-valued property ($value IN node.listProp) — the mirror ofinList
String Operations:
contains- CONTAINSstartsWith- STARTS WITHendsWith- ENDS WITHmatches- regex match (=~) — not supported on FalkorDBcontainsIgnoreCase/eqIgnoreCase- case-insensitive contains / equals (toLower(...))
Null Checking:
isNull()- IS NULLisNotNull()- IS NOT NULL
Boolean / Label:
anyOf { }- OR of the enclosed conditionsnot { }- negation of the enclosed sub-expression (NOT ( … ))instanceOf<T>()- node carries all of a@NodeFragmenttype's labelshasAnyLabel("A", "B")- node carries any of the given labels (ANY(l IN labels(n) WHERE l IN $p))
Ordering:
asc()- ascending orderdesc()- descending order
When the property to filter isn't known at compile time (an arbitrary @PropertyBag key, or a
caller/tool-supplied filter key), reach for the untyped escape hatch instead of a generated accessor.
Values still bind as parameters (no injection); a dotted @PropertyBag path is backtick-quoted for you.
import org.drivine.query.dsl.property // stored-path form
import org.drivine.query.dsl.field // resolving form
import org.drivine.query.dsl.predicate
import org.drivine.query.dsl.predicateOn
where {
query.property("metadata.source") eq "wiki" // stored path → n.`metadata.source` = $p
query.field("source") eq "wiki" // resolves "source" via @GraphProperty/@PropertyBag
query.predicate("metadata.tags", ComparisonOperator.HAS_ELEMENT, "kotlin") // $p IN n.`metadata.tags`
query.predicateOn("sectionId", ComparisonOperator.EQUALS, "s1") // resolving, programmatic
}property(path)/predicate(path, op, value)— take the stored property name.field(key)/predicateOn(key, op, value)— take a logical key and resolve it to the stored path from the fragment's own@GraphPropertyon-disk names and single@PropertyBagprefix (so a consumer needn't know the on-disk name or bag prefix). Throws if the key is unresolvable.predicate/predicateOnaccept anyComparisonOperator(includingHAS_ELEMENT, the dynamic twin ofhasItem), so a runtime filter tree maps one leaf → one call.
GraphObjectManager tracks loaded objects and only saves changed fields:
// Load an object
val person = graphObjectManager.loadOrThrow<PersonCareer>(uuid)
// Modify it
val updated = person.copy(
person = person.person.copy(bio = "Updated bio")
)
// Save - only dirty fields are written!
graphObjectManager.save(updated)How a null field is treated on save is one declared, uniform contract (save, saveAll, every
engine, bagged or not) — defined purely on the object you pass:
graphObjectManager.save(chunk) // IGNORE (default): merge-patch
graphObjectManager.save(chunk, nullPolicy = NullPolicy.CLEAR) // full overwrite: nulls clearIGNORE(default) — writes only non-null fields; nulls are left untouched. A partially-loaded object never destroys stored data — including a@VectorIndexembedding (aChunkNodereconstructed without its embedding won't wipe the stored vector). This is the safe default; no field is special.CLEAR— the object is authoritative: null fields clear the corresponding property (a full overwrite). Reach for it only with a complete object.
Null handling is independent of dirty-tracking — the policy alone decides, so the result never depends
on whether the object is session-tracked. saveAll(..., nullPolicy = …) behaves identically. Under
CLEAR, a @PropertyBag also drops keys absent from the current map; under IGNORE those keys are
left (merge-patch). See docs/0.0.73-null-write-policy.md.
val person = graphObjectManager.loadOrThrow<PersonCareer>(uuid)
// Remove all employment history
val updated = person.copy(employmentHistory = emptyList())
graphObjectManager.save(updated, CascadeType.NONE)Persist a collection in one atomic round-trip group, with save's per-item semantics unchanged
(cascade, dirty tracking, MERGE identity). Within an ambient @Transactional the statements join it;
otherwise they run together in a single transaction — a failure on any item rolls the whole call back.
val saved = graphObjectManager.saveAll(views, CascadeType.DELETE_ORPHAN)Homogeneous root upserts collapse into chunked UNWIND … MERGE … SET n += row.props statements
(sub-linear round trips); relationship/cascade statements stay per-item. Heterogeneous collections are
grouped by runtime class; the returned list preserves input order. Roots with a @PropertyBag fall
back to the per-item path. Null handling follows NullPolicy
uniformly with save — IGNORE (default) leaves nulls, CLEAR clears them —
via saveAll(objs, nullPolicy = …).
GraphObjectManager provides type-safe methods for deleting graph objects.
// 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 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 matching a condition
graphObjectManager.deleteAll<Issue>("n.state = 'closed'")
// For GraphViews
graphObjectManager.deleteAll<RaisedAndAssignedIssue>("issue.locked = true")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> { }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!When saving @GraphView objects with modified relationships, CascadeType determines what happens to target nodes:
Only deletes the relationship, leaves target nodes intact:
graphObjectManager.save(updated, CascadeType.NONE)Use when: Target nodes are shared or should persist independently.
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.
Always deletes both the relationship and target nodes:
graphObjectManager.save(updated, CascadeType.DELETE_ALL)Use when: Target nodes are exclusively owned and should be deleted with the relationship.
GraphObjectManager maintains a session that tracks loaded objects:
- On Load: Takes a snapshot of the object's state
- On Save: Compares current state to snapshot
- Optimization: Only writes changed fields (dirty checking)
This means:
- Loaded objects: Optimized saves (only dirty fields)
- New objects: Full saves (all fields written)
graphObjectManager.loadAll<PersonCareer>()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 resultgraphObjectManager.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 resultDrivine supports polymorphic relationship targets using label-based type discrimination. This allows a single relationship to point to different node types. You can define polymorphic types using either sealed classes or interfaces.
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: "alice@example.com"})
For library-friendly polymorphism where implementations are defined externally by consumers, use interfaces with runtime registration.
The library defines the interface:
// Library code - interface with @NodeFragment
@NodeFragment(labels = ["SessionUser"])
interface SessionUser {
@get:NodeId // Required for Drivine change detection during save
val id: String
val displayName: String
}
// Library's GraphView uses the interface
@GraphView
data class StoredSession(
@Root val session: SessionData,
@GraphRelationship(type = "OWNED_BY", direction = Direction.OUTGOING)
val owner: SessionUser // Interface type
)Consumers implement the interface and register at startup:
// Consumer's implementation.
// Only the subtype's own label is needed — the parent's "SessionUser"
// label is inherited from the interface's @NodeFragment automatically,
// so saved nodes carry (:AppUser:SessionUser).
@NodeFragment(labels = ["AppUser"])
data class AppUser(
@NodeId override val id: String,
override val displayName: String,
val email: String // Consumer's custom fields
) : SessionUser
// Register in configuration (handles both Drivine and Jackson)
@Bean
fun persistenceManager(factory: PersistenceManagerFactory): PersistenceManager {
val pm = factory.get("neo")
pm.registerSubtype(
SessionUser::class.java,
listOf("AppUser", "SessionUser"), // labels the persisted node carries
AppUser::class.java
)
return pm
}The registerSubtype() call configures both:
- Drivine's label-based polymorphism for loading
- Jackson's abstract type mapping for save operations
Note:
@NodeFragmentlabels declared on a parent interface (or superclass) are inherited at save time — a subtype persists with the union of its own labels and every annotated supertype's labels, de-duplicated. You no longer need to repeat the parent's labels in each subtype's@NodeFragment.
Use interfaces when:
- Implementations are defined in different modules/libraries
- You want to allow external extensions
- The type hierarchy isn't known at compile time
Use sealed classes when:
- All subtypes are defined in your codebase
- You want exhaustive
whenchecking in Kotlin - Subtypes are automatically discovered (no registration needed)
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")
}
}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
@NodeFragmentannotation on typeT - Generates a Cypher label check:
WHERE EXISTS { ... WHERE webUser:WebUser:Anonymous } - Works with
anyOffor 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 |
Drivine distinguishes between required (non-nullable) and optional (nullable) relationships in @GraphView classes.
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")
}
}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 != nullThe 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.
| 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 |
val activeAdults = manager.query(
QuerySpecification
.withStatement("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)
)val fullNames: List<String> = manager.query(
QuerySpecification
.withStatement("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
)@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)
}
}val updates = partial<Person> {
set(Person::email, "newemail@example.com")
set(Person::age, 30)
}
personRepo.update(personId, updates)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(findActiveUsers.statement)
.transform(Person::class.java)
)
}
}// 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)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
.withStatement(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 resultsdata 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
)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
EnumtoString - Converts
UUIDtoString - Converts
InstanttoZonedDateTime - Converts
DatetoZonedDateTime - Includes null values by default (so a bound map/object can carry explicit nulls)
- Ignores unknown properties when deserializing
To exclude nulls on specific properties, use @JsonInclude(JsonInclude.Include.NON_NULL).
This governs the low-level
PersistenceManagerbinding only. Whether a null field clears a property on aGraphObjectManagersave/saveAllis governed byNullPolicy(defaultIGNORE— nulls are left untouched).
Drivine4j supports multiple graph database engines from the same codebase. Switch engines with a one-line YAML change — your models, queries, and DSL code stay the same.
| Engine | Type | Transactions | Collection Sort | Auth |
|---|---|---|---|---|
| Neo4j 5.x | NEO4J |
Full ACID | APOC (default) or CALL subquery | Basic (user/pass) |
| Neo4j 4.x | NEO4J |
Full ACID | APOC (required) | Basic (user/pass) |
| FalkorDB | FALKORDB |
Passthrough (no multi-statement) | CALL subquery | None |
| Amazon Neptune | NEPTUNE |
Full ACID | CALL subquery | IAM SigV4 or None (tunnel) |
| Memgraph | MEMGRAPH |
Full ACID | CALL subquery | Basic (user/pass) or None |
The default engine. Works out of the box with Testcontainers or a local instance:
database:
datasources:
graph:
type: NEO4J
host: localhost
port: 7687
user-name: neo4j
password: your-password
database-name: neo4jFalkorDB is an in-memory graph database built on Redis. It offers extremely fast query execution but does not support multi-statement transactions.
database:
datasources:
graph:
type: FALKORDB
host: localhost
port: 6379
database-name: mygraphTransactions: FalkorDB does not support multi-statement transactions. @Transactional methods work but each query executes and commits independently. By default, startTransaction() logs a debug message and rollbackTransaction() logs a warning. To enforce strict no-transaction usage (throw on @Transactional), set:
falkor-db-transaction-mode: STRICT # default: WARNKnown limitations:
- Nested pattern comprehensions return NULL (FalkorDB#1888) — Drivine works around this with CALL subquery prologs
collect()on null includes null maps (FalkorDB#1889) — Drivine filters withCASE WHEN IS NOT NULL
CASCADE DELETE_ORPHAN is supported on current FalkorDB (FalkorDB#1890 is fixed in the graph module Drivine tracks); older builds lacking that fix are not supported for orphan delete.
Neptune is AWS's managed graph database. Drivine connects via the Bolt protocol with two authentication modes:
IAM SigV4 authentication (recommended for production):
database:
datasources:
graph:
type: NEPTUNE
host: your-cluster.us-east-1.neptune.amazonaws.com
port: 8182
region: us-east-1
neptune-auth: IAMRequires AWS credentials available via the standard AWS credential chain (~/.aws/credentials, environment variables, IAM role, etc.) and the AWS SDK on the classpath:
implementation 'software.amazon.awssdk:auth:2.31.3'
implementation 'software.amazon.awssdk:regions:2.31.3'
implementation 'software.amazon.awssdk:http-client-spi:2.31.3'Tokens are automatically refreshed before expiry.
No authentication (SSH tunnel for development):
database:
datasources:
graph:
type: NEPTUNE
host: localhost
port: 8182
neptune-auth: NONEUse with an SSH tunnel to a Neptune cluster that has IAM auth disabled:
ssh -N -o ServerAliveInterval=60 -L 8182:your-cluster.neptune.amazonaws.com:8182 ec2-user@bastion-ipKnown limitations:
- No
date()function — use string dates ordatetime()for temporal properties - No list/array property values — annotate collection fields with
@JsonPackedto store as JSON strings collSortMapsuses{key: 'prop', order: 'asc'}syntax (differs from APOC)
Memgraph is an in-memory, Bolt-compatible graph database with Neo4j-compatible Cypher. Drivine reuses the Neo4j driver stack — only the Cypher dialect differs (no APOC; collection sorting via CALL subqueries).
database:
datasources:
graph:
type: MEMGRAPH
host: localhost
port: 7687
user-name: "" # Memgraph accepts empty credentials by default
password: ""Notes:
- Full ACID transactions (
startTransaction/commit/rollbackall work as expected) EXISTS { pattern }and nested pattern comprehensions are supported, so@GraphViewqueries use the same inline projector as Neo4j- No APOC — use MAGE for procedures; collection sorting uses CALL subqueries by default
- For MAGE algorithms or Memgraph Lab, switch the image to
memgraph/memgraph-platform
For engines that don't support list property values (Neptune), annotate collection fields to transparently serialize as JSON strings:
@RelationshipFragment
data class WorkHistory(
val role: String,
@JsonPacked val tags: List<String>? = null,
val target: Organization
)On write: ["backend", "senior"] is stored as the string '["backend","senior"]'. On read: the JSON string is deserialized back to List<String>. Works across all engines.
Overrides the on-disk property name for a fragment field, decoupling the stored graph property from the Kotlin/Java field name:
@NodeFragment(labels = ["Chunk"])
data class ChunkNode(
@NodeId val id: String,
@GraphProperty("container_section_id") val containerSectionId: String? = null,
)The field is containerSectionId everywhere in your code and the DSL (query.containerSectionId), but
it's stored/matched as container_section_id in the graph. The mapping is applied on save, load, the
generated query DSL, and index creation. It also feeds model-aware key resolution — field("containerSectionId")
and field("container_section_id") both resolve to the on-disk name.
Maps an open Map<String, *> field to a set of flat, prefixed node properties — round-tripped on
save and load. Unlike @JsonPacked (one opaque JSON string), each entry becomes a real property, so
it's visible, filterable, and indexable. Mirrors Spring Data Neo4j's @CompositeProperty (available
as an alias).
@NodeFragment(labels = ["Proposition"])
data class PropositionNode(
@NodeId val id: String,
val text: String,
@PropertyBag val metadata: Map<String, Any?> = emptyMap(), // -> metadata.<key> properties
)metadata = {"source": "wiki", "score": 3} persists as metadata.source = "wiki",
metadata.score = 3 alongside id/text. Use prefix to decouple the graph namespace from the
field name and delimiter to change the separator; a fragment may carry several bags.
- Values must be storable Neo4j primitives or homogeneous arrays (String, Number, Boolean,
temporal, or arrays/lists thereof) — a nested map/object throws an
IllegalArgumentExceptionnaming the key. - Stale keys (removing an entry then saving) are removed only under
NullPolicy.CLEAR, for a session-tracked object (load → mutate → save). The defaultIGNOREis a merge-patch and leaves orphaned keys; a detached save upserts but can't clear orphans either. - Read asymmetry:
Map<String, Any?>reads back driver-mapped types (anIntwritten returns asLong). - Filter by key in the type-safe DSL — composes on the load path and inside
loadNearest/loadMatching:
loadAll<PropositionView> { where { proposition.metadata.key("source") eq "wiki" } }
// -> WHERE proposition.`metadata.source` = $pFor a runtime key (not known at compile time), use the dynamic
property(path) / field(key) predicates instead of .key(...).
Works across Neo4j, FalkorDB, and Memgraph.
Each engine uses a Cypher dialect that controls query generation. The dialect is auto-detected from the database type but can be overridden:
cypher-dialect: NEO4J_5 # NEO4J_5, NEO4J_4, FALKORDB, NEPTUNE, MEMGRAPHDrivine manages vector indexes, full-text indexes, range indexes, and uniqueness constraints across Neo4j, Memgraph, and FalkorDB — with idempotent, drift-aware ensure semantics and engine differences (DDL syntax, introspection, FalkorDB's Redis-command constraints) handled for you. Opt-in: declare nothing, pay nothing.
Every PersistenceManager exposes indexes and constraints managers. Operations always run in auto-commit mode (schema DDL cannot run inside a data transaction):
// Idempotent — call on every startup
val result = persistenceManager.indexes.ensure(
VectorIndexSpec(label = "Proposition", property = "embedding", dimensions = 1536)
)
persistenceManager.indexes.ensure(RangeIndexSpec("Proposition", "contextId"))
persistenceManager.indexes.ensure(RangeIndexSpec("Message", listOf("sessionId", "createdAt"))) // composite
persistenceManager.indexes.ensure(FullTextIndexSpec("Chunk", "text")) // full-text (single property)
persistenceManager.indexes.ensure(FullTextIndexSpec("Entity", listOf("name", "description"))) // full-text (multi-property)
persistenceManager.constraints.ensure(UniquenessConstraintSpec("ChatSession", "sessionId"))
persistenceManager.constraints.ensure(UniquenessConstraintSpec("Membership", listOf("tenantId", "userId")))ensure returns an EnsureResult:
| Result | Meaning |
|---|---|
Created |
Nothing existed; the item was created |
AlreadyMatching |
A matching item exists; nothing changed |
Drift |
An item exists with a different shape (e.g. vector dimensions changed). Nothing changed — call recreate(spec) to replace it (destructive) |
Violation |
Constraint only: existing data violates it. Includes a bounded sample of the conflicting values |
Recreated |
From an explicit recreate(spec) — old item dropped, new one created |
Register a SchemaCatalog bean and Drivine ensures everything on startup (indexes before constraints):
@Bean
fun propositionSchema(embeddingService: EmbeddingService) = SchemaCatalog.of(
VectorIndexSpec("Proposition", "embedding", embeddingService.dimensions),
RangeIndexSpec("Proposition", "contextId"),
UniquenessConstraintSpec("Proposition", "id"),
)Multiple catalog beans merge; identical declarations deduplicate; conflicting declarations for the same (kind, label, properties) fail startup.
Which databases a catalog applies to. By default a catalog broadcasts to every schema-capable registered database — engines without DDL support (Neptune, openCypher) are skipped with a warning. Narrow it when you need to:
SchemaCatalog.of(...) // all schema-capable databases (default)
SchemaCatalog.of(...).forDefaultDatabase() // only the primary (first-registered) datasource
SchemaCatalog.of(...).forDatabase("users") // one named datasource
SchemaCatalog.of(...).forDatabases("a", "b") // a specific setBroadcast is lenient (skips engines that can't do schema); an explicitly named target is strict — pointing it at an unknown or schema-incapable datasource fails startup. "default" resolves to the first-registered datasource, consistent with the rest of Drivine.
Targeting is replace/last-wins, not additive — use one forDatabases("a", "b") call to target several; chaining forDatabase(...) calls does not accumulate (last wins).
@NodeFragment(labels = ["Proposition"])
data class PropositionNode(
@NodeId
@RangeIndex
@Unique
val id: String,
@RangeIndex
val contextId: String,
val text: String,
@VectorIndex(similarity = SimilarityFunction.COSINE)
val embedding: List<Float>?,
@FullTextIndex // full-text index on this property (also drives loadMatching)
val body: String,
)
// Composite declarations go on the class
@NodeFragment(labels = ["Message"])
@RangeIndex(properties = ["sessionId", "createdAt"])
@Unique(properties = ["sessionId", "sequence"])
@FullTextIndex(properties = ["subject", "body"]) // multi-property full-text index
data class MessageNode(/* ... */)Scan them into a catalog — vector dimensions come from your embedding model at runtime, via a VectorDimensionProvider:
@Bean
fun schema(embeddingService: EmbeddingService) = SchemaCatalog.fromFragments(
VectorDimensionProvider { _, _ -> embeddingService.dimensions },
PropositionNode::class,
MessageNode::class,
)Works for Java fragments too (SchemaCatalog.fromFragments(JavaNode.class)).
Catalogs are applied by a SchemaManager bean, enforced once on startup. It's also injectable, so you can drive schema changes at runtime — e.g. build indexes after a bulk load, or rebuild after re-embedding:
@Component
class Reindexer(private val schema: SchemaManager) {
fun afterBulkLoad() {
schema.enforce() // idempotent: create what's missing, recreate on version change
}
fun afterReembed() {
schema.recreateAll() // brute-force: drop + recreate every declared item
}
}enforce() is safe to call repeatedly; recreateAll() is the destructive hammer. (drivine.schema.enabled=false disables only the startup run — the bean is still there for runtime calls.)
Drift detection only catches changes introspection can see (dimensions, similarity, properties). A change it can't see — swapping the embedding model for one with the same dimensions — leaves stale vectors behind. Tag a catalog with a version token to force a one-time rebuild when that token changes:
@Bean
fun chunkSchema(embeddingService: EmbeddingService) = SchemaCatalog.of(
VectorIndexSpec("Chunk", "embedding", embeddingService.dimensions),
).withVersion(embeddingService.modelId) // bump this → recreate onceHow it works: the last-applied token is stored in a reserved _DrivineSchema marker node per database (portable across all three engines). On enforce(), a changed token drops and recreates that catalog's items, then records the new token; a first-ever token is adopted without recreating, so turning versioning on never nukes a healthy schema.
Recreating a vector index rebuilds it from the stored embedding properties — it does not re-embed. After a model swap, re-embed the nodes first (same dimensions → the index even auto-updates), then
enforce()/recreateAll()for the structural half. Drivine manages schema, not your embeddings.
drivine:
schema:
enabled: true # master switch for startup initialization
mode: FAIL_FAST # FAIL_FAST (default) or WARN on drift/violations
recreate-on-drift: false # destructive: rebuild items whose shape changed
recreate-on-startup: false # destructive: rebuild everything every startup
violation-sample-size: 10 # conflicting rows sampled on constraint violations| Neo4j | Memgraph | FalkorDB | |
|---|---|---|---|
| Vector indexes | CREATE VECTOR INDEX … IF NOT EXISTS |
WITH CONFIG {…}, uSearch metrics |
OPTIONS {…}, unnamed |
| Full-text indexes | CREATE FULLTEXT INDEX … ON EACH […] (+ analyzer) |
CREATE TEXT INDEX … ON :L(props) |
db.idx.fulltext.createNodeIndex(label, prop), per-property, unnamed |
| Range indexes | Named, composite supported | Label-property style | Per-label coverage; extended incrementally |
| Uniqueness | REQUIRE … IS UNIQUE |
ASSERT … IS UNIQUE |
Redis command GRAPH.CONSTRAINT (not Cypher) — Drivine issues it at driver level, auto-creates the required backing index, and polls the asynchronous build |
| Item names | Yes | Vector only | No |
Neptune and generic openCypher have no schema management support — operations fail loudly rather than silently no-op.
@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
) { /* ... */ }@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
}
}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: neo4j2. Use @EnableDrivineTestConfig in your test configuration:
@Configuration
@EnableDrivine
@EnableDrivineTestConfig
class TestConfig3. 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 falseWhat 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
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
}Drivine4j provides a fluent, type-safe query API for Java that mirrors the Kotlin DSL capabilities.
import org.drivine.query.dsl.JavaQueryBuilderKt;
List<RaisedAndAssignedIssue> results = JavaQueryBuilderKt
.query(graphObjectManager, RaisedAndAssignedIssue.class)
.filterWith(RaisedAndAssignedIssueQueryDsl.class)
.where(dsl -> dsl.getIssue().getState().eq("open"))
.loadAll();The pattern is:
query(graphObjectManager, GraphViewClass)- Start a queryfilterWith(QueryDslClass)- Specify the generated DSL for type-safe filtering- Chain
where(),whereAny(),orderBy()as needed - Terminate with
loadAll(),loadFirst(), ordeleteAll()
Comparison:
.where(dsl -> dsl.getIssue().getId().eq(100L)) // equals
.where(dsl -> dsl.getIssue().getId().neq(100L)) // not equals
.where(dsl -> dsl.getIssue().getId().gt(100L)) // greater than
.where(dsl -> dsl.getIssue().getId().gte(100L)) // greater than or equal
.where(dsl -> dsl.getIssue().getId().lt(100L)) // less than
.where(dsl -> dsl.getIssue().getId().lte(100L)) // less than or equalString Operations:
.where(dsl -> dsl.getIssue().getTitle().contains("Bug"))
.where(dsl -> dsl.getIssue().getTitle().startsWith("Feature"))
.where(dsl -> dsl.getIssue().getTitle().endsWith("needed"))Null Checks:
.where(dsl -> dsl.getIssue().getBody().isNull())
.where(dsl -> dsl.getIssue().getBody().isNotNull())Collections:
.where(dsl -> dsl.getIssue().getState().isIn(Arrays.asList("open", "reopened")))Chain multiple where() calls for AND logic:
List<RaisedAndAssignedIssue> results = JavaQueryBuilderKt
.query(graphObjectManager, RaisedAndAssignedIssue.class)
.filterWith(RaisedAndAssignedIssueQueryDsl.class)
.where(dsl -> dsl.getIssue().getState().eq("open"))
.where(dsl -> dsl.getIssue().getLocked().eq(false))
.where(dsl -> dsl.getIssue().getId().gte(100L))
.loadAll();
// WHERE issue.state = 'open' AND issue.locked = false AND issue.id >= 100Use whereAny() for OR logic:
List<RaisedAndAssignedIssue> results = JavaQueryBuilderKt
.query(graphObjectManager, RaisedAndAssignedIssue.class)
.filterWith(RaisedAndAssignedIssueQueryDsl.class)
.whereAny(dsl -> Arrays.asList(
dsl.getIssue().getState().eq("open"),
dsl.getIssue().getState().eq("reopened")
))
.loadAll();
// WHERE (issue.state = 'open' OR issue.state = 'reopened')Combine AND and OR:
// locked=false AND (state='open' OR state='reopened')
List<RaisedAndAssignedIssue> results = JavaQueryBuilderKt
.query(graphObjectManager, RaisedAndAssignedIssue.class)
.filterWith(RaisedAndAssignedIssueQueryDsl.class)
.where(dsl -> dsl.getIssue().getLocked().eq(false))
.whereAny(dsl -> Arrays.asList(
dsl.getIssue().getState().eq("open"),
dsl.getIssue().getState().eq("reopened")
))
.loadAll();List<RaisedAndAssignedIssue> results = JavaQueryBuilderKt
.query(graphObjectManager, RaisedAndAssignedIssue.class)
.filterWith(RaisedAndAssignedIssueQueryDsl.class)
.where(dsl -> dsl.getIssue().getState().eq("open"))
.orderBy(dsl -> dsl.getIssue().getId().desc())
.loadAll();Get only the first matching result:
RaisedAndAssignedIssue result = JavaQueryBuilderKt
.query(graphObjectManager, RaisedAndAssignedIssue.class)
.filterWith(RaisedAndAssignedIssueQueryDsl.class)
.where(dsl -> dsl.getIssue().getState().eq("open"))
.orderBy(dsl -> dsl.getIssue().getId().desc())
.loadFirst(); // Returns null if no matchesFilter by @NodeFragment subtype using instanceOf():
import org.drivine.sample.fragment.AnonymousWebUser;
import org.drivine.sample.fragment.RegisteredWebUser;
// Filter to only anonymous web users
List<GuideUserWithPolymorphicWebUser> results = JavaQueryBuilderKt
.query(graphObjectManager, GuideUserWithPolymorphicWebUser.class)
.filterWith(GuideUserWithPolymorphicWebUserQueryDsl.class)
.where(dsl -> dsl.getWebUser().instanceOf(AnonymousWebUser.class))
.loadAll();
// Combine with other conditions
List<GuideUserWithPolymorphicWebUser> activeAnonymous = JavaQueryBuilderKt
.query(graphObjectManager, GuideUserWithPolymorphicWebUser.class)
.filterWith(GuideUserWithPolymorphicWebUserQueryDsl.class)
.where(dsl -> dsl.getCore().getGuideProgress().gte(10))
.where(dsl -> dsl.getWebUser().instanceOf(AnonymousWebUser.class))
.loadAll();
// Use in OR conditions
List<GuideUserWithPolymorphicWebUser> allUsers = JavaQueryBuilderKt
.query(graphObjectManager, GuideUserWithPolymorphicWebUser.class)
.filterWith(GuideUserWithPolymorphicWebUserQueryDsl.class)
.whereAny(dsl -> Arrays.asList(
dsl.getWebUser().instanceOf(AnonymousWebUser.class),
dsl.getWebUser().instanceOf(RegisteredWebUser.class)
))
.loadAll();int deleted = JavaQueryBuilderKt
.query(graphObjectManager, RaisedAndAssignedIssue.class)
.filterWith(RaisedAndAssignedIssueQueryDsl.class)
.where(dsl -> dsl.getIssue().getState().eq("closed"))
.deleteAll();| Operation | Example |
|---|---|
| Equality | .eq("value"), .neq("value") |
| Comparison | .gt(n), .gte(n), .lt(n), .lte(n) |
| Strings | .contains("x"), .startsWith("x"), .endsWith("x") |
| Null | .isNull(), .isNotNull() |
| Collections | .isIn(Arrays.asList(...)) |
| Type filter | .instanceOf(SubtypeClass.class) |
| AND | Chain multiple .where() |
| OR | .whereAny(dsl -> Arrays.asList(...)) |
| Order | .orderBy(dsl -> dsl.getProp().asc()) |
The code generator (KSP) only processes Kotlin source files. For the best experience:
- Define your
@GraphViewclasses in Kotlin to get the generated type-safe DSL - Your
@NodeFragmentclasses can be in Java or Kotlin - At runtime, both Java and Kotlin classes work fully with
GraphObjectManager
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;
}// Define GraphViews in Kotlin to get DSL generation
@GraphView
data class PersonContext(
@Root val person: Person, // References Java class!
@GraphRelationship(type = "WORKS_FOR")
val worksFor: List<Organization>
)// Use from Java with the fluent DSL API
List<PersonContext> results = JavaQueryBuilderKt
.query(graphObjectManager, PersonContext.class)
.filterWith(PersonContextQueryDsl.class)
.where(dsl -> dsl.getPerson().getName().contains("Alice"))
.loadAll();| 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 |
| Type-safe DSL | ✅ Full | Use filterWith() API from Java |
instanceOf() |
✅ Full | Filter by @NodeFragment subtype |
| DSL generation | Define @GraphView in Kotlin |
|
| Generic collections | ✅ Full | Java reflection handles List<T>, Set<T> |
| Polymorphic types | ✅ Full | Works with sealed classes or @JsonSubTypes |
# Run tests
./gradlew test
# Build library
./gradlew build
# Publish to local Maven (~/.m2/repository)
./gradlew publishToMavenLocalApache License 2.0