├── .npmrc ├── test ├── e2e │ ├── client.ts │ └── smoke.test.ts ├── unit │ ├── helpers │ │ ├── wait.ts │ │ └── createParams.ts │ ├── errors.test.ts │ └── args.test.ts └── scripts │ └── run-with-postgres.sh ├── .gitignore ├── tsconfig.build.json ├── jest.config.js ├── jest.config.e2e.js ├── jest.config.unit.js ├── src ├── index.ts └── lib │ ├── utils │ ├── cloneArgs.ts │ ├── relations.ts │ ├── operations.ts │ ├── execution.ts │ ├── targets.ts │ ├── results.ts │ ├── params.ts │ └── extractNestedOperations.ts │ ├── types.ts │ └── nestedOperations.ts ├── tsconfig.esm.json ├── docker-compose.yml ├── tsconfig.json ├── .eslintrc ├── .github └── workflows │ └── prisma-extension-nested-operations.yml ├── prisma └── schema.prisma ├── package.json ├── LICENSE └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /test/e2e/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | export default new PrismaClient(); 4 | -------------------------------------------------------------------------------- /test/unit/helpers/wait.ts: -------------------------------------------------------------------------------- 1 | export function wait(ms: number) { 2 | return new Promise(resolve => setTimeout(resolve, ms)); 3 | }; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Build output directory 5 | dist 6 | 7 | # Test coverage directory 8 | coverage 9 | 10 | # vscode 11 | .vscode 12 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "noEmit": false 6 | }, 7 | "exclude": ["test", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | testRegex: ".+\\.test\\.ts$", 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.e2e.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | testRegex: "test/e2e/.+\\.test\\.ts$", 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.unit.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | testRegex: "test/unit/.+\\.test\\.ts$", 6 | }; 7 | -------------------------------------------------------------------------------- /test/scripts/run-with-postgres.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export DATABASE_URL=postgres://postgres:123@localhost:5433/test 4 | 5 | trap "docker compose down" EXIT 6 | 7 | docker compose up -d && sleep 1 8 | npx prisma db push 9 | 10 | $@ 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { withNestedOperations } from "./lib/nestedOperations"; 2 | 3 | export { 4 | NestedReadOperation, 5 | NestedWriteOperation, 6 | NestedOperation, 7 | NestedParams, 8 | ExecuteFunction, 9 | } from "./lib/types"; 10 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "./dist/esm", 6 | "moduleResolution": "node", 7 | "declaration": true, 8 | "noEmit": false 9 | }, 10 | "exclude": ["test", "dist"] 11 | } 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | postgres: 5 | image: "postgres:latest" 6 | hostname: postgres 7 | user: postgres 8 | restart: always 9 | environment: 10 | - POSTGRES_DATABASE=test 11 | - POSTGRES_PASSWORD=123 12 | ports: 13 | - '5433:5432' 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es2019", 5 | "module": "commonjs", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "declaration": true, 9 | "outDir": "./dist", 10 | "skipLibCheck": true, 11 | "noEmit": true 12 | }, 13 | "exclude": ["test"] 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/utils/cloneArgs.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import { cloneDeep, cloneDeepWith } from "lodash"; 3 | 4 | // Prisma v4 requires that instances of Prisma.NullTypes are not cloned, 5 | // otherwise it will parse them as 'undefined' and the operation will fail. 6 | function passThroughNullTypes(value: any) { 7 | if ( 8 | value instanceof Prisma.NullTypes.DbNull || 9 | value instanceof Prisma.NullTypes.JsonNull || 10 | value instanceof Prisma.NullTypes.AnyNull 11 | ) { 12 | return value; 13 | } 14 | } 15 | 16 | export function cloneArgs(args: any) { 17 | // only handle null types if they are present, Prisma versions lower than v4 18 | // do not have them and we can clone the string values as usual 19 | if (Prisma.NullTypes) { 20 | return cloneDeepWith(args, passThroughNullTypes); 21 | } 22 | 23 | return cloneDeep(args); 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/utils/relations.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | 3 | if (!Prisma.dmmf) { 4 | throw new Error( 5 | "Prisma DMMF not found, please generate Prisma client using `npx prisma generate`" 6 | ); 7 | } 8 | 9 | export const relationsByModel: Record = {}; 10 | Prisma.dmmf.datamodel.models.forEach((model: Prisma.DMMF.Model) => { 11 | relationsByModel[model.name] = model.fields.filter( 12 | (field) => field.kind === "object" && field.relationName 13 | ); 14 | }); 15 | 16 | export function findOppositeRelation(relation: Prisma.DMMF.Field) { 17 | const parentRelations = 18 | relationsByModel[relation.type as Prisma.ModelName] || []; 19 | 20 | const oppositeRelation = parentRelations.find( 21 | (parentRelation) => 22 | parentRelation !== relation && 23 | parentRelation.relationName === relation.relationName 24 | ); 25 | 26 | if (!oppositeRelation) { 27 | throw new Error(`Unable to find opposite relation to ${relation.name}`); 28 | } 29 | 30 | return oppositeRelation; 31 | } 32 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "./node_modules/kcd-scripts/eslint.js", 5 | "plugin:import/typescript" 6 | ], 7 | "plugins": ["@typescript-eslint"], 8 | "rules": { 9 | "babel/new-cap": "off", 10 | "func-names": "off", 11 | "babel/no-unused-expressions": "off", 12 | "prefer-arrow-callback": "off", 13 | "testing-library/no-await-sync-query": "off", 14 | "testing-library/no-dom-import": "off", 15 | "testing-library/prefer-screen-queries": "off", 16 | "no-undef": "off", 17 | "no-use-before-define": "off", 18 | "no-unused-vars": "off", 19 | "@typescript-eslint/no-unused-vars": [ 20 | "error", 21 | { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" } 22 | ], 23 | "max-lines-per-function": "off", 24 | "consistent-return": "off", 25 | "jest/no-if": "off", 26 | "one-var": "off" 27 | }, 28 | "overrides": [ 29 | { 30 | "files": ["*.test.ts"], 31 | "rules": { 32 | "max-lines": "off" 33 | } 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/prisma-extension-nested-operations.yml: -------------------------------------------------------------------------------- 1 | name: prisma-extension-nested-operations 2 | on: 3 | push: 4 | branches: 5 | - 'main' 6 | - 'next' 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: 'node ${{ matrix.node }} chrome ${{ matrix.os }} ' 12 | runs-on: '${{ matrix.os }}' 13 | strategy: 14 | matrix: 15 | os: [ubuntu-latest] 16 | node: [16] 17 | steps: 18 | - uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node }} 21 | - uses: actions/checkout@v2 22 | - run: npm install 23 | - run: npm run validate 24 | env: 25 | CI: true 26 | 27 | release: 28 | runs-on: ubuntu-latest 29 | needs: test 30 | steps: 31 | - uses: actions/setup-node@v2 32 | with: 33 | node-version: 16 34 | - uses: actions/checkout@v2 35 | - run: npm install 36 | - run: npm run build 37 | - run: ls -asl dist 38 | - run: npx semantic-release 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | -------------------------------------------------------------------------------- /src/lib/utils/operations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LogicalOperator, 3 | Modifier, 4 | NestedQueryOperation, 5 | NestedReadOperation, 6 | NestedWriteOperation, 7 | } from "../types"; 8 | 9 | export const queryOperations: NestedQueryOperation[] = ["where"]; 10 | export const readOperations: NestedReadOperation[] = ["include", "select"]; 11 | export const writeOperations: NestedWriteOperation[] = [ 12 | "create", 13 | "update", 14 | "upsert", 15 | "createMany", 16 | "updateMany", 17 | "delete", 18 | "deleteMany", 19 | "disconnect", 20 | "connect", 21 | "connectOrCreate", 22 | ]; 23 | export const toOneRelationNonListOperations: NestedWriteOperation[] = [ 24 | "create", 25 | "update", 26 | "delete", 27 | "upsert", 28 | "connect", 29 | "connectOrCreate", 30 | "disconnect", 31 | ]; 32 | 33 | export function isQueryOperation(action: any): action is NestedQueryOperation { 34 | return queryOperations.includes(action); 35 | } 36 | 37 | export function isReadOperation(action: any): action is NestedReadOperation { 38 | return readOperations.includes(action); 39 | } 40 | 41 | export function isWriteOperation(action: any): action is NestedWriteOperation { 42 | return writeOperations.includes(action); 43 | } 44 | 45 | export const modifiers: Modifier[] = ["is", "isNot", "some", "none", "every"]; 46 | export const logicalOperators: LogicalOperator[] = ["AND", "OR", "NOT"]; 47 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id Int @id @default(autoincrement()) 12 | email String @unique 13 | name String? 14 | posts Post[] 15 | profile Profile? 16 | comments Comment[] 17 | } 18 | 19 | model Post { 20 | id Int @id @default(autoincrement()) 21 | createdAt DateTime @default(now()) 22 | updatedAt DateTime @updatedAt 23 | published Boolean @default(false) 24 | title String 25 | content String? 26 | deleted Boolean @default(false) 27 | author User @relation(fields: [authorId], references: [id], onDelete: Cascade, onUpdate: Cascade) 28 | authorId Int 29 | comments Comment[] 30 | } 31 | 32 | model Comment { 33 | id Int @id @default(autoincrement()) 34 | createdAt DateTime @default(now()) 35 | updatedAt DateTime @updatedAt 36 | content String 37 | author User @relation(fields: [authorId], references: [id], onDelete: Cascade, onUpdate: Cascade) 38 | authorId Int 39 | post Post? @relation(fields: [postId], references: [id], onDelete: Cascade, onUpdate: Cascade) 40 | postId Int? 41 | repliedTo Comment? @relation("replies", fields: [repliedToId], references: [id], onDelete: Cascade, onUpdate: Cascade) 42 | repliedToId Int? 43 | replies Comment[] @relation("replies") 44 | } 45 | 46 | model Profile { 47 | id Int @id @default(autoincrement()) 48 | bio String? 49 | age Int? 50 | user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) 51 | userId Int @unique 52 | meta Json? 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/utils/execution.ts: -------------------------------------------------------------------------------- 1 | import { Types } from "@prisma/client/runtime/library"; 2 | import { DeferredPromise } from "@open-draft/deferred-promise"; 3 | import { omit } from "lodash"; 4 | 5 | import { ExecuteFunction, NestedParams, OperationCall, Target } from "../types"; 6 | import { cloneArgs } from "./cloneArgs"; 7 | 8 | export async function executeOperation( 9 | execute: ExecuteFunction, 10 | params: NestedParams, 11 | target: Target 12 | ): Promise> { 13 | const queryCalledPromise = new DeferredPromise(); 14 | const queryPromise = new DeferredPromise(); 15 | 16 | const result = execute({ 17 | ...cloneArgs(params), 18 | query: (updatedArgs, updatedOperation = params.operation) => { 19 | queryCalledPromise.resolve({ 20 | updatedArgs, 21 | updatedOperation 22 | }); 23 | return queryPromise; 24 | }, 25 | }).catch((e) => { 26 | // reject params updated callback so it throws when awaited 27 | queryCalledPromise.reject(e); 28 | 29 | // if next has already been resolved we must throw 30 | if (queryPromise.state === "fulfilled") { 31 | throw e; 32 | } 33 | }); 34 | 35 | const { updatedArgs, updatedOperation } = await queryCalledPromise; 36 | 37 | // execute middleware with updated params if action has changed 38 | if (updatedOperation !== params.operation) { 39 | return executeOperation( 40 | execute, 41 | { 42 | ...params, 43 | operation: updatedOperation, 44 | args: updatedArgs, 45 | }, 46 | omit(target, "index") as Target 47 | ); 48 | } 49 | 50 | // execute middleware with updated params if action has changed 51 | return { 52 | queryPromise, 53 | result, 54 | updatedArgs, 55 | origin: target, 56 | target: { ...target, operation: params.operation as any }, 57 | scope: params.scope, 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prisma-extension-nested-operations", 3 | "version": "1.0.0-semantically-released", 4 | "description": "Utils for creating Prisma client extensions with nested operations", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "module": "dist/esm/index.js", 8 | "scripts": { 9 | "build": "npm-run-all build:cjs build:esm", 10 | "build:cjs": "tsc -p tsconfig.build.json", 11 | "build:esm": "tsc -p tsconfig.esm.json", 12 | "test:unit": "prisma generate && jest --config jest.config.unit.js", 13 | "test:e2e": "./test/scripts/run-with-postgres.sh jest --config jest.config.e2e.js --runInBand", 14 | "test": "./test/scripts/run-with-postgres.sh jest --runInBand", 15 | "lint": "eslint ./src --fix --ext .ts", 16 | "typecheck": "npm run build:cjs -- --noEmit && npm run build:esm -- --noEmit", 17 | "validate": "prisma generate && kcd-scripts validate lint,typecheck,test", 18 | "semantic-release": "semantic-release", 19 | "doctoc": "doctoc ." 20 | }, 21 | "files": [ 22 | "dist" 23 | ], 24 | "keywords": [ 25 | "prisma", 26 | "client", 27 | "extensions", 28 | "extension" 29 | ], 30 | "author": "Olivier Wilkinson", 31 | "license": "Apache-2.0", 32 | "dependencies": { 33 | "@open-draft/deferred-promise": "^2.1.0", 34 | "lodash": "^4.17.21" 35 | }, 36 | "peerDependencies": { 37 | "@prisma/client": "*" 38 | }, 39 | "devDependencies": { 40 | "@prisma/client": "^5.0.0", 41 | "@types/faker": "^5.5.9", 42 | "@types/jest": "^29.2.5", 43 | "@types/lodash": "^4.14.185", 44 | "@typescript-eslint/eslint-plugin": "^4.14.0", 45 | "@typescript-eslint/parser": "^4.14.0", 46 | "doctoc": "^2.2.0", 47 | "dotenv": "^16.0.3", 48 | "eslint": "^7.6.0", 49 | "faker": "^5.0.0", 50 | "jest": "^29.3.1", 51 | "kcd-scripts": "^5.0.0", 52 | "npm-run-all": "^4.1.5", 53 | "prisma": "^5.0.0", 54 | "semantic-release": "^17.0.2", 55 | "ts-jest": "^29.0.3", 56 | "ts-node": "^9.1.1", 57 | "typescript": "^4.1.3" 58 | }, 59 | "repository": { 60 | "type": "git", 61 | "url": "https://github.com/olivierwilkinson/prisma-extension-nested-operations.git" 62 | }, 63 | "release": { 64 | "branches": [ 65 | "main", 66 | "next" 67 | ] 68 | }, 69 | "publishConfig": { 70 | "access": "public" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import { Types } from "@prisma/client/runtime/library"; 3 | import { DeferredPromise } from "@open-draft/deferred-promise"; 4 | 5 | export type Modifier = "is" | "isNot" | "some" | "none" | "every"; 6 | export type LogicalOperator = "AND" | "OR" | "NOT"; 7 | 8 | export type NestedQueryOperation = "where"; 9 | export type NestedReadOperation = "include" | "select"; 10 | export type NestedWriteOperation = 11 | | "create" 12 | | "update" 13 | | "upsert" 14 | | "connectOrCreate" 15 | | "connect" 16 | | "disconnect" 17 | | "createMany" 18 | | "updateMany" 19 | | "delete" 20 | | "deleteMany"; 21 | 22 | export type NestedOperation = 23 | | NestedWriteOperation 24 | | NestedReadOperation 25 | | NestedQueryOperation; 26 | 27 | export type QueryTarget = { 28 | operation: NestedQueryOperation; 29 | relationName?: string; 30 | modifier?: Modifier; 31 | logicalOperations?: { logicalOperator: LogicalOperator; index?: number }[]; 32 | readOperation?: NestedReadOperation; 33 | parentTarget?: Target; 34 | }; 35 | 36 | export type ReadTarget = { 37 | operation: NestedReadOperation; 38 | relationName?: string; 39 | field?: string; 40 | parentTarget?: Target; 41 | }; 42 | 43 | export type WriteTarget = { 44 | operation: NestedWriteOperation; 45 | relationName: string; 46 | field?: string; 47 | index?: number; 48 | parentTarget?: Target; 49 | }; 50 | 51 | export type Target = ReadTarget | WriteTarget | QueryTarget; 52 | 53 | export type OperationCall = { 54 | queryPromise: DeferredPromise; 55 | result: Promise; 56 | updatedArgs: any; 57 | origin: Target; 58 | target: Target; 59 | scope?: Scope; 60 | }; 61 | 62 | export type Scope = { 63 | parentParams: Omit, "query">; 64 | relations: { to: Prisma.DMMF.Field; from: Prisma.DMMF.Field }; 65 | modifier?: Modifier; 66 | logicalOperators?: LogicalOperator[]; 67 | }; 68 | 69 | export type NestedParams = { 70 | query: (args: any, operation?: NestedOperation) => Prisma.PrismaPromise; 71 | model: keyof Prisma.TypeMap['model']; 72 | args: any; 73 | operation: NestedOperation; 74 | scope?: Scope; 75 | }; 76 | 77 | export type ExecuteFunction< 78 | ExtArgs extends Types.Extensions.InternalArgs = Types.Extensions.DefaultArgs, 79 | T = any 80 | > = (params: NestedParams) => Promise; 81 | -------------------------------------------------------------------------------- /test/unit/helpers/createParams.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | 3 | type AnyExtension = { client: any, model: any, query: any; result: any } 4 | 5 | type DelegateByModel = Model extends "User" 6 | ? Prisma.UserDelegate 7 | : Model extends "Post" 8 | ? Prisma.PostDelegate 9 | : Model extends "Profile" 10 | ? Prisma.ProfileDelegate 11 | : Model extends "Comment" 12 | ? Prisma.CommentDelegate 13 | : never; 14 | 15 | type SelectByModel = Model extends "User" 16 | ? Prisma.UserSelect 17 | : Model extends "Post" 18 | ? Prisma.PostSelect 19 | : Model extends "Profile" 20 | ? Prisma.ProfileSelect 21 | : Model extends "Comment" 22 | ? Prisma.CommentSelect 23 | : never; 24 | 25 | type IncludeByModel = Model extends "User" 26 | ? Prisma.UserInclude 27 | : Model extends "Post" 28 | ? Prisma.PostInclude 29 | : Model extends "Profile" 30 | ? Prisma.ProfileInclude 31 | : Model extends "Comment" 32 | ? Prisma.CommentInclude 33 | : never; 34 | 35 | type ActionByModel = 36 | | keyof DelegateByModel 37 | | "connectOrCreate" 38 | | "select" 39 | | "include" 40 | | "where"; 41 | 42 | type ArgsByAction< 43 | Model extends Prisma.ModelName, 44 | Action extends ActionByModel 45 | > = Action extends "create" 46 | ? Parameters["create"]>[0] 47 | : Action extends "update" 48 | ? Parameters["update"]>[0] 49 | : Action extends "upsert" 50 | ? Parameters["upsert"]>[0] 51 | : Action extends "delete" 52 | ? Parameters["delete"]>[0] 53 | : Action extends "createMany" 54 | ? Parameters["createMany"]>[0] 55 | : Action extends "updateMany" 56 | ? Parameters["updateMany"]>[0] 57 | : Action extends "deleteMany" 58 | ? Parameters["deleteMany"]>[0] 59 | : Action extends "findUnique" 60 | ? Parameters["findUnique"]>[0] 61 | : Action extends "findFirst" 62 | ? Parameters["findFirst"]>[0] 63 | : Action extends "findMany" 64 | ? Parameters["findMany"]>[0] 65 | : Action extends "count" 66 | ? Parameters["count"]>[0] 67 | : Action extends "aggregate" 68 | ? Parameters["aggregate"]>[0] 69 | : Action extends "groupBy" 70 | ? Parameters["groupBy"]>[0] 71 | : Action extends "connectOrCreate" 72 | ? { 73 | where: Parameters["findUnique"]>[0]; 74 | create: Parameters["create"]>[0]; 75 | } 76 | : Action extends "select" 77 | ? SelectByModel 78 | : Action extends "include" 79 | ? IncludeByModel 80 | : never; 81 | 82 | /** 83 | * Creates params objects with strict typing of the `args` object to ensure it 84 | * is valid for the `model` and `action` passed. 85 | */ 86 | export const createParams = < 87 | Model extends Prisma.ModelName, 88 | Action extends ActionByModel = ActionByModel, 89 | >( 90 | query: (args: any) => Promise, 91 | model: Model, 92 | operation: Action, 93 | args: ArgsByAction, 94 | ) => ({ 95 | query: query as any, 96 | model, 97 | operation: operation as any, 98 | args: args as any, 99 | }); 100 | -------------------------------------------------------------------------------- /src/lib/utils/targets.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LogicalOperator, 3 | QueryTarget, 4 | ReadTarget, 5 | Target, 6 | WriteTarget, 7 | } from "../types"; 8 | 9 | import { isQueryOperation, isReadOperation, isWriteOperation } from "./operations"; 10 | 11 | export function isQueryTarget(target: any): target is QueryTarget { 12 | return isQueryOperation(target.operation); 13 | } 14 | 15 | export function isReadTarget(target: any): target is ReadTarget { 16 | return isReadOperation(target.operation); 17 | } 18 | 19 | export function isWriteTarget(target: any): target is WriteTarget { 20 | return isWriteOperation(target.operation); 21 | } 22 | 23 | export function buildOperationsPath( 24 | operations?: { logicalOperator: LogicalOperator; index?: number }[] 25 | ) { 26 | if (!operations) return []; 27 | 28 | return operations.flatMap((op) => { 29 | if (typeof op.index === "number") 30 | return [op.logicalOperator, op.index.toString()]; 31 | 32 | return [op.logicalOperator]; 33 | }); 34 | } 35 | 36 | export function buildQueryTargetPath(target: QueryTarget): string[] { 37 | const path = target.parentTarget 38 | ? buildTargetPath(target.parentTarget) 39 | : []; 40 | 41 | if (!target.relationName) { 42 | return [...path, target.operation]; 43 | } 44 | 45 | if (target.logicalOperations) { 46 | path.push(...buildOperationsPath(target.logicalOperations)); 47 | } 48 | 49 | if (target.readOperation) { 50 | path.push(target.readOperation); 51 | } 52 | 53 | path.push(target.relationName); 54 | 55 | if (target.readOperation) { 56 | path.push("where"); 57 | } 58 | 59 | if (target.modifier) { 60 | path.push(target.modifier); 61 | } 62 | 63 | return path; 64 | } 65 | 66 | export function buildWriteTargetPath(target: WriteTarget): string[] { 67 | const path = target.parentTarget ? buildTargetPath(target.parentTarget) : []; 68 | 69 | if (target.field) { 70 | path.push(target.field); 71 | } 72 | 73 | path.push(target.relationName, target.operation); 74 | 75 | if (typeof target.index === "number") { 76 | path.push(target.index.toString()); 77 | } 78 | 79 | return path; 80 | } 81 | 82 | export function buildReadTargetPath(target: ReadTarget): string[] { 83 | const path = target.parentTarget ? buildTargetPath(target.parentTarget) : []; 84 | 85 | if (!target.relationName) { 86 | return [...path, target.operation]; 87 | } 88 | 89 | if (!target.field) { 90 | return [...path, target.operation, target.relationName]; 91 | } 92 | 93 | return [...path, target.field, target.relationName, target.operation]; 94 | } 95 | 96 | export function buildTargetPath(target: Target) { 97 | if (isQueryTarget(target)) return buildQueryTargetPath(target); 98 | if (isReadTarget(target)) return buildReadTargetPath(target); 99 | return buildWriteTargetPath(target); 100 | } 101 | 102 | export const buildTargetRelationPath = (target: Target): string[] | null => { 103 | if (!isReadTarget(target)) return null; 104 | 105 | if (target.parentTarget) { 106 | const basePath = buildTargetRelationPath(target.parentTarget); 107 | if (!basePath) return null; 108 | 109 | return target.relationName ? [...basePath, target.relationName] : basePath; 110 | } 111 | 112 | return target.relationName ? [target.relationName] : []; 113 | }; 114 | 115 | export function targetChainLength(target: Target, count = 0): number { 116 | if (!target.parentTarget) { 117 | return count + 1; 118 | } 119 | return targetChainLength(target.parentTarget, count + 1); 120 | } 121 | -------------------------------------------------------------------------------- /test/unit/errors.test.ts: -------------------------------------------------------------------------------- 1 | import faker from "faker"; 2 | 3 | import { withNestedOperations } from "../../src"; 4 | import { createParams } from "./helpers/createParams"; 5 | import { wait } from "./helpers/wait"; 6 | 7 | async function createAsyncError() { 8 | await wait(100); 9 | throw new Error("oops"); 10 | } 11 | 12 | describe("errors", () => { 13 | it("throws when error encountered while modifying root params", async () => { 14 | const allOperations = withNestedOperations({ 15 | $rootOperation: async (params) => { 16 | await createAsyncError(); 17 | return params.query(params.args); 18 | }, 19 | $allNestedOperations: (params) => { 20 | return params.query(params.args); 21 | }, 22 | }); 23 | 24 | const query = (_: any) => Promise.resolve({}); 25 | const params = createParams(query, "User", "create", { 26 | data: { email: faker.internet.email() }, 27 | }); 28 | await expect(() => allOperations(params)).rejects.toThrow("oops"); 29 | }); 30 | 31 | it("throws when error encountered while modifying nested params", async () => { 32 | const allOperations = withNestedOperations({ 33 | $rootOperation: (params) => { 34 | return params.query(params.args); 35 | }, 36 | $allNestedOperations: async (params) => { 37 | if (params.model === "Post") { 38 | await createAsyncError(); 39 | } 40 | return params.query(params.args); 41 | }, 42 | }); 43 | 44 | const query = (_: any) => Promise.resolve({}); 45 | const params = createParams(query, "User", "create", { 46 | data: { 47 | email: faker.internet.email(), 48 | posts: { 49 | create: { title: faker.lorem.sentence() }, 50 | }, 51 | }, 52 | }); 53 | 54 | await expect(() => allOperations(params)).rejects.toThrow("oops"); 55 | }); 56 | 57 | it("throws if next encounters an error", async () => { 58 | const allOperations = withNestedOperations({ 59 | $rootOperation: (params) => { 60 | return params.query(params.args); 61 | }, 62 | $allNestedOperations: (params) => { 63 | return params.query(params.args); 64 | }, 65 | }); 66 | 67 | const query = jest.fn(() => { 68 | return createAsyncError(); 69 | }); 70 | const params = createParams(query, "User", "create", { 71 | data: { 72 | email: faker.internet.email(), 73 | posts: { 74 | create: { title: faker.lorem.sentence() }, 75 | }, 76 | }, 77 | }); 78 | 79 | await expect(() => allOperations(params)).rejects.toThrow("oops"); 80 | }); 81 | 82 | it("throws if error encountered modifying root result", async () => { 83 | const allOperations = withNestedOperations({ 84 | $allNestedOperations: (params) => { 85 | return params.query(params.args); 86 | }, 87 | $rootOperation: async (params) => { 88 | const result = await params.query(params.args); 89 | await createAsyncError(); 90 | return result; 91 | }, 92 | }); 93 | 94 | const query = (_: any) => Promise.resolve({}); 95 | const params = createParams(query, "User", "create", { 96 | data: { 97 | email: faker.internet.email(), 98 | }, 99 | }); 100 | 101 | await expect(() => allOperations(params)).rejects.toThrow("oops"); 102 | }); 103 | 104 | it("throws if error encountered modifying nested result", async () => { 105 | const allOperations = withNestedOperations({ 106 | $rootOperation: (params) => { 107 | return params.query(params.args); 108 | }, 109 | $allNestedOperations: async (params) => { 110 | const result = await params.query(params.args); 111 | if (params.model === "Post") { 112 | await createAsyncError(); 113 | } 114 | return result; 115 | }, 116 | }); 117 | 118 | const query = (_: any) => Promise.resolve({}); 119 | const params = createParams(query, "User", "create", { 120 | data: { 121 | email: faker.internet.email(), 122 | posts: { 123 | create: { title: faker.lorem.sentence() }, 124 | }, 125 | }, 126 | }); 127 | 128 | await expect(() => allOperations(params)).rejects.toThrow("oops"); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /src/lib/utils/results.ts: -------------------------------------------------------------------------------- 1 | const idSymbol = Symbol("id"); 2 | const parentIdSymbol = Symbol("parentId"); 3 | 4 | function addIdSymbolsToObject( 5 | obj: Record, 6 | id: number, 7 | parentId?: number 8 | ) { 9 | obj[idSymbol] = id; 10 | if (parentId) { 11 | obj[parentIdSymbol] = parentId; 12 | } 13 | } 14 | 15 | function stripIdSymbolsFromObject(obj: Record) { 16 | if (obj[idSymbol]) { 17 | delete obj[idSymbol]; 18 | } 19 | if (obj[parentIdSymbol]) { 20 | delete obj[parentIdSymbol]; 21 | } 22 | } 23 | 24 | export function addIdSymbolsToResult( 25 | result: any, 26 | parentId?: number, 27 | startId = 1 28 | ): number { 29 | let id = startId; 30 | 31 | if (Array.isArray(result)) { 32 | result.forEach((item) => { 33 | if (typeof item === "object" && item !== null) { 34 | addIdSymbolsToObject(item, id, parentId); 35 | id += 1; 36 | 37 | Object.getOwnPropertyNames(item).forEach((key) => { 38 | if (typeof item[key] === "object" && item[key] !== null) { 39 | id = addIdSymbolsToResult(item[key], item[idSymbol], id); 40 | } 41 | }); 42 | } 43 | }); 44 | 45 | return id; 46 | } 47 | 48 | if (typeof result === "object" && result !== null) { 49 | addIdSymbolsToObject(result, id, parentId); 50 | id += 1; 51 | 52 | Object.getOwnPropertyNames(result).forEach((key) => { 53 | if (typeof result[key] === "object" && result[key] !== null) { 54 | id = addIdSymbolsToResult(result[key], result[idSymbol], id); 55 | } 56 | }); 57 | } 58 | 59 | return id; 60 | } 61 | 62 | export function stripIdSymbolsFromResult(result: any) { 63 | if (Array.isArray(result)) { 64 | result.forEach((item) => { 65 | if (typeof item === "object" && item !== null) { 66 | stripIdSymbolsFromObject(item); 67 | 68 | Object.getOwnPropertyNames(item).forEach((key) => { 69 | if (typeof item[key] === "object" && item[key] !== null) { 70 | stripIdSymbolsFromResult(item[key]); 71 | } 72 | }); 73 | } 74 | }); 75 | return; 76 | } 77 | 78 | if (typeof result === "object" && result !== null) { 79 | stripIdSymbolsFromObject(result); 80 | 81 | Object.getOwnPropertyNames(result).forEach((key) => { 82 | if (typeof result[key] === "object" && result[key] !== null) { 83 | stripIdSymbolsFromResult(result[key]); 84 | } 85 | }); 86 | } 87 | } 88 | 89 | export function getRelationResult(result: any, relations: string[]): any { 90 | let relationResult = result; 91 | 92 | for (const relation of relations) { 93 | if (!relationResult) return; 94 | 95 | if (Array.isArray(relationResult)) { 96 | relationResult = relationResult 97 | .flatMap((item) => item[relation]) 98 | .filter(Boolean); 99 | } else { 100 | relationResult = relationResult[relation]; 101 | } 102 | } 103 | 104 | return relationResult; 105 | } 106 | 107 | function injectRelationResult( 108 | result: any, 109 | relation: string, 110 | relationResult: any 111 | ) { 112 | if (Array.isArray(relationResult) && Array.isArray(result[relation])) { 113 | result[relation] = relationResult.filter( 114 | (item) => item[parentIdSymbol] === result[idSymbol] 115 | ); 116 | return; 117 | } 118 | 119 | if (Array.isArray(relationResult) && !Array.isArray(result[relation])) { 120 | result[relation] = 121 | relationResult.find( 122 | (item) => item[parentIdSymbol] === result[idSymbol] 123 | ) || null; 124 | return; 125 | } 126 | 127 | if (Array.isArray(result[relation])) { 128 | throw new Error("Cannot inject a single result into an array result"); 129 | } 130 | 131 | result[relation] = relationResult; 132 | } 133 | 134 | export function updateResultRelation( 135 | result: any, 136 | relation: string, 137 | relationResult: any 138 | ) { 139 | if (Array.isArray(result)) { 140 | result.forEach((item) => { 141 | if (typeof item === "object" && item !== null && item[relation]) { 142 | injectRelationResult(item, relation, relationResult); 143 | } 144 | }); 145 | 146 | return result; 147 | } 148 | 149 | if (typeof result === "object" && result !== null && result[relation]) { 150 | injectRelationResult(result, relation, relationResult); 151 | } 152 | 153 | return result; 154 | } 155 | -------------------------------------------------------------------------------- /src/lib/nestedOperations.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import { Types } from "@prisma/client/runtime/library"; 3 | 4 | import { OperationCall, NestedParams } from "./types"; 5 | import { extractNestedOperations } from "./utils/extractNestedOperations"; 6 | import { executeOperation } from "./utils/execution"; 7 | import { buildArgsFromCalls } from "./utils/params"; 8 | import { buildTargetRelationPath } from "./utils/targets"; 9 | import { 10 | addIdSymbolsToResult, 11 | getRelationResult, 12 | stripIdSymbolsFromResult, 13 | updateResultRelation, 14 | } from "./utils/results"; 15 | 16 | type NonNullable = Exclude; 17 | 18 | function isFulfilled( 19 | result: PromiseSettledResult 20 | ): result is PromiseFulfilledResult { 21 | return result.status === "fulfilled"; 22 | } 23 | 24 | function isRejected( 25 | result: PromiseSettledResult 26 | ): result is PromiseRejectedResult { 27 | return result.status === "rejected"; 28 | } 29 | 30 | export function withNestedOperations< 31 | ExtArgs extends Types.Extensions.InternalArgs = Types.Extensions.DefaultArgs 32 | >({ 33 | $rootOperation, 34 | $allNestedOperations, 35 | }: { 36 | $rootOperation: NonNullable< 37 | Types.Extensions.DynamicQueryExtensionArgs< 38 | { $allModels: { $allOperations: any } }, 39 | Prisma.TypeMap 40 | >["$allModels"]["$allOperations"] 41 | >; 42 | $allNestedOperations: (params: NestedParams) => Promise; 43 | }): typeof $rootOperation { 44 | return async (rootParams) => { 45 | let calls: OperationCall[] = []; 46 | 47 | try { 48 | const executionResults = await Promise.allSettled( 49 | extractNestedOperations( 50 | rootParams as NestedParams 51 | ).map((nestedOperation) => 52 | executeOperation( 53 | $allNestedOperations, 54 | nestedOperation.params, 55 | nestedOperation.target 56 | ) 57 | ) 58 | ); 59 | 60 | // populate middlewareCalls with successful calls first so we can resolve 61 | // next promises if we find a rejection 62 | calls = executionResults.filter(isFulfilled).map(({ value }) => value); 63 | 64 | // consider any rejected execution as a failure of all nested middleware 65 | const failedExecution = executionResults.find(isRejected); 66 | if (failedExecution) throw failedExecution.reason; 67 | 68 | // build updated params from middleware calls 69 | const updatedArgs = buildArgsFromCalls( 70 | calls, 71 | rootParams as NestedParams 72 | ); 73 | 74 | const result = await $rootOperation({ 75 | ...rootParams, 76 | args: updatedArgs, 77 | }); 78 | 79 | // bail out if result is null 80 | if (result === null) { 81 | calls.forEach((call) => call.queryPromise.resolve(undefined)); 82 | await Promise.all(calls.map((call) => call.result)); 83 | return null; 84 | } 85 | 86 | // add id symbols to result so we can use them to update result relations 87 | // with the results from nested middleware 88 | addIdSymbolsToResult(result); 89 | 90 | const nestedNextResults = await Promise.all( 91 | calls.map(async (call) => { 92 | const relationsPath = buildTargetRelationPath(call.target); 93 | 94 | if (result === null || !relationsPath) { 95 | call.queryPromise.resolve(undefined); 96 | await call.result; 97 | return null; 98 | } 99 | 100 | const relationResults = getRelationResult(result, relationsPath); 101 | call.queryPromise.resolve(relationResults); 102 | const updatedResult = await call.result; 103 | 104 | if (typeof relationResults === "undefined") { 105 | return null; 106 | } 107 | 108 | return { 109 | relationsPath, 110 | updatedResult, 111 | }; 112 | }) 113 | ); 114 | 115 | // keep only the relevant result updates from nested next results 116 | const resultUpdates = nestedNextResults.filter( 117 | (update): update is { relationsPath: string[]; updatedResult: any } => 118 | !!update 119 | ); 120 | 121 | resultUpdates 122 | .sort((a, b) => b.relationsPath.length - a.relationsPath.length) 123 | .forEach(({ relationsPath, updatedResult }, i) => { 124 | const remainingUpdates = resultUpdates.slice(i); 125 | const nextUpdatePath = relationsPath.slice(0, -1).join("."); 126 | 127 | const nextUpdate = remainingUpdates.find( 128 | (update) => update?.relationsPath.join(".") === nextUpdatePath 129 | ); 130 | 131 | if (nextUpdate) { 132 | updateResultRelation( 133 | nextUpdate.updatedResult, 134 | relationsPath[relationsPath.length - 1], 135 | updatedResult 136 | ); 137 | return; 138 | } 139 | 140 | updateResultRelation( 141 | result, 142 | relationsPath[relationsPath.length - 1], 143 | updatedResult 144 | ); 145 | }); 146 | 147 | stripIdSymbolsFromResult(result); 148 | 149 | return result; 150 | } catch (e) { 151 | // if an error occurs reject the nested next functions promises to stop 152 | // them being pending forever 153 | calls.forEach((call) => call.queryPromise.reject(e)); 154 | 155 | // wait for all nested middleware to settle before rethrowing 156 | await Promise.all(calls.map((call) => call.result.catch(() => {}))); 157 | 158 | // bubble error up to parent middleware 159 | throw e; 160 | } 161 | }; 162 | } 163 | -------------------------------------------------------------------------------- /test/e2e/smoke.test.ts: -------------------------------------------------------------------------------- 1 | import { Post, Prisma, User } from "@prisma/client"; 2 | import faker from "faker"; 3 | import { set } from "lodash"; 4 | 5 | import { withNestedOperations } from "../../src"; 6 | import client from "./client"; 7 | 8 | describe("smoke", () => { 9 | let testClient: any; 10 | let firstUser: User; 11 | let secondUser: User; 12 | let post: Post; 13 | 14 | beforeEach(async () => { 15 | firstUser = await client.user.create({ 16 | data: { 17 | email: faker.internet.email(), 18 | name: "Jack", 19 | }, 20 | }); 21 | secondUser = await client.user.create({ 22 | data: { 23 | email: faker.internet.email(), 24 | name: "John", 25 | }, 26 | }); 27 | await client.profile.create({ 28 | data: { 29 | bio: faker.lorem.sentence(), 30 | user: { 31 | connect: { 32 | id: firstUser.id, 33 | }, 34 | }, 35 | }, 36 | }); 37 | post = await client.post.create({ 38 | data: { 39 | title: faker.lorem.sentence(), 40 | authorId: firstUser.id, 41 | }, 42 | }); 43 | await client.comment.create({ 44 | data: { 45 | content: faker.lorem.sentence(), 46 | author: { 47 | connect: { 48 | id: firstUser.id, 49 | }, 50 | }, 51 | post: { 52 | connect: { 53 | id: post.id, 54 | }, 55 | }, 56 | }, 57 | }); 58 | }); 59 | afterEach(async () => { 60 | await client.comment.deleteMany({ where: {} }); 61 | await client.post.deleteMany({ where: {} }); 62 | await client.profile.deleteMany({ where: {} }); 63 | await client.user.deleteMany({ where: {} }); 64 | }); 65 | afterAll(async () => { 66 | await testClient.$disconnect(); 67 | }); 68 | 69 | describe("vanilla", () => { 70 | beforeAll(() => { 71 | testClient = client.$extends({ 72 | query: { 73 | $allModels: { 74 | $allOperations: withNestedOperations({ 75 | $rootOperation: ({ args, query }) => { 76 | return query(args); 77 | }, 78 | $allNestedOperations: ({ args, query }) => { 79 | return query(args); 80 | }, 81 | }), 82 | }, 83 | }, 84 | }); 85 | }); 86 | 87 | it("does not break client when middleware does nothing", async () => { 88 | const user = await testClient.user.findFirst({ 89 | where: { id: firstUser.id }, 90 | }); 91 | expect(user).not.toBeNull(); 92 | 93 | const users = await testClient.user.findMany({ 94 | where: { id: { in: [firstUser.id, secondUser.id] } }, 95 | }); 96 | expect(users).toHaveLength(2); 97 | 98 | const dbProfile = await testClient.profile.findFirst({ 99 | where: { user: { id: firstUser.id } }, 100 | }); 101 | expect(dbProfile).not.toBeNull(); 102 | 103 | const dbComment = await testClient.comment.findFirst({ 104 | where: { post: { author: { id: firstUser.id } } }, 105 | }); 106 | expect(dbComment).not.toBeNull(); 107 | 108 | const dbComments = await testClient.comment.findMany({ 109 | where: { post: { author: { id: firstUser.id } } }, 110 | include: { 111 | post: { 112 | include: { 113 | comments: { 114 | select: { 115 | id: true, 116 | }, 117 | }, 118 | }, 119 | }, 120 | }, 121 | }); 122 | expect(dbComments).toHaveLength(1); 123 | expect(dbComments[0].post).not.toBeNull(); 124 | expect(dbComments[0].post!.comments).toHaveLength(1); 125 | 126 | const createdComment = await testClient.comment.create({ 127 | data: { 128 | content: faker.lorem.sentence(), 129 | authorId: firstUser.id, 130 | }, 131 | }); 132 | expect(createdComment).not.toBeNull(); 133 | 134 | const updatedComment = await testClient.comment.update({ 135 | where: { id: createdComment.id }, 136 | data: { 137 | content: faker.lorem.sentence(), 138 | }, 139 | }); 140 | expect(updatedComment).not.toBeNull(); 141 | 142 | // supports Json null values 143 | await testClient.profile.create({ 144 | data: { 145 | userId: secondUser.id, 146 | meta: Prisma.DbNull, 147 | }, 148 | }); 149 | 150 | const profile = await testClient.profile.findFirst({ 151 | where: { 152 | OR: [ 153 | { meta: { equals: Prisma.AnyNull } }, 154 | { meta: { equals: Prisma.DbNull } }, 155 | { meta: { equals: Prisma.JsonNull } }, 156 | ], 157 | }, 158 | }); 159 | expect(profile).not.toBeNull(); 160 | expect(profile!.meta).toBe(null); 161 | }); 162 | }); 163 | 164 | describe("groupBy", () => { 165 | beforeAll(() => { 166 | testClient = client.$extends({ 167 | query: { 168 | $allModels: { 169 | $allOperations: withNestedOperations({ 170 | $rootOperation: async ({ operation, args, query }) => { 171 | if (operation !== "groupBy") { 172 | await Promise.resolve(); 173 | throw new Error("expected groupBy action"); 174 | } 175 | return query(args); 176 | }, 177 | $allNestedOperations: ({ args, query }) => { 178 | return query(args); 179 | }, 180 | }), 181 | }, 182 | }, 183 | }); 184 | }); 185 | 186 | it("calls middleware with groupBy action", async () => { 187 | await expect(testClient.comment.findMany()).rejects.toThrowError( 188 | "expected groupBy action" 189 | ); 190 | 191 | const groupBy = await testClient.comment.groupBy({ 192 | by: ["authorId"], 193 | orderBy: { 194 | authorId: "asc", 195 | }, 196 | }); 197 | 198 | expect(groupBy).toHaveLength(1); 199 | }); 200 | }); 201 | 202 | describe("Changing Operations", () => { 203 | beforeAll(() => { 204 | testClient = client.$extends({ 205 | query: { 206 | $allModels: { 207 | $allOperations: withNestedOperations({ 208 | $rootOperation: ({ model, operation, args, query }) => { 209 | if (model === "User" && operation === "updateMany") { 210 | return client.user.deleteMany({ 211 | where: { id: args.where?.id }, 212 | }); 213 | } 214 | 215 | return query(args); 216 | }, 217 | $allNestedOperations: ({ model, operation, args, query }) => { 218 | if (model === "Profile" && operation === "update") { 219 | return query(true, "delete"); 220 | } 221 | return query(args); 222 | }, 223 | }), 224 | }, 225 | }, 226 | }); 227 | }); 228 | 229 | it("changes root operation", async () => { 230 | await testClient.user.updateMany({ 231 | where: { id: firstUser.id }, 232 | data: { 233 | email: faker.internet.email(), 234 | }, 235 | }); 236 | const dbUser = await testClient.user.findUnique({ 237 | where: { id: firstUser.id }, 238 | }); 239 | expect(dbUser).toBeNull(); 240 | }); 241 | 242 | it("changes nested operation", async () => { 243 | await testClient.user.update({ 244 | where: { id: firstUser.id }, 245 | data: { 246 | profile: { 247 | update: { 248 | bio: faker.lorem.sentence(), 249 | }, 250 | }, 251 | }, 252 | }); 253 | const dbUser = await testClient.user.findUnique({ 254 | where: { id: firstUser.id }, 255 | include: { 256 | profile: true, 257 | }, 258 | }); 259 | expect(dbUser.profile).toBeNull(); 260 | }); 261 | }); 262 | }); 263 | -------------------------------------------------------------------------------- /src/lib/utils/params.ts: -------------------------------------------------------------------------------- 1 | import { Types } from "@prisma/client/runtime/library"; 2 | import { merge, omit, get, set, unset } from "lodash"; 3 | 4 | import { 5 | OperationCall, 6 | NestedOperation, 7 | Target, 8 | WriteTarget, 9 | Scope, 10 | ReadTarget, 11 | NestedWriteOperation, 12 | NestedParams, 13 | } from "../types"; 14 | 15 | import { 16 | buildQueryTargetPath, 17 | buildReadTargetPath, 18 | buildTargetPath, 19 | buildWriteTargetPath, 20 | isQueryTarget, 21 | isReadTarget, 22 | isWriteTarget, 23 | targetChainLength, 24 | } from "./targets"; 25 | import { 26 | isQueryOperation, 27 | isReadOperation, 28 | isWriteOperation, 29 | toOneRelationNonListOperations, 30 | } from "./operations"; 31 | import { cloneArgs } from "./cloneArgs"; 32 | import { fieldsByWriteOperation } from "./extractNestedOperations"; 33 | 34 | function addWriteToArgs< 35 | ExtArgs extends Types.Extensions.InternalArgs = Types.Extensions.DefaultArgs 36 | >(args: any, updatedArgs: any, target: WriteTarget, scope?: Scope) { 37 | const toOneRelation = !scope?.relations.to.isList; 38 | const targetPath = buildWriteTargetPath(target); 39 | const targetArgs = get(args, targetPath); 40 | 41 | // it's possible to target args that have already been updated if the user 42 | // has reused the same object in multiple places when changing action, in this 43 | // case we can just return 44 | if (targetArgs === updatedArgs) { 45 | return; 46 | } 47 | 48 | // if target doesn't exist or is a boolean action, we can just set the args 49 | if (!targetArgs || typeof targetArgs === "boolean") { 50 | set(args, targetPath, updatedArgs); 51 | return; 52 | } 53 | 54 | // createMany operations cannot be turned into arrays of operations so merge 55 | // their data fields 56 | if (target.operation === "createMany") { 57 | set( 58 | args, 59 | [...targetPath, "data"], 60 | [...targetArgs.data, ...updatedArgs.data] 61 | ); 62 | return; 63 | } 64 | 65 | // to one relations have actions that cannot be turned into arrays of operations 66 | // so merge their args 67 | if ( 68 | toOneRelation && 69 | toOneRelationNonListOperations.includes(target.operation) 70 | ) { 71 | merge(get(args, targetPath), updatedArgs); 72 | return; 73 | } 74 | 75 | // if target is an array of operations push args as another operation 76 | if (Array.isArray(targetArgs)) { 77 | targetArgs.push(updatedArgs); 78 | return; 79 | } 80 | 81 | // convert target to an array of operations with the target args as the 82 | // first operation and passed args as the second 83 | set(args, targetPath, [targetArgs, updatedArgs]); 84 | } 85 | 86 | function removeWriteFromArgs(args: any, target: WriteTarget) { 87 | // remove args from target 88 | const targetPath = buildWriteTargetPath(target); 89 | unset(args, targetPath); 90 | 91 | // if target parent is now an empty object or array we must remove it 92 | const targetParentPath = targetPath.slice(0, -1); 93 | const targetParent = get(args, targetParentPath); 94 | if (Object.keys(targetParent).length === 0) { 95 | unset(args, targetParentPath); 96 | } 97 | } 98 | 99 | function removeReadFromArgs(args: any, target: ReadTarget) { 100 | // remove args from target 101 | const targetPath = buildReadTargetPath(target); 102 | unset(args, targetPath); 103 | 104 | // if target parent is an array with only unset values we must remove it 105 | const targetParentPath = targetPath.slice(0, -1); 106 | const targetParent = get(args, targetParentPath); 107 | if (Object.keys(targetParent).length === 0) { 108 | unset(args, targetParentPath); 109 | } 110 | } 111 | 112 | export function assertOperationChangeIsValid( 113 | previousOperation: NestedOperation, 114 | nextOperation: NestedOperation 115 | ) { 116 | if (isReadOperation(previousOperation) && isWriteOperation(nextOperation)) { 117 | throw new Error( 118 | "Changing a read action to a write action is not supported" 119 | ); 120 | } 121 | 122 | if (isWriteOperation(previousOperation) && isReadOperation(nextOperation)) { 123 | throw new Error( 124 | "Changing a write action to a read action is not supported" 125 | ); 126 | } 127 | 128 | if (isQueryOperation(previousOperation) && !isQueryOperation(nextOperation)) { 129 | throw new Error( 130 | "Changing a query action to a non-query action is not supported" 131 | ); 132 | } 133 | } 134 | 135 | function moveOperationChangesToEnd( 136 | callA: { target: Target; origin: Target }, 137 | callB: { target: Target; origin: Target } 138 | ) { 139 | if (callA.target.operation !== callA.origin.operation) { 140 | return 1; 141 | } 142 | if (callB.target.operation !== callB.origin.operation) { 143 | return -1; 144 | } 145 | return 0; 146 | } 147 | 148 | function findParentCall( 149 | calls: Call[], 150 | origin: Target 151 | ): Call | undefined { 152 | return calls.find( 153 | (call) => 154 | origin.parentTarget && 155 | buildTargetPath(origin.parentTarget).join(".") === 156 | buildTargetPath(call.origin).join(".") 157 | ); 158 | } 159 | 160 | export function buildArgsFromCalls< 161 | ExtArgs extends Types.Extensions.InternalArgs, 162 | Call extends Omit, "queryPromise" | "result"> 163 | >(calls: Call[], rootParams: NestedParams) { 164 | const finalArgs = cloneArgs(rootParams.args); 165 | 166 | // calls should update the parent calls updated params 167 | 168 | // sort calls so we set from deepest to shallowest 169 | // actions that are at the same depth should put action changes at the end 170 | const sortedCalls = calls.sort((a, b) => { 171 | const aDepth = targetChainLength(a.target); 172 | const bDepth = targetChainLength(b.target); 173 | 174 | if (aDepth === bDepth) { 175 | return moveOperationChangesToEnd(a, b); 176 | } 177 | 178 | return bDepth - aDepth; 179 | }); 180 | 181 | // eslint-disable-next-line complexity 182 | sortedCalls.forEach((call, i) => { 183 | const parentCall = findParentCall(calls.slice(i), call.origin); 184 | const parentArgs = parentCall?.updatedArgs || finalArgs; 185 | const parentOperation = 186 | parentCall?.target.operation || rootParams.operation; 187 | 188 | const origin = omit(call.origin, "parentTarget"); 189 | const target = omit(call.target, "parentTarget"); 190 | 191 | if (origin.operation !== target.operation) { 192 | assertOperationChangeIsValid(origin.operation, target.operation); 193 | } 194 | 195 | if (isWriteTarget(target) && isWriteTarget(origin)) { 196 | // if action has not changed use normal target to set args 197 | if (target.operation === origin.operation) { 198 | const targetPath = buildWriteTargetPath(target); 199 | const callTargetArgs = get(parentArgs, targetPath); 200 | 201 | // if target hasn't changed but is an array it has been merged 202 | // the original target must be the first element of the array 203 | if (Array.isArray(callTargetArgs)) { 204 | callTargetArgs[0] = call.updatedArgs; 205 | return; 206 | } 207 | 208 | // set the updated args if the target hasn't changed 209 | set(parentArgs, targetPath, call.updatedArgs); 210 | return; 211 | } 212 | 213 | // if parent action has not changed we can use our normal targets 214 | if (parentOperation === call.scope?.parentParams.operation) { 215 | addWriteToArgs(parentArgs, call.updatedArgs, target, call.scope); 216 | removeWriteFromArgs(parentArgs, origin); 217 | return; 218 | } 219 | 220 | // if parent action has changed we must modify out target to match the 221 | // parent action 222 | const fields = 223 | // NOTE:- this might need to be origin.operation 224 | fieldsByWriteOperation[target.operation as NestedWriteOperation]; 225 | 226 | fields.forEach((field) => { 227 | const newOrigin = { ...origin, field }; 228 | const newTarget = { ...target, field }; 229 | 230 | if (get(parentArgs, buildWriteTargetPath(newOrigin))) { 231 | // if action has changed we add merge args with target and remove the 232 | // args from the origin 233 | addWriteToArgs(parentArgs, call.updatedArgs, newTarget, call.scope); 234 | removeWriteFromArgs(parentArgs, newOrigin); 235 | } 236 | }); 237 | } 238 | 239 | if (isReadTarget(target) && isReadTarget(origin)) { 240 | const targetPath = buildReadTargetPath(target); 241 | // because includes and selects cannot be at the same level we can safely 242 | // set target path to be the updated args without worrying about 243 | // overwriting the original args 244 | set(parentArgs, targetPath, call.updatedArgs); 245 | 246 | // remove the origin args if the action has changed 247 | if (target.operation !== origin.operation) { 248 | removeReadFromArgs(parentArgs, origin); 249 | } 250 | } 251 | 252 | if (isQueryTarget(target) && isQueryTarget(origin)) { 253 | if (target.readOperation) { 254 | set(parentArgs, "where", call.updatedArgs); 255 | return; 256 | } 257 | 258 | const basePath = parentCall ? [] : ["where"]; 259 | set( 260 | parentArgs, 261 | [...basePath, ...buildQueryTargetPath(target)], 262 | call.updatedArgs 263 | ); 264 | } 265 | }); 266 | 267 | return finalArgs; 268 | } 269 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/lib/utils/extractNestedOperations.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import { Types } from "@prisma/client/runtime/library"; 3 | import get from "lodash/get"; 4 | 5 | import { 6 | LogicalOperator, 7 | NestedParams, 8 | NestedWriteOperation, 9 | Target, 10 | } from "../types"; 11 | 12 | import { 13 | isWriteOperation, 14 | logicalOperators, 15 | modifiers, 16 | readOperations, 17 | } from "./operations"; 18 | import { findOppositeRelation, relationsByModel } from "./relations"; 19 | 20 | type NestedOperationInfo< 21 | ExtArgs extends Types.Extensions.InternalArgs = Types.Extensions.DefaultArgs 22 | > = { 23 | params: NestedParams; 24 | target: Target; 25 | }; 26 | 27 | // actions have nested relations inside fields within the args object, sometimes 28 | // relations are defined directly in the args object because the action is in a 29 | // to one relation, for example the update action. Add undefined for actions where this 30 | // can happen 31 | export const fieldsByWriteOperation: Record< 32 | NestedWriteOperation, 33 | (string | undefined)[] 34 | > = { 35 | create: [undefined, "data"], 36 | update: [undefined, "data"], 37 | upsert: ["update", "create"], 38 | connectOrCreate: ["create"], 39 | createMany: ["data"], 40 | updateMany: ["data"], 41 | connect: [], 42 | disconnect: [], 43 | delete: [], 44 | deleteMany: [], 45 | }; 46 | 47 | export function extractRelationLogicalWhereOperations< 48 | ExtArgs extends Types.Extensions.InternalArgs = Types.Extensions.DefaultArgs 49 | >( 50 | params: NestedParams, 51 | parentTarget?: Target, 52 | parentOperations: { logicalOperator: LogicalOperator; index?: number }[] = [] 53 | ): NestedOperationInfo[] { 54 | const relations = relationsByModel[params.model || ""] || []; 55 | const nestedWhereOperations: NestedOperationInfo[] = []; 56 | 57 | const operationsPath: string[] = []; 58 | parentOperations.forEach(({ logicalOperator, index }) => { 59 | operationsPath.push(logicalOperator); 60 | 61 | if (typeof index === "number") { 62 | operationsPath.push(index.toString()); 63 | } 64 | }); 65 | 66 | logicalOperators.forEach((logicalOperator) => { 67 | const baseArgPath = params.scope ? ["args"] : ["args", "where"]; 68 | const logicalArg = get(params, [ 69 | ...baseArgPath, 70 | ...operationsPath, 71 | logicalOperator, 72 | ]); 73 | if (!logicalArg) return; 74 | 75 | const nestedOperators = Array.isArray(logicalArg) 76 | ? logicalArg.map((_, index) => ({ logicalOperator, index })) 77 | : [{ logicalOperator }]; 78 | 79 | nestedOperators.forEach((nestedOperator) => { 80 | nestedWhereOperations.push( 81 | ...extractRelationLogicalWhereOperations(params, parentTarget, [ 82 | ...parentOperations, 83 | nestedOperator, 84 | ]) 85 | ); 86 | }); 87 | 88 | relations.forEach((relation) => { 89 | const model = relation.type as Prisma.ModelName; 90 | const oppositeRelation = findOppositeRelation(relation); 91 | 92 | if (Array.isArray(logicalArg)) { 93 | logicalArg.forEach((where, index) => { 94 | const arg = where?.[relation.name]; 95 | if (!arg) return; 96 | 97 | const logicalOperations = [ 98 | ...parentOperations, 99 | { logicalOperator, index }, 100 | ]; 101 | const foundModifiers = modifiers.filter((mod) => arg[mod]); 102 | 103 | // if there are no modifiers call the where action without a modifier 104 | if (!foundModifiers.length) { 105 | nestedWhereOperations.push({ 106 | target: { 107 | operation: "where" as const, 108 | relationName: relation.name, 109 | logicalOperations, 110 | parentTarget, 111 | }, 112 | params: { 113 | model, 114 | operation: "where", 115 | args: arg, 116 | scope: { 117 | parentParams: params, 118 | logicalOperators: logicalOperations.map( 119 | (op) => op.logicalOperator 120 | ), 121 | relations: { to: relation, from: oppositeRelation }, 122 | }, 123 | query: params.query, 124 | }, 125 | }); 126 | 127 | return; 128 | } 129 | 130 | // if there are modifiers call the where action with each modifier but 131 | // not the action without a modifier 132 | foundModifiers.forEach((modifier) => { 133 | nestedWhereOperations.push({ 134 | target: { 135 | operation: "where" as const, 136 | relationName: relation.name, 137 | modifier, 138 | logicalOperations, 139 | parentTarget, 140 | }, 141 | params: { 142 | model, 143 | operation: "where", 144 | args: arg[modifier], 145 | scope: { 146 | parentParams: params, 147 | modifier, 148 | logicalOperators: logicalOperations.map( 149 | (op) => op.logicalOperator 150 | ), 151 | relations: { to: relation, from: oppositeRelation }, 152 | }, 153 | query: params.query, 154 | }, 155 | }); 156 | }); 157 | }); 158 | 159 | return; 160 | } 161 | 162 | const arg = logicalArg[relation.name]; 163 | if (!arg) return; 164 | 165 | const logicalOperations = [...parentOperations, { logicalOperator }]; 166 | const foundModifiers = modifiers.filter((mod) => arg[mod]); 167 | 168 | if (!foundModifiers.length) { 169 | nestedWhereOperations.push({ 170 | target: { 171 | operation: "where", 172 | relationName: relation.name, 173 | logicalOperations, 174 | parentTarget, 175 | }, 176 | params: { 177 | model, 178 | operation: "where", 179 | args: arg, 180 | scope: { 181 | parentParams: params, 182 | logicalOperators: logicalOperations.map( 183 | (op) => op.logicalOperator 184 | ), 185 | relations: { to: relation, from: oppositeRelation }, 186 | }, 187 | query: params.query, 188 | }, 189 | }); 190 | 191 | return; 192 | } 193 | 194 | foundModifiers.forEach((modifier) => { 195 | nestedWhereOperations.push({ 196 | target: { 197 | operation: "where", 198 | relationName: relation.name, 199 | modifier, 200 | logicalOperations, 201 | parentTarget, 202 | }, 203 | params: { 204 | model, 205 | operation: "where", 206 | args: modifier ? arg[modifier] : arg, 207 | scope: { 208 | parentParams: params, 209 | modifier, 210 | logicalOperators: logicalOperations.map( 211 | (op) => op.logicalOperator 212 | ), 213 | relations: { to: relation, from: oppositeRelation }, 214 | }, 215 | query: params.query, 216 | }, 217 | }); 218 | }); 219 | }); 220 | }); 221 | 222 | return nestedWhereOperations; 223 | } 224 | 225 | export function extractRelationWhereOperations< 226 | ExtArgs extends Types.Extensions.InternalArgs = Types.Extensions.DefaultArgs 227 | >(params: NestedParams, parentTarget?: Target): NestedOperationInfo[] { 228 | const relations = relationsByModel[params.model || ""] || []; 229 | 230 | const nestedWhereOperations = extractRelationLogicalWhereOperations( 231 | params, 232 | parentTarget 233 | ); 234 | 235 | relations.forEach((relation) => { 236 | const model = relation.type as Prisma.ModelName; 237 | const oppositeRelation = findOppositeRelation(relation); 238 | 239 | const baseArgPath = params.scope ? ["args"] : ["args", "where"]; 240 | const arg = get(params, [...baseArgPath, relation.name]); 241 | if (!arg) return; 242 | 243 | const foundModifiers = modifiers.filter((mod) => arg[mod]); 244 | if (!foundModifiers.length) { 245 | nestedWhereOperations.push({ 246 | target: { 247 | operation: "where", 248 | relationName: relation.name, 249 | parentTarget, 250 | }, 251 | params: { 252 | model, 253 | operation: "where", 254 | args: arg, 255 | scope: { 256 | parentParams: params, 257 | relations: { to: relation, from: oppositeRelation }, 258 | }, 259 | query: params.query, 260 | }, 261 | }); 262 | 263 | return; 264 | } 265 | 266 | foundModifiers.forEach((modifier) => { 267 | nestedWhereOperations.push({ 268 | target: { 269 | operation: "where", 270 | relationName: relation.name, 271 | modifier, 272 | parentTarget, 273 | }, 274 | params: { 275 | model, 276 | operation: "where", 277 | args: modifier ? arg[modifier] : arg, 278 | scope: { 279 | parentParams: params, 280 | modifier, 281 | relations: { to: relation, from: oppositeRelation }, 282 | }, 283 | query: params.query, 284 | }, 285 | }); 286 | }); 287 | }); 288 | 289 | return nestedWhereOperations.concat( 290 | nestedWhereOperations.flatMap((nestedOperationInfo) => 291 | extractRelationWhereOperations( 292 | nestedOperationInfo.params, 293 | nestedOperationInfo.target 294 | ) 295 | ) 296 | ); 297 | } 298 | 299 | export function extractRelationWriteOperations< 300 | ExtArgs extends Types.Extensions.InternalArgs = Types.Extensions.DefaultArgs 301 | >(params: NestedParams, parentTarget?: Target): NestedOperationInfo[] { 302 | const relations = relationsByModel[params.model || ""] || []; 303 | 304 | if (!isWriteOperation(params.operation)) return []; 305 | 306 | const nestedWriteOperations: NestedOperationInfo[] = []; 307 | const fields = fieldsByWriteOperation[params.operation] || []; 308 | 309 | relations.forEach((relation) => { 310 | const model = relation.type as Prisma.ModelName; 311 | const oppositeRelation = findOppositeRelation(relation); 312 | 313 | fields.forEach((field) => { 314 | const argPath = ["args", field, relation.name].filter( 315 | (part): part is string => !!part 316 | ); 317 | const arg = get(params, argPath, {}); 318 | 319 | Object.keys(arg) 320 | .filter(isWriteOperation) 321 | .forEach((operation) => { 322 | /* 323 | Add single writes passed as a list as separate operations. 324 | 325 | Checking if the operation is an array is enough since only lists of 326 | separate operations are passed as arrays at the top level. For example 327 | a nested create may be passed as an array but a nested createMany will 328 | pass an object with a data array. 329 | */ 330 | if (Array.isArray(arg[operation])) { 331 | nestedWriteOperations.push( 332 | ...arg[operation].map( 333 | (item: any, index: number): NestedOperationInfo => ({ 334 | target: { 335 | field, 336 | relationName: relation.name, 337 | operation, 338 | index, 339 | parentTarget, 340 | }, 341 | params: { 342 | model, 343 | operation, 344 | args: item, 345 | scope: { 346 | parentParams: params, 347 | relations: { to: relation, from: oppositeRelation }, 348 | }, 349 | query: params.query, 350 | }, 351 | }) 352 | ) 353 | ); 354 | return; 355 | } 356 | 357 | nestedWriteOperations.push({ 358 | target: { 359 | field, 360 | relationName: relation.name, 361 | operation, 362 | parentTarget, 363 | }, 364 | params: { 365 | model, 366 | operation, 367 | args: arg[operation], 368 | scope: { 369 | parentParams: params, 370 | relations: { to: relation, from: oppositeRelation }, 371 | }, 372 | query: params.query, 373 | }, 374 | }); 375 | }); 376 | }); 377 | }); 378 | 379 | return nestedWriteOperations.concat( 380 | nestedWriteOperations.flatMap((nestedOperationInfo) => 381 | extractRelationWriteOperations( 382 | nestedOperationInfo.params, 383 | nestedOperationInfo.target 384 | ) 385 | ) 386 | ); 387 | } 388 | 389 | export function extractRelationReadOperations< 390 | ExtArgs extends Types.Extensions.InternalArgs = Types.Extensions.DefaultArgs 391 | >(params: NestedParams, parentTarget?: Target): NestedOperationInfo[] { 392 | const relations = relationsByModel[params.model || ""] || []; 393 | const nestedOperations: NestedOperationInfo[] = []; 394 | 395 | relations.forEach((relation) => { 396 | const model = relation.type as Prisma.ModelName; 397 | const oppositeRelation = findOppositeRelation(relation); 398 | 399 | readOperations.forEach((operation) => { 400 | const arg = get(params, ["args", operation, relation.name]); 401 | if (!arg) return; 402 | 403 | const readOperationInfo = { 404 | params: { 405 | model, 406 | operation, 407 | args: arg, 408 | scope: { 409 | parentParams: params, 410 | relations: { to: relation, from: oppositeRelation }, 411 | }, 412 | // this needs to be nested query function 413 | query: params.query, 414 | }, 415 | target: { operation, relationName: relation.name, parentTarget }, 416 | }; 417 | 418 | nestedOperations.push(readOperationInfo); 419 | 420 | if (readOperationInfo.params.args?.where) { 421 | const whereOperationInfo = { 422 | target: { 423 | operation: "where" as const, 424 | relationName: relation.name, 425 | readOperation: operation, 426 | parentTarget: readOperationInfo.target, 427 | }, 428 | params: { 429 | model: readOperationInfo.params.model, 430 | operation: "where" as const, 431 | args: readOperationInfo.params.args.where, 432 | scope: { 433 | parentParams: readOperationInfo.params, 434 | relations: readOperationInfo.params.scope.relations, 435 | }, 436 | query: params.query, 437 | }, 438 | }; 439 | nestedOperations.push(whereOperationInfo); 440 | nestedOperations.push( 441 | ...extractRelationWhereOperations( 442 | whereOperationInfo.params, 443 | whereOperationInfo.target 444 | ) 445 | ); 446 | } 447 | 448 | // push select nested in an include 449 | if (operation === "include" && arg.select) { 450 | const nestedSelectOperationInfo = { 451 | params: { 452 | model, 453 | operation: "select" as const, 454 | args: arg.select, 455 | scope: { 456 | parentParams: readOperationInfo.params, 457 | relations: readOperationInfo.params.scope.relations, 458 | }, 459 | query: params.query, 460 | }, 461 | target: { 462 | field: "include" as const, 463 | operation: "select" as const, 464 | relationName: relation.name, 465 | parentTarget, 466 | }, 467 | }; 468 | 469 | nestedOperations.push(nestedSelectOperationInfo); 470 | 471 | if (nestedSelectOperationInfo.params.args?.where) { 472 | const whereOperationInfo = { 473 | target: { 474 | operation: "where" as const, 475 | relationName: relation.name, 476 | readOperation: "select" as const, 477 | parentTarget: nestedSelectOperationInfo.target, 478 | }, 479 | params: { 480 | model: nestedSelectOperationInfo.params.model, 481 | operation: "where" as const, 482 | args: nestedSelectOperationInfo.params.args.where, 483 | scope: { 484 | parentParams: nestedSelectOperationInfo.params, 485 | relations: nestedSelectOperationInfo.params.scope.relations, 486 | }, 487 | query: params.query, 488 | }, 489 | }; 490 | nestedOperations.push(whereOperationInfo); 491 | nestedOperations.push( 492 | ...extractRelationWhereOperations( 493 | whereOperationInfo.params, 494 | whereOperationInfo.target 495 | ) 496 | ); 497 | } 498 | } 499 | }); 500 | }); 501 | 502 | return nestedOperations.concat( 503 | nestedOperations.flatMap((nestedOperation) => 504 | extractRelationReadOperations( 505 | nestedOperation.params, 506 | nestedOperation.target 507 | ) 508 | ) 509 | ); 510 | } 511 | 512 | export function extractNestedOperations< 513 | ExtArgs extends Types.Extensions.InternalArgs = Types.Extensions.DefaultArgs 514 | >(params: NestedParams): NestedOperationInfo[] { 515 | return [ 516 | ...extractRelationWhereOperations(params), 517 | ...extractRelationReadOperations(params), 518 | ...extractRelationWriteOperations(params), 519 | ]; 520 | } 521 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Prisma Extension Nested Operations

