diff --git a/.eslintrc b/.eslintrc index 188503d..561e626 100644 --- a/.eslintrc +++ b/.eslintrc @@ -16,6 +16,9 @@ "no-undef": "off", "no-use-before-define": "off", "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error"] + "@typescript-eslint/no-unused-vars": ["error"], + "max-lines-per-function": "off", + "consistent-return": "off", + "jest/no-if": "off" } } diff --git a/.gitignore b/.gitignore index d73a1bc..934a37c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ # Build output directory dist + +# Test coverage directory +coverage diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..281729c --- /dev/null +++ b/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testRegex: ".+\\.test\\.ts$", +}; diff --git a/package.json b/package.json index 477736f..6e92bd8 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "build": "npm-run-all build:cjs build:esm", "build:cjs": "tsc -p tsconfig.build.json", "build:esm": "tsc -p tsconfig.esm.json", + "test": "prisma generate && jest", "lint": "eslint ./src --fix --ext .ts", "typecheck": "npm run build:cjs -- --noEmit && npm run build:esm -- --noEmit", - "validate": "kcd-scripts validate lint,typecheck", + "validate": "kcd-scripts validate lint,typecheck,test", "semantic-release": "semantic-release", "doctoc": "doctoc ." }, @@ -32,14 +33,22 @@ "@prisma/client": "*" }, "devDependencies": { + "@prisma/client": "^4.8.1", + "@types/faker": "^5.5.9", + "@types/jest": "^29.2.5", "@types/lodash": "^4.14.185", "@typescript-eslint/eslint-plugin": "^4.14.0", "@typescript-eslint/parser": "^4.14.0", "doctoc": "^2.2.0", + "dotenv": "^16.0.3", "eslint": "^7.6.0", + "faker": "^5.0.0", + "jest": "^29.3.1", "kcd-scripts": "^5.0.0", "npm-run-all": "^4.1.5", + "prisma": "^4.8.1", "semantic-release": "^17.0.2", + "ts-jest": "^29.0.3", "ts-node": "^9.1.1", "typescript": "^4.1.3" }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..bc6d63f --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,50 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String? + posts Post[] + profile Profile? + comments Comment[] +} + +model Post { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + published Boolean @default(false) + title String + content String? + author User @relation(fields: [authorId], references: [id]) + authorId Int + comments Comment[] +} + +model Comment { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + content String + author User @relation(fields: [authorId], references: [id]) + authorId Int + post Post @relation(fields: [postId], references: [id]) + postId Int + repliedTo Comment? @relation("replies", fields: [repliedToId], references: [id]) + repliedToId Int? + replies Comment[] @relation("replies") +} + +model Profile { + id Int @id @default(autoincrement()) + bio String? + user User @relation(fields: [userId], references: [id]) + userId Int @unique +} diff --git a/src/index.ts b/src/index.ts index c599cdc..57940b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ /* eslint-disable import/no-unresolved */ -// @ts-expect-error unable to generate prisma client before building +// @ts-ignore unable to generate prisma client before building import { Prisma } from '@prisma/client'; import get from 'lodash/get'; @@ -9,20 +9,8 @@ if (!Prisma.dmmf) { throw new Error('Prisma DMMF not found, please generate Prisma client using `npx prisma generate`'); } -type Field = { - kind: "scalar" | "object" | "enum" | "list"; - relationName?: string; - name: string; - type: string; -} - -type Model = { - name: string; - fields: Field[]; -} - -const relationsByModel: Record = {}; -Prisma.dmmf.datamodel.models.forEach((model: Model) => { +const relationsByModel: Record = {}; +Prisma.dmmf.datamodel.models.forEach((model: Prisma.DMMF.Model) => { relationsByModel[model.name] = model.fields.filter( (field) => field.kind === 'object' && field.relationName ); @@ -71,7 +59,7 @@ function isWriteOperation(key: any): key is NestedAction { function extractWriteInfo( params: NestedParams, - model: string, + model: Prisma.ModelName, argPath: string ): WriteInfo[] { const arg = get(params.args, argPath, {}); @@ -92,9 +80,9 @@ function extractWriteInfo( function extractNestedWriteInfo( params: NestedParams, - relation: Field + relation: Prisma.DMMF.Field ): WriteInfo[] { - const model = relation.type; + const model = relation.type as Prisma.ModelName; switch (params.action) { case 'upsert': diff --git a/test/calls.test.ts b/test/calls.test.ts new file mode 100644 index 0000000..1818973 --- /dev/null +++ b/test/calls.test.ts @@ -0,0 +1,1059 @@ +import { Prisma } from "@prisma/client"; +import faker from "faker"; +import { get } from "lodash"; + +import { createNestedMiddleware, NestedParams } from "../src"; +import { createParams } from "./utils/createParams"; + +type MiddlewareCall = { + model: Model; + action: + | "create" + | "update" + | "upsert" + | "delete" + | "createMany" + | "updateMany" + | "deleteMany" + | "connectOrCreate"; + argsPath: string; + scope?: MiddlewareCall; +}; + +function nestedParamsFromCall( + rootParams: Prisma.MiddlewareParams, + call: MiddlewareCall +): NestedParams { + return { + ...createParams(call.model, call.action, get(rootParams, call.argsPath)), + scope: call.scope + ? nestedParamsFromCall(rootParams, call.scope) + : rootParams, + }; +} + +describe("calls", () => { + it("calls middleware once when there are no nested operations", async () => { + const middleware = jest.fn((params, next) => next(params)) + const nestedMiddleware = createNestedMiddleware(middleware); + + const next = jest.fn((params: any) => params); + const params = createParams("User", "create", { + data: { email: faker.internet.email() }, + }); + await nestedMiddleware(params, next); + + // middleware is called with params and next + expect(middleware).toHaveBeenCalledTimes(1); + expect(middleware).toHaveBeenCalledWith(params, next); + }); + + it.each<{ + description: string; + rootParams: Prisma.MiddlewareParams; + calls: MiddlewareCall[]; + }>([ + { + description: "nested create in create", + rootParams: createParams("User", "create", { + data: { + email: faker.internet.email(), + profile: { create: { bio: faker.lorem.paragraph() } }, + }, + }), + calls: [ + { + action: "create", + model: "Profile", + argsPath: "args.data.profile.create", + }, + ], + }, + { + description: "nested create in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + profile: { create: { bio: faker.lorem.paragraph() } }, + }, + }), + calls: [ + { + action: "create", + model: "Profile", + argsPath: "args.data.profile.create", + }, + ], + }, + { + description: "nested creates in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + profile: { create: { bio: faker.lorem.paragraph() } }, + }, + update: { + email: faker.internet.email(), + profile: { create: { bio: faker.lorem.paragraph() } }, + }, + }), + calls: [ + { + action: "create", + model: "Profile", + argsPath: "args.create.profile.create", + }, + { + action: "create", + model: "Profile", + argsPath: "args.update.profile.create", + }, + ], + }, + { + description: "nested create array in create", + rootParams: createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + create: [ + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + ], + }, + }, + }), + calls: [ + { + action: "create", + model: "Post", + argsPath: "args.data.posts.create", + }, + ], + }, + { + description: "nested create array in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + create: [ + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + ], + }, + }, + }), + calls: [ + { + action: "create", + model: "Post", + argsPath: "args.data.posts.create", + }, + ], + }, + { + description: "nested create array in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + posts: { + create: [ + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + ], + }, + }, + update: { + email: faker.internet.email(), + posts: { + create: [ + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + ], + }, + }, + }), + calls: [ + { + action: "create", + model: "Post", + argsPath: "args.create.posts.create", + }, + { + action: "create", + model: "Post", + argsPath: "args.update.posts.create", + }, + ], + }, + { + description: "nested update in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + + data: { + email: faker.internet.email(), + profile: { update: { bio: faker.lorem.paragraph() } }, + }, + }), + calls: [ + { + action: "update", + model: "Profile", + argsPath: "args.data.profile.update", + }, + ], + }, + { + description: "nested update in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + }, + update: { + email: faker.internet.email(), + profile: { update: { bio: faker.lorem.paragraph() } }, + }, + }), + calls: [ + { + action: "update", + model: "Profile", + argsPath: "args.update.profile.update", + }, + ], + }, + { + description: "nested update array in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + update: [ + { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + }, + { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + }, + ], + }, + }, + }), + calls: [ + { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + ], + }, + { + description: "nested update array in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + }, + update: { + email: faker.internet.email(), + posts: { + update: [ + { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + }, + { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + }, + ], + }, + }, + }), + calls: [ + { + action: "update", + model: "Post", + argsPath: "args.update.posts.update", + }, + ], + }, + { + description: "nested upsert in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + profile: { + upsert: { + create: { bio: faker.lorem.paragraph() }, + update: { bio: faker.lorem.paragraph() }, + }, + }, + }, + }), + calls: [ + { + action: "upsert", + model: "Profile", + argsPath: "args.data.profile.upsert", + }, + ], + }, + { + description: "nested upsert in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + }, + update: { + email: faker.internet.email(), + + profile: { + upsert: { + create: { bio: faker.lorem.paragraph() }, + update: { bio: faker.lorem.paragraph() }, + }, + }, + }, + }), + calls: [ + { + action: "upsert", + model: "Profile", + argsPath: "args.update.profile.upsert", + }, + ], + }, + { + description: "nested delete in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + profile: { delete: true }, + }, + }), + calls: [ + { + action: "delete", + model: "Profile", + argsPath: "args.data.profile.delete", + }, + ], + }, + { + description: "nested delete in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + }, + + update: { + email: faker.internet.email(), + profile: { delete: true }, + }, + }), + calls: [ + { + action: "delete", + model: "Profile", + argsPath: "args.update.profile.delete", + }, + ], + }, + { + description: "nested delete array in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + delete: [ + { id: faker.datatype.number() }, + { id: faker.datatype.number() }, + ], + }, + }, + }), + calls: [ + { + action: "delete", + model: "Post", + argsPath: "args.data.posts.delete", + }, + ], + }, + { + description: "nested delete array in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + }, + update: { + email: faker.internet.email(), + posts: { + delete: [ + { id: faker.datatype.number() }, + { id: faker.datatype.number() }, + ], + }, + }, + }), + calls: [ + { + action: "delete", + model: "Post", + argsPath: "args.update.posts.delete", + }, + ], + }, + { + description: "nested createMany in create", + rootParams: createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + createMany: { + data: [ + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + ], + }, + }, + }, + }), + calls: [ + { + action: "createMany", + model: "Post", + argsPath: "args.data.posts.createMany", + }, + ], + }, + { + description: "nested createMany in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + createMany: { + data: [ + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + ], + }, + }, + }, + }), + calls: [ + { + action: "createMany", + model: "Post", + argsPath: "args.data.posts.createMany", + }, + ], + }, + { + description: "nested createMany in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + }, + update: { + email: faker.internet.email(), + posts: { + createMany: { + data: [ + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + ], + }, + }, + }, + }), + calls: [ + { + action: "createMany", + model: "Post", + argsPath: "args.update.posts.createMany", + }, + ], + }, + { + description: "nested updateMany in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + updateMany: { + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + where: { id: faker.datatype.number() }, + }, + }, + }, + }), + calls: [ + { + action: "updateMany", + model: "Post", + argsPath: "args.data.posts.updateMany", + }, + ], + }, + { + description: "nested updateMany in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + }, + update: { + email: faker.internet.email(), + posts: { + updateMany: { + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + }, + where: { id: faker.datatype.number() }, + }, + }, + }, + }), + calls: [ + { + action: "updateMany", + model: "Post", + argsPath: "args.update.posts.updateMany", + }, + ], + }, + { + description: "nested deleteMany in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + deleteMany: { id: faker.datatype.number() }, + }, + }, + }), + calls: [ + { + action: "deleteMany", + model: "Post", + argsPath: "args.data.posts.deleteMany", + }, + ], + }, + { + description: "nested deleteMany in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + }, + update: { + email: faker.internet.email(), + posts: { + deleteMany: { id: faker.datatype.number() }, + }, + }, + }), + calls: [ + { + action: "deleteMany", + model: "Post", + + argsPath: "args.update.posts.deleteMany", + }, + ], + }, + { + description: "nested connectOrCreate in update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + profile: { + connectOrCreate: { + where: { id: faker.datatype.number() }, + create: { bio: faker.lorem.paragraph() }, + }, + }, + }, + }), + calls: [ + { + action: "connectOrCreate", + model: "Profile", + argsPath: "args.data.profile.connectOrCreate", + }, + ], + }, + { + description: "nested connectOrCreate in upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + }, + update: { + email: faker.internet.email(), + profile: { + connectOrCreate: { + where: { id: faker.datatype.number() }, + create: { bio: faker.lorem.paragraph() }, + }, + }, + }, + }), + calls: [ + { + action: "connectOrCreate", + model: "Profile", + argsPath: "args.update.profile.connectOrCreate", + }, + ], + }, + { + description: "deeply nested creates", + rootParams: createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + create: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + comments: { + create: { + authorId: faker.datatype.number(), + content: faker.lorem.paragraph(), + }, + }, + }, + }, + }, + }), + calls: [ + { + action: "create", + model: "Post", + argsPath: "args.data.posts.create", + }, + { + action: "create", + model: "Comment", + argsPath: "args.data.posts.create.comments.create", + scope: { + action: "create", + model: "Post", + argsPath: "args.data.posts.create", + }, + }, + ], + }, + { + description: "deeply nested update", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + update: { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + comments: { + update: { + where: { id: faker.datatype.number() }, + data: { + authorId: faker.datatype.number(), + content: faker.lorem.paragraph(), + }, + }, + }, + }, + }, + }, + }, + }), + calls: [ + { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + { + action: "update", + model: "Comment", + argsPath: "args.data.posts.update.data.comments.update", + scope: { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + }, + ], + }, + { + description: "deeply nested delete", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + update: { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + comments: { + delete: [ + { id: faker.datatype.number() }, + { id: faker.datatype.number() }, + ], + }, + }, + }, + }, + }, + }), + calls: [ + { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + { + action: "delete", + model: "Comment", + argsPath: "args.data.posts.update.data.comments.delete", + scope: { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + }, + ], + }, + { + description: "deeply nested upsert", + rootParams: createParams("User", "upsert", { + where: { id: faker.datatype.number() }, + create: { + email: faker.internet.email(), + }, + update: { + email: faker.internet.email(), + posts: { + update: { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + comments: { + upsert: { + where: { id: faker.datatype.number() }, + create: { + authorId: faker.datatype.number(), + content: faker.lorem.paragraph(), + }, + update: { + authorId: faker.datatype.number(), + content: faker.lorem.paragraph(), + }, + }, + }, + }, + }, + }, + }, + }), + calls: [ + { + action: "update", + model: "Post", + argsPath: "args.update.posts.update", + }, + { + action: "upsert", + model: "Comment", + argsPath: "args.update.posts.update.data.comments.upsert", + scope: { + action: "update", + model: "Post", + argsPath: "args.update.posts.update", + }, + }, + ], + }, + { + description: "deeply nested nested createMany", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + update: { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + comments: { + createMany: { + data: [ + { + authorId: faker.datatype.number(), + content: faker.lorem.paragraph(), + }, + { + authorId: faker.datatype.number(), + content: faker.lorem.paragraph(), + }, + ], + }, + }, + }, + }, + }, + }, + }), + calls: [ + { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + { + action: "createMany", + model: "Comment", + argsPath: "args.data.posts.update.data.comments.createMany", + scope: { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + }, + ], + }, + { + description: "deeply nested nested updateMany", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + update: { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + comments: { + updateMany: { + where: { id: faker.datatype.number() }, + data: { + content: faker.lorem.paragraph(), + }, + }, + }, + }, + }, + }, + }, + }), + calls: [ + { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + { + action: "updateMany", + model: "Comment", + argsPath: "args.data.posts.update.data.comments.updateMany", + scope: { + action: "update", + model: "Post", + + argsPath: "args.data.posts.update", + }, + }, + ], + }, + { + description: "deeply nested nested deleteMany", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + update: { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + comments: { + deleteMany: { id: faker.datatype.number() }, + }, + }, + }, + }, + }, + }), + calls: [ + { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + { + action: "deleteMany", + model: "Comment", + + argsPath: "args.data.posts.update.data.comments.deleteMany", + scope: { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + }, + ], + }, + { + description: "deeply nested nested connectOrCreate", + rootParams: createParams("User", "update", { + where: { id: faker.datatype.number() }, + data: { + email: faker.internet.email(), + posts: { + update: { + where: { id: faker.datatype.number() }, + data: { + title: faker.lorem.sentence(), + content: faker.lorem.paragraph(), + comments: { + connectOrCreate: { + where: { id: faker.datatype.number() }, + create: { + authorId: faker.datatype.number(), + content: faker.lorem.paragraph(), + }, + }, + }, + }, + }, + }, + }, + }), + calls: [ + { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + { + action: "connectOrCreate", + model: "Comment", + argsPath: "args.data.posts.update.data.comments.connectOrCreate", + scope: { + action: "update", + model: "Post", + argsPath: "args.data.posts.update", + }, + }, + ], + }, + ])("calls middleware with $description", async ({ rootParams, calls }) => { + const middleware = jest.fn((params, next) => next(params)); + const nestedMiddleware = createNestedMiddleware(middleware); + + const next = (params: any) => params; + await nestedMiddleware(rootParams, next); + + expect(middleware).toHaveBeenCalledTimes(calls.length + 1); + expect(middleware).toHaveBeenCalledWith(rootParams, next); + calls.forEach((call) => { + expect(middleware).toHaveBeenCalledWith( + nestedParamsFromCall(rootParams, call), + expect.any(Function) + ); + }); + }); +}); diff --git a/test/errors.test.ts b/test/errors.test.ts new file mode 100644 index 0000000..530d9b9 --- /dev/null +++ b/test/errors.test.ts @@ -0,0 +1,105 @@ +import faker from "faker"; + +import { createNestedMiddleware } from "../src"; +import { createParams } from "./utils/createParams"; +import { wait } from "./utils/wait"; + +async function createAsyncError() { + await wait(100); + throw new Error("oops") +} + +describe("errors", () => { + it("throws when error encountered while modifying root params", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + await createAsyncError(); + return next(params); + }); + + const next = jest.fn((params: any) => params); + const params = createParams("User", "create", { + data: { email: faker.internet.email() }, + }); + await expect(() => nestedMiddleware(params, next)).rejects.toThrow("oops"); + }); + + it("throws when error encountered while modifying nested params", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + if (params.model === "Post") { + await createAsyncError(); + } + return next(params); + }); + + const next = jest.fn((params: any) => params); + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + create: { title: faker.lorem.sentence() }, + }, + }, + }); + + await expect(() => nestedMiddleware(params, next)).rejects.toThrow("oops"); + }); + + it("throws if next encounters an error", async () => { + const nestedMiddleware = createNestedMiddleware((params, next) => { + return next(params); + }); + + const next = jest.fn(() => { + return createAsyncError(); + }); + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + create: { title: faker.lorem.sentence() }, + }, + }, + }); + + await expect(() => nestedMiddleware(params, next)).rejects.toThrow("oops"); + }); + + it("throws if error encountered modifying root result", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + await createAsyncError(); + return result; + }); + + const next = jest.fn((params) => params); + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + }, + }); + + await expect(() => nestedMiddleware(params, next)).rejects.toThrow("oops"); + }); + + it("throws if error encountered modifying nested result", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + if (params.model === "Post") { + await createAsyncError(); + } + return result; + }); + + const next = jest.fn((params) => params); + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + create: { title: faker.lorem.sentence() }, + }, + }, + }); + + await expect(() => nestedMiddleware(params, next)).rejects.toThrow("oops"); + }); +}); diff --git a/test/params.test.ts b/test/params.test.ts new file mode 100644 index 0000000..79cb9c1 --- /dev/null +++ b/test/params.test.ts @@ -0,0 +1,225 @@ +import faker from "faker"; + +import { createNestedMiddleware } from "../src"; +import { createParams } from "./utils/createParams"; +import { wait } from "./utils/wait"; + +describe("params", () => { + it("allows middleware to modify root params", async () => { + const nestedMiddleware = createNestedMiddleware((params, next) => { + return next({ + ...params, + args: { + ...params.args, + data: { + ...params.args.data, + name: params.args.data.name || "Default Name", + }, + }, + }); + }); + + const next = jest.fn((params: any) => params); + const params = createParams("User", "create", { + data: { email: faker.internet.email() }, + }); + await nestedMiddleware(params, next); + + expect(next).toHaveBeenCalledWith({ + ...params, + args: { + ...params.args, + data: { + ...params.args.data, + name: "Default Name", + }, + }, + }); + }); + + it("allows middleware to modify root params asynchronously", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + return next({ + ...params, + args: { + ...params.args, + data: { + ...params.args.data, + name: params.args.data.name || "Default Name", + }, + }, + }); + }); + + const next = jest.fn((params: any) => params); + const params = createParams("User", "create", { + data: { email: faker.internet.email() }, + }); + await nestedMiddleware(params, next); + + expect(next).toHaveBeenCalledWith({ + ...params, + args: { + ...params.args, + data: { + ...params.args.data, + name: "Default Name", + }, + }, + }); + }); + + it("allows middleware to modify nested params", async () => { + const nestedMiddleware = createNestedMiddleware((params, next) => { + if (params.model === "Post") { + return next({ + ...params, + args: { + ...params.args, + number: faker.datatype.number(), + }, + }); + } + return next(params); + }); + + const next = jest.fn((params: any) => params); + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + create: { title: faker.lorem.sentence() }, + }, + }, + }); + await nestedMiddleware(params, next); + + expect(next).toHaveBeenCalledWith({ + ...params, + args: { + ...params.args, + data: { + ...params.args.data, + posts: { + create: { + title: params.args.data.posts.create.title, + number: expect.any(Number), + }, + }, + }, + }, + }); + }); + + it("allows middleware to modify nested params asynchronously", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + if (params.model === "Post") { + await wait(100); + return next({ + ...params, + args: { + ...params.args, + number: faker.datatype.number(), + }, + }); + } + return next(params); + }); + + const next = jest.fn((params: any) => params); + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + create: { title: faker.lorem.sentence() }, + }, + }, + }); + await nestedMiddleware(params, next); + + expect(next).toHaveBeenCalledWith({ + ...params, + args: { + ...params.args, + data: { + ...params.args.data, + posts: { + create: { + title: params.args.data.posts.create.title, + number: expect.any(Number), + }, + }, + }, + }, + }); + }); + + it("waits for all middleware to finish before calling next", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + if (params.model === "Post") { + await wait(100); + return next({ + ...params, + args: { + ...params.args, + number: faker.datatype.number(), + }, + }); + } + + if (params.model === "Comment") { + await wait(200); + return next({ + ...params, + args: { + ...params.args, + number: faker.datatype.number(), + }, + }); + } + + return next(params); + }); + + const next = jest.fn((params: any) => params); + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + create: { + title: faker.lorem.sentence(), + comments: { + create: { + content: faker.lorem.sentence(), + authorId: faker.datatype.number(), + }, + }, + }, + }, + }, + }); + await nestedMiddleware(params, next); + + expect(next).toHaveBeenCalledWith({ + ...params, + args: { + ...params.args, + data: { + ...params.args.data, + posts: { + create: { + title: params.args.data.posts.create.title, + number: expect.any(Number), + comments: { + create: { + ...params.args.data.posts.create.comments.create, + number: expect.any(Number), + }, + }, + }, + }, + }, + }, + }); + }); +}); diff --git a/test/result.test.ts b/test/result.test.ts new file mode 100644 index 0000000..1e04046 --- /dev/null +++ b/test/result.test.ts @@ -0,0 +1,333 @@ +import faker from "faker"; + +import { createNestedMiddleware } from "../src"; +import { createParams } from "./utils/createParams"; +import { wait } from "./utils/wait"; + +function addReturnedDate(result: any) { + if (typeof result === "undefined") return; + const returned = new Date(); + + if (Array.isArray(result)) { + return result.map((item) => ({ ...item, returned })); + } + + return { ...result, returned }; +} + +describe("results", () => { + it("allows middleware to modify root result", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + return addReturnedDate(result); + }); + + const params = createParams("User", "create", { + data: { email: faker.internet.email() }, + }); + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + email: params.args.data.email, + }) + ); + const result = await nestedMiddleware(params, next); + + expect(result).toEqual({ + id: expect.any(Number), + email: params.args.data.email, + returned: expect.any(Date), + }); + }); + + it("allows middleware to modify root result asynchronously", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + await wait(100); + return addReturnedDate(result); + }); + + const params = createParams("User", "create", { + data: { email: faker.internet.email() }, + }); + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + email: params.args.data.email, + }) + ); + const result = await nestedMiddleware(params, next); + + expect(result).toEqual({ + id: expect.any(Number), + email: params.args.data.email, + returned: expect.any(Date), + }); + }); + + it("allows middleware to modify nested results", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + if (!result) return; + + if (params.model === "Post") { + return addReturnedDate(result); + } + + return result; + }); + + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + email: params.args.data.email, + posts: [ + { + id: faker.datatype.number(), + title: params.args.data.posts.create.title, + }, + ], + }) + ); + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { create: { title: faker.lorem.sentence() } }, + }, + }); + const result = await nestedMiddleware(params, next); + + expect(result).toEqual({ + id: expect.any(Number), + email: params.args.data.email, + posts: [ + { + id: expect.any(Number), + title: params.args.data.posts.create.title, + returned: expect.any(Date), + }, + ], + }); + }); + + it("allows middleware to modify nested results asynchronously", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + if (!result) return; + + if (params.model === "Post") { + await wait(100); + return addReturnedDate(result); + } + + return result; + }); + + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + email: params.args.data.email, + posts: [ + { + id: faker.datatype.number(), + title: params.args.data.posts.create.title, + }, + ], + }) + ); + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { create: { title: faker.lorem.sentence() } }, + }, + }); + const result = await nestedMiddleware(params, next); + + expect(result).toEqual({ + id: expect.any(Number), + email: params.args.data.email, + posts: [ + { + id: expect.any(Number), + title: params.args.data.posts.create.title, + returned: expect.any(Date), + }, + ], + }); + }); + + it.failing("allows middleware to modify deeply 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); + } + // modify Post result last to make sure comments are not overwritten + if (params.model === "Post") { + await wait(200); + } + + return result; + }); + + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + create: { + title: faker.lorem.sentence(), + comments: { + create: { + content: faker.lorem.sentence(), + authorId: faker.datatype.number(), + }, + }, + }, + }, + }, + }); + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + email: params.args.data.email, + posts: [ + { + 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, + }, + ], + }, + ], + }) + ); + const result = await nestedMiddleware(params, next); + + expect(result).toEqual({ + id: expect.any(Number), + email: params.args.data.email, + 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), + }, + ], + }, + ], + }); + }); + + it("waits for all middleware to finish modifying result before resolving", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + if (typeof result === "undefined") return; + + if (params.model === "Post") { + await wait(100); + } + if (params.model === "Profile") { + await wait(200); + } + return addReturnedDate(result); + }); + + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { + create: { + title: faker.lorem.sentence(), + }, + }, + profile: { + create: { + bio: faker.lorem.sentence(), + }, + }, + }, + }); + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + email: params.args.data.email, + posts: [ + { + id: faker.datatype.number(), + title: params.args.data.posts.create.title, + }, + ], + profile: { + id: faker.datatype.number(), + bio: params.args.data.profile.create.bio, + }, + }) + ); + const result = await nestedMiddleware(params, next); + + 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, + returned: expect.any(Date), + }, + ], + profile: { + id: expect.any(Number), + bio: params.args.data.profile.create.bio, + returned: expect.any(Date), + }, + }); + }); + + it("nested middleware next functions return undefined when nested model is not included", async () => { + const nestedMiddleware = createNestedMiddleware(async (params, next) => { + const result = await next(params); + if (typeof result === "undefined") return; + + if (params.model === "Post") { + const returned = new Date(); + + if (Array.isArray(result)) { + return result.map((post) => ({ ...post, returned })); + } + + return { ...result, returned }; + } + + return result; + }); + + const next = jest.fn(() => + Promise.resolve({ + id: faker.datatype.number(), + email: params.args.data.email, + }) + ); + const params = createParams("User", "create", { + data: { + email: faker.internet.email(), + posts: { create: { title: faker.lorem.sentence() } }, + }, + }); + const result = await nestedMiddleware(params, next); + + expect(result).toEqual({ + id: expect.any(Number), + email: params.args.data.email, + }); + }); +}); diff --git a/test/utils/createParams.ts b/test/utils/createParams.ts new file mode 100644 index 0000000..f8052cf --- /dev/null +++ b/test/utils/createParams.ts @@ -0,0 +1,54 @@ +import { Prisma } from "@prisma/client"; + +type DelegateByModel = Model extends "User" + ? Prisma.UserDelegate + : Model extends "Post" + ? Prisma.PostDelegate + : Model extends "Profile" + ? Prisma.ProfileDelegate + : never; + +type ArgsByAction< + Model extends Prisma.ModelName, + Action extends keyof DelegateByModel | "connectOrCreate" +> = Action extends "create" + ? Parameters["create"]>[0] + : Action extends "update" + ? Parameters["update"]>[0] + : Action extends "upsert" + ? Parameters["upsert"]>[0] + : Action extends "delete" + ? Parameters["delete"]>[0] + : Action extends "deleteMany" + ? Parameters["deleteMany"]>[0] + : Action extends "updateMany" + ? Parameters["updateMany"]>[0] + : Action extends "connectOrCreate" + ? { + where: Parameters["findUnique"]>[0]; + create: Parameters["create"]>[0]; + } + : never; + +/** + * Creates params objects with strict typing of the `args` object to ensure it + * is valid for the `model` and `action` passed. + */ +export const createParams = < + Model extends Prisma.ModelName, + Action extends keyof DelegateByModel | "connectOrCreate" = + | keyof DelegateByModel + | "connectOrCreate" +>( + model: Model, + action: Action, + args: ArgsByAction, + dataPath: string[] = [], + runInTransaction: boolean = false +): Prisma.MiddlewareParams => ({ + model, + action: action as Prisma.PrismaAction, + args, + dataPath, + runInTransaction, +}); diff --git a/test/utils/wait.ts b/test/utils/wait.ts new file mode 100644 index 0000000..11b2ea2 --- /dev/null +++ b/test/utils/wait.ts @@ -0,0 +1,3 @@ +export function wait(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +};