Query conditions
Which of MikroORM's filter operators this driver translates, and what they become in Cypher.
mikro-orm-neo4j ≥ 0.3.0 · @mikro-orm/core 7.x · Neo4j ≥ 5
const books = await em.find(Book, {
price: { $gte: 10, $lt: 100 },
author: { name: 'Ann' }, // traverses the relation
search: { $fulltext: 'graph databases' }, // reads the index that field declares
});
Supported operators
| Operator | Cypher | Notes |
|---|---|---|
$eq | n.prop = $p | Also the plain form, { prop: value }. null becomes IS NULL. |
$ne | n.prop <> $p | $ne: null becomes IS NOT NULL. |
$gt, $gte, $lt, $lte | n.prop > $p, … | |
$in | n.prop IN $p | An array value is shorthand for it: { id: [1, 2] }. |
$nin | NOT (n.prop IN $p) | Cypher has no negated membership operator. |
$like | STARTS WITH / ENDS WITH / CONTAINS, else =~ | See patterns. |
$ilike | =~ '(?is)…' | Case-insensitive $like. |
$re | n.prop =~ $p | Searches rather than full-matches — see patterns. A RegExp on the key itself carries its flags. |
$exists | IS NOT NULL / IS NULL | |
$and, $or, $not | AND, OR, NOT (…) | Nest freely; $not is allowed at the top level. |
$overlap | any(x IN n.prop WHERE x IN $p) | Lists share at least one element. |
$contains | all(x IN $p WHERE x IN n.prop) | On a string property it is Cypher's CONTAINS instead. |
$contained | all(x IN n.prop WHERE x IN $p) | The stored list is a subset of the given one. |
$size | size(n.prop) or COUNT { … } | A number or a comparison object ({ $gte: 3 }). |
$some, $none, $every | EXISTS { … }, NOT EXISTS { … } | On relations — see collections. |
| relation key | EXISTS { MATCH (n)-[:REL]->(t) WHERE … } | { tags: { name: 'Fiction' } }; nests to any depth. |
$fulltext | CALL db.index.fulltext.queryNodes(…) | See full-text search. |
raw() | inlined fragment | { title: raw('toUpper(n.title)') }. |
$hasKey, $hasKeys and $hasSomeKeys have no equivalent: Neo4j node properties hold primitives
and lists, never maps, so there are no keys to test. Any operator the driver cannot translate throws
by name — an unknown $operator used to fall through to an equality against the operator map itself,
which quietly matched nothing.
Patterns
$like maps onto a real Cypher string operator whenever the pattern allows, because those keep a
TEXT/RANGE index usable and a regex never does:
| Pattern | Cypher |
|---|---|
'Graph%' | n.title STARTS WITH 'Graph' |
'%graph' | n.title ENDS WITH 'graph' |
'%graph%' | n.title CONTAINS 'graph' |
'graph' | n.title = 'graph' |
'Gr_ph%s' | n.title =~ '(?s)Gr.ph.*s' |
\% and \_ match the character itself. $ilike always takes the regex form, with (?i).
$re searches inside the value, the way REGEXP does in SQL and $regex does in MongoDB. Cypher's
=~ matches the whole string, so the pattern is wrapped ([\s\S]*(?:…)[\s\S]*) to keep the
meaning the same across drivers — an anchored pattern still anchors:
await em.find(Book, { title: { $re: '^[Gg]raph' } });
await em.find(Book, { title: /^graph/i }); // flags travel inline as `(?i)`
Collections
Relation filters take the collection operators, each becoming a subquery over the edge:
await em.find(Book, { tags: { $some: { name: 'Fiction' } } }); // EXISTS { … WHERE … }
await em.find(Book, { tags: { $none: {} } }); // NOT EXISTS { … }
await em.find(Book, { tags: { $every: { name: 'Reference' } } });
await em.find(Book, { tags: { $size: { $gte: 2 } } }); // COUNT { … } >= 2
$every is expressed as "none of them fails", which is also why a parent with no related records
matches it — the same vacuous truth the SQL drivers produce. One collection operator per relation:
two of them would be two subqueries over the same edge with no defined way to combine, so the driver
throws and points at $and.
$size reads a list property as size(n.prop) and a relation as a COUNT subquery, so
{ keywords: { $size: 2 } } and { tags: { $size: 2 } } both mean what they look like.
Ordering
orderBy reads a path the same way a filter does, and resolves it in the order that costs least:
await em.find(Book, {}, { orderBy: { author: { name: 'ASC' }, title: 'DESC' } });
| What you order by | What runs |
|---|---|
| A property of the entity | ORDER BY n.title |
| The related entity's primary key | ORDER BY n.author — the key is denormalized onto the node, so nothing is traversed |
| A relation that is already matched (populated, or expanded by a stream) | ORDER BY a.name on the variable the query already binds |
| Anything else through a to-one relation | one OPTIONAL MATCH for the hop, then ORDER BY a.name |
The hop is optional on purpose: an entity whose relation is missing still comes back, sorted among
the nulls, the way a LEFT JOIN behaves. Cypher sorts nulls last ascending and first descending, and
has no syntax to change that — so 'DESC NULLS LAST' and friends keep their direction and drop the
qualifier.
Ordering by a to-many relation throws unless the query has already expanded it. A row per parent
has no single child value to sort on, and picking one (the first? the smallest?) would sort by
something you never asked for. em.stream() does expand its populated relations, so ordering by them
there works and sorts the expanded rows — the same meaning a joined result set has in SQL. To order
the children themselves, use populateOrderBy.
Relationship entities sort by their own edge properties, and may also reach through either endpoint
(orderBy: { actor: { name: 'ASC' } }) — both nodes are already bound by the pattern.
Partial loading
fields and exclude narrow what a node hands back, using a Cypher map projection — the
properties left out never leave the server:
await em.find(Book, {}, { fields: ['title', 'author.name'], populate: ['author'] });
MATCH (this0:Book)
OPTIONAL MATCH (this1:Author)-[this2:WROTE]->(this0)
RETURN this0 { .id, .title, .author } AS node, this1 { .id, .name } AS rel_author
| Written as | Projected |
|---|---|
fields: ['title'] | n { .id, .title } — the primary key is always included, since the entity is built around it |
fields: ['address'] | every property the embeddable flattens to |
fields: ['address.city'] | exactly that one |
fields: ['author.name'] | .author on the root (so the relation resolves) and { .id, .name } on the author |
fields: ['*'] | nothing — the node itself |
exclude: ['note'] | everything the node stores except note |
| nothing | nothing — RETURN n, exactly as before |
That last row is deliberate: a read that narrows nothing emits no projection at all, so the common case keeps the cheapest possible plan. A single-table hierarchy also keeps its discriminator, which decides which entity each row becomes.
Projections are what make lazy mean something. A @Property({ lazy: true }) is left out of the
projection until it is populated — before this, it travelled with every read and core simply
discarded it.
Full-text search
$fulltext is a different shape from every operator above — it reads an index through a procedure
rather than filtering a scan — and has a page of its own:
full-text search.