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.

eqIgnoreCase and containsIgnoreCase lowercase the property, which no index reads. On a @CaseInsensitive field whose index ignores case, they compare with a lowercased copy instead, which the index serves: see Lookups That Ignore Case.

Logging in by email whatever its case is a lookup by eqIgnoreCase alone, and on its own it reads every node of the label. Mark the field @CaseInsensitive() and give it an index that ignores case:

@NodeFragment({ labels: ['Account'] })
export class Account {
@NodeId() id: string;
@CaseInsensitive() @Unique({ ignoreCase: true }) email: string;
}
await gom.loadAll(Account, (q) => ({ where: q.email.eqIgnoreCase('Ada@Example.com') }));
  • The copy. Every save, create, update, saveAll, patch and patchAll, and the save of a view’s related nodes, writes a lowercased copy of the property beside it, under __drivine.lower.<property> (the name it is stored under, after @GraphProperty). The database lowercases it, as it lowercases the value a lookup compares with, so Drivine, drivine4j and drivine-python store and match the same thing. A property with no value has no copy. A load doesn’t read it, and a flat @PropertyBag never holds it. The copy is no part of whether a save changed the node, so a save that only fills one in keeps the node’s stamp. Cypher you send yourself writes no copy: see below.

  • The index. @RangeIndex({ ignoreCase: true }), @Unique({ ignoreCase: true }), rangeIndex(label, property, { ignoreCase: true }) and uniquenessConstraint(label, property, { ignoreCase: true }) are made over the copy, under their own names, <Label>_<property>_ci_range and _ci_unique. One that ignores case is refused on a field that isn’t @CaseInsensitive, since no save would keep its copy. A uniqueness constraint that ignores case refuses A@x.com beside a@x.com. FalkorDB can’t make one: GRAPH.CONSTRAINT takes no property name with a dot in it (FalkorDB#551), so it is refused there; its range index works.

  • Filling it in. Ensuring an item that ignores case first fills the copy in on every node of the label that lacks it, 10,000 nodes a statement, and records that every node has it: on the store’s DrivineStore node on the graph engines, in the graph’s catalog on Oracle and Postgres. An item made anew, or recreated, fills the copies in again.

  • The lookup. Once the copy is recorded, eqIgnoreCase and containsIgnoreCase on the field compare with the copy: in a load, a count, a deleteAll, a grouped read and a patch’s condition, on the root’s own fields. Until then, and for an empty value or a related node’s field, they lowercase the property, as for any field: the same answer, without the index. A manager knows at once what it recorded, and reads what another recorded at least once a minute.

  • Cypher of your own keeps no copy, on any store. Drivine runs a statement you send as written on the graph engines, and on Oracle, Postgres and SQLite compiles it to SQL and adds nothing to it. So a SET, CREATE, MERGE or REMOVE of the property leaves the copy as it was, or leaves the node without one, and so does SQL of your own. Once the copy is recorded, a lookup misses a node so written, or finds it by its old value. A node saved by a process whose fragment isn’t yet @CaseInsensitive, as while an upgrade rolls out, is missed the same way. Set the copy in the same statement, in a SET of its own after the one that writes the property, since an engine may read every value of one SET before it writes any. The same SET after a REMOVE of the property removes the copy.

    MATCH (a:Account {id: $id})
    SET a.email = $email
    SET a.`__drivine.lower.email` = toLower(a.email)

    LowerCopies.setClause('a', 'email') gives that last item. Or, after the writes, recreate the index that ignores case, which fills the copy in again on every node of the label:

    await gom.indexes.recreate(rangeIndex('Account', 'email', { ignoreCase: true }));

    A constraint that ignores case has no recreate: drop it and ensure it again, which fills the copies in again as it makes the constraint anew.

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. Without Nest, give it to the factory: new GraphObjectManagerFactory(persistenceManagers, { indexAdvice: 'FAIL' }). 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().