Skip to main content

Stored routines

Neo4j's procedures and functions are not an advanced corner of the database. APOC is the de-facto standard library, the Graph Data Science library exposes every algorithm as a procedure, and the built-in db.* / dbms.* namespaces cover index and metadata work. This driver makes them typed, discoverable and composable — declared once, called like any other ORM method, and generated from the server rather than hand-written.

The inversion, first​

MikroORM's stored-routine model is built for SQL, where a routine is something you author and the ORM creates: hence body, language, security, and a schema comparator that emits CREATE and DROP.

In Neo4j a routine is something that already exists and you discover. Procedures arrive as server plugins or are built in; there is no client-side CREATE PROCEDURE. That invalidates the authoring half of the model — and it is exactly what makes the feature tractable, because Neo4j will describe every installed routine on request.

Three consequences run through everything below:

  1. The driver never emits routine DDL. Not create, not drop, not a diff. Asserted by a test, not assumed.
  2. Declarations can be generated, which is the difference between covering a hand-written handful and covering all of APOC and GDS.
  3. Fields that presuppose authoring are refused, with a message, rather than accepted and ignored.

Declaring a routine​

import { defineRoutine } from 'mikro-orm-neo4j';

export const QueryBooks = defineRoutine({
name: 'db.index.fulltext.queryNodes',
type: 'procedure',
access: 'read',
params: {
indexName: { runtimeType: 'string' },
queryString: { runtimeType: 'string' },
options: { runtimeType: 'object', nullable: true },
},
columns: {
node: { entity: () => Book },
score: { runtimeType: 'number' },
},
});

const orm = await MikroORM.init({ entities: [Book], routines: [QueryBooks] });

// rows: Array<{ node: Book; score: number }> — no generics at the call site
const rows = await em.callRoutine(QueryBooks, { indexName: 'Book_title', queryString: 'graph' });

Argument and return types are inferred from the literal config. defineRoutine<TArgs, TReturn> overrides the inference where it is too loose — note that TypeScript's generics are all-or-nothing, so supplying one means supplying both.

Field by field​

DeclaredOutcome
nameThe real, dotted routine name. Validated as a namespaced identifier — see Names are not parameters.
type: 'function'A scalar-returning expression.
type: 'procedure'A source returning a stream of rows.
paramsInput arguments, in declaration order.
columnsThe columns a procedure yields. Needed for composition and for the inferred row type.
accessread | write | schema | dbms. Absent means write.
adminMarks a routine that needs elevated privileges. Informational.
bodyJsA local fallback, never registered with the server. See Standing in for a routine.
returnsA function's return type and marshalling.
commentFree text.

And what is refused, with the reason:

DeclaredOutcome
body, expression, language, security, definer, deterministic, dataAccessInitialisation fails. Neo4j routines are installed server-side and cannot be authored from a client.
direction: 'out' / 'inout', ref: trueInitialisation fails. Neo4j has no output parameters — for procedures either, which core allows and this driver does not.
returns: () => Entity, returns: { hydrate }Initialisation fails. Those describe an entity-shaped row set or several result sets. A Neo4j procedure yields named columns; declare columns, and give the node-bearing one an entity.
a SQL column type at params[].typeWarned and ignored. A graph has no column catalogue. runtimeType shapes the type, customType marshals the value.
ignoreSchemaChangesWarned and ignored. Nothing is ever diffed.
schemaWarned and ignored. A Neo4j routine's namespace is part of its name.
returns.columnTypeWarned and ignored.

Refusing rather than ignoring is the whole point: a declaration that silently drops security looks like it did something.

Where declarations live​

Routines are declared in the routines option, as core documents — but this driver keeps them out of core's own registry, because core validates that option against the SQL model in three ways no Neo4j routine can satisfy:

  • every routine must declare a body, expression or bodyJs; a Neo4j routine has none;
  • bodyJs is refused on a procedure, since SQLite (the only dialect that registers one) has no analogue — but APOC ships procedures, which is exactly where a local stand-in is wanted;
  • (schema, name) must be unique, whereas here the same procedure is legitimately declared more than once, once per entity a yielded node should hydrate into.

So the declarations are moved aside before core sees them and validated here instead. Nothing about the public surface changes. One behaviour does: two declarations may share a name. Registration is by reference, so calling a declaration you did not register is still an error.

This only happens for configs built through defineConfig / MikroORM.init / the Neo4jMikroORM constructor. A config assembled around them leaves the routines where core will reject them — loudly, at construction.

What a call compiles to​

RETURN apoc.text.clean($param0) AS value -- function: a scalar
CALL db.labels() YIELD label RETURN label -- procedure with declared columns
CALL apoc.meta.stats() -- procedure without

