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.
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
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;
));The params object passed to the middleware function is a normal Prisma.MiddlewareParams object with the following differences:
-
the
actionfield adds the following options: 'connectOrCreate', 'connect', 'disconnect', 'include', 'select' and 'where' -
there is an additional
scopefield that contains information specific to nested relations:- the
parentParamsfield contains the params object of the parent relation - the
modifierfield contains any modifiers the params were wrapped in, for examplesomeorevery. - the
logicalOperatorsfield contains any logical operators between the current relation and it's parent, for exampleANDorNOT. - the
relationsfield contains an object with the relationtothe current model andfromthe 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)[];
};
};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],
}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,
},
},
},
});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 },
},
},
});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.
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: {...}
}The next function for a where action always resolves with undefined.
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: {...}
}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
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.
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.
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: "[email protected]",
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, ... },
},
},
}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:
selectincludefindManyfindFirstfindUniquefindFirstOrThrowfindUniqueOrThrowcreateupdateupsertdelete
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)
})
);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);
})
);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
includeandselectactions.
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.
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.
Apache 2.0