Skip to content

Query DSL

A query is a function of the class’s references, q, which mirror its fields. The compiler checks each one, so a query over a field the class doesn’t have, or a comparison with a value of the wrong type, doesn’t compile:

const leads = await gom.loadAll(PersonCareer, (q) => ({
where: q.person.bio.contains('Lead'),
orderBy: q.person.name.asc(),
limit: 20
}));

A query may hold where, orderBy, limit, skip, seek and depth.

// AND: a list of conditions
where: [q.person.name.eq('Alice'), q.person.bio.isNotNull()]
// OR, AND and NOT
where: anyOf(q.person.name.eq('Alice'), q.person.name.eq('Bob'))
where: [q.issue.state.eq('open'), anyOf(q.issue.id.eq(123), q.assignedTo.name.eq('Alice'))]
where: not(q.issue.locked.eq(true))
// a relationship's target, or a nested view's root and relationships
where: q.assignedTo.name.eq('Alice')
where: q.raisedBy.worksFor.name.startsWith('Ac')

A condition on a relationship holds when at least one related node meets it. Each condition is its own existence check, so two conditions need not hold of the same related node. When they must, use any:

// propositions with at least one mention that resolves to this entity
where: q.mentions.any((m) => m.resolvedId.eq(entityId))
// one mention that is both the subject and resolves to this entity
where: q.mentions.any((m) => [m.role.eq('SUBJECT'), m.resolvedId.eq(entityId)])
// propositions with no such mention, including those with no mentions at all
where: q.mentions.none((m) => m.resolvedId.eq(entityId))
Kind Operators
Comparison eq, neq, gt, gte, lt, lte, in, notIn
Text contains, startsWith, endsWith, matches (a regular expression; not on FalkorDB), containsIgnoreCase, eqIgnoreCase
Null isNull(), isNotNull()
List property hasItem(value): the value is in a list-valued property, the mirror of in
Logic anyOf(...), allOf(...), not(...), a list for AND
Labels instanceOf(Type): the node carries all the labels of a fragment class. hasAnyLabel('A', 'B'): it carries any of them
Order asc(), desc(), then nullsFirst() or nullsLast()

gt, gte, lt and lte are offered on numbers, texts and dates, and the text operators on texts alone.

The DSL isn’t only for views. Every fragment has references to its own fields:

await gom.loadAll(ChunkNode, (q) => ({ where: q.sectionId.eq('sec-1') }));
await gom.count(ChunkNode, (q) => ({ where: q.sectionId.eq('sec-1') }));
await gom.loadAll(WebUser, (q) => ({ where: q.instanceOf(AnonymousWebUser) }));

A reference uses the field’s name. Drivine maps it to the stored name, whether @GraphProperty renamed it or it is a @NodeStamp field, and an entry of a @PropertyBag is reached through the bag: q.metadata.source.eq('wiki').

When the property isn’t known until run time, such as an arbitrary bag key or a filter a caller names, give the stored name. Values are still bound as parameters:

where: q.property('metadata.source').eq('wiki')
where: q.predicate('metadata.tags', 'HAS_ITEM', 'kotlin')

A name that isn’t a plain identifier is quoted for you, and can’t break out of its quotes.

orderBy: [q.person.name.asc(), q.person.uuid.asc()]
orderBy: q.proposition.confidence.desc().nullsLast()

Left to itself, the database decides where rows with no value go. Cypher ranks null above every value, so a descending order starts with them. nullsFirst() and nullsLast() say otherwise. No index supplies that order, so leave it off a property that is never null.

An order by an expression. A ranking computed from several properties is an expression over the root’s properties, built from plus, minus, times, div, negate, exp, floor, coalesce, epochSeconds and caseOf. Every value you give is bound. The ranking runs in the database with its limit, and later terms break its ties:

orderBy: [
caseOf([above(q.validFrom, asOf).or(atMost(q.validTo, asOf)).then(0)],
times(q.confidence, exp(negate(times(q.decay, floor(div(minus(epochSeconds(asOf), q.lastAccessedSeconds), 86400)))))))
.desc(),
q.id.asc()
]

An expression reads only the root’s properties, and is refused with seek.

Ordering a relationship’s list. An order on a relationship’s property sorts that list in each object, not the roots: orderBy: [q.issue.id.desc(), q.assignedTo.name.asc()] orders the issues by id, and each issue’s assignees by name. A nested view’s list sorts the same way: q.raisedBy.worksFor.name.desc(). This works the same on every engine and needs no plugin. A recursive list can’t be ordered by the query: use @SortedBy, which orders every level.

@SortedBy sorts a list relationship after it is loaded, on every load of the view:

@GraphRelationship({ type: 'HAS_CONTRIBUTOR', target: () => Contributor })
@SortedBy('name') // ascending; @SortedBy('name', false) for descending
contributors: Contributor[];
@SortedBy('contributor.name') // a path into a related view or relationship fragment

Nulls go last.

limit and skip are applied in the database, after the order:

await gom.loadAll(PropositionView, (q) => ({ orderBy: q.proposition.created.desc(), limit: 20 }));
await gom.loadAll(PropositionView, (q) => ({ orderBy: q.proposition.created.desc(), skip: 40, limit: 20 }));

For a view, limit bounds the roots: each view still has all its relationships. Pair it with orderBy, or the subset is arbitrary. count ignores limit and skip.

For large or changing results, page by keyset with seek. Its values follow the order’s terms, in the same order. Drivine works out each comparison from the direction. End the order with a unique key, so that ties can’t be skipped or repeated:

const page = await gom.loadAll(SessionView, (q) => ({
orderBy: [q.session.lastActivityAt.desc(), q.session.sessionId.desc()],
seek: cursor ? [q.session.lastActivityAt.after(cursor.lastActivityAt), q.session.sessionId.after(cursor.sessionId)] : undefined,
limit: pageSize + 1 // one more than a page tells you whether there is a next one
}));
  • The ordered properties must not be null. A row whose key is null meets no comparison, so it drops out of every page after the first.
  • seek is refused without a matching order, with a null cursor value, with skip, with an order that places its nulls, and with an expression.
  • Index the cursor. A keyset is cheap only where the engine can seek into an index and stop. The index mirrors the cursor: a range index over exactly the ordered properties, in order, such as @RangeIndex({ properties: ['lastActivityAt', 'sessionId'] }) on the class. Profiled on Neo4j with 200,000 nodes, a page from the middle took 27 database accesses with that index, 200,020 with an index on the first key alone, and 500,010 with none.
  • This is about Neo4j. Memgraph and FalkorDB sort between the scan and the limit whatever is indexed, so a keyset costs what is left rather than a page. seek is still worth having there, for pages that don’t shift under concurrent writes.

Drivine tells you when the index is missing. An ordered query is checked against the database’s indexes, and one that nothing mirrors is reported, once for each label and set of properties. Ordering without an index is correct, only unindexed, so the default is to warn. Turn it up in development or CI:

DrivineModule.withOptions({
connectionProviders: [DatabaseRegistry.buildOrResolveFromEnv()],
query: { indexAdvice: 'FAIL' } // 'WARN' (the default) or 'OFF'
});

That applies to every manager the module makes. To change one manager: gom.indexAdvice = 'FAIL'. A query pinned to one root, by an equality on the id or on a uniquely constrained property, is never advised on. The index list is read once and kept; after making indexes as the application runs, call gom.indexAdvisor.refresh().