Skip to content

Type-Safe Query DSL

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 NULL
val 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, pass that subtype's DSL
graphObjectManager.loadAll<ChunkNode, _>(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.

Quantified Predicates over a To-Many Relationship (any / none)

Section titled “Quantified Predicates over a To-Many Relationship (any / none)”

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.)

Inside a scored search (loadNearest { where { } }, loadMatching { where { } }) the filter is applied to the projected view, and any / none reach what the projection holds: a collection of fragments, a collection of nested views (the nested view’s root, and its own relationships), and a single-valued relationship, which is read as a list of one or none.

not { } around a condition on a relationship’s target is one existence check for each condition, as or is: two conditions under it need not hold of the same related node.

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.grounding

Renders 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()
}
}

Where the nulls go. Left to itself the database decides where the rows with no value for the ordered property go, and Cypher ranks null above every value, so a descending order starts with them. nullsLast() and nullsFirst() say otherwise, whichever way the values run:

val best = graphObjectManager.loadAll<PropositionView> {
orderBy { proposition.effectiveConfidence.desc().nullsLast() }
limit(20)
}

That is the 20 most confident, with the unranked rows only for what the limit leaves, in one statement. Reading the ranked rows and then the unranked ones is not the same thing: under read committed each read sees its own committed state, and a row that gains a value between the two is in neither.

On a Cypher backend the order is ORDER BY p.x IS NULL, p.x DESC (false sorts before true); on Oracle, DESC NULLS LAST. No index supplies that order: Neo4j sorts by the extra term whatever is indexed (a Top over the scan, where an order an index mirrors is read from the index with no sort), so leave the placement off a property that is never null, and expect no index advice for an order that has one.

It is for the order of the roots: an order of a relationship collection that places its nulls is refused, and so is one under seek, whose cursor does not say which side of the nulls it is on. From inside an orderBy block it follows asc() / desc(); an order built apart from one, with ascending() / descending(), takes the same two calls.

Ordering by an expression. A ranking computed from several properties is an expression over the root’s properties, built from arithmetic, exp, floor, coalesce, epochSeconds and case, with every value the caller gives bound as a parameter. The ranking runs in the database with its LIMIT, and the terms after it break its ties:

val top = graphObjectManager.loadAll<PropositionView> {
orderBy {
case(
(proposition.validFrom above asOf) or (proposition.validTo atMost asOf) then 0.0,
otherwise = proposition.confidence *
exp(-proposition.decay * floor((epochSeconds(asOf) - proposition.lastAccessedSeconds) / 86400)),
).desc()
proposition.id.asc()
}
limit(20)
}

Neo4j, FalkorDB, Memgraph and Oracle each write the arithmetic, the functions and case, which live in org.drivine.query.dsl. A function that not every backend can write lives in a package named for its capability, behind a Kotlin opt-in: epochSeconds(property), which reads a stored moment as a number, is org.drivine.query.dsl.temporal.epochSeconds and needs @OptIn(TemporalOrder::class) (from Java, the statics of TemporalOrderExpressions). FalkorDB cannot write it, and refuses the load with an UnsupportedOrderFunctionException before anything runs. An expression reads only the root’s properties, and is refused under seek. See docs/0.1.7-order-by-expression.md.

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. An order that places its nulls (nullsLast()) is refused with seek.

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) | OFF

The 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.FAIL

An order that places its nulls is never advised on either: it sorts whatever is indexed.

A 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().

Ordering Nested Collections (Database-Side)

Section titled “Ordering Nested Collections (Database-Side)”

The DSL supports sorting nested relationship collections directly in the database. The Cypher emitted depends on the engine’s dialect:

Engine Strategy A relationship of the root A collection inside a nested view
Neo4j (default) apoc.coll.sortMaps() Sorted Sorted
Neo4j (CALL) CALL { ORDER BY + collect } Sorted Refused
FalkorDB CALL { ORDER BY + collect } Sorted Sorted
Neptune CALL { ORDER BY + collect } Sorted Refused
Memgraph CALL { ORDER BY + collect } Sorted Refused

