Raw Cypher
Some traversals are not expressible as a filter, and should not have to be. This page covers the ways to write Cypher by hand here, and — more importantly — which one to reach for.
The rule
| You are… | Use |
|---|---|
| modelling something you will query again, with filters, ordering and paging | a virtual entity |
| writing one query, in one place, and just want its rows | em.run<T>(), with cypher`…` where you need values interpolated |
| adding a Cypher expression inside an ordinary filter | cypher`…` as a filter key or value |
The first is a read model. The others are an escape hatch. Reaching for the escape hatch repeatedly with the same query is the signal that it wanted to be a virtual entity.
cypher — a fragment of Cypher inside a filter
import { cypher } from 'mikro-orm-neo4j';
// as a key: the fragment is the thing being compared
await em.find(Book, { [cypher`toLower(this0.title)`]: 'graph databases' });
// as a value: the fragment is what the property is compared to
await em.find(Book, { price: cypher`coalesce(${fallback}, 0)` });
// with an operator
await em.find(Book, { [cypher`this0.price * 1.2`]: { $gt: 100 } });
// as the whole predicate — an empty array means "no comparison"
await em.find(Book, { [cypher`this0.published IS NOT NULL`]: [] });
Fragments nest: inside $and, $or, $not, and beside ordinary conditions.
An interpolated value can never become Cypher
This is the one thing people get wrong about tagged templates, so it is worth stating plainly:
cypher`n.title = ${userInput}`
userInput is always a parameter. Only the literal parts of the template — the text you typed between the ${} — become Cypher. Whatever the value contains, and whatever its type, it cannot change the shape of the query.
// Compares the title to that exact string. Matches nothing. Does not "escape".
await em.find(Book, { title: cypher`${"' OR true //"}` });
What is not safe is building the template text itself from user input — but that is true of every query language, and a tagged template cannot do it by accident.
Parameters are named, and cannot collide
Core's sql tag binds positionally (?), because that is what knex and kysely want. Bolt binds by name. cypher therefore has its own tag rather than reusing sql, and the names are allocated by the query builder at compile time — the driver never invents one, so a fragment's parameters cannot collide with the $paramN series the driver generates for the surrounding query.
A literal question mark in a fragment is escaped as \?.
SQL fragments are refused
await em.find(Book, { [sql`lower(title)`]: 'x' });
// Neo4jRawFragmentError: Raw SQL fragments are not supported by the Neo4j driver:
// raw('lower(title)'). Use the `cypher` tagged template instead — cypher`lower(title)` —
// which binds its interpolated values as named Bolt parameters.
This used to fail in two quieter ways: as a key it was dropped in silence, producing a query missing the predicate and returning wrong rows with no error; as a value its parameters were dropped and a literal ? reached Cypher. Both are now one loud error.
Helpers
cypher.ref('n', 'title') // `n`.`title`, each segment backtick-escaped
cypher.lower('n.title') // toLower(n.title)
cypher.upper('n.title') // toUpper(n.title)
There is deliberately no now() — datetime() is the Cypher spelling and reads better inline — and no identifier-quoting tag, since ref covers it.
em.run<T>() — one query, plain rows
const rows = await em.run<{ name: string; total: number }>(
'MATCH (u:User)-[:CREATED]->(p) RETURN u.name AS name, count(p) AS total',
);
// with interpolated values
const recent = await em.run<{ id: string }>(
cypher`MATCH (b:Book) WHERE b.price > ${threshold} RETURN b.id AS id`,
);
// streaming, for results too large to hold
for await (const row of em.streamRaw<{ id: string }>('MATCH (b:Book) RETURN b.id AS id')) {
// …
}
Values are converted on the way out: integers become numbers, date-times become Date, nodes and relationships become plain objects carrying their properties. Time, Duration and Point are handed over as the driver's own values, because there is no lossless JavaScript equivalent to convert them into.
Two things to know:
- The type parameter is a declaration, not a validation. Nothing checks that the query returns that shape. It is there so the call site reads well and the compiler can help downstream; get the aliases wrong and you get a wrong type, not an error.
- Rows are never managed entities. They do not enter the identity map, they are not change-tracked, and flushing does not persist them. Hydration is what virtual entities are for.
Virtual entities — the modelled path
A virtual entity turns a hand-written query into a read model with the full read surface: filters, ordering, paging, Loaded<> typing, serialization.
@Entity({
expression: `
MATCH (b:Book)-[:WROTE]-(a:Author)
RETURN b.id AS id, b.title AS title, b.price AS price, a.name AS author
`,
})
export class BookListing {
@Property() id!: string;
@Property() title!: string;
@Property() price!: number;
@Property() author!: string;
}
await em.find(BookListing, { price: { $gt: 20 } }, { orderBy: { title: 'ASC' }, limit: 20 });
await em.count(BookListing, { author: 'Ann' });
The driver applies those options around your expression:
CALL { <your expression> }
WITH { id: id, title: title, price: price, author: author } AS row
WHERE row.price > $param0
RETURN row.id AS id, row.title AS title, row.price AS price, row.author AS author
ORDER BY title ASC LIMIT 20
Collecting the columns into one variable is what lets the ordinary filter translation apply — every operator and every boolean nesting works here exactly as it does on a stored entity.
With nothing to apply, the expression runs byte-identical. No filter, no ordering, no window means no wrapper.
Callback expressions
An expression can also be a function, which core hands where and options. Those are yours to apply — the driver does not wrap a callback expression, because it cannot tell whether you already used them, and applying them twice would be worse than not applying them at all.
@Entity({
expression: (em: Neo4jEntityManager, where, options) =>
em.createQueryBuilder(Book).match().where(/* … */).limit(options.limit),
})
export class CustomListing { /* … */ }
Use a string when the query is fixed, a callback when it needs to react to the query being made.
Limits
- A virtual entity has no primary key — core rejects one.
- Its expression is not scoped by
schema, like every raw surface. Add the predicate yourself if you need it; see schemas. - Rows are plain values of the declared properties; relations are not populated.