diff --git a/.eslintrc b/.eslintrc index 561e626..f295ab2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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" diff --git a/README.md b/README.md index 3c41ca9..13516ac 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ - - [Installation](#installation) - [Usage](#usage) + - [Lifecycle](#lifecycle) + - [Operations Nested in Lists](#operations-nested-in-lists) - [LICENSE](#license) @@ -55,10 +56,15 @@ 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: @@ -66,16 +72,16 @@ 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" }, }, }, }, @@ -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: {...} } }` @@ -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 @@ -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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc6d63f..37217ab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/index.ts b/src/index.ts index 57940b9..fb93eb2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 = {}; 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 & { +export type NestedParams = Omit & { action: NestedAction; scope?: NestedParams; }; @@ -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 { @@ -85,13 +87,13 @@ 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); @@ -99,12 +101,12 @@ function extractNestedWriteInfo( 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: @@ -112,11 +114,82 @@ function extractNestedWriteInfo( } } +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( middleware: NestedMiddleware ): Prisma.Middleware { const nestedMiddleware: NestedMiddleware = async (params, next) => { - const relations = relationsByModel[params.model || ''] || []; + const relations = relationsByModel[params.model || ""] || []; const finalParams = params; const nestedWrites: { relationName: string; @@ -197,13 +270,20 @@ export function createNestedMiddleware( 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 + ); }) ); diff --git a/test/result.test.ts b/test/result.test.ts index 83f0f7e..b0b4172 100644 --- a/test/result.test.ts +++ b/test/result.test.ts @@ -156,20 +156,306 @@ describe("results", () => { }); }); - it.failing("allows middleware to modify deeply nested results", async () => { + it("allows middleware to modify results within nested results", async () => { const nestedMiddleware = createNestedMiddleware(async (params, next) => { const result = await next(params); if (typeof result === "undefined") return; - if (params.model === "Comment") { - await wait(100); + if (params.model === "Profile") { + return addReturnedDate(result); + } + + // modify profile first to check it is not overwritten by other calls + await wait(100); + return addReturnedDate(result); + }); + + const params = createParams("Post", "create", { + data: { + title: faker.lorem.sentence(), + author: { + create: { + email: faker.internet.email(), + profile: { + create: { bio: faker.lorem.sentence() }, + }, + }, + }, + }, + }); + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + title: params.args.data.title, + author: { + id: faker.datatype.number(), + email: params.args.data.author.create.email, + profile: { + id: faker.datatype.number(), + bio: params.args.data.author.create.profile.create.bio, + }, + }, + }) + ); + const result = await nestedMiddleware(params, next); + + expect(result).toEqual({ + id: expect.any(Number), + title: params.args.data.title, + returned: expect.any(Date), + author: { + id: expect.any(Number), + email: params.args.data.author.create.email, + returned: expect.any(Date), + profile: { + id: expect.any(Number), + bio: params.args.data.author.create.profile.create.bio, + returned: expect.any(Date), + }, + }, + }); + }); + + it("allows middleware to modify results within nested list results", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + if (typeof result === "undefined") return; + + if (params.model === "User") { + return addReturnedDate(result); } - // modify Post result last to make sure comments are not overwritten + + // modify author first to check it is not overwritten by other calls + await wait(100); + return addReturnedDate(result); + }); + + const params = createParams("Post", "create", { + data: { + title: faker.lorem.sentence(), + authorId: faker.datatype.number(), + comments: { + create: { + content: faker.lorem.sentence(), + author: { + create: { + email: faker.internet.email(), + }, + }, + }, + }, + }, + }); + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + title: params.args.data.title, + authorId: params.args.data.authorId, + comments: [ + { + id: faker.datatype.number(), + content: params.args.data.comments.create.content, + author: { + id: faker.datatype.number(), + email: params.args.data.comments.create.author.create.email, + }, + }, + ], + }) + ); + const result = await nestedMiddleware(params, next); + + expect(result).toEqual({ + id: expect.any(Number), + title: params.args.data.title, + authorId: params.args.data.authorId, + returned: expect.any(Date), + comments: [ + { + id: expect.any(Number), + content: params.args.data.comments.create.content, + returned: expect.any(Date), + author: { + id: expect.any(Number), + email: params.args.data.comments.create.author.create.email, + returned: expect.any(Date), + }, + }, + ], + }); + }); + + it("allows middleware to modify results within doubly nested list results", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + if (typeof result === "undefined") return; + + if (params.model === "User") { + return addReturnedDate(result); + } + + // modify author first to check it is not overwritten by other calls + await wait(100); + return addReturnedDate(result); + }); + + const params = createParams("Post", "create", { + data: { + title: faker.lorem.sentence(), + authorId: faker.datatype.number(), + comments: { + create: { + content: faker.lorem.sentence(), + authorId: faker.datatype.number(), + replies: { + create: { + content: faker.lorem.sentence(), + author: { + create: { + email: faker.internet.email(), + }, + }, + }, + }, + }, + }, + }, + }); + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + title: params.args.data.title, + authorId: params.args.data.authorId, + comments: [ + { + id: faker.datatype.number(), + content: params.args.data.comments.create.content, + authorId: params.args.data.comments.create.authorId, + replies: [ + { + id: faker.datatype.number(), + content: + params.args.data.comments.create.replies.create.content, + author: { + id: faker.datatype.number(), + email: + params.args.data.comments.create.replies.create.author + .create.email, + }, + }, + ], + }, + ], + }) + ); + const result = await nestedMiddleware(params, next); + + expect(result).toEqual({ + id: expect.any(Number), + title: params.args.data.title, + authorId: params.args.data.authorId, + returned: expect.any(Date), + comments: [ + { + id: expect.any(Number), + content: params.args.data.comments.create.content, + authorId: params.args.data.comments.create.authorId, + returned: expect.any(Date), + replies: [ + { + id: expect.any(Number), + content: params.args.data.comments.create.replies.create.content, + returned: expect.any(Date), + author: { + id: expect.any(Number), + email: params.args.data.comments.create.replies.create.author.create.email, + returned: expect.any(Date), + }, + }, + ], + }, + ], + }); + }); + + it("allows middleware to modify list results within nested results", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + if (typeof result === "undefined") return; + if (params.model === "Post") { - await wait(200); + return addReturnedDate(result); } - return result; + // modify posts first to check they are not overwritten by other calls + await wait(100); + return addReturnedDate(result); + }); + + const params = createParams("Profile", "create", { + data: { + bio: faker.lorem.sentence(), + user: { + create: { + email: faker.internet.email(), + posts: { + create: { + title: faker.lorem.sentence(), + }, + }, + }, + }, + }, + }); + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + bio: params.args.data.bio, + user: { + id: faker.datatype.number(), + email: params.args.data.user.create.email, + posts: [ + { + id: faker.datatype.number(), + title: params.args.data.user.create.posts.create.title, + }, + ], + }, + }) + ); + const result = await nestedMiddleware(params, next); + + expect(result).toEqual({ + id: expect.any(Number), + bio: params.args.data.bio, + returned: expect.any(Date), + user: { + id: expect.any(Number), + email: params.args.data.user.create.email, + returned: expect.any(Date), + posts: [ + { + id: expect.any(Number), + title: params.args.data.user.create.posts.create.title, + returned: expect.any(Date), + }, + ], + }, + }); + }); + + it("allows middleware to modify list results within nested list results", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + if (typeof result === "undefined") return; + + if (params.model === "Comment") { + return addReturnedDate(result); + } + + // modify comments first to check they are not overwritten by other calls + await wait(100); + return addReturnedDate(result); }); const params = createParams("User", "create", { @@ -179,10 +465,16 @@ describe("results", () => { create: { title: faker.lorem.sentence(), comments: { - create: { - content: faker.lorem.sentence(), - authorId: faker.datatype.number(), - }, + create: [ + { + content: faker.lorem.sentence(), + authorId: faker.datatype.number(), + }, + { + content: faker.lorem.sentence(), + authorId: faker.datatype.number(), + }, + ], }, }, }, @@ -196,12 +488,7 @@ describe("results", () => { { id: faker.datatype.number(), title: params.args.data.posts.create.title, - comments: [ - { - id: faker.datatype.number(), - content: params.args.data.posts.create.comments.create.content, - }, - ], + comments: params.args.data.posts.create.comments.create, }, ], }) @@ -211,17 +498,18 @@ describe("results", () => { expect(result).toEqual({ id: expect.any(Number), email: params.args.data.email, + returned: expect.any(Date), posts: [ { id: expect.any(Number), title: params.args.data.posts.create.title, - comments: [ - { - id: expect.any(Number), - content: params.args.data.posts.create.comments.create.content, + returned: expect.any(Date), + comments: params.args.data.posts.create.comments.create.map( + (comment: any) => ({ + ...comment, returned: expect.any(Date), - }, - ], + }) + ), }, ], });