Where a nested collection’s sort is refused, the query throws UnsupportedOperationException and names the alternatives: APOC on Neo4j, or client-side @SortedBy.

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’s ORDER 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 needed

The 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.

What a view load does not sort or order:

  • A recursive collection (a view nested in itself) cannot be sorted in the query: the load throws UnsupportedOperationException. Use @SortedBy, which orders every level.
  • A view is ordered, and paged by keyset, on what it projects. Ordering a view on a root property that is not a declared field of the root fragment, such as query.issue.property("createdAt") where Issue has no createdAt field, is refused with IllegalArgumentException: the projected root does not hold it, and the order would be by null. Declare the field. The same holds for a filter on such a property in a scored search (loadNearest, loadMatching). A where { } of a plain load reads the node and needs no field.
  • A load filters first: its WHERE stands directly after the MATCH, so sorted collections, paths and counts are computed only for the roots the load keeps.

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 Comparable property 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 of inList

String Operations:

  • contains - CONTAINS
  • startsWith - STARTS WITH
  • endsWith - ENDS WITH
  • matches - regex match (=~) — not supported on FalkorDB
  • containsIgnoreCase / eqIgnoreCase - case-insensitive contains / equals (toLower(...))

Null Checking:

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

Boolean / Label:

  • anyOf { } - OR of the enclosed conditions
  • not { } - negation of the enclosed sub-expression (NOT ( … ))
  • instanceOf<T>() - node carries all of a @NodeFragment type’s labels
  • hasAnyLabel("A", "B") - node carries any of the given labels (ANY(l IN labels(n) WHERE l IN $p))

Ordering:

  • asc() - ascending order
  • desc() - 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 key that is not a plain identifier, such as a dotted @PropertyBag path or one with a hyphen or a space, is backtick-quoted for you, and cannot end its quotes: a backtick in it is doubled, and so is the escape \u0060, which Neo4j reads as one. An empty key is refused. FalkorDB refuses a key that holds a backtick.

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 @GraphProperty on-disk names and single @PropertyBag prefix (so a consumer needn’t know the on-disk name or bag prefix). Throws if the key is unresolvable.
  • predicate / predicateOn accept any ComparisonOperator (including HAS_ELEMENT, the dynamic twin of hasItem), so a runtime filter tree maps one leaf → one call.

graphObjectManager.groups groups the nodes of a fragment by a key and gives numbers about each group: a count, a sum, a ranking. It is one statement on every engine, Oracle among them, and the groups, their numbers and their order are held to one contract on each. Everything else on the manager returns the objects it maps; this returns summaries of them, so nothing is loaded to be tallied in the application.

Both managers have it. On the deprecated GraphObjectManager a node key is an object it loaded, and is tracked as one.

Each line of a grouped read is a clause of the query you would have written by hand, and a val is its AS:

Here Cypher SQL
groups.load<T, _> MATCH (n:T) FROM t
where { … } WHERE … before the WITH WHERE …
val page = key(query.page) WITH n.page AS page, … GROUP BY page
val user = key(path<U>(…)) MATCH (n)-[…]->(user:U), then WITH user, … a join, then GROUP BY its id
val votes = count() count(n) AS votes COUNT(*) AS votes
val likes = count { … } sum(CASE WHEN … THEN 1 ELSE 0 END) AS likes the same
having { … } WHERE … after the WITH HAVING …
orderBy, limit ORDER BY, LIMIT ORDER BY, FETCH FIRST
rows { … } RETURN { … } the select list

The steps below use one fragment, a piece of feedback on a docs page, and five nodes of it:

@NodeFragment(labels = ["Feedback"])
data class FeedbackData(@NodeId val id: String, val page: String, val helpful: Boolean, val createdAt: Instant)
(f1:Feedback {page: "/install", helpful: true, createdAt: January})
(f2:Feedback {page: "/install", helpful: true, createdAt: March})
(f3:Feedback {page: "/install", helpful: false, createdAt: March})
(f4:Feedback {page: "/agents", helpful: true, createdAt: March})
(f5:Feedback {page: "/agents", helpful: false, createdAt: March})

1. Group and count. “For each page, how many?”

val votes: Map<String, Long> = graphObjectManager.groups.countBy(FeedbackData::page)
// {"/agents" = 2, "/install" = 3}

The property says what is counted (FeedbackData) and what puts two of them in one group (page). This needs no block and no code generator. Where the field may be null, the nodes with no value are counted under null.

2. More than one number. “For each page, how many, and how many of those were helpful?” Two numbers do not fit a map, so the read is spelled out. Each line makes one column and gives a handle to it, and rows builds one result from the handles:

val ratings: List<PageVotes> = graphObjectManager.groups.load<FeedbackData, _> {
val page = key(query.page) // n.page AS page
val votes = count() // count(n) AS votes
val likes = count { query.helpful eq true } // counts a node only when this holds
rows { PageVotes(it[page], it[votes], it[likes]) }
}
// PageVotes("/agents", 2, 1), PageVotes("/install", 3, 2)
  • The first type argument is the source: the fragment whose nodes are grouped. The second is what rows returns, and _ leaves it to the compiler.
  • key says what puts two nodes in the same group. Every other line is a number about the group.
  • A handle is typed. it[page] is a String and it[votes] a Long. A number that was never declared has no handle, so ordering by it or reading it does not compile.
  • PageVotes is any class of your own; Drivine knows nothing of it. rows { it[page] to it[votes] } gives a List<Pair<String, Long>>.

3. The three filters. A grouped read has three places a condition can go, and each answers a different question. A condition in the wrong place gives a wrong number and no error.

graphObjectManager.groups.load<FeedbackData, _> {
where { query.createdAt gte march } // 1. which nodes are read at all
val page = key(query.page)
val votes = count()
val likes = count { query.helpful eq true } // 2. which nodes count toward this one number
having { votes gte 3 } // 3. which groups are returned
orderBy { votes.desc() }
limit(10)
rows { PageVotes(it[page], it[votes], it[likes]) }
}
Filter Removes In the graph above
where { … } nodes, before anything is grouped f1 is gone, so /install has 2 votes
count { … } nothing. It decides which nodes that one number counts likes of /install is 1, and votes is still 2
having { … } whole groups, after they are counted no page has 3 votes, so the answer is empty

The mistake to look for is helpful eq true moved up into where. It compiles, and then votes counts only the helpful ones, so likes equals votes on every row.

4. Group by a node, not a value. “For each user, how many messages did they write?” What is counted is messages. What puts two messages in one group is having the same owner, and a path says how a message reaches its owner:

val top: List<Pair<String, Long>> = graphObjectManager.groups.load<MessageData, _> {
where { query.role eq MessageRole.USER }
val user = key(path<GuideUserData>(
Hop("HAS_MESSAGE", Direction.INCOMING, label = "ChatSession"),
Hop("OWNED_BY"),
))
val messages = count()
orderBy { messages.desc() }
limit(5)
rows { (it[user].username ?: it[user].id) to it[messages] }
}
MATCH (n:StoredMessage)<-[:HAS_MESSAGE]-(:ChatSession)-[:OWNED_BY]->(k0:GuideUser:User)
WHERE n.role = $param_n_role_0
WITH DISTINCT n, k0
WITH k0, count(n) AS a0
RETURN { k0: { … }, a0: a0 } AS row
ORDER BY a0 DESC, k0.id ASC
LIMIT $_limit

The key comes back as the node itself, hydrated as a load of its fragment hydrates it: its subtype where the class is sealed, its @GraphProperty names, its @PropertyBag and its labels. Hop is the annotation a @GraphPath is made of, written here as a value, so no view is declared for the path. The read touches role of a message and nothing else of it.

The parts

where { … } The where of a load, over the source’s own properties.
key(query.x) Groups by a property. A property the fragment declares nullable is refused: it is keyOrNull(query.x)’s, whose nodes with no value are a group of their own. Two key lines are a compound key.
key(path<N>(hops…)) Groups by a @NodeFragment the source reaches, found by N’s labels at the end of the hops.
count() Long. count { … } counts the nodes that meet the block.
sum(query.x) Long for an Int or a Long property, Double for a Float or a Double. 0 over no values.
avg(query.x) Double?.
min(query.x), max(query.x) The property’s type, nullable.
having { votes gte 3 } Compares one aggregate with a value: eq, neq, gt, gte, lt, lte. Lines are joined by AND. A count is compared with any number.
orderBy { votes.desc(); page.asc() } Orders by handles, with nullsFirst() and nullsLast(). A node key orders by its node’s id.
limit(n), skip(n) Page the groups.
rows { … } The last line, and required: its value is what the block returns. Every key and aggregate is declared before it: one declared as a row is read is refused.

sum, avg, min and max take a filter block too: sum(query.tokens) { query.role eq MessageRole.USER }.

What every engine agrees on

  • A group exists because a node is in it: no group has no members. A user with no message is not in the ranking.
  • A node that reaches no key node is in no group. One that reaches several is in each, and once in each: however many ways lead from a node to a key node, it is one member of that group, in a count and in a sum alike.
  • A path walks a relationship once: two hops of one type are two relationships, so a path there and back does not lead a node to itself. Two path keys may be reached over the same relationship.
  • A read of keys alone gives each key once.
  • An aggregate’s filter never takes a node out of its group. A group none of whose nodes meet it is still returned, with 0.
  • sum, avg, min and max skip a node with no value. Over no values sum is 0 and the others are null.
  • Ties are broken by the key. Every order ends with the keys it left out, ascending, so a top 5 is the same five on every engine.
  • Every value is bound as a parameter, and each condition has its own.

Where Oracle differs, and stays different:

  • min and max of a text skip a value stored as "", which Oracle reads as no value. A key does not: "" is a key of its own everywhere.
  • min and max of a property no field declares compare it as text, so "10" is less than "9". A key no field declares reads as a number where a number is stored.
  • A key no field declares is text or a number there: a boolean reads as "true", a whole 10.0 as 10, and a list, a map or a text past 4000 bytes as no value.
  • A sum or an avg of a Double is decimal arithmetic, so 0.1 + 0.2 is 0.3 there and 0.30000000000000004 on the graph engines.
  • A text key is ordered by the session’s NLS_SORT, which is binary unless it has been set otherwise.

What is refused, before anything is sent, where the compiler does not refuse it first: a read with no key (a count of everything is count); a @GraphView as the source; a where or a filter that names anything but the source’s own properties; key on a property declared nullable, and key or an aggregate on one that holds a list; a path with no hops, to a class that is no @NodeFragment, or whose last hop asks for a label the class does not carry.

Not in this version: a view as the source, arithmetic inside having or orderBy (compute it in rows, as it[votes] - it[likes]), an aggregate over a related node’s property (@Aggregate does that for one root), and a keyset seek over groups.

Without the code generator, pass the query object: groups.load(FeedbackData::class.java, queryObject) { … }. A subtype of a sealed fragment is read that way too, with its own query object: load<Subtype, _> { } is ambiguous with the base’s, as loadAll<Subtype> { } is. From Java the handles come from the spec, and the block returns rowsOf:

List<PageVotes> ratings = manager.getGroups().load(FeedbackData.class, dsl, spec -> {
spec.onlyIf(dsl.getCreatedAt().isAtLeast(march));
Key<String> page = spec.key(dsl.getPage());
Aggregate<Long> votes = spec.count();
Aggregate<Long> likes = spec.countWhere(dsl.getHelpful().isEqualTo(true));
spec.having(votes, ComparisonOperator.GREATER_THAN_OR_EQUAL, 3);
spec.orderBy(votes.descending());
return spec.rowsOf(row -> new PageVotes(row.get(page), row.get(votes), row.get(likes)));
});

A path is spec.key(spec.path(User.class, spec.hop("HAS_MESSAGE", Direction.INCOMING, "ChatSession"), spec.hop("OWNED_BY"))), a sum spec.sumOfLongs(dsl.getTokens()), a filtered one spec.filtered(spec.sumOfLongs(dsl.getTokens()), dsl.getRole().isEqualTo(Role.USER)), and manager.getGroups().countBy(FeedbackData.class, "page") counts by a field.