Type-Safe Query DSL
The code generator creates a type-safe DSL for each @GraphView, giving you IntelliJ autocomplete and compile-time type checking.
Basic Filtering
Section titled “Basic Filtering”// Load people whose bio contains "Lead"val leads = graphObjectManager.loadAll<PersonCareer> { where { person.bio contains "Lead" // Direct property access! }}Multiple Conditions (AND)
Section titled “Multiple Conditions (AND)”val results = graphObjectManager.loadAll<PersonCareer> { where { person.name eq "Alice Engineer" person.bio.isNotNull() }}// Generates: WHERE person.name = $p0 AND person.bio IS NOT NULLOR Conditions
Section titled “OR Conditions”val results = graphObjectManager.loadAll<PersonCareer> { where { anyOf { person.name eq "Alice" person.name eq "Bob" } }}// Generates: WHERE (person.name = $p0 OR person.name = $p1)Querying Bare Fragments
Section titled “Querying Bare Fragments”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 DSLgraphObjectManager.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 entitygraphObjectManager.loadAll<PropositionView> { where { mentions.any { resolvedId eq entityId } }}
// ... or to any of severalgraphObjectManager.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 entitygraphObjectManager.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.
List-Valued Properties (hasItem)
Section titled “List-Valued Properties (hasItem)”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 idgraphObjectManager.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.)
Ordering
Section titled “Ordering”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 & Pagination
Section titled “Limit & Pagination”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 20graphObjectManager.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 belowdata 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 andsorts instead of seeking. Declare @RangeIndex(properties = ["lastActivityAt", "sessionId"]) on thefragment, 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.FAILAn 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 issueval 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 raisedByval 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.
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")whereIssuehas nocreatedAtfield, is refused withIllegalArgumentException: 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). Awhere { }of a plain load reads the node and needs no field. - A load filters first: its
WHEREstands directly after theMATCH, so sorted collections, paths and counts are computed only for the roots the load keeps.
Client-Side Sorting with @SortedBy
Section titled “Client-Side Sorting with @SortedBy”For declarative client-side sorting without APOC, use the @SortedBy annotation on relationship fields:
@GraphViewdata 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@GraphViewdata 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)@GraphViewdata 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)
Available Operators
Section titled “Available Operators”Comparison:
eq- equals (=)neq- not equals (<>)gt- greater than (>)gte- greater than or equal (>=)lt- less than (<)lte- less than or equal (<=)in/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
Dynamic / Runtime-Key Predicates
Section titled “Dynamic / Runtime-Key Predicates”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 formimport org.drivine.query.dsl.field // resolving formimport org.drivine.query.dsl.predicateimport 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.
Grouped Reads (groups)
Section titled “Grouped Reads (groups)”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
rowsreturns, and_leaves it to the compiler. keysays what puts two nodes in the same group. Every other line is a number about the group.- A handle is typed.
it[page]is aStringandit[votes]aLong. A number that was never declared has no handle, so ordering by it or reading it does not compile. PageVotesis any class of your own; Drivine knows nothing of it.rows { it[page] to it[votes] }gives aList<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_0WITH DISTINCT n, k0WITH k0, count(n) AS a0RETURN { k0: { … }, a0: a0 } AS rowORDER BY a0 DESC, k0.id ASCLIMIT $_limitThe 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,minandmaxskip a node with no value. Over no valuessumis 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:
minandmaxof a text skip a value stored as"", which Oracle reads as no value. A key does not:""is a key of its own everywhere.minandmaxof 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 whole10.0as10, and a list, a map or a text past 4000 bytes as no value. - A
sumor anavgof aDoubleis 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.

