Prisma Nested Middleware
Middleware that is called for every nested relation in a Prisma query.
Vanilla Prisma middleware is great for modifying top-level queries but becomes difficult to use when middleware must handle nested writes or modify where objects that reference relations. See the existing issue regarding nested middleware for more information.
This library creates middleware that is called for relations nested in the params object, allowing you to modify params and results without having to recurse through params objects yourself.
Table of Contents
Installation
This module is distributed via npm which is bundled with node and should be installed as one of your project's dependencies:
npm install --save prisma-nested-middleware
@prisma/client
is a peer dependency of this library, so you will need to
install it if you haven't already:
npm install --save @prisma/client
Usage
Pass a middleware function to
createNestedMiddleware
, the returned middleware can be passed to Prisma client's $use
method:
import { createNestedMiddleware } from 'prisma-nested-middleware'
client.$use(createNestedMiddleware(async (params, next) => {
// update params here
const result = await next(params)
// update result here
return result;
));
Params
The params object passed to the middleware function is a normal Prisma.MiddlewareParams object with the following differences:
-
the
action
field adds the following options: 'connectOrCreate', 'connect', 'disconnect', 'include', 'select' and 'where' -
there is an additional
scope
field that contains information specific to nested relations:- the
parentParams
field contains the params object of the parent relation - the
modifier
field contains any modifiers the params were wrapped in, for examplesome
orevery
. - the
logicalOperators
field contains any logical operators between the current relation and it's parent, for exampleAND
orNOT
. - the
relations
field contains an object with the relationto
the current model andfrom
the model back to it's parent.
- the
For more information on the modifier
and logicalOperators
fields see the Where section.
For more information on the relations
field see the Relations section.
The type for the params object is:
type NestedParams = Omit<Prisma.MiddlewareParams, "action"> & {
action:
| Prisma.PrismaAction
| "where"
| "include"
| "select"
| "connect"
| "connectOrCreate"
| "disconnect";
scope?: {
parentParams: NestedParams;
relations: { to: Prisma.DMMF.Field; from: Prisma.DMMF.Field };
modifier?: "is" | "isNot" | "some" | "none" | "every";
logicalOperators?: ("AND" | "OR" | "NOT)[];
};
};
Nested Writes
The middleware function is called for every nested write
operation in the query. The action
field is set to the operation being performed, for example "create" or "update".
The model
field is set to the model being operated on, for example "User" or "Post".
For example take the following query:
const result = await client.user.update({
data: {
posts: {
update: {
where: { id: 1 },
data: { title: "Hello World" },
},
},
},
});
The middleware function will be called with:
{
action: 'update',
model: 'Post',
args: {
where: { id: 1 },
data: { title: 'Hello World' }
},
relations: {
to: { kind: 'object', name: 'posts', isList: true, ... },
from: { kind: 'object', name: 'author', isList: false, ... },
},
scope: [root params],
}
Some nested writes can be passed as an array of operations. In this case the middleware function is called for each operation in the array. For example take the following query:
const result = await client.user.update({
data: {
posts: {
update: [
{ where: { id: 1 }, data: { title: "Hello World" } },
{ where: { id: 2 }, data: { title: "Hello World 2" } },
],
},
},
});
The middleware function will be called with:
{
action: 'update',
model: 'Post',
args: {
where: { id: 1 },
data: { title: 'Hello World' }
},
relations: {
to: { kind: 'object', name: 'posts', isList: true, ... },
from: { kind: 'object', name: 'author', isList: false, ... },
},
scope: [root params],
}
and
{
action: 'update',
model: 'Post',
args: {
where: { id: 2 },
data: { title: 'Hello World 2' }
},
relations: {
to: { kind: 'object', name: 'posts', isList: true, ... },
from: { kind: 'object', name: 'author', isList: false, ... },
},
scope: [root params],
}
Changing Nested Write Actions
The middleware function can change the action that is performed on the model. For example take the following query:
const result = await client.user.update({
data: {
posts: {
update: {
where: { id: 1 }
data: { title: 'Hello World' }
},
},
},
});
The middleware function could be used to change the action to upsert
:
const middleware = createNestedMiddleware((params, next) => {
if (params.model === "Post" && params.action === "update") {
return next({
...params,
action: "upsert",
args: {
where: params.args.where,
create: params.args.data,
update: params.args.data,
},
});
}
return next(params);
});
The final query would be modified by the above middleware to:
const result = await client.user.update({
data: {
posts: {
upsert: {
where: { id: 1 },
create: { title: "Hello World" },
update: { title: "Hello World" },
},
},
},
});
When changing the action it is possible for the action to already exist. In this case the resulting actions are merged. For example take the following query:
const result = await client.user.update({
data: {
posts: {
update: {
where: { id: 1 },
data: { title: "Hello World" },
},
upsert: {
where: { id: 2 },
create: { title: "Hello World 2" },
update: { title: "Hello World 2" },
},
},
},
});
Using the same middleware defined before the update action would be changed to an upsert action, however there is already an upsert action so the two actions are merged into a upsert operation array with the new operation added to the end of the array. When the existing action is already a list of operations the new operation is added to the end of the list. The final query in this case would be:
const result = await client.user.update({
data: {
posts: {
upsert: [
{
where: { id: 2 },
create: { title: "Hello World 2" },
update: { title: "Hello World 2" },
},
{
where: { id: 1 },
create: { title: "Hello World" },
update: { title: "Hello World" },
},
],
},
},
});
Sometimes it is not possible to merge the actions together in this way. The createMany
action does not support
operation arrays so the data
field of the createMany
action is merged instead. For example take the following query:
const result = await client.user.create({
data: {
posts: {
createMany: {
data: [{ title: "Hello World" }, { title: "Hello World 2" }],
},
create: {
title: "Hello World 3",
},
},
},
});
If the create
action was changed to be a createMany
action the data
field would be added to the end of the existing
createMany
action. The final query would be:
const result = await client.user.create({
data: {
posts: {
createMany: {
data: [
{ title: "Hello World" },
{ title: "Hello World 2" },
{ title: "Hello World 3" },
],
},
},
},
});
It is also not possible to merge the actions together by creating an array of operations for non-list relations. For example take the following query:
const result = await client.user.update({
data: {
profile: {
create: {
bio: "My personal bio",
age: 30,
},
update: {
where: { id: 1 },
data: { bio: "Updated bio" },
},
},
},
});
If the update
action was changed to be a create
action using the following middleware:
const middleware = createNestedMiddleware((params, next) => {
if (params.model === "Profile" && params.action === "update") {
return next({
...params,
action: "create",
args: params.args.data,
});
}
return next(params);
});
The create
action from the update
action would need be merged with the existing create
action, however since
profile
is not a list relation we must merge together the resulting objects instead, resulting in the final query:
const result = await client.user.create({
data: {
profile: {
create: {
bio: "Updated bio",
age: 30,
},
},
},
});
Splitting Nested Write Actions
The middleware function can also split the action into multiple actions by passing an array of params to the next
function. For example take the following query:
const result = await client.user.update({
data: {
posts: {
delete: { id: 1 },
},
},
});
A middleware function that changes the delete
into an update
and disconnect
could be defined as:
const middleware = createNestedMiddleware((params, next) => {
if (params.model === "Post" && params.action === "delete") {
return next([
{
...params,
action: "update",
args: {
where: params.args,
data: { deleted: true },
},
},
{
...params,
action: "disconnect",
args: params.args.where,
},
]);
}
return next(params);
});
The final query would be:
const result = await client.user.update({
data: {
posts: {
update: {
where: { id: 1 },
data: { deleted: true },
},
disconnect: { id: 1 },
},
},
});
Write Results
The next
function of middleware calls for nested write actions always return undefined
as their result. This is
because it the results returned from the root query may not include the data for a particular nested write.
For example take the following query:
const result = await client.user.update({
data: {
profile: {
create: {
bio: "My personal bio",
age: 30,
},
}
posts: {
updateMany: {
where: {
published: false,
},
data: {
published: true,
},
},
},
},
select: {
id: true,
posts: {
where: {
title: {
contains: "Hello",
},
},
select: {
id: true,
},
},
}
});
The profile
field is not included in the select
object so the result of the create
action will not be included in
the root result. The posts
field is included in the select
object but the where
object only includes posts with
titles that contain "Hello" and returns only the "id" field, in this case it is not possible to match the result of the
updateMany
action to the returned Posts.
See Modifying Results for more information on how to update the results of queries.
Where
The where
action is called for any relations found inside where objects in params.
Note that the where
action is not called for the root where object, this is because you need the root action to know
what properties the root where object accepts. For nested where objects this is not a problem as they always follow the
same pattern.
To see where the where
action is called take the following query:
const result = await client.user.findMany({
where: {
posts: {
some: {
published: true,
},
},
},
});
The where object above produces a call for "posts" relation found in the where object. The modifier
field is set to
"some" since the where object is within the "some" field.
{
action: 'where',
model: 'Post',
args: {
published: true,
},
scope: {
parentParams: {...}
modifier: 'some',
relations: {...}
},
}
Relations found inside where AND, OR and NOT logical operators are also found and called with the middleware function,
however the where
action is not called for the logical operators themselves. For example take the following query:
const result = await client.user.findMany({
where: {
posts: {
some: {
published: true,
AND: [
{
title: "Hello World",
},
{
comments: {
every: {
text: "Great post!",
},
},
},
],
},
},
},
});
The middleware function will be called with the params for "posts" similarly to before, however it will also be called with the following params:
{
action: 'where',
model: 'Comment',
args: {
text: "Great post!",
},
scope: {
parentParams: {...}
modifier: 'every',
logicalOperators: ['AND'],
relations: {...}
},
}
Since the "comments" relation is found inside the "AND" logical operator the
middleware is called for it. The modifier
field is set to "every" since the where object is in the "every" field and
the logicalOperators
field is set to ['AND']
since the where object is inside the "AND" logical operator.
Notice that the middleware function is not called for the first item in the "AND" array, this is because the first item does not contain any relations.
The logicalOperators
field tracks all the logical operators between the parentParams
and the current params. For
example take the following query:
const result = await client.user.findMany({
where: {
AND: [
{
NOT: {
OR: [
{
posts: {
some: {
published: true,
},
},
},
],
},
},
],
},
});
The middleware function will be called with the following params:
{
action: 'where',
model: 'Post',
args: {
published: true,
},
scope: {
parentParams: {...}
modifier: 'some',
logicalOperators: ['AND', 'NOT', 'OR'],
relations: {...},
},
}
The where
action is also called for relations found in the where
field of includes and selects. For example:
const result = await client.user.findMany({
select: {
posts: {
where: {
published: true,
},
},
},
});
The middleware function will be called with the following params:
{
action: 'where',
model: 'Post',
args: {
published: true,
},
scope: {...}
}
Where Results
The next
function for a where
action always resolves with undefined
.
Include
The include
action will be called for any included relation. The args
field will contain the object or boolean
passed as the relation include. For example take the following query:
const result = await client.user.findMany({
include: {
profile: true,
posts: {
where: {
published: true,
},
},
},
});
For the "profile" relation the middleware function will be called with:
{
action: 'include',
model: 'Profile',
args: true,
scope: {...}
}
and for the "posts" relation the middleware function will be called with:
{
action: 'include',
model: 'Post',
args: {
where: {
published: true,
},
},
scope: {...}
}
Include Results
The next
function for an include
action resolves with the result of the include
action. For example take the
following query:
const result = await client.user.findMany({
include: {
profile: true,
},
});
The middleware function for the "profile" relation will be called with:
{
action: 'include',
model: 'Profile',
args: true,
scope: {...}
}
And the next
function will resolve with the result of the include
action, in this case something like:
{
id: 2,
bio: 'My personal bio',
age: 30,
userId: 1,
}
For relations that are included within a list of parent results the next
function will resolve with a flattened array
of all the models from each parent result. For example take the following query:
const result = await client.user.findMany({
include: {
posts: true,
},
});
If the root result looks like the following:
[
{
id: 1,
name: "Alice",
posts: [
{
id: 1,
title: "Hello World",
published: false,
userId: 1,
},
{
id: 2,
title: "My first published post",
published: true,
userId: 1,
},
],
},
{
id: 2,
name: "Bob",
posts: [
{
id: 3,
title: "Clean Code",
published: true,
userId: 2,
},
],
},
];
The next
function for the "posts" relation will resolve with the following:
[
{
id: 1,
title: "Hello World",
published: false,
userId: 1,
},
{
id: 2,
title: "My first published post",
published: true,
userId: 1,
},
{
id: 3,
title: "Clean Code",
published: true,
userId: 2,
},
];
For more information on how to modify the results of an include
action see the Modifying Results
Select
Similarly to the include
action, the select
action will be called for any selected relation with the args
field
containing the object or boolean passed as the relation select. For example take the following query:
const result = await client.user.findMany({
select: {
posts: true,
profile: {
select: {
bio: true,
},
},
},
});
and for the "posts" relation the middleware function will be called with:
{
action: 'select',
model: 'Post',
args: true,
scope: {...}
}
For the "profile" relation the middleware function will be called with:
{
action: 'select',
model: 'Profile',
args: {
bio: true,
},
scope: {...}
}
There is another case possible for selecting fields in Prisma. When including a model it is supported to use a select object to select fields from the included model. For example take the following query:
const result = await client.user.findMany({
include: {
profile: {
select: {
bio: true,
},
},
},
});
From v4 the "select" action is not called for the "profile" relation. This is because it caused two different kinds of "select" action args, and it was not always possible to distinguish between them. See Modifying Selected Fields for more information on how to handle selects.
Select Results
The next
function for a select
action resolves with the result of the select
action. This is the same as the
include
action. See the Include Results section for more information.
Relations
The relations
field of the scope
object contains the relations relevant to the current model. For example take the
following query:
const result = await client.user.create({
data: {
email: "test@test.com",
profile: {
create: {
bio: "Hello World",
},
},
posts: {
create: {
title: "Hello World",
},
},
},
});
The middleware function will be called with the following params for the "profile" relation:
{
action: 'create',
model: 'Profile',
args: {
bio: "Hello World",
},
scope: {
parentParams: {...}
relations: {
to: { name: 'profile', kind: 'object', isList: false, ... },
from: { name: 'user', kind: 'object', isList: false, ... },
},
},
}
and the following params for the "posts" relation:
{
action: 'create',
model: 'Post',
args: {
title: "Hello World",
},
scope: {
parentParams: {...}
relations: {
to: { name: 'posts', kind: 'object', isList: true, ... },
from: { name: 'author', kind: 'object', isList: false, ... },
},
},
}
Modifying Nested Write Params
When writing middleware that modifies the params of a query you should first write the middleware as if it were vanilla middleware and then add conditions for nested writes.
Say you are writing middleware that sets a default value when creating a model for a particular model:
client.$use(
createNestedMiddleware((params, next) => {
// ignore any non-root actions
if (params.scope) {
return next(params);
}
// we only want to add default values for the "Invite" model
if (params.model !== "Invite") {
return next(params);
}
// handle root actions
if (params.action === "create") {
// set default value for the "code" field
if (!params.args.data.code) {
params.args.data.code = createCode();
}
}
if (params.action === "createMany") {
// set default value for the "code" field
params.args.data.forEach((data) => {
if (!data.code) {
data.code = createCode();
}
});
}
if (params.action === "upsert") {
// set default value for the "code" field
if (!params.args.create.code) {
params.args.create.code = createCode();
}
}
// pass params to next middleware
return next(params);
})
);
Then add conditions for the different args and actions that can be found in nested writes:
client.$use(
createNestedMiddleware((params, next) => {
// we only want to add default values for the "Invite" model
if (params.model !== "Invite") {
return next(params);
}
// handle root actions
if (params.action === "create") {
// when the "create" action is from a nested write the data is not in the "data" field
if (params.scope) {
if (!params.args.code) {
params.args.code = createCode();
}
} else {
if (!params.args.data.code) {
params.args.data.code = createCode();
}
}
}
// createMany and upsert do not change
[...]
// handle the "connectOrCreate" action
if (params.action === "connectOrCreate") {
if (!params.args.create.code) {
params.args.create.code = createCode();
}
}
// pass params to next middleware
return next(params);
})
);
### Modifying Selected Fields
When writing middleware that modifies the selected fields of a model you must handle all actions that can contain a select object, this includes:
select
include
findMany
findFirst
findUnique
findFirstOrThrow
findUniqueOrThrow
create
update
upsert
delete
This is because the select
action is only called for relations found within a select object. For example take the
following query:
const result = await client.user.findMany({
include: {
comments: {
select: {
title: true,
replies: {
select: {
title: true,
},
},
},
},
},
});
For the above query the middleware function will be called with the following for the replies relation:
{
action: 'select',
model: 'Comment',
args: {
select: {
title: true,
},
},
scope: {...}
}
and the following for the comments relation:
{
action: 'include',
model: 'Comment',
args: {
select: {
title: true,
replies: {
select: {
title: true,
}
},
},
},
scope: {...}
}
So if you wanted to ensure that the "id" field is always selected you could write the following middleware:
client.$use(
createNestedMiddleware((params, next) => {
if ([
'select',
'include',
'findMany',
'findFirst',
'findUnique',
'findFirstOrThrow',
'findUniqueOrThrow',
'create',
'update',
'upsert',
'delete',
].includes(params.action)) {
if (typeof params.args === 'object' && params.args !== null && params.args.select) {
return next({
...params,
args: {
...params.args,
select: {
...params.args.select,
id: true,
},
},
});
}
}
return next(params)
})
);
Modifying Where Params
When writing middleware that modifies the where params of a query it is very important to first write the middleware as
if it were vanilla middleware and then handle the where
action. This is because the where
action is not called for
the root where object and so you will need to handle it manually.
Say you are writing middleware that excludes models with a particular field, let's call it "invisible" rather than "deleted" to make this less familiar:
client.$use(
createNestedMiddleware((params, next) => {
// ignore any non-root actions
if (params.scope) {
return next(params);
}
// handle root actions
// don't handle actions that only accept unique fields such as findUnique or upsert
if (
params.action === "findFirst" ||
params.action === "findMany" ||
params.action === "updateMany" ||
params.action === "deleteMany" ||
params.action === "count" ||
params.action === "aggregate"
) {
return next({
...params,
where: {
...params.where,
invisible: false,
},
});
}
// pass params to next middleware
return next(params);
})
);
Then add conditions for the where
action:
client.$use(
createNestedMiddleware((params, next) => {
// handle the "where" action
if (params.action === "where") {
return next({
...params,
args: {
...params.args,
invisible: false,
},
});
}
// handle root actions
// don't handle actions that only accept unique fields such as findUnique or upsert
if (
params.action === "findFirst" ||
params.action === "findMany" ||
params.action === "updateMany" ||
params.action === "deleteMany" ||
params.action === "count" ||
params.action === "aggregate"
) {
return next({
...params,
where: {
...params.where,
invisible: false,
},
});
}
// pass params to next middleware
return next(params);
})
);
Modifying Results
When writing middleware that modifies the results of a query you should take the following process:
- handle all the root cases in the same way as you would with vanilla Prisma middleware.
- handle nested results using the
include
andselect
actions.
Say you are writing middleware that adds a timestamp to the results of a query. You would first handle the root cases:
client.$use(
createNestedMiddleware((params, next) => {
// ignore any non-root actions
if (params.scope) {
return next(params);
}
// get result from next middleware
const result = await next(params);
// ensure result is defined
if (!result) return result;
// handle root actions
if (
params.action === 'findFirst' ||
params.action === 'findUnique' ||
params.action === 'create' ||
params.action === 'update' ||
params.action === 'upsert' ||
params.action === 'delete'
) {
result.timestamp = Date.now();
return result;
}
if (params.action === 'findMany') {
const result = await next(params);
result.forEach(model => {
model.timestamp = Date.now();
})
return result;
}
return result;
})
)
Then you would handle the nested results using the include
and select
actions:
client.$use(
createNestedMiddleware((params, next) => {
// get result from next middleware
const result = await next(params);
// ensure result is defined
if (!result) return result;
// handle root actions
[...]
// handle nested actions
if (
params.action === 'include' ||
params.action === 'select'
) {
if (Array.isArray(result)) {
result.forEach(model => {
model.timestamp = Date.now();
})
} else {
result.timestamp = Date.now();
}
return result
}
return result;
})
)
You could also write the above middleware by creating new objects for each result rather than mutating the existing objects:
client.$use(
createNestedMiddleware((params, next) => {
[...]
if (
params.action === 'include' ||
params.action === 'select'
) {
if (Array.isArray(result)) {
return result.map(model => ({
...model,
timestamp: Date.now(),
}))
} else {
return {
...result,
timestamp: Date.now(),
}
}
}
return result;
})
)
NOTE: When modifying results from include
or select
actions it is important to either mutate the existing objects or
spread the existing objects into the new objects. This is because createNestedMiddleware
needs some fields from the
original objects in order to correct update the root results.
Errors
If any middleware throws an error at any point then the root query will throw with that error. Any middleware that is pending will have it's promises rejects at that point.
LICENSE
Apache 2.0