A function resolves to its value; a procedure resolves to an array of rows, and a procedure that produces none resolves to [] rather than null.

The third form is a standalone call, which Neo4j allows and which returns every column the procedure yields. It is the only form whose columns the declaration does not know — which is why composition needs the second.

Every call goes through the same execution funnel as a find, so routine calls appear in debug: ['query'] alongside everything else, with the same timing and slow-query handling.

Arguments​

Arguments are ordered by each parameter's declared position and bound as Bolt parameters, whose names the query builder allocates.

await em.callRoutine(Clean, { text: "') YIELD node MATCH (n) DETACH DELETE n //" });
// RETURN apoc.text.clean($param0) AS value

A value can never become part of the statement, whatever it contains.

Cypher can only leave out trailing arguments, which is the only way to get the server's own default rather than an explicit null. So an argument declared nullable: true is optional at the call site, and one left out in the middle is an error naming the parameter — guessing silently is how every later argument ends up shifted one position left.

Names are not parameters​

There is no CALL $name(...) in Cypher, so a routine's name is written into the statement. It is therefore validated at initialisation against a dotted-identifier pattern, and a name that is not one fails the boot rather than reaching a query.

Marshalling​

A declared customType marshals through core's own conversion, in both directions, exactly as it does for an entity property. Anything without one takes the driver's standard Neo4j conversion — the same one em.run() applies, so integers become numbers, date-times become Date, nodes and relationships become plain objects, and Time / Duration / Point arrive as the driver's own values because there is no lossless JavaScript equivalent.

Access mode​

A routine's declared access selects the session it runs on:

  • read may run on a read session, and on a cluster may be routed to a replica;
  • write, schema and dbms need the primary;
  • absent means write, which is the safe direction to guess: a read routine wrongly run on a write session merely misses a routing opportunity, while the reverse fails.

A write routine invoked on a read-only connection is refused with the driver's read-only exception before a statement is sent. The server would refuse it too, but only after a round trip and with a message about the statement rather than about the routine.

Calls join the ambient transaction, so their effects commit and roll back with it, and a read routine inside a transaction observes that transaction's uncommitted state.

Standing in for a routine you do not have​

A routine may declare bodyJs. When the routine is not installed on the target server, the driver runs that function locally and returns its value in the routine's declared shape.

const Clean = defineRoutine({
name: 'apoc.text.clean',
type: 'function',
access: 'read',
params: { text: { runtimeType: 'string' } },
returns: { runtimeType: 'string' },
bodyJs: ({ text }) => text.replace(/[^a-z0-9]/gi, '').toLowerCase(),
});

This exists so a code path that depends on APOC can be exercised against a plain Neo4j container — a real problem for a suite built on Testcontainers.

The guarantee is exact, and both halves are asserted by tests:

  • it is never registered with the server, because Neo4j has no client-side UDF registration;
  • it is never used when the routine is installed. A fallback that silently shadowed a real routine would be a correctness hazard, not a convenience.

Installed-ness is checked once per kind and remembered for the connection's lifetime, and only for routines that declare a fallback — nothing else pays for it. A routine that is absent and declares no fallback fails with an error naming it.

Composition​

Core's callRoutine is standalone. For a graph, the shape that matters more is a procedure as a query's source — which is what db.index.fulltext.queryNodes and most of GDS actually are.

const rows = await em
.createQueryBuilder()
.callProcedure(QueryBooks, { indexName: 'Book_title', queryString: 'graph' })
.yield('node', 'score')
.where('score', { $gt: 0.5 })
.orderBy('score', 'DESC')
.limit(10)
.execute();

Yielded columns become ordinary query values: where, orderBy, limit, offset and return all apply to them, and where takes the same $operator objects a filter on a stored property does.

yield() is optional — every declared column is yielded without it — and is checked against the declaration before anything is built, so a mistyped column names itself here rather than arriving as a syntax error from the server naming the procedure.

Two things it does not do. A filter object cannot be applied to a procedure query: it filters an entity's properties, and a procedure yields columns, so it is refused rather than compiled against an unbound variable. And a function cannot be a query source — call it, or use it as an expression inside a query.

As a virtual entity​

A routine can back a virtual entity, which gives its rows the full read surface — filters, ordering, paging, count, Loaded<> typing:

@Entity({ expression: () => routineSource(DbLabels) })
class LabelView {
@Property() label!: string;
}

await em.find(LabelView, { label: { $like: 'Book%' } }, { orderBy: { label: 'ASC' }, limit: 10 });
await em.count(LabelView, {});

