Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
"no-undef": "off",
"no-use-before-define": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/no-unused-vars": [
"error",
{ "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }
],
"max-lines-per-function": "off",
"consistent-return": "off",
"jest/no-if": "off"
Expand Down
27 changes: 20 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->


- [Installation](#installation)
- [Usage](#usage)
- [Lifecycle](#lifecycle)
- [Operations Nested in Lists](#operations-nested-in-lists)
- [LICENSE](#license)

<!-- END doctoc generated TOC please keep comment here to allow auto update -->
Expand Down Expand Up @@ -55,27 +56,32 @@ The middleware function passed to `createNestedMiddleware` is called for every
[nested write](https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries#nested-writes) operation.

There are some differences to note when using nested middleware:

- the list of actions that might be in params is expanded to include `connectOrCreate`
- If a relation is not included using `include` then that middleware's `next` function will resolve with `undefined`.
- The parent operation's params have been added to the params of nested middleware as a `scope` object. This is useful when the parent is relevant, for example when handling a `connectOrCreate` and you need to know the parent being connected to.
- when handling a nested `create` action `params.args` does not include a `data` field, that must be handled manually. You can use the existence of `params.scope` to know when to handle a nested `create`.
- the return value of `next` matches the part of the response that the middleware was called for. For example if the middleware function is called for a nested create, the `next` function resolves with the value of that create.
- if a relation is not included using `include` then that middleware's `next` function will resolve with `undefined`.
- if a nested operation's result is within an array then the nested operation's `next` function returns a flattened array of all the models found in the parent array. See [Operations Nested in Lists](#operations-nested-in-lists) for more information.

### Lifecycle

It is helpful to walk through the lifecycle of an operation:

For the following update

```javascript
client.country.update({
where: { id: 'imagination-land' },
where: { id: "imagination-land" },
data: {
nationalDish: {
update: {
where: { id: 'stardust-pie' },
where: { id: "stardust-pie" },
data: {
keyIngredient: {
connectOrCreate: {
create: { name: 'Stardust' },
connect: { id: 'stardust' },
create: { name: "Stardust" },
connect: { id: "stardust" },
},
},
},
Expand All @@ -86,6 +92,7 @@ client.country.update({
```

`createNestedMiddleware` calls the passed middleware function with params in the following order:

1. `{ model: 'Recipe', action: 'update', args: { where: { id: 'stardust-pie' }, data: {...} } }`
2. `{ model: 'Food', action: 'connectOrCreate', args: { create: {...}, connect: {...} } }`
3. `{ model: 'Country', action: 'update', args: { where: { id: 'imagination-land', data: {...} } }`
Expand All @@ -99,6 +106,12 @@ and that modified object is the one `client.country.update` resolves with.

If any middleware throws an error then `client.country.update` will throw with that error.

### Operations Nested in Lists

When a `next` function needs to return a relation that is nested within a list it combines all the relation values into a single flat array. This means middleware only has to handle flat arrays of results which makes modifying the result before it is returned easier. If the result's parent is needed then it is possible to go through the parent middleware and traverse that relation; only a single depth of relation needs to be traversed as the middleware will be called for each layer.

For example if a comment is created within an array of posts, the `next` function for comments returns a flattened array of all the comments found within the posts array. When the flattened array is returned at the end of the middleware function the comments are put back into their corresponding posts.

## LICENSE

Apache 2.0
Expand All @@ -109,7 +122,7 @@ Apache 2.0
[build]: https://github.com/olivierwilkinson/prisma-nested-middleware/actions?query=branch%3Amaster+workflow%3Aprisma-nested-middleware
[version-badge]: https://img.shields.io/npm/v/prisma-nested-middleware.svg?style=flat-square
[package]: https://www.npmjs.com/package/prisma-nested-middleware
[downloads-badge]:https://img.shields.io/npm/dm/prisma-nested-middleware.svg?style=flat-square
[downloads-badge]: https://img.shields.io/npm/dm/prisma-nested-middleware.svg?style=flat-square
[npmtrends]: http://www.npmtrends.com/prisma-nested-middleware
[license-badge]: https://img.shields.io/npm/l/prisma-nested-middleware.svg?style=flat-square
[license]: https://github.com/olivierwilkinson/prisma-nested-middleware/blob/master/LICENSE
Expand Down
4 changes: 2 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ model Comment {
content String
author User @relation(fields: [authorId], references: [id])
authorId Int
post Post @relation(fields: [postId], references: [id])
postId Int
post Post? @relation(fields: [postId], references: [id])
postId Int?
repliedTo Comment? @relation("replies", fields: [repliedToId], references: [id])
repliedToId Int?
replies Comment[] @relation("replies")
Expand Down
128 changes: 104 additions & 24 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
/* eslint-disable import/no-unresolved */
// @ts-ignore unable to generate prisma client before building
import { Prisma } from '@prisma/client';
import { Prisma } from "@prisma/client";

import get from 'lodash/get';
import set from 'lodash/set';
import get from "lodash/get";
import set from "lodash/set";

if (!Prisma.dmmf) {
throw new Error('Prisma DMMF not found, please generate Prisma client using `npx prisma generate`');
throw new Error(
"Prisma DMMF not found, please generate Prisma client using `npx prisma generate`"
);
}

const relationsByModel: Record<string, Prisma.DMMF.Field[]> = {};
Prisma.dmmf.datamodel.models.forEach((model: Prisma.DMMF.Model) => {
relationsByModel[model.name] = model.fields.filter(
(field) => field.kind === 'object' && field.relationName
(field) => field.kind === "object" && field.relationName
);
});

export type NestedAction = Prisma.PrismaAction | 'connectOrCreate';
export type NestedAction = Prisma.PrismaAction | "connectOrCreate";

export type NestedParams = Omit<Prisma.MiddlewareParams, 'action'> & {
export type NestedParams = Omit<Prisma.MiddlewareParams, "action"> & {
action: NestedAction;
scope?: NestedParams;
};
Expand All @@ -39,18 +41,18 @@ type PromiseCallbackRef = {
};

const writeOperationsSupportingNestedWrites: NestedAction[] = [
'create',
'update',
'upsert',
'connectOrCreate',
"create",
"update",
"upsert",
"connectOrCreate",
];

const writeOperations: NestedAction[] = [
...writeOperationsSupportingNestedWrites,
'createMany',
'updateMany',
'delete',
'deleteMany',
"createMany",
"updateMany",
"delete",
"deleteMany",
];

function isWriteOperation(key: any): key is NestedAction {
Expand Down Expand Up @@ -85,38 +87,109 @@ function extractNestedWriteInfo(
const model = relation.type as Prisma.ModelName;

switch (params.action) {
case 'upsert':
case "upsert":
return [
...extractWriteInfo(params, model, `update.${relation.name}`),
...extractWriteInfo(params, model, `create.${relation.name}`),
];

case 'create':
case "create":
// nested creates use args as data instead of including a data field.
if (params.scope) {
return extractWriteInfo(params, model, relation.name);
}

return extractWriteInfo(params, model, `data.${relation.name}`);

case 'update':
case 'updateMany':
case 'createMany':
case "update":
case "updateMany":
case "createMany":
return extractWriteInfo(params, model, `data.${relation.name}`);

case 'connectOrCreate':
case "connectOrCreate":
return extractWriteInfo(params, model, `create.${relation.name}`);

default:
return [];
}
}

const parentSymbol = Symbol("parent");

function addParentToResult(parent: any, result: any) {
if (!Array.isArray(result)) {
return { ...result, [parentSymbol]: parent };
}

return result.map((item) => ({ ...item, [parentSymbol]: parent }));
}

function removeParentFromResult(result: any) {
if (!Array.isArray(result)) {
const { [parentSymbol]: _, ...rest } = result;
return rest;
}

return result.map(({ [parentSymbol]: _, ...rest }: any) => rest);
}

function getNestedResult(result: any, relationName: string) {
if (!Array.isArray(result)) {
return get(result, relationName);
}

return result.reduce((acc, item) => {
const itemResult = get(item, relationName);
if (typeof itemResult === "undefined") {
return acc;
}

return acc.concat(addParentToResult(item, itemResult));
}, []);
}

function setNestedResult(
result: any,
relationName: string,
modifiedResult: any
) {
if (!Array.isArray(result)) {
return set(result, relationName, modifiedResult);
}

result.forEach((item: any) => {
const originalResult = get(item, relationName);

// if original result was an array we need to filter the result to match
if (Array.isArray(originalResult)) {
return set(
item,
relationName,
removeParentFromResult(
modifiedResult.filter(
(modifiedItem: any) => modifiedItem[parentSymbol] === item
)
)
);
}

// if the orginal result was not an array we can just set the result
const modifiedResultItem = modifiedResult.find(
({ [parentSymbol]: parent }: any) => parent === item
);
return set(
item,
relationName,
modifiedResultItem && removeParentFromResult(modifiedResultItem)
);
});
}

export function createNestedMiddleware<T>(
middleware: NestedMiddleware
): Prisma.Middleware<T> {
const nestedMiddleware: NestedMiddleware = async (params, next) => {
const relations = relationsByModel[params.model || ''] || [];
const relations = relationsByModel[params.model || ""] || [];
const finalParams = params;
const nestedWrites: {
relationName: string;
Expand Down Expand Up @@ -197,13 +270,20 @@ export function createNestedMiddleware<T>(
await Promise.all(
nestedWrites.map(async (nestedWrite) => {
// result cannot be null because only writes can have nested writes.
const nestedResult = get(result, nestedWrite.relationName);
const nestedResult = getNestedResult(
result,
nestedWrite.relationName
);

// if relationship hasn't been included nestedResult is undefined.
nestedWrite.resultCallbacks.resolve(nestedResult);

// set final result relation to be result of nested middleware
set(result, nestedWrite.relationName, await nestedWrite.result);
setNestedResult(
result,
nestedWrite.relationName,
await nestedWrite.result
);
})
);

Expand Down
Loading