Pipeline builder reference
The pipeline builder gives you a typed way to build MongoDB aggregation pipelines. You reach it through db.query, chain aggregation stages onto a starting collection, and finish the chain with the call that builds the query. It is the MongoDB counterpart to the SQL query builder: a lower-level API for the queries the ORM client doesn't express.
Everything the builder produces becomes a MongoDB aggregation pipeline. There is no find() or distinct() here, so use db.orm or rawCommand() for those. To fetch a single document, filter and then limit(1). Use the ORM client (db.orm) for everyday reads and writes across models and relations. Reach for the pipeline builder when you need to group documents, to join another collection and keep transforming the result, or to write the result into a collection ($out and $merge). This page documents every stage, accumulator, expression helper, and write method.
The examples run against the schema below, the same MongoDB schema as the ORM client page. Article and Tutorial are two kinds of Post, stored in one posts collection and told apart by the value of the kind field. @@base(Post, "article") and @@discriminator(kind) set that up. See @@base on the ORM client page. @@type("mongo/string@1") says the enum is stored as a MongoDB string, and @1 is the version of that type id, always @1 today. None of that matters to a pipeline: kind is an ordinary field you can filter and group on.
Expand for the example schema
enum UserRole {
@@type("mongo/string@1")
Admin = "admin"
Author = "author"
Reader = "reader"
}
type Address {
street String
city String
zip String?
country String
}
model User {
id ObjectId @id @map("_id")
name String
email String
bio String?
role UserRole
address Address?
posts Post[]
@@map("users")
}
model Post {
id ObjectId @id @map("_id")
title String
content String
kind String
authorId ObjectId
createdAt Date
author User @relation(fields: [authorId], references: [id])
@@discriminator(kind)
@@index([authorId])
@@index([createdAt(sort: Desc), authorId])
@@map("posts")
}
model Article {
summary String
@@base(Post, "article")
@@unique([summary])
}
model Tutorial {
difficulty String
duration Int32
@@base(Post, "tutorial")
}Every pipeline starts with db.query.from('posts'), naming the collection to aggregate over. It ends with a call that turns the chain into a query you can run, and between them you chain stages.
db is the client you create with mongo(...), whose dbName option is the name of the MongoDB database to use. See Transactions and runtime for every option and for how to create the client. Three Prisma ORM import paths appear on this page: @prisma/orm-mongo/runtime holds the mongo() client, @prisma/orm-mongo/query-builder holds mongoQuery, fn, acc, and expr, and @prisma/orm-mongo/query-ast/execution holds the Mongo*Stage classes, MongoAggFieldRef, and MongoFieldFilter. Every example holds the built query in a variable named plan.
Prisma ORM 8 renames schema.prisma to contract.prisma. Run npx prisma contract emit before any code on this page compiles: it writes contract.json, used at run time, and contract.d.ts, used for types. The example below imports contract.json with with { type: 'json' }, which works with the TypeScript settings prisma orm init writes for you. Coming from Prisma ORM 7 has the setup steps.
Enter the pipeline builder on a collection.
from()takes the collection name, the same name you use ondb.orm('posts','users'), not the model namePost. The collection name is the model's@@mapvalue, or the model name exactly as written when there is no@@map.- Passing an unknown collection name throws right away, with an error whose
codeisORM.MODEL_UNKNOWNand whose message isUnknown root: "<name>". Valid roots: ...(the message says root; it means the collection name). from()returns a builder you chain stages onto. CallinsertOne()andinsertMany()onfrom()directly, before any stage.- You can build the same pipelines without a client.
import { mongoQuery } from '@prisma/orm-mongo/query-builder', thenmongoQuery({ contractJson }).from('posts'). ImportcontractJsonthe same way the example below does.mongoQueryonly builds queries. It never runs one, so you still need a client'sruntimeto get results. Use it in tests, or to build a query once and run it through any client you like.
| Name | Type | Required | Description |
|---|---|---|---|
collection |
Collection name (string literal) |
Yes | The collection to aggregate over. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts') |
A builder you chain stages onto, then finish with build(). |
import mongo from '@prisma/orm-mongo/runtime';
import type { Contract } from './contract.d';
import contractJson from './contract.json' with { type: 'json' };
const db = mongo<Contract>({ contractJson, url: process.env.MONGODB_URL, dbName: 'app' });
const runtime = await db.runtime();
const plan = db.query.from('posts').build();
const posts = await runtime.query(plan);build() returns the built query, which the examples hold in a variable named plan. Nothing runs until you pass it to runtime.query(...). Every later example reuses the db and runtime from the example above.
- On MongoDB,
db.runtime()returns a promise, so you must await it:const runtime = await db.runtime(). Hold it in a variable and reuse it. awaitthat call for an array of documents, or writefor await (const doc of runtime.query(plan)) { ... }to take one document at a time.- There is no
db.executeon the MongoDB client. - Read results are decoded. If every stage in your pipeline is one of
match(),sort(),limit(),skip(),sample(),project(),addFields(), andvectorSearch(),_idcomes back as a hex string. If any stage is not in that list, includinglookup()andgroup(), every document comes back raw, with each value as MongoDB stores it. An_idthat MongoDB generated is then anObjectIdobject from themongodbpackage, the MongoDB driver Prisma ORM uses. Compare those ids as strings, aslookup()shows. ADateTimefield comes back as a JavaScriptDateeither way.
const plan = db.query.from('posts').sort({ createdAt: 1 }).build();
const posts = await runtime.query(plan);Stages transform the documents flowing through the pipeline. Chain them in order; each stage's output feeds the next. The (f) => ... callbacks reference the current document's fields, one property per field.
Filter documents by a predicate.
match()takes a callback whosefargument has one property per field, and returns a filter expression ((f) => f.kind.eq('tutorial')).- Scalar fields expose these filter operators:
eq,ne,gt,gte,lt,lte,in,nin,exists, andtype. They work like the MongoDB query operators of the same name ($ne,$gt,$in,$exists,$type, and so on). - To compare two computed values rather than a field against a constant, wrap the comparison in
expr(...):match((f) => expr(fn.gt(fn.year(f.createdAt), fn.literal(2023)))).fnholds the helpers that build expressions, one per MongoDB operator.expr()wraps one of those values somatch()accepts it. Thefn.*helpers take expressions, not plain values, so wrap every constant infn.literal(). See Expression helpers.
Where each form goes. .node is the raw MongoDB expression inside an fn.* value:
| Call site | What you pass |
|---|---|
project(), addFields(), redact(), and the arguments of an accumulator |
The fn.* value itself. |
The condition argument of fn.cond(), and the stages that take an options object |
The .node property of the fn.* value. |
match() |
The fn.* value wrapped in expr(...). |
| Name | Type | Required | Description |
|---|---|---|---|
predicate |
Callback (f) => FilterExpression |
Yes | The condition documents must satisfy. |
| Return type | Example | Description |
|---|---|---|
| Filtered builder | db.query.from('posts').match(...) |
A builder narrowed by the filter, chainable into more stages, write methods, or build(). |
const plan = db.query
.from('posts')
.match((f) => f.kind.eq('tutorial'))
.build();
const tutorials = await runtime.query(plan);import { fn, expr } from '@prisma/orm-mongo/query-builder';
const plan = db.query
.from('posts')
.match((f) => expr(fn.gt(fn.year(f.createdAt), fn.literal(2023))))
.build();
const recent = await runtime.query(plan);Order documents by a field spec.
sort()takes a plain object spec:{ field: 1 }ascending,{ field: -1 }descending. Multiple keys sort in the order they appear.
| Name | Type | Required | Description |
|---|---|---|---|
spec |
{ [field]: 1 | -1 } |
Yes | The sort key(s) and direction(s). |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').sort({ createdAt: -1 }) |
A builder with an ordering applied. |
const plan = db.query.from('posts').sort({ createdAt: -1 }).build();
const newestFirst = await runtime.query(plan);Cap the number of documents.
- Combine
sort()thenlimit(1)to fetch a single document: the pipeline builder has nofirst().
| Name | Type | Required | Description |
|---|---|---|---|
count |
number |
Yes | Maximum number of documents to return. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').limit(1) |
A builder limited to count documents. |
const plan = db.query.from('posts').sort({ createdAt: 1 }).limit(1).build();
const [oldest] = await runtime.query(plan);Offset into the sorted result set.
| Name | Type | Required | Description |
|---|---|---|---|
count |
number |
Yes | Number of documents to skip. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').skip(1) |
A builder offset by count documents. |
const plan = db.query.from('posts').sort({ createdAt: 1 }).skip(1).build();
const afterFirst = await runtime.query(plan);Draw a random subset of documents.
| Name | Type | Required | Description |
|---|---|---|---|
size |
number |
Yes | Number of documents to sample. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').sample(1) |
A builder emitting a random subset. |
const plan = db.query.from('posts').sample(1).build();
const oneRandom = await runtime.query(plan);Compute new fields and attach them to each document.
addFields()takes a callback returning an object of new field names to computed expression-helper values. Existing fields are preserved.
| Name | Type | Required | Description |
|---|---|---|---|
spec |
Callback (f) => ({ [field]: Expression }) |
Yes | The new fields to compute. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').addFields(...) |
A builder whose documents carry the new fields. |
import { fn } from '@prisma/orm-mongo/query-builder';
const plan = db.query
.from('posts')
.addFields((f) => ({ shoutTitle: fn.toUpper(f.title) }))
.build();
const withShout = await runtime.query(plan);Join documents from another collection ($lookup).
lookup()takes a callback that builds the join withfrom(collection).on((local, foreign) => ({ local, foreign })).as(name): the other collection, the two fields to match on, and the name of the output array field.- The object returned by
on()has two fixed keys,localandforeign. Write({ local: local.authorId, foreign: foreign._id }). - The joined documents arrive in an array under the
asname. When nothing matches, that array is empty. - A joined document's
_idand the_idof the document it was joined to are bothObjectIdobjects, and twoObjectIdobjects are never===even when they hold the same id. Compare them as strings:String(post.author[0]._id) === String(post.authorId).
| Name | Type | Required | Description |
|---|---|---|---|
builder |
Callback (from) => from(collection).on(...).as(name) |
Yes | The join specification. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').lookup(...) |
A builder whose documents carry the joined array. |
const plan = db.query
.from('posts')
.match((f) => f.title.eq('Hello world'))
.lookup((from) =>
from('users')
.on((local, foreign) => ({ local: local.authorId, foreign: foreign._id }))
.as('author'),
)
.build();
const withAuthor = await runtime.query(plan);Reshape each document.
project()has two forms. A key-list form narrows to the named fields (project('title', 'kind'));_idis retained implicitly even when not listed.- A callback form computes a projection spec (
project((f) => ({ title: 1, shout: fn.toUpper(f.title) }))). Write1to keep a field, or an expression to compute it. There is no0form here: the callback form cannot drop a field.
| Name | Type | Required | Description |
|---|---|---|---|
...fields |
Field names (string) |
Key-list form | The fields to keep. |
spec |
Callback (f) => ({ [field]: 1 | Expression }) |
Callback form | The projection specification. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').project('title', 'kind') |
A builder projected to the given shape. |
const plan = db.query.from('posts').project('title', 'kind').build();
const trimmed = await runtime.query(plan);import { fn } from '@prisma/orm-mongo/query-builder';
const plan = db.query
.from('posts')
.project((f) => ({ title: 1, shout: fn.toUpper(f.title) }))
.build();
const projected = await runtime.query(plan);Unroll an array field into one document per element.
unwind(field, { preserveNullAndEmptyArrays? })maps to MongoDB's$unwind. Name the field holding the array. TypeScript accepts any field of the document; nothing checks that it holds an array.
| Name | Type | Required | Description |
|---|---|---|---|
field |
Field name (string) |
Yes | The array field to unroll. |
options.preserveNullAndEmptyArrays |
boolean |
No | Keep documents whose array is null, missing, or empty. Defaults to false. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').unwind('tags') |
A builder emitting one document per array element. |
This example needs a tags String[] field on Post, which the example schema does not have.
const plan = db.query
.from('posts')
.match((f) => f.title.eq('Hello world'))
.unwind('tags')
.sort({ tags: 1 })
.build();
const perTag = await runtime.query(plan);Group documents by a key and compute per-group aggregates.
group()takes a callback that receives only thefargument, one property per field ((f) => ...), and returns a spec object. The spec's_idsets the grouping key; every other key must be an accumulator.accholds the accumulatorsgroup()accepts, such as a count or a sum, documented under Accumulators. Import it and use it inside the callback, not as a second callback argument:import { acc } from '@prisma/orm-mongo/query-builder', thenacc.count(),acc.push(f.title). The callback takes one argument; there is no(f, acc) => ...form.- A
_id: nullkey groups the whole collection into a single bucket. - A non-accumulator value for a non-
_idkey throws as soon as you callgroup(), with an error whosecodeisORM.ARGUMENT_INVALID:group() field "<name>" must use an accumulator (e.g. acc.sum(), acc.count()). Got "<kind>" expression.Anullvalue for a key other than_idthrows with the same code and the messagegroup() field "<name>" must not be null. Only _id can be null.
| Name | Type | Required | Description |
|---|---|---|---|
spec |
Callback (f) => ({ _id: keyExpression | null, [alias]: acc.* }) |
Yes | The grouping key and per-group accumulators. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').group(...) |
A builder emitting one document per group. |
import { acc } from '@prisma/orm-mongo/query-builder';
const plan = db.query
.from('posts')
.group((f) => ({
_id: f.authorId,
postCount: acc.count(),
titles: acc.push(f.title),
}))
.build();
const perAuthor = await runtime.query(plan);import { acc } from '@prisma/orm-mongo/query-builder';
const plan = db.query
.from('posts')
.group((f) => ({ _id: null, total: acc.count(), latest: acc.max(f.createdAt) }))
.build();
const [summary] = await runtime.query(plan);For Prisma ORM 7 users, groupBy becomes a group() stage on the pipeline builder:
- const perAuthor = await prisma.post.groupBy({ by: ['authorId'], _count: true });
+ const perAuthor = await runtime.query(
+ db.query.from('posts').group((f) => ({ _id: f.authorId, postCount: acc.count() })).build(),
+ );Promote a computed sub-document to the top level.
replaceRoot()takes a callback returning an expression that evaluates to an object. A bare scalar is rejected by MongoDB at runtime ('newRoot' expression must evaluate to an object).- A common pattern is to promote the first element of a
lookup()array withfn.arrayElemAt(f.author, fn.literal(0)).
| Name | Type | Required | Description |
|---|---|---|---|
spec |
Callback (f) => documentExpression |
Yes | The document to promote to the root. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').replaceRoot(...) |
A builder whose documents are the promoted sub-document. |
import { fn } from '@prisma/orm-mongo/query-builder';
const plan = db.query
.from('posts')
.match((f) => f.title.eq('Hello world'))
.lookup((from) =>
from('users')
.on((local, foreign) => ({ local: local.authorId, foreign: foreign._id }))
.as('author'),
)
.replaceRoot((f) => fn.arrayElemAt(f.author, fn.literal(0)))
.build();
const authors = await runtime.query(plan);Reduce the pipeline to a single document holding the count. It is not acc.count(), which counts the documents in one group inside group().
| Name | Type | Required | Description |
|---|---|---|---|
field |
string |
Yes | The output field name for the count. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').count('total') |
A builder emitting one { [field]: n } document. |
const plan = db.query.from('posts').count('total').build();
const [{ total }] = await runtime.query(plan);Group by an expression and sort descending by group size.
sortByCount()takes a callback returning the grouping expression. It emits one document per distinct value, each{ _id, count }, sorted bycountdescending.
| Name | Type | Required | Description |
|---|---|---|---|
expression |
Callback (f) => expression |
Yes | The value to group and count by. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').sortByCount((f) => f.kind) |
A builder emitting { _id, count } documents. |
const plan = db.query
.from('posts')
.sortByCount((f) => f.kind)
.build();
const byKind = await runtime.query(plan);Keep or prune documents (and sub-documents) based on an expression.
redact()maps to MongoDB's$redact, whose expression must evaluate to one of MongoDB's$$variables:$$KEEP,$$DESCEND, or$$PRUNE.- Write those variables with
f.rawPath('$KEEP')andf.rawPath('$PRUNE').f.rawPath(path)names a field or a MongoDB$$variable by its raw string, with no checking against your model, and it prefixes$to the string you give it. There is no expression helper for a$$variable, andfn.literal(...)cannot stand in: the server rejects bothfn.literal('KEEP')andfn.literal('$$KEEP'). - For which
fn.*values need their.nodeproperty here, see the table undermatch(). For anything more involved, userawCommand(), where you write the$redactstage with$$KEEPand$$PRUNEdirectly.
| Name | Type | Required | Description |
|---|---|---|---|
spec |
Callback (f) => expression |
Yes | An expression evaluating to $$KEEP, $$DESCEND, or $$PRUNE. |
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').redact(...) |
A builder that keeps or prunes each document. |
import { fn } from '@prisma/orm-mongo/query-builder';
const plan = db.query
.from('posts')
.redact((f) =>
fn.cond(fn.eq(f.kind, fn.literal('tutorial')).node, f.rawPath('$KEEP'), f.rawPath('$PRUNE')),
)
.build();
const tutorials = await runtime.query(plan);Several stages take a single MongoDB stage options object rather than a typed (f) => ... callback. Each one carries the same meaning it has in MongoDB. Where an option below is called a raw expression, it wants a MongoDB expression object rather than a typed builder value: build one with the fn.* helpers and pass its .node property, or name a field with MongoAggFieldRef.of('duration'), which is already a raw expression and takes no .node.
bucket({ groupBy, boundaries, default_?, output? })sorts documents into the ranges you name.defaultis a reserved word in JavaScript, so the key isdefault_.groupByis a raw expression.bucketAuto({ groupBy, buckets, output?, granularity? })sorts them into a given number of ranges, chosen so each holds about the same number of documents.groupByis a raw expression.granularityis a string that MongoDB reads as the series it rounds the bucket boundaries to. Prisma ORM passes it through unchanged, accepts any string, and MongoDB rejects the ones it does not know.geoNear({ near, distanceField, spherical?, maxDistance?, minDistance?, query?, key?, distanceMultiplier?, includeLocs? })sorts documents by their distance from a point and writes that distance intodistanceField.nearis the point to measure from, and Prisma ORM passes it through to MongoDB unchanged.graphLookup({ from, startWith, connectFromField, connectToField, as, maxDepth?, depthField?, restrictSearchWithMatch? })follows links from document to document through one collection and collects what it reaches intoas.startWithis a raw expression.setWindowFields({ partitionBy?, sortBy?, output })computes a value for each document from the documents around it, such as a running total.partitionByis a raw expression.outputis an object mapping each new field name to{ operator, window? }, whereoperatoris a raw expression, usually an accumulator:{ runningTotal: { operator: MongoAggAccumulator.sum(MongoAggFieldRef.of('duration')), window: { documents: ['unbounded', 0] } } }.densify({ field, partitionByFields?, range })adds documents to fill the gaps in a sequence of numbers or dates.rangeis{ step, unit?, bounds }, whereboundsis'full','partition', or a pair of start and end values.fill({ partitionBy?, partitionByFields?, sortBy?, output })replaces missing or null values with a value you choose.partitionByis a raw expression.outputis an object mapping each field name to{ method }or{ value }, wherevalueis an expression andmethodis a string that Prisma ORM passes through to MongoDB unchanged.facet(facets)takes an object mapping each output field name to an array of stages.unionWith(collection, pipeline?)takes a collection name and an optional array of stages.- The stages you pass to
facetandunionWithare classes, one class per MongoDB stage, namedMongo<Stage>Stage. Import them, along withMongoAggFieldRefandMongoAggAccumulator, from@prisma/orm-mongo/query-ast/execution. Each class takes the same arguments as the MongoDB stage it builds. The three used below areMongoCountStage(field),MongoSortStage(spec), andMongoLimitStage(n).
Concatenate another collection with unionWith()
Section titled “Concatenate another collection with unionWith()”const plan = db.query.from('posts').unionWith('users').build();
const combined = await runtime.query(plan);MongoFieldFilter is the filter class from @prisma/orm-mongo/query-ast/execution. See MongoFieldFilter on the ORM client page for its methods, such as gt and in.
import { MongoCountStage, MongoFieldFilter, MongoLimitStage, MongoMatchStage, MongoSortStage } from '@prisma/orm-mongo/query-ast/execution';
const plan = db.query
.from('posts')
.facet({
totalCount: [new MongoCountStage('count')],
newest: [new MongoSortStage({ createdAt: -1 }), new MongoLimitStage(2)],
tutorials: [
new MongoMatchStage(MongoFieldFilter.eq('kind', 'tutorial')),
new MongoCountStage('count'),
],
})
.build();
const [facets] = await runtime.query(plan);
// facets.totalCount is [{ count: <number of posts> }]; facets.newest is the two newest postsMongoAggFieldRef.of() takes any field path as a string and does not check it against your models. duration is a field on Tutorial, so posts without it fall into the Other bucket.
import { MongoAggFieldRef } from '@prisma/orm-mongo/query-ast/execution';
const plan = db.query
.from('posts')
.bucket({
groupBy: MongoAggFieldRef.of('duration'),
boundaries: [0, 15, 60],
default_: 'Other',
})
.build();
const buckets = await runtime.query(plan);
// one document per bucket, each { _id: <lower boundary>, count: n }search(), searchMeta(), and vectorSearch() build MongoDB Atlas Search stages ($search, $searchMeta, $vectorSearch).
- These stages need MongoDB Atlas. Nothing stops you from building them against a database that is not on Atlas. The error only appears when you run the query.
search(config, index?)takes an Atlas Search operator object, for example{ text: { query: 'hello', path: 'title' } }, and an optionalindex, which names the Atlas Search index to use.searchMeta(config, index?)takes the same two arguments and returns counts and facets instead of documents.vectorSearch({ index, path, queryVector, numCandidates, limit, filter? })takes the index name, the field holding the vectors, the vector to search for as an array of numbers, how many candidates to consider, how many documents to return, and an optional filter object.
| Return type | Example | Description |
|---|---|---|
| Pipeline builder | db.query.from('posts').search({ text: { query: 'hello', path: 'title' } }) |
A builder with an Atlas Search stage. Executes only on MongoDB Atlas. |
Accumulators compute per-group values inside a group() stage. _id: null in the examples below means one group holding every document. Import them from @prisma/orm-mongo/query-builder:
import { acc } from '@prisma/orm-mongo/query-builder';The builder exposes nineteen accumulators:
| Accumulator | Signature | Description |
|---|---|---|
acc.count() |
count() |
Number of documents in the group. |
acc.sum(expr) |
sum(expression) |
Sum of the expression across the group. |
acc.avg(expr) |
avg(expression) |
Average of the expression. |
acc.min(expr) |
min(expression) |
Minimum value. |
acc.max(expr) |
max(expression) |
Maximum value. |
acc.first(expr) |
first(expression) |
Value from the first document in the group. |
acc.last(expr) |
last(expression) |
Value from the last document in the group. |
acc.push(expr) |
push(expression) |
Array of the expression's value from every document. |
acc.addToSet(expr) |
addToSet(expression) |
Array of distinct values. |
acc.firstN({ input, n }) |
firstN({ input, n }) |
First n values. |
acc.lastN({ input, n }) |
lastN({ input, n }) |
Last n values. |
acc.maxN({ input, n }) |
maxN({ input, n }) |
The n largest values. |
acc.minN({ input, n }) |
minN({ input, n }) |
The n smallest values. |
acc.top({ output, sortBy }) |
top({ output, sortBy }) |
The output value from the document that sorts first under sortBy. |
acc.bottom({ output, sortBy }) |
bottom({ output, sortBy }) |
The output value from the document that sorts last under sortBy. |
acc.topN({ output, sortBy, n }) |
topN({ output, sortBy, n }) |
The output values from the first n documents under sortBy. |
acc.bottomN({ output, sortBy, n }) |
bottomN({ output, sortBy, n }) |
The output values from the last n documents under sortBy. |
acc.stdDevPop(expr) |
stdDevPop(expression) |
Population standard deviation. |
acc.stdDevSamp(expr) |
stdDevSamp(expression) |
Sample standard deviation. |
firstN,lastN,maxN, andminNtake{ input, n }.inputis the expression whose values you collect.topandbottomtake{ output, sortBy }.topNandbottomNtake the same two plusn.outputis the expression whose value you want back from each chosen document.sortByis a plain object such as{ createdAt: 1 }, where1sorts ascending and-1sorts descending. Its keys are plain strings because they name fields, not values.- Wherever a signature takes
n, wrap the number: writen: fn.literal(2), notn: 2.fn.literalcomes from the expression helpers below.
Put a sort() before the group() when you use acc.first() or acc.last(). Without one, the document MongoDB calls first is arbitrary and can change between runs.
acc.sum(fn.literal(1)) adds 1 for each document, which counts them. To add up a field instead, pass the field, as in acc.sum(f.views) on a collection with a numeric views field. The example schema's posts has no such field, so that call needs a collection of your own.
import { acc, fn } from '@prisma/orm-mongo/query-builder';
const plan = db.query.from('posts')
.group((f) => ({ _id: null, total: acc.count(), counted: acc.sum(fn.literal(1)) })).build();
const [{ total, counted }] = await runtime.query(plan);
// { total: 2, counted: 2 }const plan = db.query.from('posts')
.group((f) => ({ _id: null, earliest: acc.min(f.createdAt), avgYear: acc.avg(fn.year(f.createdAt)) }))
.build();
const [stats] = await runtime.query(plan);
// { earliest: <Date>, avgYear: 2024 }const plan = db.query.from('posts').sort({ createdAt: 1 })
.group((f) => ({ _id: null, firstTitle: acc.first(f.title), lastTitle: acc.last(f.title) }))
.build();
const [{ firstTitle, lastTitle }] = await runtime.query(plan);
// { firstTitle: 'Hello world', lastTitle: 'Tutorial one' }Both take one expression: group((f) => ({ _id: null, allTitles: acc.push(f.title), distinctKinds: acc.addToSet(f.kind) })). allTitles holds one entry per document, while distinctKinds holds each value once.
const plan = db.query.from('posts').sort({ createdAt: 1 })
.group((f) => ({
_id: null,
firstTwo: acc.firstN({ input: f.title, n: fn.literal(2) }),
newestTitle: acc.top({ output: f.title, sortBy: { createdAt: -1 } }), // the title string, not a document
}))
.build();
const [{ firstTwo, newestTitle }] = await runtime.query(plan);
// firstTwo is ['Hello world', 'Tutorial one']Expression helpers (fn.*) build the computed values used inside stages like addFields(), project(), group() accumulators, and match() (via expr()). Inside a stage, write f.title or fn.toUpper(...), never 'hello' or 3 on their own, so wrap a constant in fn.literal('hello'). The update operators on the write methods and the MongoFieldFilter methods work the other way round: they take ordinary JavaScript values, so write f.bio.set('hello'), not f.bio.set(fn.literal('hello')). Import the helpers from @prisma/orm-mongo/query-builder:
import { fn } from '@prisma/orm-mongo/query-builder';Each helper is the camelCase name of the MongoDB aggregation operator without its $ prefix, so $toUpper is fn.toUpper and $dateToString is fn.dateToString. Five helpers are named differently:
| MongoDB operator | Helper |
|---|---|
$in |
fn.isIn |
$type |
fn.typeOf |
$toString |
fn.toString_ |
$first and $last |
fn.firstElem and fn.lastElem |
The first three are renamed because in and typeof are reserved words in JavaScript and toString is already a method on every JavaScript object.
Here is every helper. Anything missing from this list has no helper, including $and, $or, $not, $switch, $ifNull, $map, $reduce, and $filter.
| Group | Helpers |
|---|---|
| Arithmetic | add, subtract, multiply, divide |
| Strings | concat, toLower, toUpper, substr, substrBytes, trim, ltrim, rtrim, split, strLenCP, strLenBytes, replaceOne, replaceAll |
| Regular expressions | regexMatch, regexFind, regexFindAll |
| Dates | year, month, dayOfMonth, hour, minute, second, millisecond, dateToString, dateFromString, dateDiff, dateAdd, dateSubtract, dateTrunc |
| Comparison | cmp, eq, ne, gt, gte, lt, lte |
| Arrays | size, arrayElemAt, concatArrays, firstElem, lastElem, isIn, indexOfArray, isArray, reverseArray, slice, zip, range |
| Sets | setUnion, setIntersection, setDifference, setEquals, setIsSubset, anyElementTrue, allElementsTrue |
| Type conversion | typeOf, convert, toInt, toLong, toDouble, toDecimal, toString_, toObjectId, toBool, toDate |
| Objects | objectToArray, arrayToObject, getField, setField |
| Constants and branching | literal, cond |
fn.literal(value) wraps a constant so MongoDB reads it as a value and not as the name of a field. .node is the raw MongoDB expression inside an fn.* value, and a field reference such as f.title has one too. Passing an expr(...) value where .node is expected throws a TypeError. This table is the whole rule for when to write .node:
| Call site | What you pass |
|---|---|
project(), addFields(), redact(), and the arguments of an accumulator |
The fn.* value itself. |
The condition argument of fn.cond() |
Its .node property. |
f.stage.set(), f.stage.replaceRoot(), and f.stage.replaceWith(), documented under Update operation forms |
The .node property. |
The groupBy, startWith, and partitionBy options of bucket(), bucketAuto(), graphLookup(), setWindowFields(), and fill() |
The .node property. |
match() |
The fn.* value wrapped in expr(...). |
expr() is what lets a match() filter compare computed values. Import it beside fn:
import { expr, fn } from '@prisma/orm-mongo/query-builder';
const plan = db.query.from('posts').match((f) => expr(fn.gt(fn.year(f.createdAt), fn.literal(2024)))).build();For OR, AND, or NOT, pass match() a filter you build yourself instead of a callback:
import { MongoAndExpr, MongoFieldFilter, MongoOrExpr } from '@prisma/orm-mongo/query-ast/execution';
const kindFilter = MongoOrExpr.of([
MongoFieldFilter.eq('kind', 'tutorial'),
MongoFieldFilter.eq('kind', 'guide'),
]);
const plan = db.query.from('posts').match(kindFilter).build();
const rows = await runtime.query(plan);
// rows are the documents whose kind is either tutorial or guide- There is no
fn.*helper for OR, AND, or NOT. In amatch()filter, build the filter yourself:MongoOrExpr.of([...])is OR,MongoAndExpr.of([...])is AND, and.not()on any filter is NOT. The example above shows OR. - Each entry in those lists is a
MongoFieldFilter, which haseq,neq,gt,gte,lt,lte,in,nin,isNull, andisNotNull. These are the same filter methods the ORM client uses, described under Combinators. A filter you build yourself names the field as a string, as inMongoFieldFilter.eq('kind', 'tutorial'), and nothing checks that string against your model. - "Not equal" has two spellings on this page. The filter method is
MongoFieldFilter.neqand the expression helper isfn.ne. - Inside a computed value in
project()oraddFields()there is no way to write OR, AND, or NOT. Write that stage yourself withpipe(), or write the whole command withrawCommand(). fn.slice(array, ...rest)andfn.range(start, end, step)take positional arguments, and so does every helper not listed in the table below. This page does not give the positional order for those helpers. Look each one up in MongoDB's aggregation operator reference. Each argument is an expression from thefargument or afn.literal(...)value.fn.toObjectId()cannot take afn.literal()value.fn.toObjectId(fn.literal(id))throws aTypeError. There is no way to turn a string id into anObjectIdinside a pipeline. Build theObjectIdin your own code instead and put it in a raw$matchwithrawCommand(), as the last example on this page does.
These helpers take one object of named arguments instead of positional ones:
| Helper | Arguments |
|---|---|
fn.dateDiff |
{ startDate, endDate, unit, timezone?, startOfWeek? } |
fn.dateTrunc |
{ date, unit, binSize?, timezone?, startOfWeek? } |
fn.dateAdd |
{ startDate, unit, amount, timezone? } |
fn.dateSubtract |
The same keys as fn.dateAdd. |
fn.dateToString |
{ date, format?, timezone?, onNull? } |
fn.dateFromString |
{ dateString, format?, timezone?, onError?, onNull? } |
fn.trim, fn.ltrim, and fn.rtrim |
{ input, chars? } |
fn.regexMatch, fn.regexFind, and fn.regexFindAll |
{ input, regex, options? } |
fn.replaceOne and fn.replaceAll |
{ input, find, replacement } |
fn.zip |
{ inputs, useLongestLength?, defaults? } |
fn.getField |
{ field, input? } |
fn.setField |
{ field, input, value } |
fn.convert |
{ input, to, onError?, onNull? } |
In fn.convert, to takes any MongoDB type name written as an expression, such as fn.literal('int') or fn.literal('objectId').
const plan = db.query.from('posts')
.project((f) => ({ title: 1, computed: fn.add(fn.literal(1), fn.multiply(fn.literal(2), fn.literal(3))) }))
.build();
const rows = await runtime.query(plan);
// computed is 7 for every documentproject((f) => ({ shout: fn.concat(fn.toUpper(f.title), fn.literal('!')) })) puts the title in upper case next to a !.
const plan = db.query.from('posts')
.project((f) => ({
title: 1,
label: fn.cond(fn.eq(f.kind, fn.literal('tutorial')).node, fn.literal('is-tutorial'), fn.literal('not-tutorial')),
}))
.build();
const rows = await runtime.query(plan);Append an arbitrary raw pipeline stage the typed methods don't cover.
pipe(stage)appends a raw stage that you construct yourself, and keeps the row type you already had. The stages are theMongo*Stageclasses, one per MongoDB stage, from@prisma/orm-mongo/query-ast/execution. The class name isMongo, then the stage name without its$, thenStage, so$unwindisMongoUnwindStage. The example below usesMongoMatchStageandMongoCountStage. The same module exportsMongoFieldFilter, which builds the filter aMongoMatchStagetakes.pipe<NewShape>(stage)declares the row type the rows have after that stage.NewShapeis a type you write yourself, as at the end of the example below.- After a
pipe()stage the TypeScript type no longer describes the rows. They come back exactly as MongoDB returns them, so a date is aDateand an_idthat MongoDB generated is anObjectIdfrom themongodbpackage, the driver Prisma ORM runs your queries through. - Prefer a typed stage where one exists.
pipe()adds one raw stage to a typed chain, andrawCommand()replaces the whole command.
import { MongoCountStage, MongoFieldFilter, MongoMatchStage } from '@prisma/orm-mongo/query-ast/execution';
const plan = db.query.from('posts')
.pipe(new MongoMatchStage(MongoFieldFilter.eq('kind', 'tutorial'))).build();
const rows = await runtime.query(plan);
type Counted = { total: number };
const countPlan = db.query.from('posts').pipe<Counted>(new MongoCountStage('total')).build();Finish a read chain with build(), then run the built query with runtime.query(...).
Compile the pipeline into a query you can run.
- Write
build().aggregate()is another name for the same method. Neither runs the query: pass the result toruntime.query(...)to fetch documents.
The pipeline builder can write, not just read. Write methods are available at three points: on the root collection, after a match() filter, and at the end of any pipeline, where out() and merge() write the output into a collection. A write method returns the built query on its own, so there is no build() call after one. The examples below name that built query plan.
Updater callbacks (the (f) => [...] argument to the update methods) must return an array of operations. This is a firm rule:
- Always return an array. Returning a bare operation,
f.bio.set('x')rather than[f.bio.set('x')], throws as soon as you call the write method. - An empty array throws an error whose
codeisORM.MUTATION_DATA_MISSING:Updater returned no operations. Return at least one update from the callback .... - An updater is either all operator form or all pipeline form. Operator form calls an operator on a field, as in
f.bio.set(value). Pipeline form can read other fields of the same document, and it callsf.stage.*. Typef.stageexactly:stageis not a placeholder for a stage name. You cannot mix the two in one updater. TypeScript rejects the mixed array, and at run time it throws an error whosecodeisORM.ARGUMENT_INVALID:Cannot mix .... - Applying the same operator to the same field twice in one updater throws an error whose
codeisORM.ARGUMENT_INVALID:Update spec collision: ....
insertOne, insertMany, updateAll, deleteAll, and upsertOne are available on the collection you name in from(...), before you add any stage.
- These return a built query. Run it with
runtime.query(...). - Write results are result objects, not documents:
insertOnegives{ insertedId },insertManygives{ insertedIds, insertedCount }, the update methods andupsertOnegive{ matchedCount, modifiedCount }, plusupsertedCountandupsertedIdwhen an upsert inserted a document, and the delete methods give{ deletedCount }. A write returns one result object, as the single row of the result, which is why every example here reads it withconst [result] = .... - The document you pass to
insertOne/insertManyis a plain record. TypeScript requires no particular field, and the builder does not check the record against your contract. You can leave nullable fields out. The examples below passnullfor them to make the document shape explicit. - You can leave
_idout and let MongoDB assign one. - On the way in, values are stored exactly as you pass them, so pass a
Datefor a date field and anObjectIdfrom themongodbpackage for an id field. Nothing turns a string into either. This is the write direction only. Reads are decoded as the first half of this page describes. match()filter values follow the same rule as the values you insert. The one value you cannot filter on this way is_id. An_idfilter written inmatch()never matches anything, so userawCommand()instead. Thematch()warning gives the detail, and the last example on this page shows the form that works.insertMany([])throws an error whosecodeisORM.MUTATION_DATA_MISSING.
const onePlan = db.query.from('users')
.insertOne({ name: 'Carol', email: 'carol@example.com', bio: null, role: 'author', address: null });
const [insertOneResult] = await runtime.query(onePlan);
// { insertedId: <ObjectId> }
const manyPlan = db.query.from('users').insertMany([
{ name: 'Carol', email: 'carol@example.com', bio: null, role: 'author', address: null },
{ name: 'Dave', email: 'dave@example.com', bio: null, role: 'reader', address: null },
]);
const [insertManyResult] = await runtime.query(manyPlan);
// { insertedIds: [<ObjectId>, <ObjectId>], insertedCount: 2 }db.query.from('users').updateAll((f) => [f.bio.set('everyone now has bio')]) builds the update. Pass it to runtime.query(...) to run it.
db.query.from('users').deleteAll() builds a delete for every document. Pass it to runtime.query(...) and read deletedCount from the single result object.
upsertOne() on the root (filter, then updater)
Section titled “upsertOne() on the root (filter, then updater)”upsertOne(filterCallback, updaterCallback). The first callback returns the filter, and the second returns the array of update operations.
const plan = db.query.from('users')
.upsertOne((f) => f.email.eq('erin@example.com'), (f) => [f.name.set('Erin'), f.email.set('erin@example.com')]);
const [result] = await runtime.query(plan);
// inserts when no document matches: result.upsertedId is definedAfter a match() filter, updateMany, updateOne, deleteMany, deleteOne, upsertOne, findOneAndUpdate, and findOneAndDelete write against the matched documents.
- The
match()filter supplies the write's filter, so you do not repeat it. - After
match(),upsertOne()takes the updater callback only (the filter comes frommatch()). updateOne/deleteOneaffect at most one matching document.updateMany/deleteManyaffect all matches. Which document that is, this builder gives you no way to choose.findOneAndUpdate()andfindOneAndDelete()return the document exactly as themongodbdriver gives it, so its_idis anObjectIdfrom themongodbpackage even though the TypeScript type saysstring. To get the id as a string, writeString(result._id).findOneAndUpdate()andfindOneAndDelete()are offered straight aftermatch()and nowhere else. TypeScript withdraws them after every other stage,sort()andskip()included. If a cast gets one past TypeScript after askip(), it throws an error whosecodeisORM.OPERATION_UNSUPPORTED.
const manyPlan = db.query.from('users').match((f) => f.role.eq('author'))
.updateMany((f) => [f.bio.set('matched-many')]);
await runtime.query(manyPlan);
const onePlan = db.query.from('users').match((f) => f.email.eq('alice@example.com'))
.updateOne((f) => [f.bio.set('single-update')]);
const [result] = await runtime.query(onePlan);
// { matchedCount: 1, modifiedCount: 1 }const plan = db.query.from('users').match((f) => f.role.eq('author')).deleteMany();
const [result] = await runtime.query(plan);
// { deletedCount: 2 }db.query.from('users').match((f) => f.email.eq('alice@example.com')).upsertOne((f) => [f.bio.set('upserted via match')]) builds the upsert. On a hit, result.modifiedCount is 1.
findOneAndUpdate() returns the matched document. Its second argument is optional and takes returnDocument, which controls which version you get back: 'before' returns the document before the update, 'after' returns it after. The default is 'after'. The same argument takes upsert, which defaults to false and inserts a document when nothing matches.
const plan = db.query.from('users').match((f) => f.email.eq('alice@example.com'))
.findOneAndUpdate((f) => [f.bio.set('changed')], { returnDocument: 'before' });
const [beforeDoc] = await runtime.query(plan);
// beforeDoc.bio is the value from before the updatedb.query.from('users').match((f) => f.email.eq('alice@example.com')).findOneAndDelete() builds the delete. Its single result row is the removed document.
Inside an updater callback, each operation targets a field. There are two mutually exclusive forms.
- Operator form: call an operator on the
fargument, one operator per field. There are fourteen, in the table below. - Pipeline form: aggregation-pipeline update stages through
f.stage.*, for examplef.stage.set({ bio: f.name.node }). The four stages areset,unset,replaceRoot, andreplaceWith. - The two forms cannot be mixed in a single updater (see the write-methods intro). An updater is entirely operator form or entirely pipeline form.
Operator form takes these arguments:
| Operator | What you pass |
|---|---|
set(value) |
The new value. |
unset() |
Nothing. Removes the field. |
rename(newName) |
The new field name, as a string. |
inc(amount) |
A number to add. A negative number subtracts. |
mul(factor) |
A number to multiply the stored value by. |
min(value) |
A value, written only if it is lower than the stored one. |
max(value) |
A value, written only if it is higher than the stored one. |
push(value) |
One value to append to an array field. |
addToSet(value) |
One value, appended only if the array does not already hold it. |
pop(direction) |
1 removes the last element, -1 the first. The argument is optional and defaults to 1. |
pull(value) |
The value to remove from an array field. |
pullAll(values) |
An array of values to remove. |
currentDate() |
Nothing. Writes the current date. |
setOnInsert(value) |
A value written only when an upsert inserts a new document, so it does nothing outside upsertOne(). |
const plan = db.query.from('users').match((f) => f.role.eq('author'))
.updateMany((f) => [f.bio.set('operator form')]);
await runtime.query(plan);
// on a collection with a numeric views field and an array tags field (posts has neither):
// updateMany((f) => [f.views.inc(1), f.tags.push('mongodb')])const plan = db.query.from('users').match((f) => f.role.eq('author'))
.updateMany((f) => [f.stage.set({ bio: f.name.node })]);
await runtime.query(plan);
// each author's bio is set to that author's own nameout() and merge() write the pipeline's output into a collection ($out / $merge).
out(collection)writes the pipeline output into a destination collection, replacing its contents. Pass a second argument, the database name as a string, to write into a different database:out('users_snapshot', 'archive').merge({ into })writes the pipeline output into a target collection, merging with existing documents.intois a collection name or{ db, coll }. The options object also takeson, which is one field name as a string or several as an array of strings, pluswhenMatchedandwhenNotMatched.whenMatchedtakes'replace','keepExisting','merge', or'fail', or an array of update stages, which areMongoAddFieldsStage,MongoProjectStage, andMongoReplaceRootStageobjects from@prisma/orm-mongo/query-ast/execution.MongoAddFieldsStagetakes one object of field names and expressions, as innew MongoAddFieldsStage({ ... }).whenNotMatchedtakes'insert','discard', or'fail'. A misspelled value fails at the database rather than in TypeScript.- Both end the chain. They return a built query you run with
runtime.query(...), and that call returns an empty array, because the output goes to the destination collection and not to your program.
db.query.from('users').out('users_snapshot') builds the write. After you pass it to runtime.query(...), the users_snapshot collection holds the pipeline output.
const plan = db.query.from('users')
.merge({ into: 'users_archive', on: 'email', whenMatched: 'merge', whenNotMatched: 'insert' });
await runtime.query(plan);Run a raw MongoDB aggregate command through the pipeline builder.
db.query.rawCommand(command)takes a command you build yourself, for examplenew RawAggregateCommand(collection, pipeline), and sends it as you wrote it. The rows come back asunknown, so you type them yourself.- Use it for anything the typed builder cannot express, including
_idequality filters (see thematch()warning) and MongoDB's$redactstage. - For the full raw MongoDB API (raw collection methods, untyped writes, and rows that come back as the driver gives them), see Raw queries.
import { RawAggregateCommand } from '@prisma/orm-mongo/query-ast/execution';
const plan = db.query.rawCommand(new RawAggregateCommand('posts', [{ $count: 'total' }]));
const rows = await runtime.query(plan);
// [{ total: 2 }]import { ObjectId } from 'mongodb';
const plan = db.query.rawCommand(
new RawAggregateCommand('posts', [{ $match: { _id: new ObjectId(postId) } }]),
);
const rows = await runtime.query(plan);
// a real ObjectId in a raw pipeline document matches correctly