The driver wraps the compiled call the same way it wraps a string expression. It can do that safely even though the expression is a callback — the rule in raw Cypher says callbacks are not wrapped — because a routineSource() is a value the driver produced, so it demonstrably has not applied the options itself.

The routine's arguments and the wrapper's own parameters are built in separate passes and would both start at param0, so the routine's are compiled under a prefix. Nothing collides.

Hydration​

Core states that routine result sets return plain dictionaries and that entity-typed hydration is not supported. For a relational routine that is a reasonable place to stop. For a graph it is not: yielding nodes is what graph procedures do.

So a column declaring an entity is hydrated through the same mapping path find() uses — the same value conversion, the same reserved-property stripping, the same identity-map merge:

columns: {
node: { entity: () => Book }, // a managed Book
score: { runtimeType: 'number' }, // a plain number
}

Columns without a declared entity stay plain values. A column declared as an entity that turns out not to carry a node is an error naming the column, not a best effort — hydrating whatever arrived would produce an entity with no properties, which reads as "the row was empty" rather than "the declaration is wrong".

This is an extension above core's contract and is documented as such, so a future core version providing it natively is a migration rather than a conflict.

Hydration is declared on the routine rather than at the call site. That is the only place that can feed the inferred return type, and em.callRoutine takes two arguments — core's signature, worth not breaking. A procedure used against several entity types is declared once per type; a Routine is a value, not a registration. Where genuine call-site flexibility is wanted, the query builder already provides it: the entity comes from the builder.

Generating declarations​

APOC and GDS have hundreds of procedures between them. Nobody is going to hand-write a declaration for each, so the driver reads the server:

mikro-orm-neo4j-routines --url bolt://localhost:7687 --user neo4j --password secret \
--namespace apoc --namespace gds --out ./src/routines
Option
--url, --user, --password, --databaseConnection. Defaults from $NEO4J_URL, $NEO4J_USER, $NEO4J_PASSWORD.
--namespace <ns>Restrict to a namespace. Repeatable. Default: everything the connected user can see.
--out <dir>Output directory. Default ./src/routines.
--single-fileOne file instead of one per namespace.
--import-from <spec>Module the generated file imports from. Default mikro-orm-neo4j.

The same thing programmatically:

const signatures = await em.getConnection().showRoutines({ namespaces: 'apoc' });
const { files, report } = Neo4jRoutineGenerator.generate(signatures);

The workflow: commit the file​

Generation is a command, not a build step, and the output is meant to be committed. That is MikroORM's own reasoning for its entity generator, and it holds here for three reasons: a committed file is reviewable when APOC is upgraded, diffable when a signature changes, and works in a CI job that has no database.

It is safe to commit because generation is deterministic: sorted by name independently of the server's response order, formatted identically every run, and carrying no timestamp. Regenerating against an unchanged server produces no diff at all.

One file per namespace by default, so upgrading a plugin produces a diff scoped to that plugin rather than to everything installed. --single-file if you would rather have one.

What the generator will not guess​

  • The export identifier is sanitised, the declared name is not. db.index.fulltext.queryNodes becomes DbIndexFulltextQueryNodes; the declaration keeps the real dotted name. Collisions get a numeric suffix, in the generator's own sorted order, so the choice is stable.
  • Unmappable types are emitted as unknown, never as anything any-like. Lists, nodes, relationships, paths, points, durations and ANY all land there. A signature the generator cannot read precisely is a decision you have to make, and a compiler error is how you find out you have to make it. defineRoutine<TArgs, TReturn> is the refinement path — and columns.<name>.entity is the hand edit that turns a NODE column into an entity.
  • Optional arguments are written out explicitly, because core's inference makes every declared key required.
  • Descriptions become documentation comments, which is what makes several hundred declarations navigable in an editor. Administrative, non-executable and deprecated routines carry a remark.
  • What could not be represented is reported, by name and with the reason, rather than emitting a file that appears complete.

The driver eats its own cooking​

$fulltext compiles through this layer rather than naming db.index.fulltext.queryNodes inline. That was the sharpest available check that the abstraction is right: if the routine layer could not describe the one procedure call the driver already makes, it would be too narrow to describe APOC. The emitted Cypher is asserted byte-identical to what the hand-built call produced, so nothing about full-text search changed.

Upstream status​

MikroORM marks stored-routine support experimental in v7.1 and says its types may move in a patch release. This driver keeps its surface over it thin and isolated in src/routines/, pins @mikro-orm/core to an exact version, and carries a dedicated contract test asserting the shape it depends on — Routine's fields, RoutineProperty's fields, Routine.create's inference and the Connection hooks — so an upstream shift fails the build here rather than silently changing what a call does.

Pin your own @mikro-orm/core if you depend on this.