3 | 4 |

Prisma Extension library that allows modifying operations on nested relations in a Prisma query.

5 | 6 |

7 | Vanilla Prisma extensions are great for modifying top-level queries but 8 | are still difficult to use when they must handle 9 | nested writes, includes, selects, 10 | or modify where objects that reference relations. 11 | This is talked about in greater depth in this issue regarding nested middleware, and the 12 | same issue applies to extensions. 13 |

14 | 15 |

16 | This library exports a withNestedOperations() helper that splits an $allOperations() hook into $rootOperation() and 17 | $allNestedOperations() hooks. 18 |

19 | 20 |
21 | 22 |
23 | 24 | [![Build Status][build-badge]][build] 25 | [![version][version-badge]][package] 26 | [![MIT License][license-badge]][license] 27 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 28 | [![PRs Welcome][prs-badge]][prs] 29 | 30 | ## Table of Contents 31 | 32 | 33 | 34 | 35 | - [Installation](#installation) 36 | - [Usage](#usage) 37 | - [`$rootOperation()`](#rootoperation) 38 | - [`$allNestedOperations()` Params](#allnestedoperations-params) 39 | - [Nested Writes](#nested-writes) 40 | - [Changing Nested Write Operations](#changing-nested-write-operations) 41 | - [Write Results](#write-results) 42 | - [Where](#where) 43 | - [Where Results](#where-results) 44 | - [Include](#include) 45 | - [Include Results](#include-results) 46 | - [Select](#select) 47 | - [Select Results](#select-results) 48 | - [Relations](#relations) 49 | - [Modifying Nested Write Params](#modifying-nested-write-params) 50 | - [Modifying Where Params](#modifying-where-params) 51 | - [Modifying Results](#modifying-results) 52 | - [Errors](#errors) 53 | - [LICENSE](#license) 54 | 55 | 56 | 57 | ## Installation 58 | 59 | This module is distributed via [npm][npm] which is bundled with [node][node] and 60 | should be installed as one of your project's dependencies: 61 | 62 | ``` 63 | npm install prisma-extension-nested-operations 64 | ``` 65 | 66 | `@prisma/client` is a peer dependency of this library, so you will need to 67 | install it if you haven't already: 68 | 69 | ``` 70 | npm install @prisma/client 71 | ``` 72 | 73 | You must have at least @prisma/client version 4.16.0 installed. 74 | 75 | ## Usage 76 | 77 | The `withNestedOperations()` function takes and object with two properties, `$rootOperation()` and `$allNestedOperations()`. 78 | The return value is an `$allOperations` hook, so it can be passed directly to an extensions `$allOperations` hook. 79 | 80 | ```javascript 81 | import { withNestedOperations } from "prisma-extension-nested-operations"; 82 | 83 | client.$extends({ 84 | query: { 85 | $allModels: { 86 | $allOperations: withNestedOperations({ 87 | async $rootOperation(params) { 88 | // update root params here 89 | const result = params.query(params.args); 90 | // update root result here 91 | return result; 92 | }, 93 | async $allNestedOperations(params) { 94 | // update nested params here 95 | const result = await params.query(params.args); 96 | // update nested result here 97 | return result; 98 | }, 99 | }), 100 | }, 101 | }, 102 | }); 103 | ``` 104 | 105 | ### `$rootOperation()` 106 | 107 | The `$rootOperation()` hook is called with the same params as the `$allOperations()` hook, however the `params.args` object 108 | has been updated by the args passed to the `$allNestedOperations()` query functions. The same pattern applies to the 109 | returned result, it is the result of the query updated by the returned results from the `$allNestedOperations()` calls. 110 | 111 | ### `$allNestedOperations()` Params 112 | 113 | The params object passed to the `$allNestedOperations()` function is similar to the params passed to `$allOperations()`. 114 | It has `args`, `model`, `operation`, and `query` fields, however there are some key differences: 115 | 116 | - the `operation` field adds the following options: 'connectOrCreate', 'connect', 'disconnect', 'include', 'select' and 'where' 117 | - the `query` field takes a second argument, which is the `operation` being performed. This is useful where the type of the nested operation should be changed. 118 | - there is an additional `scope` field that contains information specific to nested relations: 119 | 120 | - the `parentParams` field contains the params object of the parent relation 121 | - the `modifier` field contains any modifiers the params were wrapped in, for example `some` or `every`. 122 | - the `logicalOperators` field contains any logical operators between the current relation and it's parent, for example `AND` or `NOT`. 123 | - the `relations` field contains an object with the relation `to` the current model and `from` the model back to it's parent. 124 | 125 | For more information on the `modifier` and `logicalOperators` fields see the [Where](#Where) section. 126 | 127 | For more information on the `relations` field see the [Relations](#Relations) section. 128 | 129 | The type for the params object is: 130 | 131 | ```typescript 132 | type NestedParams = { 133 | query: (args: any, operation?: NestedOperation) => Prisma.PrismaPromise; 134 | model: keyof Prisma.TypeMap["model"]; 135 | args: any; 136 | operation: NestedOperation; 137 | scope?: Scope; 138 | }; 139 | 140 | export type Scope = { 141 | parentParams: Omit, "query">; 142 | relations: { to: Prisma.DMMF.Field; from: Prisma.DMMF.Field }; 143 | modifier?: Modifier; 144 | logicalOperators?: LogicalOperator[]; 145 | }; 146 | 147 | type Modifier = "is" | "isNot" | "some" | "none" | "every"; 148 | 149 | type LogicalOperator = "AND" | "OR" | "NOT"; 150 | 151 | type Operation = 152 | | "create" 153 | | "createMany" 154 | | "update" 155 | | "updateMany" 156 | | "upsert" 157 | | "delete" 158 | | "deleteMany" 159 | | "where" 160 | | "include" 161 | | "select" 162 | | "connect" 163 | | "connectOrCreate" 164 | | "disconnect"; 165 | ``` 166 | 167 | ### Nested Writes 168 | 169 | The `$allNestedOperations()` function is called for every [nested write](https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries#nested-writes) 170 | operation in the query. The `operation` field is set to the operation being performed, for example "create" or "update". 171 | The `model` field is set to the model being operated on, for example "User" or "Post". 172 | 173 | For example take the following query: 174 | 175 | ```javascript 176 | const result = await client.user.update({ 177 | data: { 178 | posts: { 179 | update: { 180 | where: { id: 1 }, 181 | data: { title: "Hello World" }, 182 | }, 183 | }, 184 | }, 185 | }); 186 | ``` 187 | 188 | The `$allNestedOperations()` function will be called with: 189 | 190 | ```javascript 191 | { 192 | operation: 'update', 193 | model: 'Post', 194 | args: { 195 | where: { id: 1 }, 196 | data: { title: 'Hello World' } 197 | }, 198 | relations: { 199 | to: { kind: 'object', name: 'posts', isList: true, ... }, 200 | from: { kind: 'object', name: 'author', isList: false, ... }, 201 | }, 202 | scope: [root params], 203 | } 204 | ``` 205 | 206 | Some nested writes can be passed as an array of operations. In this case the `$allNestedOperations()` function is called for each 207 | operation in the array. For example take the following query: 208 | 209 | ```javascript 210 | const result = await client.user.update({ 211 | data: { 212 | posts: { 213 | update: [ 214 | { where: { id: 1 }, data: { title: "Hello World" } }, 215 | { where: { id: 2 }, data: { title: "Hello World 2" } }, 216 | ], 217 | }, 218 | }, 219 | }); 220 | ``` 221 | 222 | The `$allNestedOperations()` function will be called with: 223 | 224 | ```javascript 225 | { 226 | operation: 'update', 227 | model: 'Post', 228 | args: { 229 | where: { id: 1 }, 230 | data: { title: 'Hello World' } 231 | }, 232 | relations: { 233 | to: { kind: 'object', name: 'posts', isList: true, ... }, 234 | from: { kind: 'object', name: 'author', isList: false, ... }, 235 | }, 236 | scope: [root params], 237 | } 238 | ``` 239 | 240 | and 241 | 242 | ```javascript 243 | { 244 | operation: 'update', 245 | model: 'Post', 246 | args: { 247 | where: { id: 2 }, 248 | data: { title: 'Hello World 2' } 249 | }, 250 | relations: { 251 | to: { kind: 'object', name: 'posts', isList: true, ... }, 252 | from: { kind: 'object', name: 'author', isList: false, ... }, 253 | }, 254 | scope: [root params], 255 | } 256 | ``` 257 | 258 | #### Changing Nested Write Operations 259 | 260 | The `$allNestedOperations()` function can change the operation that is performed on the model. For example take the following query: 261 | 262 | ```javascript 263 | const result = await client.user.update({ 264 | data: { 265 | posts: { 266 | update: { 267 | where: { id: 1 } 268 | data: { title: 'Hello World' } 269 | }, 270 | }, 271 | }, 272 | }); 273 | ``` 274 | 275 | The `$allNestedOperations()` function could be used to change the operation to `upsert`: 276 | 277 | ```javascript 278 | const client = _client.$extends({ 279 | query: { 280 | $allModels: { 281 | $allOperations: withNestedOperations({ 282 | $rootOperation: (params) => { 283 | return params.query(params.args); 284 | }, 285 | $allNestedOperations: (params) => { 286 | if (params.model === "Post" && params.operation === "update") { 287 | return params.query( 288 | { 289 | where: params.args.where, 290 | create: params.args.data, 291 | update: params.args.data, 292 | }, 293 | "upsert" 294 | ); 295 | } 296 | return params.query(params); 297 | }, 298 | }), 299 | }, 300 | }, 301 | }); 302 | ``` 303 | 304 | The final query would be modified by the above `$allNestedOperations()` to: 305 | 306 | ```javascript 307 | const result = await client.user.update({ 308 | data: { 309 | posts: { 310 | upsert: { 311 | where: { id: 1 }, 312 | create: { title: "Hello World" }, 313 | update: { title: "Hello World" }, 314 | }, 315 | }, 316 | }, 317 | }); 318 | ``` 319 | 320 | When changing the operation it is possible for the operation to already exist. In this case the resulting operations are merged. 321 | For example take the following query: 322 | 323 | ```javascript 324 | const result = await client.user.update({ 325 | data: { 326 | posts: { 327 | update: { 328 | where: { id: 1 }, 329 | data: { title: "Hello World" }, 330 | }, 331 | upsert: { 332 | where: { id: 2 }, 333 | create: { title: "Hello World 2" }, 334 | update: { title: "Hello World 2" }, 335 | }, 336 | }, 337 | }, 338 | }); 339 | ``` 340 | 341 | Using the same `$allNestedOperations()` defined before the update operation would be changed to an upsert operation, however there is 342 | already an upsert operation so the two operations are merged into a upsert operation array with the new operation added to 343 | the end of the array. When the existing operation is already a list of operations the new operation is added to the end of 344 | the list. The final query in this case would be: 345 | 346 | ```javascript 347 | const result = await client.user.update({ 348 | data: { 349 | posts: { 350 | upsert: [ 351 | { 352 | where: { id: 2 }, 353 | create: { title: "Hello World 2" }, 354 | update: { title: "Hello World 2" }, 355 | }, 356 | { 357 | where: { id: 1 }, 358 | create: { title: "Hello World" }, 359 | update: { title: "Hello World" }, 360 | }, 361 | ], 362 | }, 363 | }, 364 | }); 365 | ``` 366 | 367 | Sometimes it is not possible to merge the operations together in this way. The `createMany` operation does not support 368 | operation arrays so the `data` field of the `createMany` operation is merged instead. For example take the following query: 369 | 370 | ```javascript 371 | const result = await client.user.create({ 372 | data: { 373 | posts: { 374 | createMany: { 375 | data: [{ title: "Hello World" }, { title: "Hello World 2" }], 376 | }, 377 | create: { 378 | title: "Hello World 3", 379 | }, 380 | }, 381 | }, 382 | }); 383 | ``` 384 | 385 | If the `create` operation was changed to be a `createMany` operation the `data` field would be added to the end of the existing 386 | `createMany` operation. The final query would be: 387 | 388 | ```javascript 389 | const result = await client.user.create({ 390 | data: { 391 | posts: { 392 | createMany: { 393 | data: [ 394 | { title: "Hello World" }, 395 | { title: "Hello World 2" }, 396 | { title: "Hello World 3" }, 397 | ], 398 | }, 399 | }, 400 | }, 401 | }); 402 | ``` 403 | 404 | It is also not possible to merge the operations together by creating an array of operations for non-list relations. For 405 | example take the following query: 406 | 407 | ```javascript 408 | const result = await client.user.update({ 409 | data: { 410 | profile: { 411 | create: { 412 | bio: "My personal bio", 413 | age: 30, 414 | }, 415 | update: { 416 | where: { id: 1 }, 417 | data: { bio: "Updated bio" }, 418 | }, 419 | }, 420 | }, 421 | }); 422 | ``` 423 | 424 | If the `update` operation was changed to be a `create` operation using the following extension: 425 | 426 | ```javascript 427 | const client = _client.$extends({ 428 | query: { 429 | $allModels: { 430 | $allOperations: withNestedOperations({ 431 | $rootOperation: (params) => { 432 | return params.query(params.args); 433 | }, 434 | $allNestedOperations: (params) => { 435 | if (params.model === "Profile" && params.operation === "update") { 436 | return params.query(params.args.data, "create"); 437 | } 438 | return params.query(params); 439 | }, 440 | }), 441 | }, 442 | }, 443 | }); 444 | ``` 445 | 446 | The `create` operation from the `update` operation would need be merged with the existing `create` operation, however since 447 | `profile` is not a list relation we must merge together the resulting objects instead, resulting in the final query: 448 | 449 | ```javascript 450 | const result = await client.user.create({ 451 | data: { 452 | profile: { 453 | create: { 454 | bio: "Updated bio", 455 | age: 30, 456 | }, 457 | }, 458 | }, 459 | }); 460 | ``` 461 | 462 | #### Write Results 463 | 464 | The `query` function of `$allNestedOperations()` calls for nested write operations always return `undefined` as their result. 465 | This is because the results returned from the root query may not include the data for a particular nested write. 466 | 467 | For example take the following query: 468 | 469 | ```javascript 470 | const result = await client.user.update({ 471 | data: { 472 | profile: { 473 | create: { 474 | bio: "My personal bio", 475 | age: 30, 476 | }, 477 | } 478 | posts: { 479 | updateMany: { 480 | where: { 481 | published: false, 482 | }, 483 | data: { 484 | published: true, 485 | }, 486 | }, 487 | }, 488 | }, 489 | select: { 490 | id: true, 491 | posts: { 492 | where: { 493 | title: { 494 | contains: "Hello", 495 | }, 496 | }, 497 | select: { 498 | id: true, 499 | }, 500 | }, 501 | } 502 | }); 503 | ``` 504 | 505 | The `profile` field is not included in the `select` object so the result of the `create` operation will not be included in 506 | the root result. The `posts` field is included in the `select` object but the `where` object only includes posts with 507 | titles that contain "Hello" and returns only the "id" field, in this case it is not possible to match the result of the 508 | `updateMany` operation to the returned Posts. 509 | 510 | See [Modifying Results](#modifying-results) for more information on how to update the results of queries. 511 | 512 | ### Where 513 | 514 | The `where` operation is called for any relations found inside where objects in params. 515 | 516 | Note that the `where` operation is not called for the root where object, this is because you need the root operation to know 517 | what properties the root where object accepts. For nested where objects this is not a problem as they always follow the 518 | same pattern. 519 | 520 | To see where the `where` operation is called take the following query: 521 | 522 | ```javascript 523 | const result = await client.user.findMany({ 524 | where: { 525 | posts: { 526 | some: { 527 | published: true, 528 | }, 529 | }, 530 | }, 531 | }); 532 | ``` 533 | 534 | The where object above produces a call for "posts" relation found in the where object. The `modifier` field is set to 535 | "some" since the where object is within the "some" field. 536 | 537 | ```javascript 538 | { 539 | operation: 'where', 540 | model: 'Post', 541 | args: { 542 | published: true, 543 | }, 544 | scope: { 545 | parentParams: {...} 546 | modifier: 'some', 547 | relations: {...} 548 | }, 549 | } 550 | ``` 551 | 552 | Relations found inside where AND, OR and NOT logical operators are also found and called with the `$allNestedOperations()` function, 553 | however the `where` operation is not called for the logical operators themselves. For example take the following query: 554 | 555 | ```javascript 556 | const result = await client.user.findMany({ 557 | where: { 558 | posts: { 559 | some: { 560 | published: true, 561 | AND: [ 562 | { 563 | title: "Hello World", 564 | }, 565 | { 566 | comments: { 567 | every: { 568 | text: "Great post!", 569 | }, 570 | }, 571 | }, 572 | ], 573 | }, 574 | }, 575 | }, 576 | }); 577 | ``` 578 | 579 | The `$allNestedOperations()` function will be called with the params for "posts" similarly to before, however it will also be called 580 | with the following params: 581 | 582 | ```javascript 583 | { 584 | operation: 'where', 585 | model: 'Comment', 586 | args: { 587 | text: "Great post!", 588 | }, 589 | scope: { 590 | parentParams: {...} 591 | modifier: 'every', 592 | logicalOperators: ['AND'], 593 | relations: {...} 594 | }, 595 | } 596 | ``` 597 | 598 | Since the "comments" relation is found inside the "AND" logical operator the 599 | \$allNestedOperations is called for it. The `modifier` field is set to "every" since the where object is in the "every" field and 600 | the `logicalOperators` field is set to `['AND']` since the where object is inside the "AND" logical operator. 601 | 602 | Notice that the `$allNestedOperations()` function is not called for the first item in the "AND" array, this is because the first item 603 | does not contain any relations. 604 | 605 | The `logicalOperators` field tracks all the logical operators between the `parentParams` and the current params. For 606 | example take the following query: 607 | 608 | ```javascript 609 | const result = await client.user.findMany({ 610 | where: { 611 | AND: [ 612 | { 613 | NOT: { 614 | OR: [ 615 | { 616 | posts: { 617 | some: { 618 | published: true, 619 | }, 620 | }, 621 | }, 622 | ], 623 | }, 624 | }, 625 | ], 626 | }, 627 | }); 628 | ``` 629 | 630 | The `$allNestedOperations()` function will be called with the following params: 631 | 632 | ```javascript 633 | { 634 | operation: 'where', 635 | model: 'Post', 636 | args: { 637 | published: true, 638 | }, 639 | scope: { 640 | parentParams: {...} 641 | modifier: 'some', 642 | logicalOperators: ['AND', 'NOT', 'OR'], 643 | relations: {...}, 644 | }, 645 | } 646 | ``` 647 | 648 | The `where` operation is also called for relations found in the `where` field of includes and selects. For example: 649 | 650 | ```javascript 651 | const result = await client.user.findMany({ 652 | select: { 653 | posts: { 654 | where: { 655 | published: true, 656 | }, 657 | }, 658 | }, 659 | }); 660 | ``` 661 | 662 | The `$allNestedOperations()` function will be called with the following params: 663 | 664 | ```javascript 665 | { 666 | operation: 'where', 667 | model: 'Post', 668 | args: { 669 | published: true, 670 | }, 671 | scope: {...} 672 | } 673 | ``` 674 | 675 | #### Where Results 676 | 677 | The `query` function for a `where` operation always resolves with `undefined`. 678 | 679 | ### Include 680 | 681 | The `include` operation will be called for any included relation. The `args` field will contain the object or boolean 682 | passed as the relation include. For example take the following query: 683 | 684 | ```javascript 685 | const result = await client.user.findMany({ 686 | include: { 687 | profile: true, 688 | posts: { 689 | where: { 690 | published: true, 691 | }, 692 | }, 693 | }, 694 | }); 695 | ``` 696 | 697 | For the "profile" relation the `$allNestedOperations()` function will be called with: 698 | 699 | ```javascript 700 | { 701 | operation: 'include', 702 | model: 'Profile', 703 | args: true, 704 | scope: {...} 705 | } 706 | ``` 707 | 708 | and for the "posts" relation the `$allNestedOperations()` function will be called with: 709 | 710 | ```javascript 711 | { 712 | operation: 'include', 713 | model: 'Post', 714 | args: { 715 | where: { 716 | published: true, 717 | }, 718 | }, 719 | scope: {...} 720 | } 721 | ``` 722 | 723 | #### Include Results 724 | 725 | The `query` function for an `include` operation resolves with the result of the `include` operation. For example take the 726 | following query: 727 | 728 | ```javascript 729 | const result = await client.user.findMany({ 730 | include: { 731 | profile: true, 732 | }, 733 | }); 734 | ``` 735 | 736 | The `$allNestedOperations()` function for the "profile" relation will be called with: 737 | 738 | ```javascript 739 | { 740 | operation: 'include', 741 | model: 'Profile', 742 | args: true, 743 | scope: {...} 744 | } 745 | ``` 746 | 747 | And the `query` function will resolve with the result of the `include` operation, in this case something like: 748 | 749 | ```javascript 750 | { 751 | id: 2, 752 | bio: 'My personal bio', 753 | age: 30, 754 | userId: 1, 755 | } 756 | ``` 757 | 758 | For relations that are included within a list of parent results the `query` function will resolve with a flattened array 759 | of all the models from each parent result. For example take the following query: 760 | 761 | ```javascript 762 | const result = await client.user.findMany({ 763 | include: { 764 | posts: true, 765 | }, 766 | }); 767 | ``` 768 | 769 | If the root result looks like the following: 770 | 771 | ```javascript 772 | [ 773 | { 774 | id: 1, 775 | name: "Alice", 776 | posts: [ 777 | { 778 | id: 1, 779 | title: "Hello World", 780 | published: false, 781 | userId: 1, 782 | }, 783 | { 784 | id: 2, 785 | title: "My first published post", 786 | published: true, 787 | userId: 1, 788 | }, 789 | ], 790 | }, 791 | { 792 | id: 2, 793 | name: "Bob", 794 | posts: [ 795 | { 796 | id: 3, 797 | title: "Clean Code", 798 | published: true, 799 | userId: 2, 800 | }, 801 | ], 802 | }, 803 | ]; 804 | ``` 805 | 806 | The `query` function for the "posts" relation will resolve with the following: 807 | 808 | ```javascript 809 | [ 810 | { 811 | id: 1, 812 | title: "Hello World", 813 | published: false, 814 | userId: 1, 815 | }, 816 | { 817 | id: 2, 818 | title: "My first published post", 819 | published: true, 820 | userId: 1, 821 | }, 822 | { 823 | id: 3, 824 | title: "Clean Code", 825 | published: true, 826 | userId: 2, 827 | }, 828 | ]; 829 | ``` 830 | 831 | For more information on how to modify the results of an `include` operation see the [Modifying Results](#modifying-results) 832 | 833 | ### Select 834 | 835 | Similarly to the `include` operation, the `select` operation will be called for any selected relation with the `args` field 836 | containing the object or boolean passed as the relation select. For example take the following query: 837 | 838 | ```javascript 839 | const result = await client.user.findMany({ 840 | select: { 841 | posts: true, 842 | profile: { 843 | select: { 844 | bio: true, 845 | }, 846 | }, 847 | }, 848 | }); 849 | ``` 850 | 851 | and for the "posts" relation the `$allNestedOperations()` function will be called with: 852 | 853 | ```javascript 854 | { 855 | operation: 'select', 856 | model: 'Post', 857 | args: true, 858 | scope: {...} 859 | } 860 | ``` 861 | 862 | For the "profile" relation the `$allNestedOperations()` function will be called with: 863 | 864 | ```javascript 865 | { 866 | operation: 'select', 867 | model: 'Profile', 868 | args: { 869 | bio: true, 870 | }, 871 | scope: {...} 872 | } 873 | ``` 874 | 875 | #### Select Results 876 | 877 | The `query` function for a `select` operation resolves with the result of the `select` operation. This is the same as the 878 | `include` operation. See the [Include Results](#include-results) section for more information. 879 | 880 | ### Relations 881 | 882 | The `relations` field of the `scope` object contains the relations relevant to the current model. For example take the 883 | following query: 884 | 885 | ```javascript 886 | const result = await client.user.create({ 887 | data: { 888 | email: "test@test.com", 889 | profile: { 890 | create: { 891 | bio: "Hello World", 892 | }, 893 | }, 894 | posts: { 895 | create: { 896 | title: "Hello World", 897 | }, 898 | }, 899 | }, 900 | }); 901 | ``` 902 | 903 | The `$allNestedOperations()` function will be called with the following params for the "profile" relation: 904 | 905 | ```javascript 906 | { 907 | operation: 'create', 908 | model: 'Profile', 909 | args: { 910 | bio: "Hello World", 911 | }, 912 | scope: { 913 | parentParams: {...} 914 | relations: { 915 | to: { name: 'profile', kind: 'object', isList: false, ... }, 916 | from: { name: 'user', kind: 'object', isList: false, ... }, 917 | }, 918 | }, 919 | } 920 | ``` 921 | 922 | and the following params for the "posts" relation: 923 | 924 | ```javascript 925 | { 926 | operation: 'create', 927 | model: 'Post', 928 | args: { 929 | title: "Hello World", 930 | }, 931 | scope: { 932 | parentParams: {...} 933 | relations: { 934 | to: { name: 'posts', kind: 'object', isList: true, ... }, 935 | from: { name: 'author', kind: 'object', isList: false, ... }, 936 | }, 937 | }, 938 | } 939 | ``` 940 | 941 | ### Modifying Nested Write Params 942 | 943 | When writing extensions that modify the params of a query you should first write the `$rootOperation()` hook as if it were 944 | an `$allOperations()` hook, and then add the `$allNestedOperations()` hook. 945 | 946 | Say you are writing middleware that sets a default value when creating a model for a particular model: 947 | 948 | ```javascript 949 | const client = _client.$extends({ 950 | query: { 951 | $allModels: { 952 | $allOperations: withNestedOperations({ 953 | async $rootOperation(params) { 954 | // we only want to add default values for the "Invite" model 955 | if (params.model !== "Invite") { 956 | return params.query(params.args); 957 | } 958 | 959 | if (params.operation === "create" && !params.args.data.code) { 960 | params.args.data.code = createCode(); 961 | } 962 | 963 | if (params.operation === "upsert" && !params.args.create.code) { 964 | params.args.create.code = createCode(); 965 | } 966 | 967 | if (params.operation === "createMany") { 968 | params.args.data.forEach((data) => { 969 | if (!data.code) { 970 | data.code = createCode(); 971 | } 972 | }); 973 | } 974 | 975 | return params.query(params.args); 976 | }, 977 | async $allNestedOperations(params) { 978 | return params.query(params.args); 979 | }, 980 | }), 981 | }, 982 | }, 983 | }); 984 | ``` 985 | 986 | Then add conditions for the different args and operations that can be found in nested writes: 987 | 988 | ```javascript 989 | const client = _client.$extends({ 990 | query: { 991 | $allModels: { 992 | $allOperations: withNestedOperations({ 993 | async $rootOperation(params) { 994 | [...] 995 | }, 996 | async $allNestedOperations(params) { 997 | // we only want to add default values for the "Invite" model 998 | if (params.model !== "Invite") { 999 | return params.query(params.args); 1000 | } 1001 | 1002 | // when the "create" operation is from a nested write the data is not in the "data" field 1003 | if (params.operation === "create" && !params.args.code) { 1004 | params.args.code = createCode(); 1005 | } 1006 | 1007 | // handle the "connectOrCreate" operation 1008 | if (params.operation === "connectOrCreate" && !params.args.create.code) { 1009 | params.args.create.code = createCode(); 1010 | } 1011 | 1012 | // pass args to query 1013 | return params.query(params.args); 1014 | }, 1015 | }), 1016 | }, 1017 | }, 1018 | }); 1019 | ``` 1020 | 1021 | ### Modifying Where Params 1022 | 1023 | When writing extensions that modify the where params of a query you should first write the `$rootOperation()` hook as 1024 | if it were an `$allOperations()` hook, this is because the `where` operation is not called for the root where object and so you 1025 | will need to handle it manually. 1026 | 1027 | Say you are writing an extension that excludes models with a particular field, let's call it "invisible" rather than 1028 | "deleted" to make this less familiar: 1029 | 1030 | ```javascript 1031 | const client = _client.$extends({ 1032 | query: { 1033 | $allModels: { 1034 | $allOperations: withNestedOperations({ 1035 | async $rootOperation(params) { 1036 | // don't handle operations that only accept unique fields such as findUnique or upsert 1037 | if ( 1038 | params.operation === "findFirst" || 1039 | params.operation === "findFirstOrThrow" || 1040 | params.operation === "findMany" || 1041 | params.operation === "updateMany" || 1042 | params.operation === "deleteMany" || 1043 | params.operation === "count" || 1044 | params.operation === "aggregate" 1045 | ) { 1046 | return params.query({ 1047 | ...params.args, 1048 | where: { 1049 | ...params.args.where, 1050 | invisible: false, 1051 | }, 1052 | }); 1053 | } 1054 | 1055 | return params.query(params.args); 1056 | }, 1057 | async $allNestedOperations(params) { 1058 | return params.query(params.args); 1059 | }, 1060 | }), 1061 | }, 1062 | }, 1063 | }); 1064 | ``` 1065 | 1066 | Then add conditions for the `where` operation: 1067 | 1068 | ```javascript 1069 | const client = _client.$extends({ 1070 | query: { 1071 | $allModels: { 1072 | $allOperations: withNestedOperations({ 1073 | async $rootOperation(params) { 1074 | [...] 1075 | }, 1076 | async $allNestedOperations(params) { 1077 | // handle the "where" operation 1078 | if (params.operation === "where") { 1079 | return params.query({ 1080 | ...params.args, 1081 | invisible: false, 1082 | }); 1083 | } 1084 | 1085 | return params.query(params.args); 1086 | }, 1087 | }), 1088 | }, 1089 | }, 1090 | }); 1091 | ``` 1092 | 1093 | ### Modifying Results 1094 | 1095 | When writing extensions that modify the results of a query you should take the following process: 1096 | 1097 | - handle all the root cases in the `$rootOperation()` hook the same way you would with a `$allOperations()` hook. 1098 | - handle nested results using the `include` and `select` operations in the `$allNestedOperations()` hook. 1099 | 1100 | Say you are writing middleware that adds a timestamp to the results of a query. You would first handle the root cases: 1101 | 1102 | ```javascript 1103 | const client = _client.$extends({ 1104 | query: { 1105 | $allModels: { 1106 | $allOperations: withNestedOperations({ 1107 | async $rootOperation(params) { 1108 | const result = await params.query(params.args); 1109 | 1110 | // ensure result is defined 1111 | if (!result) return result; 1112 | 1113 | // handle root operations 1114 | if ( 1115 | params.operation === "findFirst" || 1116 | params.operation === "findFirstOrThrow" || 1117 | params.operation === "findUnique" || 1118 | params.operation === "findUniqueOrThrow" || 1119 | params.operation === "create" || 1120 | params.operation === "update" || 1121 | params.operation === "upsert" || 1122 | params.operation === "delete" 1123 | ) { 1124 | result.timestamp = Date.now(); 1125 | return result; 1126 | } 1127 | 1128 | if (params.operation === "findMany") { 1129 | const result = await params.query(params.args); 1130 | result.forEach((model) => { 1131 | model.timestamp = Date.now(); 1132 | }); 1133 | return result; 1134 | } 1135 | 1136 | return result; 1137 | }, 1138 | async $allNestedOperations(params) { 1139 | return params.query(params.args); 1140 | }, 1141 | }), 1142 | }, 1143 | }, 1144 | }); 1145 | ``` 1146 | 1147 | Then you would handle the nested results using the `include` and `select` operations: 1148 | 1149 | ```javascript 1150 | const client = _client.$extends({ 1151 | query: { 1152 | $allModels: { 1153 | $allOperations: withNestedOperations({ 1154 | async $rootOperation(params) { 1155 | [...] 1156 | }, 1157 | async $allNestedOperations(params) { 1158 | const result = await next(params); 1159 | 1160 | // ensure result is defined 1161 | if (!result) return result; 1162 | 1163 | // handle nested operations 1164 | if (params.operation === "include" || params.operation === "select") { 1165 | if (Array.isArray(result)) { 1166 | result.forEach((model) => { 1167 | model.timestamp = Date.now(); 1168 | }); 1169 | } else { 1170 | result.timestamp = Date.now(); 1171 | } 1172 | return result; 1173 | } 1174 | 1175 | return result; 1176 | }, 1177 | }), 1178 | }, 1179 | }, 1180 | }); 1181 | ``` 1182 | 1183 | You could also write the above middleware by creating new objects for each result rather than mutating the existing 1184 | objects: 1185 | 1186 | ```javascript 1187 | const client = _client.$extends({ 1188 | query: { 1189 | $allModels: { 1190 | $allOperations: withNestedOperations({ 1191 | async $rootOperation(params) { 1192 | [...] 1193 | }, 1194 | async $allNestedOperations(params) { 1195 | const result = await next(params); 1196 | 1197 | // ensure result is defined 1198 | if (!result) return result; 1199 | 1200 | // handle nested operations 1201 | if (params.operation === "include" || params.operation === "select") { 1202 | if (Array.isArray(result)) { 1203 | return result.map((model) => ({ 1204 | ...model, 1205 | timestamp: Date.now(), 1206 | }); 1207 | } 1208 | 1209 | return { 1210 | ...result, 1211 | timestamp: Date.now(), 1212 | }; 1213 | } 1214 | 1215 | return result; 1216 | }, 1217 | }), 1218 | }, 1219 | }, 1220 | }); 1221 | ``` 1222 | 1223 | NOTE: When modifying results from `include` or `select` operations it is important to either mutate the existing objects or 1224 | spread the existing objects into the new objects. This is because `createNestedMiddleware` needs some fields from the 1225 | original objects in order to correct update the root results. 1226 | 1227 | ### Errors 1228 | 1229 | If any middleware throws an error at any point then the root query will throw with that error. Any middleware that is 1230 | pending will have it's promises rejects at that point. 1231 | 1232 | ## LICENSE 1233 | 1234 | Apache 2.0 1235 | 1236 | [npm]: https://www.npmjs.com/ 1237 | [node]: https://nodejs.org 1238 | [build-badge]: https://github.com/olivierwilkinson/prisma-extension-nested-operations/workflows/prisma-extension-nested-operations/badge.svg 1239 | [build]: https://github.com/olivierwilkinson/prisma-extension-nested-operations/actions?query=branch%3Amain+workflow%3Aprisma-extension-nested-operations 1240 | [version-badge]: https://img.shields.io/npm/v/prisma-extension-nested-operations.svg?style=flat-square 1241 | [package]: https://www.npmjs.com/package/prisma-extension-nested-operations 1242 | [downloads-badge]: https://img.shields.io/npm/dm/prisma-extension-nested-operations.svg?style=flat-square 1243 | [npmtrends]: http://www.npmtrends.com/prisma-extension-nested-operations 1244 | [license-badge]: https://img.shields.io/npm/l/prisma-extension-nested-operations.svg?style=flat-square 1245 | [license]: https://github.com/olivierwilkinson/prisma-extension-nested-operations/blob/master/LICENSE 1246 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 1247 | [prs]: http://makeapullrequest.com 1248 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 1249 | [coc]: https://github.com/olivierwilkinson/prisma-extension-nested-operations/blob/master/other/CODE_OF_CONDUCT.md 1250 | -------------------------------------------------------------------------------- /test/unit/args.test.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import faker from "faker"; 3 | import { set } from "lodash"; 4 | 5 | import { withNestedOperations } from "../../src"; 6 | import { createParams } from "./helpers/createParams"; 7 | import { wait } from "./helpers/wait"; 8 | 9 | describe("args", () => { 10 | it("does not mutate passed params object", async () => { 11 | const allOperations = withNestedOperations({ 12 | $rootOperation: (params) => { 13 | // @ts-expect-error - testing for mutation 14 | params.args.test = "test"; 15 | return params.query(params.args); 16 | }, 17 | $allNestedOperations: (params) => { 18 | params.args.test = "test"; 19 | return params.query(params.args); 20 | }, 21 | }); 22 | 23 | const query = jest.fn((_: any) => Promise.resolve(null)); 24 | const params = createParams(query, "User", "create", { 25 | data: { 26 | email: faker.internet.email(), 27 | posts: { 28 | create: { title: faker.lorem.sentence() }, 29 | }, 30 | }, 31 | }); 32 | await allOperations(params); 33 | 34 | expect(params.args).not.toHaveProperty("test"); 35 | expect(params.args.data.posts.create).not.toHaveProperty("test"); 36 | }); 37 | 38 | it("passes through instances of Prisma.NullTypes to query", async () => { 39 | const allOperations = withNestedOperations({ 40 | $rootOperation: (params) => { 41 | return params.query(params.args); 42 | }, 43 | $allNestedOperations: (params) => { 44 | return params.query(params.args); 45 | }, 46 | }); 47 | 48 | const query = jest.fn((_: any) => Promise.resolve(null)); 49 | const params = createParams(query, "Profile", "updateMany", { 50 | where: { 51 | OR: [ 52 | { meta: { equals: Prisma.JsonNull } }, 53 | { meta: { equals: Prisma.DbNull } }, 54 | { meta: { equals: Prisma.AnyNull } }, 55 | ], 56 | }, 57 | data: [ 58 | { meta: Prisma.JsonNull }, 59 | { meta: Prisma.DbNull }, 60 | { meta: Prisma.AnyNull }, 61 | ], 62 | }); 63 | await allOperations(params); 64 | 65 | expect(query).toHaveBeenCalledWith(params.args); 66 | expect(query.mock.calls[0][0].where.OR).toHaveLength(3); 67 | query.mock.calls[0][0].where.OR.forEach(({ meta }: any, index: number) => { 68 | expect(meta.equals).toBe(params.args.where.OR[index].meta.equals); 69 | }); 70 | query.mock.calls[0][0].data.forEach(({ meta }: any, index: number) => { 71 | expect(meta).toBe(params.args.data[index].meta); 72 | }); 73 | }); 74 | 75 | it("can modify root args", async () => { 76 | const allOperations = withNestedOperations({ 77 | $rootOperation: (params) => { 78 | if (params.operation === "create" && params.model === "User") { 79 | return params.query({ 80 | ...params.args, 81 | data: set(params.args.data, "name", "Default Name"), 82 | }); 83 | } 84 | return params.query(params.args); 85 | }, 86 | $allNestedOperations: (params) => { 87 | return params.query(params.args); 88 | }, 89 | }); 90 | 91 | const query = jest.fn((_: any) => Promise.resolve({})); 92 | const params = createParams(query, "User", "create", { 93 | data: { email: faker.internet.email() }, 94 | }); 95 | await allOperations(params); 96 | 97 | expect(query).toHaveBeenCalledWith({ 98 | ...params.args, 99 | data: { 100 | ...params.args.data, 101 | name: "Default Name", 102 | }, 103 | }); 104 | }); 105 | 106 | it("can modify root args asynchronously", async () => { 107 | const allOperations = withNestedOperations({ 108 | async $rootOperation(params) { 109 | if (params.operation === "create" && params.model === "User") { 110 | await wait(100); 111 | return params.query({ 112 | ...params.args, 113 | data: set(params.args.data, "name", "Default Name"), 114 | }); 115 | } 116 | return params.query(params.args); 117 | }, 118 | $allNestedOperations: (params) => { 119 | return params.query(params.args); 120 | }, 121 | }); 122 | 123 | const query = jest.fn((_: any) => Promise.resolve(null)); 124 | const params = createParams(query, "User", "create", { 125 | data: { email: faker.internet.email() }, 126 | }); 127 | await allOperations(params); 128 | 129 | expect(query).toHaveBeenCalledWith({ 130 | ...params.args, 131 | data: { 132 | ...params.args.data, 133 | name: "Default Name", 134 | }, 135 | }); 136 | }); 137 | 138 | it("can modify nested args", async () => { 139 | const allOperations = withNestedOperations({ 140 | $rootOperation: (params) => { 141 | return params.query(params.args); 142 | }, 143 | $allNestedOperations: (params) => { 144 | if (params.model === "Post") { 145 | return params.query({ 146 | ...params.args, 147 | number: faker.datatype.number(), 148 | }); 149 | } 150 | return params.query(params.args); 151 | }, 152 | }); 153 | 154 | const query = jest.fn((_: any) => Promise.resolve(null)); 155 | const params = createParams(query, "User", "create", { 156 | data: { 157 | email: faker.internet.email(), 158 | posts: { 159 | create: { title: faker.lorem.sentence() }, 160 | }, 161 | }, 162 | }); 163 | await allOperations(params); 164 | 165 | expect(query).toHaveBeenCalledWith({ 166 | ...params.args, 167 | data: { 168 | ...params.args.data, 169 | posts: { 170 | create: { 171 | title: params.args.data.posts.create.title, 172 | number: expect.any(Number), 173 | }, 174 | }, 175 | }, 176 | }); 177 | }); 178 | 179 | it("can modify nested args asynchronously", async () => { 180 | const allOperations = withNestedOperations({ 181 | $rootOperation: (params) => { 182 | return params.query(params.args); 183 | }, 184 | $allNestedOperations: async (params) => { 185 | if (params.model === "Post") { 186 | await wait(100); 187 | return params.query({ 188 | ...params.args, 189 | number: faker.datatype.number(), 190 | }); 191 | } 192 | return params.query(params.args); 193 | }, 194 | }); 195 | 196 | const query = jest.fn((_: any) => Promise.resolve(null)); 197 | const params = createParams(query, "User", "create", { 198 | data: { 199 | email: faker.internet.email(), 200 | posts: { 201 | create: { title: faker.lorem.sentence() }, 202 | }, 203 | }, 204 | }); 205 | await allOperations(params); 206 | 207 | expect(query).toHaveBeenCalledWith({ 208 | ...params.args, 209 | data: { 210 | ...params.args.data, 211 | posts: { 212 | create: { 213 | title: params.args.data.posts.create.title, 214 | number: expect.any(Number), 215 | }, 216 | }, 217 | }, 218 | }); 219 | }); 220 | 221 | it("can modify nested create list args", async () => { 222 | const allOperations = withNestedOperations({ 223 | $rootOperation: (params) => { 224 | return params.query(params.args); 225 | }, 226 | $allNestedOperations: (params) => { 227 | if (params.model === "Post") { 228 | return params.query({ 229 | ...params.args, 230 | number: params.args.title === "first" ? 1 : 2, 231 | }); 232 | } 233 | return params.query(params.args); 234 | }, 235 | }); 236 | 237 | const query = jest.fn((_: any) => Promise.resolve(null)); 238 | const params = createParams(query, "User", "create", { 239 | data: { 240 | email: faker.internet.email(), 241 | posts: { 242 | create: [{ title: "first" }, { title: "second" }], 243 | }, 244 | }, 245 | }); 246 | await allOperations(params); 247 | 248 | expect(query).toHaveBeenCalledWith({ 249 | ...params.args, 250 | data: { 251 | ...params.args.data, 252 | posts: { 253 | create: [ 254 | { title: "first", number: 1 }, 255 | { title: "second", number: 2 }, 256 | ], 257 | }, 258 | }, 259 | }); 260 | }); 261 | 262 | it("can modify deeply nested toOne update args", async () => { 263 | const allOperations = withNestedOperations({ 264 | $rootOperation: (params) => { 265 | return params.query(params.args); 266 | }, 267 | $allNestedOperations: (params) => { 268 | if (params.model === "Comment") { 269 | if (params.scope && !params.scope.relations.to.isList) { 270 | return params.query({ 271 | ...params.args, 272 | number: parseInt(params.args.content, 10), 273 | }); 274 | } 275 | 276 | return params.query({ 277 | ...params.args, 278 | data: { 279 | ...params.args.data, 280 | number: parseInt(params.args.data.content, 10), 281 | }, 282 | }); 283 | } 284 | 285 | return params.query(params.args); 286 | }, 287 | }); 288 | 289 | const query = jest.fn((_: any) => Promise.resolve(null)); 290 | const params = createParams(query, "User", "update", { 291 | where: { id: faker.datatype.number() }, 292 | data: { 293 | email: faker.internet.email(), 294 | comments: { 295 | update: { 296 | where: { id: faker.datatype.number() }, 297 | data: { 298 | content: "1", 299 | repliedTo: { 300 | update: { 301 | content: "2", 302 | repliedTo: { 303 | update: { 304 | content: "3", 305 | }, 306 | }, 307 | }, 308 | }, 309 | }, 310 | }, 311 | }, 312 | }, 313 | }); 314 | await allOperations(params); 315 | 316 | expect(query).toHaveBeenCalledWith({ 317 | ...params.args, 318 | data: { 319 | ...params.args.data, 320 | comments: { 321 | update: { 322 | where: params.args.data.comments.update.where, 323 | data: { 324 | content: "1", 325 | number: 1, 326 | repliedTo: { 327 | update: { 328 | content: "2", 329 | number: 2, 330 | repliedTo: { 331 | update: { 332 | content: "3", 333 | number: 3, 334 | }, 335 | }, 336 | }, 337 | }, 338 | }, 339 | }, 340 | }, 341 | }, 342 | }); 343 | }); 344 | 345 | it("can modify nested update list args", async () => { 346 | const allOperations = withNestedOperations({ 347 | $rootOperation: (params) => { 348 | return params.query(params.args); 349 | }, 350 | $allNestedOperations: (params) => { 351 | if (params.model === "Post") { 352 | return params.query({ 353 | ...params.args, 354 | data: { 355 | ...params.args.data, 356 | number: params.args.data.title === "first" ? 1 : 2, 357 | }, 358 | }); 359 | } 360 | return params.query(params.args); 361 | }, 362 | }); 363 | 364 | const query = jest.fn((_: any) => Promise.resolve(null)); 365 | const params = createParams(query, "User", "update", { 366 | where: { id: faker.datatype.number() }, 367 | data: { 368 | email: faker.internet.email(), 369 | posts: { 370 | update: [ 371 | { 372 | where: { id: faker.datatype.number() }, 373 | data: { title: "first" }, 374 | }, 375 | { 376 | where: { id: faker.datatype.number() }, 377 | data: { title: "second" }, 378 | }, 379 | ], 380 | }, 381 | }, 382 | }); 383 | await allOperations(params); 384 | 385 | expect(query).toHaveBeenCalledWith({ 386 | ...params.args, 387 | data: { 388 | ...params.args.data, 389 | posts: { 390 | update: [ 391 | { 392 | where: params.args.data.posts.update[0].where, 393 | data: { title: "first", number: 1 }, 394 | }, 395 | { 396 | where: params.args.data.posts.update[1].where, 397 | data: { title: "second", number: 2 }, 398 | }, 399 | ], 400 | }, 401 | }, 402 | }); 403 | }); 404 | 405 | it("can modify nested delete list args", async () => { 406 | const allOperations = withNestedOperations({ 407 | $rootOperation: (params) => { 408 | return params.query(params.args); 409 | }, 410 | $allNestedOperations: (params) => { 411 | if (params.operation === "delete" && params.model === "Post") { 412 | return params.query({ id: params.args.id + 1 }); 413 | } 414 | return params.query(params.args); 415 | }, 416 | }); 417 | 418 | const query = jest.fn((_: any) => Promise.resolve(null)); 419 | const params = createParams(query, "User", "update", { 420 | where: { id: faker.datatype.number() }, 421 | data: { 422 | email: faker.internet.email(), 423 | posts: { 424 | delete: [{ id: 1 }, { id: 2 }], 425 | }, 426 | }, 427 | }); 428 | await allOperations(params); 429 | 430 | expect(query).toHaveBeenCalledWith({ 431 | ...params.args, 432 | data: { 433 | ...params.args.data, 434 | posts: { 435 | delete: [{ id: 2 }, { id: 3 }], 436 | }, 437 | }, 438 | }); 439 | }); 440 | 441 | it("can modify args of operations nested in list", async () => { 442 | const allOperations = withNestedOperations({ 443 | $rootOperation: (params) => { 444 | return params.query(params.args); 445 | }, 446 | $allNestedOperations: (params) => { 447 | if (params.operation === "create" && params.model === "Comment") { 448 | return params.query({ 449 | ...params.args, 450 | number: params.args.content === "first post comment" ? 1 : 2, 451 | }); 452 | } 453 | return params.query(params.args); 454 | }, 455 | }); 456 | 457 | const query = jest.fn((_: any) => Promise.resolve(null)); 458 | const params = createParams(query, "User", "update", { 459 | where: { id: faker.datatype.number() }, 460 | data: { 461 | email: faker.internet.email(), 462 | posts: { 463 | update: [ 464 | { 465 | where: { id: faker.datatype.number() }, 466 | data: { 467 | title: "first", 468 | comments: { 469 | create: { 470 | content: "first post comment", 471 | authorId: faker.datatype.number(), 472 | }, 473 | }, 474 | }, 475 | }, 476 | { 477 | where: { id: faker.datatype.number() }, 478 | data: { 479 | title: "second", 480 | comments: { 481 | create: { 482 | content: "second post comment", 483 | authorId: faker.datatype.number(), 484 | }, 485 | }, 486 | }, 487 | }, 488 | ], 489 | }, 490 | }, 491 | }); 492 | await allOperations(params); 493 | 494 | expect(query).toHaveBeenCalledWith({ 495 | ...params.args, 496 | data: { 497 | ...params.args.data, 498 | posts: { 499 | update: [ 500 | { 501 | where: params.args.data.posts.update[0].where, 502 | data: { 503 | title: "first", 504 | comments: { 505 | create: { 506 | content: "first post comment", 507 | authorId: expect.any(Number), 508 | number: 1, 509 | }, 510 | }, 511 | }, 512 | }, 513 | { 514 | where: params.args.data.posts.update[1].where, 515 | data: { 516 | title: "second", 517 | comments: { 518 | create: { 519 | content: "second post comment", 520 | authorId: expect.any(Number), 521 | number: 2, 522 | }, 523 | }, 524 | }, 525 | }, 526 | ], 527 | }, 528 | }, 529 | }); 530 | }); 531 | 532 | it("can modify args of deeply nested lists of create operations", async () => { 533 | const allOperations = withNestedOperations({ 534 | $rootOperation: (params) => { 535 | return params.query(params.args); 536 | }, 537 | $allNestedOperations: (params) => { 538 | if (params.operation === "create" && params.model === "Comment") { 539 | if (params.scope) { 540 | return params.query({ 541 | ...params.args, 542 | number: params.args.content === "first post comment" ? 1 : 2, 543 | }); 544 | } 545 | 546 | return params.query({ 547 | ...params.args, 548 | data: { 549 | ...params.args.data, 550 | number: params.args.data.content === "first post comment" ? 1 : 2, 551 | }, 552 | }); 553 | } 554 | return params.query(params.args); 555 | }, 556 | }); 557 | 558 | const query = jest.fn((_: any) => Promise.resolve(null)); 559 | const params = createParams(query, "User", "update", { 560 | where: { id: faker.datatype.number() }, 561 | data: { 562 | email: faker.internet.email(), 563 | posts: { 564 | create: [ 565 | { 566 | title: "first", 567 | comments: { 568 | create: [ 569 | { 570 | content: "first post comment", 571 | authorId: faker.datatype.number(), 572 | }, 573 | { 574 | content: "second post comment", 575 | authorId: faker.datatype.number(), 576 | }, 577 | ], 578 | }, 579 | }, 580 | { 581 | title: "second", 582 | comments: { 583 | create: [ 584 | { 585 | content: "first post comment", 586 | authorId: faker.datatype.number(), 587 | }, 588 | { 589 | content: "second post comment", 590 | authorId: faker.datatype.number(), 591 | }, 592 | ], 593 | }, 594 | }, 595 | ], 596 | }, 597 | }, 598 | }); 599 | await allOperations(params); 600 | 601 | expect(query).toHaveBeenCalledWith({ 602 | ...params.args, 603 | data: { 604 | ...params.args.data, 605 | posts: { 606 | create: [ 607 | { 608 | title: "first", 609 | comments: { 610 | create: [ 611 | { 612 | content: "first post comment", 613 | authorId: expect.any(Number), 614 | number: 1, 615 | }, 616 | { 617 | content: "second post comment", 618 | authorId: expect.any(Number), 619 | number: 2, 620 | }, 621 | ], 622 | }, 623 | }, 624 | { 625 | title: "second", 626 | comments: { 627 | create: [ 628 | { 629 | content: "first post comment", 630 | authorId: expect.any(Number), 631 | number: 1, 632 | }, 633 | { 634 | content: "second post comment", 635 | authorId: expect.any(Number), 636 | number: 2, 637 | }, 638 | ], 639 | }, 640 | }, 641 | ], 642 | }, 643 | }, 644 | }); 645 | }); 646 | 647 | it("can modify include args", async () => { 648 | const allOperations = withNestedOperations({ 649 | $rootOperation: (params) => { 650 | if (params.operation === "create" && params.model === "User") { 651 | return params.query({ 652 | ...params.args, 653 | include: { 654 | posts: params.args.include?.posts && { 655 | include: { 656 | comments: true, 657 | }, 658 | }, 659 | }, 660 | }); 661 | } 662 | return params.query(params.args); 663 | }, 664 | $allNestedOperations: (params) => { 665 | return params.query(params.args); 666 | }, 667 | }); 668 | 669 | const query = jest.fn((_: any) => Promise.resolve(null)); 670 | const params = createParams(query, "User", "create", { 671 | data: { 672 | email: faker.internet.email(), 673 | }, 674 | include: { 675 | posts: true, 676 | }, 677 | }); 678 | await allOperations(params); 679 | 680 | expect(query).toHaveBeenCalledWith({ 681 | ...params.args, 682 | include: { 683 | posts: { 684 | include: { 685 | comments: true, 686 | }, 687 | }, 688 | }, 689 | }); 690 | }); 691 | 692 | it("can modify include args through include actions", async () => { 693 | const allOperations = withNestedOperations({ 694 | $rootOperation: (params) => { 695 | return params.query(params.args); 696 | }, 697 | $allNestedOperations: (params) => { 698 | if (params.operation === "include" && params.model === "Post") { 699 | return params.query({ 700 | orderBy: { createdAt: "desc" }, 701 | comments: true, 702 | skip: params.args.skip + 1, 703 | }); 704 | } 705 | return params.query(params.args); 706 | }, 707 | }); 708 | 709 | const query = jest.fn((_: any) => Promise.resolve(null)); 710 | const params = createParams(query, "User", "create", { 711 | data: { 712 | email: faker.internet.email(), 713 | }, 714 | include: { 715 | posts: { 716 | orderBy: { createdAt: "asc" }, 717 | skip: 10, 718 | }, 719 | }, 720 | }); 721 | await allOperations(params); 722 | 723 | expect(query).toHaveBeenCalledWith({ 724 | ...params.args, 725 | include: { 726 | ...params.args.include, 727 | posts: { 728 | ...params.args.include.posts, 729 | orderBy: { createdAt: "desc" }, 730 | comments: true, 731 | skip: 11, 732 | }, 733 | }, 734 | }); 735 | }); 736 | 737 | it("can modify deeply nested include args through include action", async () => { 738 | const allOperations = withNestedOperations({ 739 | $rootOperation: (params) => { 740 | return params.query(params.args); 741 | }, 742 | $allNestedOperations: (params) => { 743 | if (params.operation === "include" && params.model === "Comment") { 744 | if (params.args.skip) { 745 | params.args.skip += 1; 746 | } 747 | return params.query({ 748 | ...params.args, 749 | orderBy: { createdAt: "desc" }, 750 | }); 751 | } 752 | return params.query(params.args); 753 | }, 754 | }); 755 | 756 | const query = jest.fn((_: any) => Promise.resolve(null)); 757 | const params = createParams(query, "User", "create", { 758 | data: { 759 | email: faker.internet.email(), 760 | }, 761 | include: { 762 | posts: { 763 | include: { 764 | comments: { 765 | include: { replies: { skip: 10 } }, 766 | }, 767 | }, 768 | }, 769 | }, 770 | }); 771 | await allOperations(params); 772 | 773 | expect(query).toHaveBeenCalledWith({ 774 | ...params.args, 775 | include: { 776 | posts: { 777 | include: { 778 | comments: { 779 | orderBy: { createdAt: "desc" }, 780 | include: { 781 | replies: { 782 | orderBy: { createdAt: "desc" }, 783 | skip: 11, 784 | }, 785 | }, 786 | }, 787 | }, 788 | }, 789 | }, 790 | }); 791 | }); 792 | 793 | it("can modify select args", async () => { 794 | const allOperations = withNestedOperations({ 795 | $rootOperation: (params) => { 796 | if (params.operation === "create" && params.model === "User") { 797 | return params.query({ 798 | ...params.args, 799 | select: { 800 | email: true, 801 | posts: params.args.select?.posts && { 802 | select: { 803 | title: true, 804 | }, 805 | }, 806 | }, 807 | }); 808 | } 809 | return params.query(params.args); 810 | }, 811 | $allNestedOperations: (params) => { 812 | return params.query(params.args); 813 | }, 814 | }); 815 | 816 | const query = jest.fn((_: any) => Promise.resolve(null)); 817 | const params = createParams(query, "User", "create", { 818 | data: { 819 | email: faker.internet.email(), 820 | }, 821 | select: { posts: true }, 822 | }); 823 | await allOperations(params); 824 | 825 | expect(query).toHaveBeenCalledWith({ 826 | ...params.args, 827 | select: { 828 | email: true, 829 | posts: { 830 | select: { 831 | title: true, 832 | }, 833 | }, 834 | }, 835 | }); 836 | }); 837 | 838 | it("can modify select args through select action", async () => { 839 | const allOperations = withNestedOperations({ 840 | $rootOperation: (params) => { 841 | return params.query(params.args); 842 | }, 843 | $allNestedOperations: (params) => { 844 | if (params.operation === "select" && params.model === "Post") { 845 | return params.query({ 846 | ...params.args, 847 | select: { 848 | title: true, 849 | comments: params.args.select.comments && { 850 | select: { 851 | content: true, 852 | }, 853 | }, 854 | }, 855 | }); 856 | } 857 | if (params.operation === "select" && params.model === "Comment") { 858 | return params.query({ 859 | ...params.args, 860 | select: { 861 | content: true, 862 | }, 863 | }); 864 | } 865 | return params.query(params.args); 866 | }, 867 | }); 868 | 869 | const query = jest.fn((_: any) => Promise.resolve(null)); 870 | const params = createParams(query, "User", "create", { 871 | data: { 872 | email: faker.internet.email(), 873 | }, 874 | select: { 875 | posts: { 876 | select: { 877 | comments: true, 878 | }, 879 | }, 880 | }, 881 | }); 882 | 883 | await allOperations(params); 884 | 885 | expect(query).toHaveBeenCalledWith({ 886 | ...params.args, 887 | select: { 888 | posts: { 889 | select: { 890 | title: true, 891 | comments: { 892 | select: { 893 | content: true, 894 | }, 895 | }, 896 | }, 897 | }, 898 | }, 899 | }); 900 | }); 901 | 902 | it("can modify deeply nested select args through select action", async () => { 903 | const allOperations = withNestedOperations({ 904 | $rootOperation: (params) => { 905 | return params.query(params.args); 906 | }, 907 | $allNestedOperations: (params) => { 908 | if (params.operation === "select" && params.model === "Comment") { 909 | return params.query({ 910 | ...params.args, 911 | select: { 912 | ...(typeof params.args.select === "boolean" 913 | ? {} 914 | : params.args.select), 915 | content: true, 916 | }, 917 | }); 918 | } 919 | return params.query(params.args); 920 | }, 921 | }); 922 | 923 | const query = jest.fn((_: any) => Promise.resolve(null)); 924 | const params = createParams(query, "User", "create", { 925 | data: { 926 | email: faker.internet.email(), 927 | }, 928 | select: { 929 | posts: { 930 | select: { 931 | comments: { 932 | select: { replies: true }, 933 | }, 934 | }, 935 | }, 936 | }, 937 | }); 938 | 939 | await allOperations(params); 940 | 941 | expect(query).toHaveBeenCalledWith({ 942 | ...params.args, 943 | select: { 944 | posts: { 945 | select: { 946 | comments: { 947 | select: { 948 | content: true, 949 | replies: { 950 | select: { 951 | content: true, 952 | }, 953 | }, 954 | }, 955 | }, 956 | }, 957 | }, 958 | }, 959 | }); 960 | }); 961 | 962 | it("can add data to nested createMany args", async () => { 963 | const allOperations = withNestedOperations({ 964 | $rootOperation: (params) => { 965 | return params.query(params.args); 966 | }, 967 | $allNestedOperations: (params) => { 968 | if (params.operation === "createMany") { 969 | return params.query({ 970 | ...params.args, 971 | data: [ 972 | ...params.args.data.map((data: any) => ({ 973 | ...data, 974 | number: faker.datatype.number(), 975 | })), 976 | { 977 | content: faker.lorem.sentence(), 978 | number: faker.datatype.number(), 979 | }, 980 | ], 981 | }); 982 | } 983 | return params.query(params.args); 984 | }, 985 | }); 986 | 987 | const query = jest.fn((_: any) => Promise.resolve(null)); 988 | const params = createParams(query, "User", "create", { 989 | data: { 990 | email: faker.internet.email(), 991 | comments: { 992 | createMany: { data: [{ content: faker.lorem.sentence() }] }, 993 | }, 994 | }, 995 | }); 996 | await allOperations(params); 997 | 998 | expect(query).toHaveBeenCalledWith({ 999 | ...params.args, 1000 | data: { 1001 | ...params.args.data, 1002 | comments: { 1003 | createMany: { 1004 | data: [ 1005 | { 1006 | content: params.args.data.comments.createMany.data[0].content, 1007 | number: expect.any(Number), 1008 | }, 1009 | { content: expect.any(String), number: expect.any(Number) }, 1010 | ], 1011 | }, 1012 | }, 1013 | }, 1014 | }); 1015 | }); 1016 | 1017 | it("allows user to reorder nested createMany args", async () => { 1018 | const allOperations = withNestedOperations({ 1019 | $rootOperation: (params) => { 1020 | return params.query(params.args); 1021 | }, 1022 | $allNestedOperations: (params) => { 1023 | if (params.operation === "createMany") { 1024 | return params.query({ 1025 | ...params.args, 1026 | data: [...params.args.data].reverse(), 1027 | }); 1028 | } 1029 | return params.query(params.args); 1030 | }, 1031 | }); 1032 | 1033 | const query = jest.fn((_: any) => Promise.resolve(null)); 1034 | const params = createParams(query, "User", "create", { 1035 | data: { 1036 | email: faker.internet.email(), 1037 | comments: { 1038 | createMany: { 1039 | data: [{ content: "first" }, { content: "second" }], 1040 | }, 1041 | }, 1042 | }, 1043 | }); 1044 | await allOperations(params); 1045 | 1046 | expect(query).toHaveBeenCalledWith({ 1047 | ...params.args, 1048 | data: { 1049 | ...params.args.data, 1050 | comments: { 1051 | createMany: { 1052 | data: [{ content: "second" }, { content: "first" }], 1053 | }, 1054 | }, 1055 | }, 1056 | }); 1057 | }); 1058 | 1059 | it("allows user to add data to nested createMany args", async () => { 1060 | const allOperations = withNestedOperations({ 1061 | $rootOperation: (params) => { 1062 | return params.query(params.args); 1063 | }, 1064 | $allNestedOperations: (params) => { 1065 | if (params.operation === "createMany") { 1066 | return params.query({ 1067 | ...params.args, 1068 | data: [ 1069 | ...params.args.data.map((data: any) => ({ 1070 | ...data, 1071 | number: faker.datatype.number(), 1072 | })), 1073 | { 1074 | content: faker.lorem.sentence(), 1075 | number: faker.datatype.number(), 1076 | }, 1077 | ], 1078 | }); 1079 | } 1080 | return params.query(params.args); 1081 | }, 1082 | }); 1083 | 1084 | const query = jest.fn((_: any) => Promise.resolve(null)); 1085 | const params = createParams(query, "User", "create", { 1086 | data: { 1087 | email: faker.internet.email(), 1088 | comments: { 1089 | createMany: { data: [{ content: faker.lorem.sentence() }] }, 1090 | }, 1091 | }, 1092 | }); 1093 | await allOperations(params); 1094 | 1095 | expect(query).toHaveBeenCalledWith({ 1096 | ...params.args, 1097 | data: { 1098 | ...params.args.data, 1099 | comments: { 1100 | createMany: { 1101 | data: [ 1102 | { 1103 | content: params.args.data.comments.createMany.data[0].content, 1104 | number: expect.any(Number), 1105 | }, 1106 | { content: expect.any(String), number: expect.any(Number) }, 1107 | ], 1108 | }, 1109 | }, 1110 | }, 1111 | }); 1112 | }); 1113 | 1114 | it("allows user to remove data from nested createMany args", async () => { 1115 | const allOperations = withNestedOperations({ 1116 | $rootOperation: (params) => { 1117 | return params.query(params.args); 1118 | }, 1119 | $allNestedOperations: (params) => { 1120 | if (params.operation === "createMany") { 1121 | return params.query({ 1122 | ...params.args, 1123 | data: [ 1124 | { ...params.args.data[0], number: faker.datatype.number() }, 1125 | { number: faker.datatype.number() }, 1126 | ], 1127 | }); 1128 | } 1129 | return params.query(params.args); 1130 | }, 1131 | }); 1132 | 1133 | const query = jest.fn((_: any) => Promise.resolve(null)); 1134 | const params = createParams(query, "User", "create", { 1135 | data: { 1136 | email: faker.internet.email(), 1137 | comments: { 1138 | createMany: { 1139 | data: [ 1140 | { content: faker.lorem.sentence() }, 1141 | { content: faker.lorem.sentence() }, 1142 | { content: faker.lorem.sentence() }, 1143 | ], 1144 | }, 1145 | }, 1146 | }, 1147 | }); 1148 | await allOperations(params); 1149 | 1150 | expect(query).toHaveBeenCalledWith({ 1151 | ...params.args, 1152 | data: { 1153 | ...params.args.data, 1154 | comments: { 1155 | createMany: { 1156 | data: [ 1157 | { 1158 | content: params.args.data.comments.createMany.data[0].content, 1159 | number: expect.any(Number), 1160 | }, 1161 | { number: expect.any(Number) }, 1162 | ], 1163 | }, 1164 | }, 1165 | }, 1166 | }); 1167 | }); 1168 | 1169 | it("allows user to modify nested where args", async () => { 1170 | const allOperations = withNestedOperations({ 1171 | $rootOperation: (params) => { 1172 | return params.query(params.args); 1173 | }, 1174 | $allNestedOperations: (params) => { 1175 | if (params.operation === "where" && params.model === "Comment") { 1176 | return params.query({ 1177 | ...params.args, 1178 | content: "bar", 1179 | }); 1180 | } 1181 | return params.query(params.args); 1182 | }, 1183 | }); 1184 | 1185 | const query = jest.fn((_: any) => Promise.resolve(null)); 1186 | const params = createParams(query, "User", "findMany", { 1187 | where: { 1188 | posts: { 1189 | some: { 1190 | comments: { 1191 | some: { 1192 | content: "foo", 1193 | }, 1194 | }, 1195 | }, 1196 | }, 1197 | }, 1198 | }); 1199 | 1200 | await allOperations(params); 1201 | 1202 | expect(query).toHaveBeenCalledWith( 1203 | set(params.args, "where.posts.some.comments.some.content", "bar") 1204 | ); 1205 | }); 1206 | 1207 | it("allows user to modify nested where args by removing a field", async () => { 1208 | const allOperations = withNestedOperations({ 1209 | $rootOperation: (params) => { 1210 | return params.query(params.args); 1211 | }, 1212 | $allNestedOperations: (params) => { 1213 | if (params.operation === "where" && params.model === "Comment") { 1214 | return params.query({ 1215 | // remove content and replace it with updatedAt 1216 | updatedAt: { 1217 | gt: new Date(), 1218 | }, 1219 | }); 1220 | } 1221 | return params.query(params.args); 1222 | }, 1223 | }); 1224 | 1225 | const query = jest.fn((_: any) => Promise.resolve(null)); 1226 | const params = createParams(query, "User", "findMany", { 1227 | where: { 1228 | posts: { 1229 | some: { 1230 | comments: { 1231 | some: { 1232 | content: "foo", 1233 | }, 1234 | }, 1235 | }, 1236 | }, 1237 | }, 1238 | }); 1239 | 1240 | await allOperations(params); 1241 | 1242 | expect(query).toHaveBeenCalledWith( 1243 | set(params.args, "where.posts.some.comments.some", { 1244 | updatedAt: { 1245 | gt: expect.any(Date), 1246 | }, 1247 | }) 1248 | ); 1249 | }); 1250 | 1251 | it("allows user to modify nested where args with nested where", async () => { 1252 | const allOperations = withNestedOperations({ 1253 | $rootOperation: (params) => { 1254 | return params.query(params.args); 1255 | }, 1256 | $allNestedOperations: (params) => { 1257 | if (params.operation === "where" && params.model === "Comment") { 1258 | return params.query({ 1259 | ...params.args, 1260 | content: { 1261 | contains: "bar", 1262 | }, 1263 | }); 1264 | } 1265 | return params.query(params.args); 1266 | }, 1267 | }); 1268 | 1269 | const query = jest.fn((_: any) => Promise.resolve(null)); 1270 | const params = createParams(query, "User", "findMany", { 1271 | where: { 1272 | posts: { 1273 | some: { 1274 | comments: { 1275 | some: { 1276 | content: "foo", 1277 | }, 1278 | }, 1279 | }, 1280 | }, 1281 | }, 1282 | }); 1283 | 1284 | await allOperations(params); 1285 | 1286 | expect(query).toHaveBeenCalledWith( 1287 | set(params.args, "where.posts.some.comments.some.content", { 1288 | contains: "bar", 1289 | }) 1290 | ); 1291 | }); 1292 | 1293 | it("allows user to modify nested where args with nested where in logical operation", async () => { 1294 | const allOperations = withNestedOperations({ 1295 | $rootOperation: (params) => { 1296 | return params.query(params.args); 1297 | }, 1298 | $allNestedOperations: (params) => { 1299 | if (params.operation === "where" && params.model === "Comment") { 1300 | return params.query({ 1301 | ...params.args, 1302 | content: { 1303 | contains: "bar", 1304 | }, 1305 | }); 1306 | } 1307 | return params.query(params.args); 1308 | }, 1309 | }); 1310 | 1311 | const query = jest.fn((_: any) => Promise.resolve(null)); 1312 | const params = createParams(query, "User", "findMany", { 1313 | where: { 1314 | posts: { 1315 | some: { 1316 | AND: [ 1317 | { 1318 | author: { 1319 | id: 1, 1320 | }, 1321 | }, 1322 | { 1323 | comments: { 1324 | some: { 1325 | content: "foo", 1326 | }, 1327 | }, 1328 | }, 1329 | ], 1330 | }, 1331 | }, 1332 | }, 1333 | }); 1334 | 1335 | await allOperations(params); 1336 | 1337 | expect(query).toHaveBeenCalledWith( 1338 | set(params.args, "where.posts.some.AND.1.comments.some.content", { 1339 | contains: "bar", 1340 | }) 1341 | ); 1342 | }); 1343 | 1344 | it("allows user to modify where args deeply nested in logical operations", async () => { 1345 | const allOperations = withNestedOperations({ 1346 | $rootOperation: (params) => { 1347 | return params.query(params.args); 1348 | }, 1349 | $allNestedOperations: (params) => { 1350 | if ( 1351 | params.operation === "where" && 1352 | params.model === "User" && 1353 | params.scope 1354 | ) { 1355 | return params.query({ 1356 | ...params.args, 1357 | ...(params.args.id ? { id: params.args.id + 1 } : {}), 1358 | }); 1359 | } 1360 | 1361 | if (params.operation === "where" && params.model === "Comment") { 1362 | return params.query({ 1363 | ...params.args, 1364 | content: "bar", 1365 | }); 1366 | } 1367 | return params.query(params.args); 1368 | }, 1369 | }); 1370 | 1371 | const query = jest.fn((_: any) => Promise.resolve(null)); 1372 | const params = createParams(query, "User", "findMany", { 1373 | where: { 1374 | posts: { 1375 | some: { 1376 | AND: [ 1377 | { 1378 | NOT: { 1379 | OR: [ 1380 | { 1381 | AND: [ 1382 | { 1383 | NOT: { 1384 | OR: [ 1385 | { 1386 | id: 1, 1387 | author: { 1388 | id: 2, 1389 | }, 1390 | }, 1391 | ], 1392 | }, 1393 | }, 1394 | ], 1395 | comments: { 1396 | some: { 1397 | content: "foo", 1398 | }, 1399 | }, 1400 | }, 1401 | ], 1402 | }, 1403 | }, 1404 | ], 1405 | }, 1406 | }, 1407 | }, 1408 | }); 1409 | 1410 | await allOperations(params); 1411 | 1412 | set( 1413 | params, 1414 | "args.where.posts.some.AND.0.NOT.OR.0.AND.0.NOT.OR.0.author.id", 1415 | 3 1416 | ); 1417 | set( 1418 | params, 1419 | "args.where.posts.some.AND.0.NOT.OR.0.comments.some.content", 1420 | "bar" 1421 | ); 1422 | 1423 | expect(query).toHaveBeenCalledWith(params.args); 1424 | }); 1425 | 1426 | it("allows user to modify nested include where args", async () => { 1427 | const allOperations = withNestedOperations({ 1428 | $rootOperation: (params) => { 1429 | return params.query(params.args); 1430 | }, 1431 | $allNestedOperations: (params) => { 1432 | if (params.operation === "where" && params.model === "Post") { 1433 | return params.query({ 1434 | ...params.args, 1435 | title: "bar", 1436 | }); 1437 | } 1438 | return params.query(params.args); 1439 | }, 1440 | }); 1441 | 1442 | const query = jest.fn((_: any) => Promise.resolve(null)); 1443 | const params = createParams(query, "User", "findUnique", { 1444 | where: { id: 1 }, 1445 | include: { 1446 | posts: { 1447 | where: { 1448 | title: "foo", 1449 | }, 1450 | }, 1451 | }, 1452 | }); 1453 | 1454 | await allOperations(params); 1455 | 1456 | expect(query).toHaveBeenCalledWith( 1457 | set(params.args, "include.posts.where.title", "bar") 1458 | ); 1459 | }); 1460 | 1461 | it("allows user to modify nested select where args", async () => { 1462 | const allOperations = withNestedOperations({ 1463 | $rootOperation: (params) => { 1464 | return params.query(params.args); 1465 | }, 1466 | $allNestedOperations: (params) => { 1467 | if (params.operation === "where" && params.model === "Post") { 1468 | return params.query({ 1469 | ...params.args, 1470 | title: "bar", 1471 | }); 1472 | } 1473 | return params.query(params.args); 1474 | }, 1475 | }); 1476 | 1477 | const query = jest.fn((_: any) => Promise.resolve(null)); 1478 | const params = createParams(query, "User", "findUnique", { 1479 | where: { id: 1 }, 1480 | select: { 1481 | posts: { 1482 | where: { 1483 | title: "foo", 1484 | }, 1485 | }, 1486 | }, 1487 | }); 1488 | 1489 | await allOperations(params); 1490 | 1491 | expect(query).toHaveBeenCalledWith( 1492 | set(params.args, "select.posts.where.title", "bar") 1493 | ); 1494 | }); 1495 | 1496 | it("allows user to modify nested where relation args in nested include where", async () => { 1497 | const allOperations = withNestedOperations({ 1498 | $rootOperation: (params) => { 1499 | return params.query(params.args); 1500 | }, 1501 | $allNestedOperations: (params) => { 1502 | if (params.operation === "where" && params.model === "Post") { 1503 | return params.query({ 1504 | ...params.args, 1505 | title: "bar", 1506 | }); 1507 | } 1508 | if (params.operation === "where" && params.model === "Comment") { 1509 | return params.query({ 1510 | ...params.args, 1511 | content: "bar", 1512 | }); 1513 | } 1514 | if (params.operation === "where" && params.model === "User") { 1515 | return params.query({ 1516 | ...params.args, 1517 | email: "bar", 1518 | }); 1519 | } 1520 | return params.query(params.args); 1521 | }, 1522 | }); 1523 | 1524 | const query = jest.fn((_: any) => Promise.resolve(null)); 1525 | const params = createParams(query, "User", "findUnique", { 1526 | where: { id: 1 }, 1527 | include: { 1528 | posts: { 1529 | where: { 1530 | title: "foo", 1531 | AND: [ 1532 | { author: { id: 1, email: "foo" } }, 1533 | { comments: { every: { content: "foo" } } }, 1534 | ], 1535 | OR: [{ NOT: { author: { id: 1, email: "foo" } } }], 1536 | NOT: { comments: { some: { content: "foo" } } }, 1537 | }, 1538 | }, 1539 | }, 1540 | }); 1541 | 1542 | await allOperations(params); 1543 | 1544 | set(params, "args.include.posts.where.title", "bar"); 1545 | set(params, "args.include.posts.where.AND.0.author.email", "bar"); 1546 | set(params, "args.include.posts.where.AND.1.comments.every.content", "bar"); 1547 | set(params, "args.include.posts.where.OR.0.NOT.author.email", "bar"); 1548 | set(params, "args.include.posts.where.NOT.comments.some.content", "bar"); 1549 | 1550 | expect(query).toHaveBeenCalledWith(params.args); 1551 | }); 1552 | 1553 | it("allows user to modify nested where relation args in nested select where", async () => { 1554 | const allOperations = withNestedOperations({ 1555 | $rootOperation: (params) => { 1556 | return params.query(params.args); 1557 | }, 1558 | $allNestedOperations: (params) => { 1559 | if (params.operation === "where" && params.model === "Post") { 1560 | return params.query({ 1561 | ...params.args, 1562 | title: "bar", 1563 | }); 1564 | } 1565 | if (params.operation === "where" && params.model === "Comment") { 1566 | return params.query({ 1567 | ...params.args, 1568 | content: "bar", 1569 | }); 1570 | } 1571 | if (params.operation === "where" && params.model === "User") { 1572 | return params.query({ 1573 | ...params.args, 1574 | email: "bar", 1575 | }); 1576 | } 1577 | return params.query(params.args); 1578 | }, 1579 | }); 1580 | 1581 | const query = jest.fn((_: any) => Promise.resolve(null)); 1582 | const params = createParams(query, "User", "findUnique", { 1583 | where: { id: 1 }, 1584 | select: { 1585 | posts: { 1586 | where: { 1587 | title: "foo", 1588 | AND: [ 1589 | { author: { id: 1, email: "foo" } }, 1590 | { comments: { every: { content: "foo" } } }, 1591 | ], 1592 | OR: [{ NOT: { author: { id: 1, email: "foo" } } }], 1593 | NOT: { comments: { some: { content: "foo" } } }, 1594 | }, 1595 | }, 1596 | }, 1597 | }); 1598 | 1599 | await allOperations(params); 1600 | 1601 | set(params, "args.select.posts.where.title", "bar"); 1602 | set(params, "args.select.posts.where.AND.0.author.email", "bar"); 1603 | set(params, "args.select.posts.where.AND.1.comments.every.content", "bar"); 1604 | set(params, "args.select.posts.where.OR.0.NOT.author.email", "bar"); 1605 | set(params, "args.select.posts.where.NOT.comments.some.content", "bar"); 1606 | 1607 | expect(query).toHaveBeenCalledWith(params.args); 1608 | }); 1609 | 1610 | it("ignores invalid values passed to where logical operations", async () => { 1611 | const allOperations = withNestedOperations({ 1612 | $rootOperation: (params) => { 1613 | return params.query(params.args); 1614 | }, 1615 | $allNestedOperations: (params) => { 1616 | if (params.operation === "where" && params.model === "Comment") { 1617 | return params.query({ 1618 | ...params.args, 1619 | content: { 1620 | contains: "bar", 1621 | }, 1622 | }); 1623 | } 1624 | return params.query(params.args); 1625 | }, 1626 | }); 1627 | 1628 | const query = jest.fn((_: any) => Promise.resolve(null)); 1629 | const params = createParams(query, "User", "findMany", { 1630 | where: { 1631 | posts: { 1632 | some: { 1633 | AND: [ 1634 | { 1635 | comments: { 1636 | some: { 1637 | content: "foo", 1638 | }, 1639 | }, 1640 | }, 1641 | // @ts-expect-error invalid value 1642 | null, 1643 | // @ts-expect-error invalid value 1644 | undefined, 1645 | // @ts-expect-error invalid value 1646 | 1, 1647 | // @ts-expect-error invalid value 1648 | "foo", 1649 | // @ts-expect-error invalid value 1650 | true, 1651 | ], 1652 | // @ts-expect-error invalid value 1653 | NOT: null, 1654 | // @ts-expect-error invalid value 1655 | OR: true, 1656 | }, 1657 | }, 1658 | }, 1659 | }); 1660 | 1661 | await allOperations(params); 1662 | 1663 | expect(query).toHaveBeenCalledWith( 1664 | set(params.args, "where.posts.some.AND.0.comments.some.content", { 1665 | contains: "bar", 1666 | }) 1667 | ); 1668 | }); 1669 | 1670 | it("waits for all middleware to finish before calling query when modifying args", async () => { 1671 | const allOperations = withNestedOperations({ 1672 | $rootOperation: (params) => { 1673 | return params.query(params.args); 1674 | }, 1675 | $allNestedOperations: async (params) => { 1676 | if (params.model === "Post") { 1677 | await wait(100); 1678 | return params.query({ 1679 | ...params.args, 1680 | number: faker.datatype.number(), 1681 | }); 1682 | } 1683 | 1684 | if (params.model === "Comment") { 1685 | await wait(200); 1686 | return params.query({ 1687 | ...params.args, 1688 | number: faker.datatype.number(), 1689 | }); 1690 | } 1691 | 1692 | return params.query(params.args); 1693 | }, 1694 | }); 1695 | 1696 | const query = jest.fn((_: any) => Promise.resolve(null)); 1697 | const params = createParams(query, "User", "create", { 1698 | data: { 1699 | email: faker.internet.email(), 1700 | posts: { 1701 | create: { 1702 | title: faker.lorem.sentence(), 1703 | comments: { 1704 | create: { 1705 | content: faker.lorem.sentence(), 1706 | authorId: faker.datatype.number(), 1707 | }, 1708 | }, 1709 | }, 1710 | }, 1711 | }, 1712 | }); 1713 | await allOperations(params); 1714 | 1715 | expect(query).toHaveBeenCalledWith({ 1716 | ...params.args, 1717 | data: { 1718 | ...params.args.data, 1719 | posts: { 1720 | create: { 1721 | title: params.args.data.posts.create.title, 1722 | number: expect.any(Number), 1723 | comments: { 1724 | create: { 1725 | ...params.args.data.posts.create.comments.create, 1726 | number: expect.any(Number), 1727 | }, 1728 | }, 1729 | }, 1730 | }, 1731 | }, 1732 | }); 1733 | }); 1734 | }); 1735 | --------------------------------------------------------------------------------