Prisma Extension library that allows modifying operations on nested relations in a Prisma query.
Vanilla Prisma extensions are great for modifying top-level queries but
are still difficult to use when they must handle
nested writes, include
s, select
s,
or modify where
objects that reference relations.
This is talked about in greater depth in this issue regarding nested middleware, and the
same issue applies to extensions.
This library exports a withNestedOperations()
helper that splits an $allOperations()
hook into $rootOperation()
and
$allNestedOperations()
hooks.
This module is distributed via npm which is bundled with node and should be installed as one of your project's dependencies:
npm install prisma-extension-nested-operations
@prisma/client
is a peer dependency of this library, so you will need to
install it if you haven't already:
npm install @prisma/client
You must have at least @prisma/client version 4.16.0 installed.
The withNestedOperations()
function takes and object with two properties, $rootOperation()
and $allNestedOperations()
.
The return value is an $allOperations
hook, so it can be passed directly to an extensions $allOperations
hook.
import { withNestedOperations } from "prisma-extension-nested-operations";
client.$extends({
query: {
$allModels: {
$allOperations: withNestedOperations({
async $rootOperation(params) {
// update root params here
const result = params.query(params.args);
// update root result here
return result;
},
async $allNestedOperations(params) {
// update nested params here
const result = await params.query(params.args);
// update nested result here
return result;
},
}),
},
},
});
The $rootOperation()
hook is called with the same params as the $allOperations()
hook, however the params.args
object
has been updated by the args passed to the $allNestedOperations()
query functions. The same pattern applies to the
returned result, it is the result of the query updated by the returned results from the $allNestedOperations()
calls.
The params object passed to the $allNestedOperations()
function is similar to the params passed to $allOperations()
.
It has args
, model
, operation
, and query
fields, however there are some key differences:
-
the
operation
field adds the following options: 'connectOrCreate', 'connect', 'disconnect', 'include', 'select' and 'where' -
the
query
field takes a second argument, which is theoperation
being performed. This is useful where the type of the nested operation should be changed. -
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<ExtArgs> = {
query: (args: any, operation?: NestedOperation) => Prisma.PrismaPromise<any>;
model: keyof Prisma.TypeMap<ExtArgs>["model"];
args: any;
operation: NestedOperation;
scope?: Scope<ExtArgs>;
};
export type Scope<ExtArgs> = {
parentParams: Omit<NestedParams<ExtArgs>, "query">;
relations: { to: Prisma.DMMF.Field; from: Prisma.DMMF.Field };
modifier?: Modifier;
logicalOperators?: LogicalOperator[];
};
type Modifier = "is" | "isNot" | "some" | "none" | "every";
type LogicalOperator = "AND" | "OR" | "NOT";
type Operation =
| "create"
| "createMany"
| "update"
| "updateMany"
| "upsert"
| "delete"
| "deleteMany"
| "where"
| "include"
| "select"
| "connect"
| "connectOrCreate"
| "disconnect";
The $allNestedOperations()
function is called for every nested write
operation in the query. The operation
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 $allNestedOperations()
function will be called with:
{
operation: '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 $allNestedOperations()
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 $allNestedOperations()
function will be called with:
{
operation: '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
{
operation: '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 $allNestedOperations()
function can change the operation 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 $allNestedOperations()
function could be used to change the operation to upsert
:
const client = _client.$extends({
query: {
$allModels: {
$allOperations: withNestedOperations({
$rootOperation: (params) => {
return params.query(params.args);
},
$allNestedOperations: (params) => {
if (params.model === "Post" && params.operation === "update") {
return params.query(
{
where: params.args.where,
create: params.args.data,
update: params.args.data,
},
"upsert"
);
}
return params.query(params);
},
}),
},
},
});
The final query would be modified by the above $allNestedOperations()
to:
const result = await client.user.update({
data: {
posts: {
upsert: {
where: { id: 1 },
create: { title: "Hello World" },
update: { title: "Hello World" },
},
},
},
});
When changing the operation it is possible for the operation to already exist. In this case the resulting operations 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 $allNestedOperations()
defined before the update operation would be changed to an upsert operation, however there is
already an upsert operation so the two operations are merged into a upsert operation array with the new operation added to
the end of the array. When the existing operation 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 operations together in this way. The createMany
operation does not support
operation arrays so the data
field of the createMany
operation 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
operation was changed to be a createMany
operation the data
field would be added to the end of the existing
createMany
operation. 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 operations 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
operation was changed to be a create
operation using the following extension:
const client = _client.$extends({
query: {
$allModels: {
$allOperations: withNestedOperations({
$rootOperation: (params) => {
return params.query(params.args);
},
$allNestedOperations: (params) => {
if (params.model === "Profile" && params.operation === "update") {
return params.query(params.args.data, "create");
}
return params.query(params);
},
}),
},
},
});
The create
operation from the update
operation would need be merged with the existing create
operation, 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 query
function of $allNestedOperations()
calls for nested write operations always return undefined
as their result.
This is because 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
operation 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
operation to the returned Posts.
See Modifying Results for more information on how to update the results of queries.
The where
operation is called for any relations found inside where objects in params.
Note that the where
operation is not called for the root where object, this is because you need the root operation 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
operation 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.
{
operation: '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 $allNestedOperations()
function,
however the where
operation 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 $allNestedOperations()
function will be called with the params for "posts" similarly to before, however it will also be called
with the following params:
{
operation: '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
$allNestedOperations 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 $allNestedOperations()
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 $allNestedOperations()
function will be called with the following params:
{
operation: 'where',
model: 'Post',
args: {
published: true,
},
scope: {
parentParams: {...}
modifier: 'some',
logicalOperators: ['AND', 'NOT', 'OR'],
relations: {...},
},
}
The where
operation 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 $allNestedOperations()
function will be called with the following params:
{
operation: 'where',
model: 'Post',
args: {
published: true,
},
scope: {...}
}
The query
function for a where
operation always resolves with undefined
.
The include
operation 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 $allNestedOperations()
function will be called with:
{
operation: 'include',
model: 'Profile',
args: true,
scope: {...}
}
and for the "posts" relation the $allNestedOperations()
function will be called with:
{
operation: 'include',
model: 'Post',
args: {
where: {
published: true,
},
},
scope: {...}
}
The query
function for an include
operation resolves with the result of the include
operation. For example take the
following query:
const result = await client.user.findMany({
include: {
profile: true,
},
});
The $allNestedOperations()
function for the "profile" relation will be called with:
{
operation: 'include',
model: 'Profile',
args: true,
scope: {...}
}
And the query
function will resolve with the result of the include
operation, 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 query
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 query
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
operation see the Modifying Results
Similarly to the include
operation, the select
operation 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 $allNestedOperations()
function will be called with:
{
operation: 'select',
model: 'Post',
args: true,
scope: {...}
}
For the "profile" relation the $allNestedOperations()
function will be called with:
{
operation: 'select',
model: 'Profile',
args: {
bio: true,
},
scope: {...}
}
The query
function for a select
operation resolves with the result of the select
operation. This is the same as the
include
operation. 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 $allNestedOperations()
function will be called with the following params for the "profile" relation:
{
operation: '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:
{
operation: '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 extensions that modify the params of a query you should first write the $rootOperation()
hook as if it were
an $allOperations()
hook, and then add the $allNestedOperations()
hook.
Say you are writing middleware that sets a default value when creating a model for a particular model:
const client = _client.$extends({
query: {
$allModels: {
$allOperations: withNestedOperations({
async $rootOperation(params) {
// we only want to add default values for the "Invite" model
if (params.model !== "Invite") {
return params.query(params.args);
}
if (params.operation === "create" && !params.args.data.code) {
params.args.data.code = createCode();
}
if (params.operation === "upsert" && !params.args.create.code) {
params.args.create.code = createCode();
}
if (params.operation === "createMany") {
params.args.data.forEach((data) => {
if (!data.code) {
data.code = createCode();
}
});
}
return params.query(params.args);
},
async $allNestedOperations(params) {
return params.query(params.args);
},
}),
},
},
});
Then add conditions for the different args and operations that can be found in nested writes:
const client = _client.$extends({
query: {
$allModels: {
$allOperations: withNestedOperations({
async $rootOperation(params) {
[...]
},
async $allNestedOperations(params) {
// we only want to add default values for the "Invite" model
if (params.model !== "Invite") {
return params.query(params.args);
}
// when the "create" operation is from a nested write the data is not in the "data" field
if (params.operation === "create" && !params.args.code) {
params.args.code = createCode();
}
// handle the "connectOrCreate" operation
if (params.operation === "connectOrCreate" && !params.args.create.code) {
params.args.create.code = createCode();
}
// pass args to query
return params.query(params.args);
},
}),
},
},
});
When writing extensions that modify the where params of a query you should first write the $rootOperation()
hook as
if it were an $allOperations()
hook, this is because the where
operation is not called for the root where object and so you
will need to handle it manually.
Say you are writing an extension that excludes models with a particular field, let's call it "invisible" rather than "deleted" to make this less familiar:
const client = _client.$extends({
query: {
$allModels: {
$allOperations: withNestedOperations({
async $rootOperation(params) {
// don't handle operations that only accept unique fields such as findUnique or upsert
if (
params.operation === "findFirst" ||
params.operation === "findFirstOrThrow" ||
params.operation === "findMany" ||
params.operation === "updateMany" ||
params.operation === "deleteMany" ||
params.operation === "count" ||
params.operation === "aggregate"
) {
return params.query({
...params.args,
where: {
...params.args.where,
invisible: false,
},
});
}
return params.query(params.args);
},
async $allNestedOperations(params) {
return params.query(params.args);
},
}),
},
},
});
Then add conditions for the where
operation:
const client = _client.$extends({
query: {
$allModels: {
$allOperations: withNestedOperations({
async $rootOperation(params) {
[...]
},
async $allNestedOperations(params) {
// handle the "where" operation
if (params.operation === "where") {
return params.query({
...params.args,
invisible: false,
});
}
return params.query(params.args);
},
}),
},
},
});
When writing extensions that modify the results of a query you should take the following process:
- handle all the root cases in the
$rootOperation()
hook the same way you would with a$allOperations()
hook. - handle nested results using the
include
andselect
operations in the$allNestedOperations()
hook.
Say you are writing middleware that adds a timestamp to the results of a query. You would first handle the root cases:
const client = _client.$extends({
query: {
$allModels: {
$allOperations: withNestedOperations({
async $rootOperation(params) {
const result = await params.query(params.args);
// ensure result is defined
if (!result) return result;
// handle root operations
if (
params.operation === "findFirst" ||
params.operation === "findFirstOrThrow" ||
params.operation === "findUnique" ||
params.operation === "findUniqueOrThrow" ||
params.operation === "create" ||
params.operation === "update" ||
params.operation === "upsert" ||
params.operation === "delete"
) {
result.timestamp = Date.now();
return result;
}
if (params.operation === "findMany") {
const result = await params.query(params.args);
result.forEach((model) => {
model.timestamp = Date.now();
});
return result;
}
return result;
},
async $allNestedOperations(params) {
return params.query(params.args);
},
}),
},
},
});
Then you would handle the nested results using the include
and select
operations:
const client = _client.$extends({
query: {
$allModels: {
$allOperations: withNestedOperations({
async $rootOperation(params) {
[...]
},
async $allNestedOperations(params) {
const result = await next(params);
// ensure result is defined
if (!result) return result;
// handle nested operations
if (params.operation === "include" || params.operation === "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:
const client = _client.$extends({
query: {
$allModels: {
$allOperations: withNestedOperations({
async $rootOperation(params) {
[...]
},
async $allNestedOperations(params) {
const result = await next(params);
// ensure result is defined
if (!result) return result;
// handle nested operations
if (params.operation === "include" || params.operation === "select") {
if (Array.isArray(result)) {
return result.map((model) => ({
...model,
timestamp: Date.now(),
});
}
return {
...result,
timestamp: Date.now(),
};
}
return result;
},
}),
},
},
});
NOTE: When modifying results from include
or select
operations 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