├── .eslintrc ├── .github └── workflows │ └── prisma-soft-delete-middleware.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── docker-compose.yml ├── jest.config.e2e.js ├── jest.config.js ├── jest.config.unit.js ├── package.json ├── prisma └── schema.prisma ├── src ├── index.ts └── lib │ ├── actionMiddleware.ts │ ├── createSoftDeleteMiddleware.ts │ ├── types.ts │ └── utils │ ├── nestedReads.ts │ └── resultFiltering.ts ├── test ├── e2e │ ├── client.ts │ ├── deletedAt.test.ts │ ├── nestedReads.test.ts │ ├── queries.test.ts │ └── where.test.ts ├── scripts │ └── run-with-postgres.sh └── unit │ ├── aggregate.test.ts │ ├── config.test.ts │ ├── count.test.ts │ ├── delete.test.ts │ ├── deleteMany.test.ts │ ├── findFirst.test.ts │ ├── findFirstOrThrow.test.ts │ ├── findMany.test.ts │ ├── findUnique.test.ts │ ├── findUniqueOrThrow.ts │ ├── groupBy.test.ts │ ├── include.test.ts │ ├── select.test.ts │ ├── update.test.ts │ ├── updateMany.test.ts │ ├── upsert.test.ts │ ├── utils │ └── createParams.ts │ └── where.test.ts ├── tsconfig.build.json ├── tsconfig.esm.json └── tsconfig.json /.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 | "babel/camelcase": "off" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/prisma-soft-delete-middleware.yml: -------------------------------------------------------------------------------- 1 | name: prisma-soft-delete-middleware 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 | release: 27 | runs-on: ubuntu-latest 28 | needs: test 29 | steps: 30 | - uses: actions/setup-node@v2 31 | with: 32 | node-version: 16 33 | - uses: actions/checkout@v2 34 | - run: npm install 35 | - run: npm run build 36 | - run: ls -asl dist 37 | - run: npx semantic-release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Build output directory 5 | dist 6 | 7 | # Test coverage directory 8 | coverage 9 | 10 | # Mac 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Prisma Soft Delete Middleware

3 | 4 |

Prisma middleware for soft deleting records.

5 | 6 |

7 | Soft deleting records is a common pattern in many applications. This library provides middleware for Prisma that 8 | allows you to soft delete records and exclude them from queries. It handles deleting records through relations and 9 | excluding soft deleted records when including relations or referencing them in where objects. It does this by using 10 | the prisma-nested-middleware library to 11 | handle nested relations. 12 |

13 | 14 |
15 | 16 |
17 | 18 | [![Build Status][build-badge]][build] 19 | [![version][version-badge]][package] 20 | [![MIT License][license-badge]][license] 21 | [![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) 22 | [![PRs Welcome][prs-badge]][prs] 23 | 24 | ## Table of Contents 25 | 26 | 27 | 28 | 29 | - [Prisma Middleware Deprecation](#prisma-middleware-deprecation) 30 | - [Installation](#installation) 31 | - [Usage](#usage) 32 | - [Middleware Setup](#middleware-setup) 33 | - [Prisma Schema Setup](#prisma-schema-setup) 34 | - [Behaviour](#behaviour) 35 | - [Deleting Records](#deleting-records) 36 | - [Deleting a Single Record](#deleting-a-single-record) 37 | - [Deleting Multiple Records](#deleting-multiple-records) 38 | - [Deleting Through a relationship](#deleting-through-a-relationship) 39 | - [Hard Deletes](#hard-deletes) 40 | - [Excluding Soft Deleted Records](#excluding-soft-deleted-records) 41 | - [Excluding Soft Deleted Records in a `findFirst` Operation](#excluding-soft-deleted-records-in-a-findfirst-operation) 42 | - [Excluding Soft Deleted Records in a `findMany` Operation](#excluding-soft-deleted-records-in-a-findmany-operation) 43 | - [Excluding Soft Deleted Records in a `findUnique` Operation](#excluding-soft-deleted-records-in-a-findunique-operation) 44 | - [Updating Records](#updating-records) 45 | - [Explicitly Updating Many Soft Deleted Records](#explicitly-updating-many-soft-deleted-records) 46 | - [Where objects](#where-objects) 47 | - [Explicitly Querying Soft Deleted Records](#explicitly-querying-soft-deleted-records) 48 | - [Including or Selecting Soft Deleted Records](#including-or-selecting-soft-deleted-records) 49 | - [Including or Selecting toMany Relations](#including-or-selecting-tomany-relations) 50 | - [Including or Selecting toOne Relations](#including-or-selecting-toone-relations) 51 | - [Explicitly Including Soft Deleted Records in toMany Relations](#explicitly-including-soft-deleted-records-in-tomany-relations) 52 | - [LICENSE](#license) 53 | 54 | 55 | 56 | ## Prisma Middleware Deprecation 57 | 58 | Since Prisma middleware is deprecated this library has been ported to an extension: 59 | [prisma-extension-soft-delete](https://github.com/olivierwilkinson/prisma-extension-soft-delete). 60 | 61 | While middleware is still supported this library will continue to be maintained, however it is recommended to use the 62 | extension instead. 63 | 64 | ## Installation 65 | 66 | This module is distributed via [npm][npm] and should be installed as one of your 67 | project's dependencies: 68 | 69 | ``` 70 | npm install --save prisma-soft-delete-middleware 71 | ``` 72 | 73 | `@prisma/client` is a peer dependency of this library, so you will need to 74 | install it if you haven't already: 75 | 76 | ``` 77 | npm install --save @prisma/client 78 | ``` 79 | 80 | ## Usage 81 | 82 | ### Middleware Setup 83 | 84 | To add soft delete functionality to your Prisma client create the middleware using the `createSoftDeleteMiddleware` 85 | function and `$use` it with your client. 86 | 87 | The `createSoftDeleteMiddleware` function takes a config object where you can define the models you want to use soft 88 | delete with. 89 | 90 | ```typescript 91 | import { PrismaClient } from "@prisma/client"; 92 | 93 | const client = new PrismaClient(); 94 | 95 | client.$use( 96 | createSoftDeleteMiddleware({ 97 | models: { 98 | Comment: true, 99 | }, 100 | }) 101 | ); 102 | ``` 103 | 104 | By default the middleware will use a `deleted` field of type `Boolean` on the model. If you want to use a custom field 105 | name or value you can pass a config object for the model. For example to use a `deletedAt` field where the value is null 106 | by default and a `DateTime` when the record is deleted you would pass the following: 107 | 108 | ```typescript 109 | client.$use( 110 | createSoftDeleteMiddleware({ 111 | models: { 112 | Comment: { 113 | field: "deletedAt", 114 | createValue: (deleted) => { 115 | if (deleted) return new Date(); 116 | return null; 117 | }, 118 | }, 119 | }, 120 | }) 121 | ); 122 | ``` 123 | 124 | The `field` property is the name of the field to use for soft delete, and the `createValue` property is a function that 125 | takes a deleted argument and returns the value for whether the record is soft deleted or not. 126 | 127 | The `createValue` method must return a deterministic value if the record is not deleted. This is because the middleware 128 | uses the value returned by `createValue` to modify the `where` object in the query, or to manually filter the results 129 | when including or selecting a toOne relationship. If the value for the field can have multiple values when the record is 130 | not deleted then this filtering will fail. Examples for good values to use when the `deleted` arg in `createValue` is 131 | false are `false`, `null`, `0` or `Date(0)`. 132 | 133 | It is possible to setup soft delete for multiple models at once by passing a config for each model in the `models` 134 | object: 135 | 136 | ```typescript 137 | client.$use( 138 | createSoftDeleteMiddleware({ 139 | models: { 140 | Comment: true, 141 | Post: true, 142 | }, 143 | }) 144 | ); 145 | ``` 146 | 147 | To modify the default field and type for all models you can pass a `defaultConfig`: 148 | 149 | ```typescript 150 | client.$use( 151 | createSoftDeleteMiddleware({ 152 | models: { 153 | Comment: true, 154 | Post: true, 155 | }, 156 | defaultConfig: { 157 | field: "deletedAt", 158 | createValue: (deleted) => { 159 | if (deleted) return new Date(); 160 | return null; 161 | }, 162 | }, 163 | }) 164 | ); 165 | ``` 166 | 167 | When using the default config you can also override the default config for a specific model by passing a config object 168 | for that model: 169 | 170 | ```typescript 171 | client.$use( 172 | createSoftDeleteMiddleware({ 173 | models: { 174 | Comment: true, 175 | Post: { 176 | field: "deleted", 177 | createValue: Boolean, 178 | }, 179 | }, 180 | defaultConfig: { 181 | field: "deletedAt", 182 | createValue: (deleted) => { 183 | if (deleted) return new Date(); 184 | return null; 185 | }, 186 | }, 187 | }) 188 | ); 189 | ``` 190 | 191 | The config object also has a `allowToOneUpdates` option that can be used to allow updates to toOne relationships through 192 | nested updates. By default this is set to `false` and will throw an error if you try to update a toOne relationship 193 | through a nested update. If you want to allow this you can set `allowToOneUpdates` to `true`: 194 | 195 | ```typescript 196 | client.$use( 197 | createSoftDeleteMiddleware({ 198 | models: { 199 | Comment: { 200 | field: "deleted", 201 | createValue: Boolean, 202 | allowToOneUpdates: true, 203 | }, 204 | }, 205 | }) 206 | ); 207 | ``` 208 | 209 | For more information for why updating through toOne relationship is disabled by default see the 210 | [Updating Records](#updating-records) section. 211 | 212 | Similarly to `allowToOneUpdates` there is an `allowCompoundUniqueIndexWhere` option that can be used to allow using 213 | where objects with compound unique index fields when using `findUnique` queries. By default this is set to `false` and 214 | will throw an error if you try to use a where with compound unique index fields. If you want to allow this you can set 215 | `allowCompoundUniqueIndexWhere` to `true`: 216 | 217 | ```typescript 218 | client.$use( 219 | createSoftDeleteMiddleware({ 220 | models: { 221 | Comment: { 222 | field: "deleted", 223 | createValue: Boolean, 224 | allowCompoundUniqueIndexWhere: true, 225 | }, 226 | }, 227 | }) 228 | ); 229 | ``` 230 | 231 | For more information for why updating through toOne relationship is disabled by default see the 232 | [Excluding Soft Deleted Records in a `findUnique` Operation](#excluding-soft-deleted-records-in-a-findunique-operation) section. 233 | 234 | To allow to one updates or compound unique index fields globally you can use the `defaultConfig` to do so: 235 | 236 | ```typescript 237 | client.$use( 238 | createSoftDeleteMiddleware({ 239 | models: { 240 | User: true, 241 | Comment: true, 242 | }, 243 | defaultConfig: { 244 | field: "deleted", 245 | createValue: Boolean, 246 | allowToOneUpdates: true, 247 | allowCompoundUniqueIndexWhere: true, 248 | }, 249 | }) 250 | ); 251 | ``` 252 | 253 | ### Prisma Schema Setup 254 | 255 | The Prisma schema must be updated to include the soft delete field for each model you want to use soft delete with. 256 | 257 | For models configured to use the default field and type you must add the `deleted` field to your Prisma schema manually. 258 | Using the Comment model configured in [Middleware Setup](#middleware-setup) you would need add the following to the 259 | Prisma schema: 260 | 261 | ```prisma 262 | model Comment { 263 | deleted Boolean @default(false) 264 | [other fields] 265 | } 266 | ``` 267 | 268 | If the Comment model was configured to use a `deletedAt` field where the value is null by default and a `DateTime` when 269 | the record is deleted you would need to add the following to your Prisma schema: 270 | 271 | ```prisma 272 | model Comment { 273 | deletedAt DateTime? 274 | [other fields] 275 | } 276 | ``` 277 | 278 | If the Comment model was configured to use a `deletedAt` field where the value is `Date(0)` by default and a `Date()` 279 | when the record is deleted you would need to add the following to your Prisma schema: 280 | 281 | ```prisma 282 | model Comment { 283 | deletedAt DateTime @default(dbgenerated("to_timestamp(0)")) 284 | // Note that the example above is for PostgreSQL, you will need to use the appropriate default function for your db. 285 | [other fields] 286 | } 287 | ``` 288 | 289 | Models configured to use soft delete that are related to other models through a toOne relationship must have this 290 | relationship defined as optional. This is because the middleware will exclude soft deleted records when the relationship 291 | is included or selected. If the relationship is not optional the types for the relation will be incorrect and you may 292 | get runtime errors. 293 | 294 | For example if you have an `author` relationship on the Comment model and the User model is configured to use soft 295 | delete you would need to change the relationship to be optional: 296 | 297 | ```prisma 298 | model Comment { 299 | authorId Int? 300 | author User? @relation(fields: [authorId], references: [id]) 301 | [other fields] 302 | } 303 | ``` 304 | 305 | `@unique` fields on models that are configured to use soft deletes may cause problems due to the records not actually 306 | being deleted. If a record is soft deleted and then a new record is created with the same value for the unique field, 307 | the new record will not be created. 308 | 309 | ## Behaviour 310 | 311 | The main behaviour of the middleware is to replace delete operations with update operations that set the soft delete 312 | field to the deleted value. 313 | 314 | The middleware also prevents accidentally fetching or updating soft deleted records by excluding soft deleted records 315 | from find queries, includes, selects and bulk updates. The middleware does allow explicit queries for soft deleted 316 | records and allows updates through unique fields such is it's id. The reason it allows updates through unique fields is 317 | because soft deleted records can only be fetched explicitly so updates through a unique fields should be intentional. 318 | 319 | ### Deleting Records 320 | 321 | When deleting a record using the `delete` or `deleteMany` operations the middleware will change the operation to an 322 | `update` operation and set the soft delete field to be the deleted value defined in the config for that model. 323 | 324 | For example if the Comment model was configured to use the default `deleted` field of type `Boolean` the middleware 325 | would change the `delete` operation to an `update` operation and set the `deleted` field to `true`. 326 | 327 | #### Deleting a Single Record 328 | 329 | When deleting a single record using the `delete` operation: 330 | 331 | ```typescript 332 | await client.comment.delete({ 333 | where: { 334 | id: 1, 335 | }, 336 | }); 337 | ``` 338 | 339 | The middleware would change the operation to: 340 | 341 | ```typescript 342 | await client.comment.update({ 343 | where: { 344 | id: 1, 345 | }, 346 | data: { 347 | deleted: true, 348 | }, 349 | }); 350 | ``` 351 | 352 | #### Deleting Multiple Records 353 | 354 | When deleting multiple records using the `deleteMany` operation: 355 | 356 | ```typescript 357 | await client.comment.deleteMany({ 358 | where: { 359 | id: { 360 | in: [1, 2, 3], 361 | }, 362 | }, 363 | }); 364 | ``` 365 | 366 | The middleware would change the operation to: 367 | 368 | ```typescript 369 | await client.comment.updateMany({ 370 | where: { 371 | id: { 372 | in: [1, 2, 3], 373 | }, 374 | }, 375 | data: { 376 | deleted: true, 377 | }, 378 | }); 379 | ``` 380 | 381 | #### Deleting Through a relationship 382 | 383 | When using a nested delete through a relationship the middleware will change the nested delete operation to an update 384 | operation: 385 | 386 | ```typescript 387 | await client.post.update({ 388 | where: { 389 | id: 1, 390 | }, 391 | data: { 392 | comments: { 393 | delete: { 394 | where: { 395 | id: 2, 396 | }, 397 | }, 398 | }, 399 | author: { 400 | delete: true, 401 | }, 402 | }, 403 | }); 404 | ``` 405 | 406 | The middleware would change the operation to: 407 | 408 | ```typescript 409 | await client.post.update({ 410 | where: { 411 | id: 1, 412 | }, 413 | data: { 414 | comments: { 415 | update: { 416 | where: { 417 | id: 2, 418 | }, 419 | data: { 420 | deleted: true, 421 | }, 422 | }, 423 | }, 424 | author: { 425 | update: { 426 | deleted: true, 427 | }, 428 | }, 429 | }, 430 | }); 431 | ``` 432 | 433 | The same behaviour applies when using a nested `deleteMany` with a toMany relationship. 434 | 435 | #### Hard Deletes 436 | 437 | Hard deletes are not currently supported by this middleware, when the `extendedWhereUnique` feature is supported 438 | it will be possible to explicitly hard delete a soft deleted record. In the meantime you can use the `executeRaw` 439 | operation to perform hard deletes. 440 | 441 | ### Excluding Soft Deleted Records 442 | 443 | When using the `findUnique`, `findFirst` and `findMany` operations the middleware will modify the `where` object passed 444 | to exclude soft deleted records. It does this by adding an additional condition to the `where` object that excludes 445 | records where the soft delete field is set to the deleted value defined in the config for that model. 446 | 447 | #### Excluding Soft Deleted Records in a `findFirst` Operation 448 | 449 | When using a `findFirst` operation the middleware will modify the `where` object to exclude soft deleted records, so for: 450 | 451 | ```typescript 452 | await client.comment.findFirst({ 453 | where: { 454 | id: 1, 455 | }, 456 | }); 457 | ``` 458 | 459 | The middleware would change the operation to: 460 | 461 | ```typescript 462 | await client.comment.findFirst({ 463 | where: { 464 | id: 1, 465 | deleted: false, 466 | }, 467 | }); 468 | ``` 469 | 470 | #### Excluding Soft Deleted Records in a `findMany` Operation 471 | 472 | When using a `findMany` operation the middleware will modify the `where` object to exclude soft deleted records, so for: 473 | 474 | ```typescript 475 | await client.comment.findMany({ 476 | where: { 477 | id: 1, 478 | }, 479 | }); 480 | ``` 481 | 482 | The middleware would change the operation to: 483 | 484 | ```typescript 485 | await client.comment.findMany({ 486 | where: { 487 | id: 1, 488 | deleted: false, 489 | }, 490 | }); 491 | ``` 492 | 493 | #### Excluding Soft Deleted Records in a `findUnique` Operation 494 | 495 | When using a `findUnique` operation the middleware will change the query to use `findFirst` so that it can modify the 496 | `where` object to exclude soft deleted records, so for: 497 | 498 | ```typescript 499 | await client.comment.findUnique({ 500 | where: { 501 | id: 1, 502 | }, 503 | }); 504 | ``` 505 | 506 | The middleware would change the operation to: 507 | 508 | ```typescript 509 | await client.comment.findFirst({ 510 | where: { 511 | id: 1, 512 | deleted: false, 513 | }, 514 | }); 515 | ``` 516 | 517 | When querying using a compound unique index in the where object the middleware will throw an error by default. This 518 | is because it is not possible to use these types of where object with `findFirst` and it is not possible to exclude 519 | soft-deleted records when using `findUnique`. For example take the following query: 520 | 521 | ```typescript 522 | await client.user.findUnique({ 523 | where: { 524 | name_email: { 525 | name: "foo", 526 | email: "bar", 527 | }, 528 | }, 529 | }); 530 | ``` 531 | 532 | Since the compound unique index `@@unique([name, email])` is being queried through the `name_email` field of the where 533 | object the middleware will throw to avoid accidentally returning a soft deleted record. 534 | 535 | It is possible to override this behaviour by setting `allowCompoundUniqueIndexWhere` to `true` in the model config. 536 | 537 | ### Updating Records 538 | 539 | Updating records is split into three categories, updating a single record using a root operation, updating a single 540 | record through a relation and updating multiple records either through a root operation or a relation. 541 | 542 | When updating a single record using a root operation such as `update` or `upsert` the middleware will not modify the 543 | operation. This is because unless explicitly queried for soft deleted records should not be returned from queries, 544 | so if these operations are updating a soft deleted record it should be intentional. 545 | 546 | When updating a single record through a relation the middleware will throw an error by default. This is because it is 547 | not possible to filter out soft deleted records for nested toOne relations. For example take the following query: 548 | 549 | ```typescript 550 | await client.post.update({ 551 | where: { 552 | id: 1, 553 | }, 554 | data: { 555 | author: { 556 | update: { 557 | name: "foo", 558 | }, 559 | }, 560 | }, 561 | }); 562 | ``` 563 | 564 | Since the `author` field is a toOne relation it does not support a where object. This means that if the `author` field 565 | is a soft deleted record it will be updated accidentally. 566 | 567 | It is possible to override this behaviour by setting `allowToOneUpdates` to `true` in the middleware config. 568 | 569 | When updating multiple records using `updateMany` the middleware will modify the `where` object passed to exclude soft 570 | deleted records. For example take the following query: 571 | 572 | ```typescript 573 | await client.comment.updateMany({ 574 | where: { 575 | id: 1, 576 | }, 577 | data: { 578 | content: "foo", 579 | }, 580 | }); 581 | ``` 582 | 583 | The middleware would change the operation to: 584 | 585 | ```typescript 586 | await client.comment.updateMany({ 587 | where: { 588 | id: 1, 589 | deleted: false, 590 | }, 591 | data: { 592 | content: "foo", 593 | }, 594 | }); 595 | ``` 596 | 597 | This also works when a toMany relation is updated: 598 | 599 | ```typescript 600 | await client.post.update({ 601 | where: { 602 | id: 1, 603 | }, 604 | data: { 605 | comments: { 606 | updateMany: { 607 | where: { 608 | id: 1, 609 | }, 610 | data: { 611 | content: "foo", 612 | }, 613 | }, 614 | }, 615 | }, 616 | }); 617 | ``` 618 | 619 | The middleware would change the operation to: 620 | 621 | ```typescript 622 | await client.post.update({ 623 | where: { 624 | id: 1, 625 | }, 626 | data: { 627 | comments: { 628 | updateMany: { 629 | where: { 630 | id: 1, 631 | deleted: false, 632 | }, 633 | data: { 634 | content: "foo", 635 | }, 636 | }, 637 | }, 638 | }, 639 | }); 640 | ``` 641 | 642 | #### Explicitly Updating Many Soft Deleted Records 643 | 644 | When using the `updateMany` operation it is possible to explicitly update many soft deleted records by setting the 645 | deleted field to the deleted value defined in the config for that model. An example that would update soft deleted 646 | records would be: 647 | 648 | ```typescript 649 | await client.comment.updateMany({ 650 | where: { 651 | content: "foo", 652 | deleted: true, 653 | }, 654 | data: { 655 | content: "bar", 656 | }, 657 | }); 658 | ``` 659 | 660 | ### Where objects 661 | 662 | When using a `where` query it is possible to reference models configured to use soft deletes. In this case the 663 | middleware will modify the `where` object to exclude soft deleted records from the query, so for: 664 | 665 | ```typescript 666 | await client.post.findMany({ 667 | where: { 668 | id: 1, 669 | comments: { 670 | some: { 671 | content: "foo", 672 | }, 673 | }, 674 | }, 675 | }); 676 | ``` 677 | 678 | The middleware would change the operation to: 679 | 680 | ```typescript 681 | await client.post.findMany({ 682 | where: { 683 | id: 1, 684 | comments: { 685 | some: { 686 | content: "foo", 687 | deleted: false, 688 | }, 689 | }, 690 | }, 691 | }); 692 | ``` 693 | 694 | This also works when the where object includes logical operators: 695 | 696 | ```typescript 697 | await client.post.findMany({ 698 | where: { 699 | id: 1, 700 | OR: [ 701 | { 702 | comments: { 703 | some: { 704 | author: { 705 | name: "Jack", 706 | }, 707 | }, 708 | }, 709 | }, 710 | { 711 | comments: { 712 | none: { 713 | author: { 714 | name: "Jill", 715 | }, 716 | }, 717 | }, 718 | }, 719 | ], 720 | }, 721 | }); 722 | ``` 723 | 724 | The middleware would change the operation to: 725 | 726 | ```typescript 727 | await client.post.findMany({ 728 | where: { 729 | id: 1, 730 | OR: [ 731 | { 732 | comments: { 733 | some: { 734 | deleted: false, 735 | author: { 736 | name: "Jack", 737 | }, 738 | }, 739 | }, 740 | }, 741 | { 742 | comments: { 743 | none: { 744 | deleted: false, 745 | author: { 746 | name: "Jill", 747 | }, 748 | }, 749 | }, 750 | }, 751 | ], 752 | }, 753 | }); 754 | ``` 755 | 756 | When using the `every` modifier the middleware will modify the `where` object to exclude soft deleted records from the 757 | query in a different way, so for: 758 | 759 | ```typescript 760 | await client.post.findMany({ 761 | where: { 762 | id: 1, 763 | comments: { 764 | every: { 765 | content: "foo", 766 | }, 767 | }, 768 | }, 769 | }); 770 | ``` 771 | 772 | The middleware would change the operation to: 773 | 774 | ```typescript 775 | await client.post.findMany({ 776 | where: { 777 | id: 1, 778 | comments: { 779 | every: { 780 | OR: [{ deleted: { not: false } }, { content: "foo" }], 781 | }, 782 | }, 783 | }, 784 | }); 785 | ``` 786 | 787 | This is because if the same logic that is used for `some` and `none` were to be used with `every` then the query would 788 | fail for cases where there are deleted models. 789 | 790 | The deleted case uses the `not` operator to ensure that the query works for custom fields and types. For example if the 791 | field was configured to be `deletedAt` where the type is `DateTime` when deleted and `null` when not deleted then the 792 | query would be: 793 | 794 | ```typescript 795 | await client.post.findMany({ 796 | where: { 797 | id: 1, 798 | comments: { 799 | every: { 800 | OR: [{ deletedAt: { not: null } }, { content: "foo" }], 801 | }, 802 | }, 803 | }, 804 | }); 805 | ``` 806 | 807 | #### Explicitly Querying Soft Deleted Records 808 | 809 | It is possible to explicitly query soft deleted records by setting the configured field in the `where` object. For 810 | example the following will include deleted records in the results: 811 | 812 | ```typescript 813 | await client.comment.findMany({ 814 | where: { 815 | deleted: true, 816 | }, 817 | }); 818 | ``` 819 | 820 | It is also possible to explicitly query soft deleted records through relationships in the `where` object. For example 821 | the following will also not be modified: 822 | 823 | ```typescript 824 | await client.post.findMany({ 825 | where: { 826 | comments: { 827 | some: { 828 | deleted: true, 829 | }, 830 | }, 831 | }, 832 | }); 833 | ``` 834 | 835 | ### Including or Selecting Soft Deleted Records 836 | 837 | When using `include` or `select` the middleware will modify the `include` and `select` objects passed to exclude soft 838 | deleted records. 839 | 840 | #### Including or Selecting toMany Relations 841 | 842 | When using `include` or `select` on a toMany relationship the middleware will modify the where object to exclude soft 843 | deleted records from the query, so for: 844 | 845 | ```typescript 846 | await client.post.findMany({ 847 | where: { 848 | id: 1, 849 | }, 850 | include: { 851 | comments: true, 852 | }, 853 | }); 854 | ``` 855 | 856 | If the Comment model was configured to be soft deleted the middleware would modify the `include` action where object to 857 | exclude soft deleted records, so the query would be: 858 | 859 | ```typescript 860 | await client.post.findMany({ 861 | where: { 862 | id: 1, 863 | }, 864 | include: { 865 | comments: { 866 | where: { 867 | deleted: false, 868 | }, 869 | }, 870 | }, 871 | }); 872 | ``` 873 | 874 | The same applies for `select`: 875 | 876 | ```typescript 877 | await client.post.findMany({ 878 | where: { 879 | id: 1, 880 | }, 881 | select: { 882 | comments: true, 883 | }, 884 | }); 885 | ``` 886 | 887 | This also works for nested includes and selects: 888 | 889 | ```typescript 890 | await client.user.findMany({ 891 | where: { 892 | id: 1, 893 | }, 894 | include: { 895 | posts: { 896 | select: { 897 | comments: { 898 | where: { 899 | content: "foo", 900 | }, 901 | }, 902 | }, 903 | }, 904 | }, 905 | }); 906 | ``` 907 | 908 | The middleware would modify the query to: 909 | 910 | ```typescript 911 | await client.user.findMany({ 912 | where: { 913 | id: 1, 914 | }, 915 | include: { 916 | posts: { 917 | select: { 918 | comments: { 919 | where: { 920 | deleted: false, 921 | content: "foo", 922 | }, 923 | }, 924 | }, 925 | }, 926 | }, 927 | }); 928 | ``` 929 | 930 | #### Including or Selecting toOne Relations 931 | 932 | Records included through a toOne relation are also excluded, however there is no way to explicitly include them. For 933 | example the following query: 934 | 935 | ```typescript 936 | await client.post.findFirst({ 937 | where: { 938 | id: 1, 939 | }, 940 | include: { 941 | author: true, 942 | }, 943 | }); 944 | ``` 945 | 946 | The middleware would not modify the query since toOne relations do not support where clauses. Instead the middleware 947 | will manually filter results based on the configured deleted field. 948 | 949 | So if the author of the Post was soft deleted the middleware would filter the results and remove the author from the 950 | results: 951 | 952 | ```typescript 953 | { 954 | id: 1, 955 | title: "foo", 956 | author: null 957 | } 958 | ``` 959 | 960 | When selecting specific fields on a toOne relation the middleware will manually add the configured deleted field to the 961 | select object, filter the results and finally strip the deleted field from the results before returning them. 962 | 963 | For example the following query would behave that way: 964 | 965 | ```typescript 966 | await client.post.findMany({ 967 | where: { 968 | id: 1, 969 | }, 970 | select: { 971 | author: { 972 | select: { 973 | name: true, 974 | }, 975 | }, 976 | }, 977 | }); 978 | ``` 979 | 980 | #### Explicitly Including Soft Deleted Records in toMany Relations 981 | 982 | It is possible to explicitly include soft deleted records in toMany relations by adding the configured deleted field to 983 | the `where` object. For example the following will include deleted records in the results: 984 | 985 | ```typescript 986 | await client.post.findMany({ 987 | where: { 988 | id: 1, 989 | }, 990 | include: { 991 | comments: { 992 | where: { 993 | deleted: true, 994 | }, 995 | }, 996 | }, 997 | }); 998 | ``` 999 | 1000 | ## LICENSE 1001 | 1002 | Apache 2.0 1003 | 1004 | [npm]: https://www.npmjs.com/ 1005 | [node]: https://nodejs.org 1006 | [build-badge]: https://github.com/olivierwilkinson/prisma-soft-delete-middleware/workflows/prisma-soft-delete-middleware/badge.svg 1007 | [build]: https://github.com/olivierwilkinson/prisma-soft-delete-middleware/actions?query=branch%3Amaster+workflow%3Aprisma-soft-delete-middleware 1008 | [version-badge]: https://img.shields.io/npm/v/prisma-soft-delete-middleware.svg?style=flat-square 1009 | [package]: https://www.npmjs.com/package/prisma-soft-delete-middleware 1010 | [downloads-badge]: https://img.shields.io/npm/dm/prisma-soft-delete-middleware.svg?style=flat-square 1011 | [npmtrends]: http://www.npmtrends.com/prisma-soft-delete-middleware 1012 | [license-badge]: https://img.shields.io/npm/l/prisma-soft-delete-middleware.svg?style=flat-square 1013 | [license]: https://github.com/olivierwilkinson/prisma-soft-delete-middleware/blob/main/LICENSE 1014 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 1015 | [prs]: http://makeapullrequest.com 1016 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 1017 | [coc]: https://github.com/olivierwilkinson/prisma-soft-delete-middleware/blob/main/other/CODE_OF_CONDUCT.md 1018 | -------------------------------------------------------------------------------- /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 | - '5432:5432' 14 | -------------------------------------------------------------------------------- /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.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.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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prisma-soft-delete-middleware", 3 | "version": "1.0.0-semantically-released", 4 | "description": "Prisma middleware for soft deleting records", 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": "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 | "middleware" 28 | ], 29 | "author": "Olivier Wilkinson", 30 | "license": "Apache-2.0", 31 | "dependencies": { 32 | "prisma-nested-middleware": "^4.0.0" 33 | }, 34 | "peerDependencies": { 35 | "@prisma/client": "*" 36 | }, 37 | "devDependencies": { 38 | "@prisma/client": "^5.4.2", 39 | "@types/faker": "^5.5.9", 40 | "@types/jest": "^29.2.5", 41 | "@types/lodash": "^4.14.192", 42 | "@typescript-eslint/eslint-plugin": "^4.14.0", 43 | "@typescript-eslint/parser": "^4.14.0", 44 | "doctoc": "^2.2.0", 45 | "eslint": "^7.6.0", 46 | "faker": "^5.0.0", 47 | "jest": "^29.3.1", 48 | "kcd-scripts": "^5.0.0", 49 | "lodash": "^4.17.21", 50 | "npm-run-all": "^4.1.5", 51 | "prisma": "^5.4.2", 52 | "semantic-release": "^17.0.2", 53 | "ts-jest": "^29.0.3", 54 | "ts-node": "^9.1.1", 55 | "typescript": "^4.1.3" 56 | }, 57 | "repository": { 58 | "type": "git", 59 | "url": "https://github.com/olivierwilkinson/prisma-soft-delete-middleware.git" 60 | }, 61 | "release": { 62 | "branches": [ 63 | "main", 64 | { 65 | "name": "prerelease", 66 | "prerelease": true 67 | }, 68 | { 69 | "name": "next", 70 | "prerelease": true 71 | } 72 | ] 73 | }, 74 | "publishConfig": { 75 | "access": "public" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /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 | profileId Int? 16 | profile Profile? @relation(fields: [profileId], references: [id]) 17 | comments Comment[] 18 | deleted Boolean @default(false) 19 | deletedAt DateTime? 20 | 21 | @@unique([name, email]) 22 | } 23 | 24 | model Post { 25 | id Int @id @default(autoincrement()) 26 | createdAt DateTime @default(now()) 27 | updatedAt DateTime @updatedAt 28 | published Boolean @default(false) 29 | title String 30 | content String? 31 | author User? @relation(fields: [authorName, authorEmail], references: [name, email]) 32 | authorName String? 33 | authorEmail String? 34 | authorId Int 35 | comments Comment[] 36 | deleted Boolean @default(false) 37 | } 38 | 39 | model Comment { 40 | id Int @id @default(autoincrement()) 41 | createdAt DateTime @default(now()) 42 | updatedAt DateTime @updatedAt 43 | content String 44 | author User? @relation(fields: [authorId], references: [id]) 45 | authorId Int? 46 | post Post? @relation(fields: [postId], references: [id]) 47 | postId Int? 48 | repliedTo Comment? @relation("replies", fields: [repliedToId], references: [id]) 49 | repliedToId Int? 50 | replies Comment[] @relation("replies") 51 | deleted Boolean @default(false) 52 | } 53 | 54 | model Profile { 55 | id Int @id @default(autoincrement()) 56 | bio String? 57 | deleted Boolean @default(false) 58 | deletedAt DateTime @default(dbgenerated("to_timestamp(0)")) 59 | users User[] 60 | } 61 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./lib/types"; 2 | 3 | export { createSoftDeleteMiddleware } from "./lib/createSoftDeleteMiddleware"; 4 | -------------------------------------------------------------------------------- /src/lib/actionMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import { NestedParams, NestedMiddleware } from "prisma-nested-middleware"; 3 | 4 | import { ModelConfig } from "./types"; 5 | import { 6 | addDeletedToSelect, 7 | stripDeletedFieldFromResults, 8 | } from "./utils/nestedReads"; 9 | import { 10 | filterSoftDeletedResults, 11 | shouldFilterDeletedFromReadResult, 12 | } from "./utils/resultFiltering"; 13 | 14 | const uniqueFieldsByModel: Record = {}; 15 | const uniqueIndexFieldsByModel: Record = {}; 16 | 17 | Prisma.dmmf.datamodel.models.forEach((model) => { 18 | // add unique fields derived from indexes 19 | const uniqueIndexFields: string[] = []; 20 | model.uniqueFields.forEach((field) => { 21 | uniqueIndexFields.push(field.join("_")); 22 | }); 23 | uniqueIndexFieldsByModel[model.name] = uniqueIndexFields; 24 | 25 | // add id field and unique fields from @unique decorator 26 | const uniqueFields: string[] = []; 27 | model.fields.forEach((field) => { 28 | if (field.isId || field.isUnique) { 29 | uniqueFields.push(field.name); 30 | } 31 | }); 32 | uniqueFieldsByModel[model.name] = uniqueFields; 33 | }); 34 | 35 | /* Delete middleware */ 36 | 37 | function createDeleteParams( 38 | params: NestedParams, 39 | { field, createValue }: ModelConfig 40 | ): NestedParams | NestedParams[] { 41 | if ( 42 | !params.model || 43 | // do nothing for delete: false 44 | (typeof params.args === "boolean" && !params.args) || 45 | // do nothing for root delete without where to allow Prisma to throw 46 | (!params.scope && !params.args?.where) 47 | ) { 48 | return params; 49 | } 50 | 51 | if (typeof params.args === "boolean") { 52 | return { 53 | ...params, 54 | action: "update", 55 | args: { 56 | __passUpdateThrough: true, 57 | [field]: createValue(true), 58 | }, 59 | }; 60 | } 61 | 62 | if (!!params.scope) { 63 | return { 64 | ...params, 65 | action: "update", 66 | args: { 67 | where: params.args, 68 | data: { 69 | [field]: createValue(true), 70 | }, 71 | }, 72 | }; 73 | } 74 | 75 | return { 76 | ...params, 77 | action: "update", 78 | args: { 79 | ...params.args, 80 | data: { 81 | [field]: createValue(true), 82 | }, 83 | }, 84 | }; 85 | } 86 | 87 | export function createDeleteMiddleware(config: ModelConfig): NestedMiddleware { 88 | return function deleteMiddleware(params, next) { 89 | return next(createDeleteParams(params, config)); 90 | }; 91 | } 92 | 93 | /* DeleteMany middleware */ 94 | 95 | function createDeleteManyParams( 96 | params: NestedParams, 97 | config: ModelConfig 98 | ): NestedParams { 99 | if (!params.model) return params; 100 | 101 | const where = params.args?.where || params.args; 102 | 103 | return { 104 | ...params, 105 | action: "updateMany", 106 | args: { 107 | where: { 108 | ...where, 109 | [config.field]: config.createValue(false), 110 | }, 111 | data: { 112 | [config.field]: config.createValue(true), 113 | }, 114 | }, 115 | }; 116 | } 117 | 118 | export function createDeleteManyMiddleware( 119 | config: ModelConfig 120 | ): NestedMiddleware { 121 | return function deleteManyMiddleware(params, next) { 122 | return next(createDeleteManyParams(params, config)); 123 | }; 124 | } 125 | 126 | /* Update middleware */ 127 | 128 | function createUpdateParams( 129 | params: NestedParams, 130 | config: ModelConfig 131 | ): NestedParams { 132 | if ( 133 | params.scope?.relations && 134 | !params.scope.relations.to.isList && 135 | !config.allowToOneUpdates && 136 | !params.args?.__passUpdateThrough 137 | ) { 138 | throw new Error( 139 | `prisma-soft-delete-middleware: update of model "${params.model}" through "${params.scope?.parentParams.model}.${params.scope.relations.to.name}" found. Updates of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.` 140 | ); 141 | } 142 | 143 | // remove __passUpdateThrough from args 144 | if (params.args?.__passUpdateThrough) { 145 | delete params.args.__passUpdateThrough; 146 | } 147 | 148 | return params; 149 | } 150 | 151 | export function createUpdateMiddleware(config: ModelConfig): NestedMiddleware { 152 | return function updateMiddleware(params, next) { 153 | return next(createUpdateParams(params, config)); 154 | }; 155 | } 156 | 157 | /* UpdateMany middleware */ 158 | 159 | function createUpdateManyParams( 160 | params: NestedParams, 161 | config: ModelConfig 162 | ): NestedParams { 163 | // do nothing if args are not defined to allow Prisma to throw an error 164 | if (!params.args) return params; 165 | 166 | return { 167 | ...params, 168 | args: { 169 | ...params.args, 170 | where: { 171 | ...params.args?.where, 172 | // allow overriding the deleted field in where 173 | [config.field]: 174 | params.args?.where?.[config.field] || config.createValue(false), 175 | }, 176 | }, 177 | }; 178 | } 179 | 180 | export function createUpdateManyMiddleware( 181 | config: ModelConfig 182 | ): NestedMiddleware { 183 | return function updateManyMiddleware(params, next) { 184 | return next(createUpdateManyParams(params, config)); 185 | }; 186 | } 187 | 188 | /* Upsert middleware */ 189 | 190 | export function createUpsertMiddleware(_: ModelConfig): NestedMiddleware { 191 | return function upsertMiddleware(params, next) { 192 | if (params.scope?.relations && !params.scope.relations.to.isList) { 193 | throw new Error( 194 | `prisma-soft-delete-middleware: upsert of model "${params.model}" through "${params.scope?.parentParams.model}.${params.scope.relations.to.name}" found. Upserts of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.` 195 | ); 196 | } 197 | 198 | return next(params); 199 | }; 200 | } 201 | 202 | /* FindUnique middleware helpers */ 203 | 204 | function validateFindUniqueParams( 205 | params: NestedParams, 206 | config: ModelConfig 207 | ): void { 208 | const uniqueIndexFields = uniqueIndexFieldsByModel[params.model || ""] || []; 209 | const uniqueIndexField = Object.keys(params.args?.where || {}).find((key) => 210 | uniqueIndexFields.includes(key) 211 | ); 212 | 213 | // when unique index field is found it is not possible to use findFirst. 214 | // Instead warn the user that soft-deleted models will not be excluded from 215 | // this query unless warnForUniqueIndexes is false. 216 | if (uniqueIndexField && !config.allowCompoundUniqueIndexWhere) { 217 | throw new Error( 218 | `prisma-soft-delete-middleware: query of model "${params.model}" through compound unique index field "${uniqueIndexField}" found. Queries of soft deleted models through a unique index are not supported. Set "allowCompoundUniqueIndexWhere" to true to override this behaviour.` 219 | ); 220 | } 221 | } 222 | 223 | function shouldPassFindUniqueParamsThrough( 224 | params: NestedParams, 225 | config: ModelConfig 226 | ): boolean { 227 | const uniqueFields = uniqueFieldsByModel[params.model || ""] || []; 228 | const uniqueIndexFields = uniqueIndexFieldsByModel[params.model || ""] || []; 229 | const uniqueIndexField = Object.keys(params.args?.where || {}).find((key) => 230 | uniqueIndexFields.includes(key) 231 | ); 232 | 233 | // pass through invalid args so Prisma throws an error 234 | return ( 235 | // findUnique must have a where object 236 | !params.args?.where || 237 | typeof params.args.where !== "object" || 238 | // where object must have at least one defined unique field 239 | !Object.entries(params.args.where).some( 240 | ([key, val]) => 241 | (uniqueFields.includes(key) || uniqueIndexFields.includes(key)) && 242 | typeof val !== "undefined" 243 | ) || 244 | // pass through if where object has a unique index field and allowCompoundUniqueIndexWhere is true 245 | !!(uniqueIndexField && config.allowCompoundUniqueIndexWhere) 246 | ); 247 | } 248 | 249 | /* FindUnique middleware */ 250 | 251 | function createFindUniqueParams( 252 | params: NestedParams, 253 | config: ModelConfig 254 | ): NestedParams { 255 | if (shouldPassFindUniqueParamsThrough(params, config)) { 256 | return params; 257 | } 258 | 259 | validateFindUniqueParams(params, config); 260 | 261 | return { 262 | ...params, 263 | action: "findFirst", 264 | args: { 265 | ...params.args, 266 | where: { 267 | ...params.args?.where, 268 | [config.field]: config.createValue(false), 269 | }, 270 | }, 271 | }; 272 | } 273 | 274 | export function createFindUniqueMiddleware( 275 | config: ModelConfig 276 | ): NestedMiddleware { 277 | return function findUniqueMiddleware(params, next) { 278 | return next(createFindUniqueParams(params, config)); 279 | }; 280 | } 281 | 282 | /* FindUniqueOrThrow middleware */ 283 | 284 | function createFindUniqueOrThrowParams( 285 | params: NestedParams, 286 | config: ModelConfig 287 | ): NestedParams { 288 | if (shouldPassFindUniqueParamsThrough(params, config)) { 289 | return params; 290 | } 291 | 292 | validateFindUniqueParams(params, config); 293 | 294 | return { 295 | ...params, 296 | action: "findFirstOrThrow", 297 | args: { 298 | ...params.args, 299 | where: { 300 | ...params.args?.where, 301 | [config.field]: config.createValue(false), 302 | }, 303 | }, 304 | }; 305 | } 306 | 307 | export function createFindUniqueOrThrowMiddleware( 308 | config: ModelConfig 309 | ): NestedMiddleware { 310 | return function findUniqueMiddleware(params, next) { 311 | return next(createFindUniqueOrThrowParams(params, config)); 312 | }; 313 | } 314 | 315 | /* FindFirst middleware */ 316 | 317 | function createFindFirstParams( 318 | params: NestedParams, 319 | config: ModelConfig 320 | ): NestedParams { 321 | return { 322 | ...params, 323 | action: "findFirst", 324 | args: { 325 | ...params.args, 326 | where: { 327 | ...params.args?.where, 328 | // allow overriding the deleted field in where 329 | [config.field]: 330 | params.args?.where?.[config.field] || config.createValue(false), 331 | }, 332 | }, 333 | }; 334 | } 335 | 336 | export function createFindFirstMiddleware( 337 | config: ModelConfig 338 | ): NestedMiddleware { 339 | return function findFirst(params, next) { 340 | return next(createFindFirstParams(params, config)); 341 | }; 342 | } 343 | 344 | /* FindFirst middleware */ 345 | 346 | function createFindFirstOrThrowParams( 347 | params: NestedParams, 348 | config: ModelConfig 349 | ): NestedParams { 350 | return { 351 | ...params, 352 | action: "findFirstOrThrow", 353 | args: { 354 | ...params.args, 355 | where: { 356 | ...params.args?.where, 357 | // allow overriding the deleted field in where 358 | [config.field]: 359 | params.args?.where?.[config.field] || config.createValue(false), 360 | }, 361 | }, 362 | }; 363 | } 364 | 365 | export function createFindFirstOrThrowMiddleware( 366 | config: ModelConfig 367 | ): NestedMiddleware { 368 | return function findFirstOrThrow(params, next) { 369 | return next(createFindFirstOrThrowParams(params, config)); 370 | }; 371 | } 372 | 373 | /* FindMany middleware */ 374 | 375 | function createFindManyParams( 376 | params: NestedParams, 377 | config: ModelConfig 378 | ): NestedParams { 379 | return { 380 | ...params, 381 | action: "findMany", 382 | args: { 383 | ...params.args, 384 | where: { 385 | ...params.args?.where, 386 | // allow overriding the deleted field in where 387 | [config.field]: 388 | params.args?.where?.[config.field] || config.createValue(false), 389 | }, 390 | }, 391 | }; 392 | } 393 | 394 | export function createFindManyMiddleware( 395 | config: ModelConfig 396 | ): NestedMiddleware { 397 | return function findManyMiddleware(params, next) { 398 | return next(createFindManyParams(params, config)); 399 | }; 400 | } 401 | 402 | /*GroupBy middleware */ 403 | function createGroupByParams( 404 | params: NestedParams, 405 | config: ModelConfig 406 | ): NestedParams { 407 | return { 408 | ...params, 409 | action: "groupBy", 410 | args: { 411 | ...params.args, 412 | where: { 413 | ...params.args?.where, 414 | // allow overriding the deleted field in where 415 | [config.field]: 416 | params.args?.where?.[config.field] || config.createValue(false), 417 | }, 418 | }, 419 | }; 420 | } 421 | 422 | export function createGroupByMiddleware(config: ModelConfig): NestedMiddleware { 423 | return function groupByMiddleware(params, next) { 424 | return next(createGroupByParams(params, config)); 425 | }; 426 | } 427 | 428 | /* Count middleware */ 429 | 430 | function createCountParams( 431 | params: NestedParams, 432 | config: ModelConfig 433 | ): NestedParams { 434 | const args = params.args || {}; 435 | const where = args.where || {}; 436 | 437 | return { 438 | ...params, 439 | args: { 440 | ...args, 441 | where: { 442 | ...where, 443 | // allow overriding the deleted field in where 444 | [config.field]: where[config.field] || config.createValue(false), 445 | }, 446 | }, 447 | }; 448 | } 449 | 450 | export function createCountMiddleware(config: ModelConfig): NestedMiddleware { 451 | return function countMiddleware(params, next) { 452 | return next(createCountParams(params, config)); 453 | }; 454 | } 455 | 456 | /* Aggregate middleware */ 457 | 458 | function createAggregateParams( 459 | params: NestedParams, 460 | config: ModelConfig 461 | ): NestedParams { 462 | const args = params.args || {}; 463 | const where = args.where || {}; 464 | 465 | return { 466 | ...params, 467 | args: { 468 | ...args, 469 | where: { 470 | ...where, 471 | // allow overriding the deleted field in where 472 | [config.field]: where[config.field] || config.createValue(false), 473 | }, 474 | }, 475 | }; 476 | } 477 | 478 | export function createAggregateMiddleware( 479 | config: ModelConfig 480 | ): NestedMiddleware { 481 | return function aggregateMiddleware(params, next) { 482 | return next(createAggregateParams(params, config)); 483 | }; 484 | } 485 | 486 | /* Where middleware */ 487 | 488 | function createWhereParams(params: NestedParams, config: ModelConfig) { 489 | // customise list queries with every modifier unless the deleted field is set 490 | if (params.scope?.modifier === "every" && !params.args[config.field]) { 491 | return { 492 | ...params, 493 | args: { 494 | OR: [ 495 | { [config.field]: { not: config.createValue(false) } }, 496 | params.args, 497 | ], 498 | }, 499 | }; 500 | } 501 | 502 | return { 503 | ...params, 504 | args: { 505 | ...params.args, 506 | [config.field]: params.args[config.field] || config.createValue(false), 507 | }, 508 | }; 509 | } 510 | 511 | export function createWhereMiddleware(config: ModelConfig): NestedMiddleware { 512 | return function whereMiddleware(params, next) { 513 | if (!params.scope) return next(params); 514 | return next(createWhereParams(params, config)); 515 | }; 516 | } 517 | 518 | /* Include middleware */ 519 | 520 | function createIncludeParams( 521 | params: NestedParams, 522 | config: ModelConfig 523 | ): NestedParams { 524 | // includes of toOne relation cannot filter deleted records using params 525 | // instead ensure that the deleted field is selected and filter the results 526 | if (params.scope?.relations?.to.isList === false) { 527 | if (params.args?.select && !params.args?.select[config.field]) { 528 | return addDeletedToSelect(params, config); 529 | } 530 | 531 | return params; 532 | } 533 | 534 | return { 535 | ...params, 536 | args: { 537 | ...params.args, 538 | where: { 539 | ...params.args?.where, 540 | // allow overriding the deleted field in where 541 | [config.field]: 542 | params.args?.where?.[config.field] || config.createValue(false), 543 | }, 544 | }, 545 | }; 546 | } 547 | 548 | export function createIncludeMiddleware(config: ModelConfig): NestedMiddleware { 549 | return async function includeMiddleware(params, next) { 550 | const updatedParams = createIncludeParams(params, config); 551 | 552 | const deletedFieldAdded = 553 | typeof updatedParams.args === "object" && 554 | updatedParams.args?.__deletedFieldAdded; 555 | 556 | if (deletedFieldAdded) { 557 | delete updatedParams.args.__deletedFieldAdded; 558 | } 559 | 560 | const result = await next(updatedParams); 561 | 562 | if (shouldFilterDeletedFromReadResult(params, config)) { 563 | const filteredResults = filterSoftDeletedResults(result, config); 564 | 565 | if (deletedFieldAdded) { 566 | stripDeletedFieldFromResults(filteredResults, config); 567 | } 568 | 569 | return filteredResults; 570 | } 571 | 572 | return result; 573 | }; 574 | } 575 | 576 | /* Select middleware */ 577 | 578 | function createSelectParams( 579 | params: NestedParams, 580 | config: ModelConfig 581 | ): NestedParams { 582 | // selects of toOne relation cannot filter deleted records using params 583 | if (params.scope?.relations?.to.isList === false) { 584 | if (params.args?.select && !params.args.select[config.field]) { 585 | return addDeletedToSelect(params, config); 586 | } 587 | 588 | return params; 589 | } 590 | 591 | return { 592 | ...params, 593 | args: { 594 | ...params.args, 595 | where: { 596 | ...params.args?.where, 597 | // allow overriding the deleted field in where 598 | [config.field]: 599 | params.args?.where?.[config.field] || config.createValue(false), 600 | }, 601 | }, 602 | }; 603 | } 604 | 605 | export function createSelectMiddleware(config: ModelConfig): NestedMiddleware { 606 | return async function selectMiddleware(params, next) { 607 | const updatedParams = createSelectParams(params, config); 608 | 609 | const deletedFieldAdded = 610 | typeof updatedParams.args === "object" && 611 | updatedParams.args?.__deletedFieldAdded; 612 | 613 | if (deletedFieldAdded) { 614 | delete updatedParams.args.__deletedFieldAdded; 615 | } 616 | 617 | const result = await next(updatedParams); 618 | 619 | if (shouldFilterDeletedFromReadResult(params, config)) { 620 | const filteredResults = filterSoftDeletedResults(result, config); 621 | 622 | if (deletedFieldAdded) { 623 | stripDeletedFieldFromResults(filteredResults, config); 624 | } 625 | 626 | return filteredResults; 627 | } 628 | 629 | return result; 630 | }; 631 | } 632 | -------------------------------------------------------------------------------- /src/lib/createSoftDeleteMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | import { 3 | createNestedMiddleware, 4 | NestedMiddleware, 5 | } from "prisma-nested-middleware"; 6 | import { 7 | createAggregateMiddleware, 8 | createCountMiddleware, 9 | createDeleteManyMiddleware, 10 | createDeleteMiddleware, 11 | createFindFirstMiddleware, 12 | createFindFirstOrThrowMiddleware, 13 | createFindManyMiddleware, 14 | createFindUniqueMiddleware, 15 | createFindUniqueOrThrowMiddleware, 16 | createIncludeMiddleware, 17 | createSelectMiddleware, 18 | createUpdateManyMiddleware, 19 | createUpdateMiddleware, 20 | createUpsertMiddleware, 21 | createWhereMiddleware, 22 | createGroupByMiddleware, 23 | } from "./actionMiddleware"; 24 | 25 | import { Config, ModelConfig } from "./types"; 26 | 27 | export function createSoftDeleteMiddleware({ 28 | models, 29 | defaultConfig = { 30 | field: "deleted", 31 | createValue: Boolean, 32 | allowToOneUpdates: false, 33 | allowCompoundUniqueIndexWhere: false, 34 | }, 35 | }: Config) { 36 | if (!defaultConfig.field) { 37 | throw new Error( 38 | "prisma-soft-delete-middleware: defaultConfig.field is required" 39 | ); 40 | } 41 | if (!defaultConfig.createValue) { 42 | throw new Error( 43 | "prisma-soft-delete-middleware: defaultConfig.createValue is required" 44 | ); 45 | } 46 | 47 | const modelConfig: Partial> = {}; 48 | 49 | Object.keys(models).forEach((model) => { 50 | const modelName = model as Prisma.ModelName; 51 | const config = models[modelName]; 52 | if (config) { 53 | modelConfig[modelName] = 54 | typeof config === "boolean" && config ? defaultConfig : config; 55 | } 56 | }); 57 | 58 | const middlewareByModel = Object.keys(modelConfig).reduce< 59 | Record> 60 | >((acc, model) => { 61 | const config = modelConfig[model as Prisma.ModelName]!; 62 | return { 63 | ...acc, 64 | [model]: { 65 | delete: createDeleteMiddleware(config), 66 | deleteMany: createDeleteManyMiddleware(config), 67 | update: createUpdateMiddleware(config), 68 | updateMany: createUpdateManyMiddleware(config), 69 | upsert: createUpsertMiddleware(config), 70 | findFirst: createFindFirstMiddleware(config), 71 | findFirstOrThrow: createFindFirstOrThrowMiddleware(config), 72 | findUnique: createFindUniqueMiddleware(config), 73 | findUniqueOrThrow: createFindUniqueOrThrowMiddleware(config), 74 | findMany: createFindManyMiddleware(config), 75 | count: createCountMiddleware(config), 76 | aggregate: createAggregateMiddleware(config), 77 | where: createWhereMiddleware(config), 78 | include: createIncludeMiddleware(config), 79 | select: createSelectMiddleware(config), 80 | groupBy: createGroupByMiddleware(config), 81 | }, 82 | }; 83 | }, {}); 84 | 85 | // before handling root params generate deleted value so it is consistent 86 | // for the query. Add it to root params and get it from scope? 87 | 88 | return createNestedMiddleware((params, next) => { 89 | const middleware = middlewareByModel[params.model || ""]?.[params.action]; 90 | 91 | // apply middleware if it is found for model and action 92 | if (middleware) return middleware(params, next); 93 | 94 | return next(params); 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | 3 | export type ModelConfig = { 4 | field: string; 5 | createValue: (deleted: boolean) => any; 6 | allowToOneUpdates?: boolean; 7 | allowCompoundUniqueIndexWhere?: boolean; 8 | }; 9 | 10 | export type Config = { 11 | models: Partial>; 12 | defaultConfig?: ModelConfig; 13 | }; -------------------------------------------------------------------------------- /src/lib/utils/nestedReads.ts: -------------------------------------------------------------------------------- 1 | import { NestedParams } from "prisma-nested-middleware"; 2 | 3 | import { ModelConfig } from "../types"; 4 | 5 | export function addDeletedToSelect(params: NestedParams, config: ModelConfig) { 6 | if (params.args.select && !params.args.select[config.field]) { 7 | return { 8 | ...params, 9 | args: { 10 | __deletedFieldAdded: true, 11 | ...params.args, 12 | select: { 13 | ...params.args.select, 14 | [config.field]: true, 15 | }, 16 | }, 17 | }; 18 | } 19 | 20 | return params; 21 | } 22 | 23 | export function stripDeletedFieldFromResults( 24 | results: any, 25 | config: ModelConfig 26 | ) { 27 | if (Array.isArray(results)) { 28 | results?.forEach((item: any) => { 29 | delete item[config.field]; 30 | }); 31 | } else if (results) { 32 | delete results[config.field]; 33 | } 34 | 35 | return results; 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/utils/resultFiltering.ts: -------------------------------------------------------------------------------- 1 | import { NestedParams } from "prisma-nested-middleware"; 2 | import isEqual from "lodash/isEqual"; 3 | 4 | import { ModelConfig } from "../types"; 5 | 6 | export function shouldFilterDeletedFromReadResult( 7 | params: NestedParams, 8 | config: ModelConfig 9 | ): boolean { 10 | return ( 11 | !params.scope?.relations.to.isList && 12 | (!params.args.where || 13 | typeof params.args.where[config.field] === "undefined" || 14 | isEqual(params.args.where[config.field], config.createValue(false))) 15 | ); 16 | } 17 | 18 | export function filterSoftDeletedResults(result: any, config: ModelConfig) { 19 | // filter out deleted records from array results 20 | if (result && Array.isArray(result)) { 21 | return result.filter((item) => 22 | isEqual(item[config.field], config.createValue(false)) 23 | ); 24 | } 25 | 26 | // if the result is deleted return null 27 | if (result && !isEqual(result[config.field], config.createValue(false))) { 28 | return null; 29 | } 30 | 31 | return result; 32 | } 33 | -------------------------------------------------------------------------------- /test/e2e/client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | export default new PrismaClient(); 4 | -------------------------------------------------------------------------------- /test/e2e/deletedAt.test.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Profile, User } from "@prisma/client"; 2 | import faker from "faker"; 3 | 4 | import { createSoftDeleteMiddleware } from "../../src"; 5 | import client from "./client"; 6 | 7 | describe("deletedAt", () => { 8 | let testClient: PrismaClient; 9 | let profile: Profile; 10 | let user: User; 11 | 12 | beforeAll(async () => { 13 | testClient = new PrismaClient(); 14 | testClient.$use( 15 | createSoftDeleteMiddleware({ 16 | models: { 17 | User: { 18 | field: "deletedAt", 19 | createValue: (deleted) => { 20 | return deleted ? new Date() : null; 21 | }, 22 | }, 23 | }, 24 | }) 25 | ); 26 | 27 | profile = await client.profile.create({ 28 | data: { 29 | bio: "foo", 30 | }, 31 | }); 32 | user = await client.user.create({ 33 | data: { 34 | email: faker.internet.email(), 35 | name: faker.name.findName(), 36 | profileId: profile.id, 37 | comments: { 38 | create: [ 39 | { content: "foo" }, 40 | { content: "foo", deleted: true }, 41 | { content: "bar", deleted: true }, 42 | ], 43 | }, 44 | }, 45 | }); 46 | }); 47 | afterEach(async () => { 48 | // restore soft deleted user 49 | await client.user.update({ 50 | where: { id: user.id }, 51 | data: { 52 | deletedAt: null, 53 | }, 54 | }); 55 | }); 56 | afterAll(async () => { 57 | // disconnect test client 58 | await testClient.$disconnect(); 59 | 60 | // delete user and related data 61 | await client.user.update({ 62 | where: { id: user.id }, 63 | data: { 64 | comments: { deleteMany: {} }, 65 | profile: { delete: true }, 66 | }, 67 | }); 68 | await client.user.deleteMany({ where: {} }) 69 | }); 70 | 71 | it("soft deletes when using delete", async () => { 72 | await testClient.user.delete({ 73 | where: { id: user.id }, 74 | }); 75 | 76 | const softDeletedUser = await testClient.user.findFirst({ 77 | where: { id: user.id }, 78 | }); 79 | expect(softDeletedUser).toBeNull(); 80 | 81 | const dbUser = await client.user.findFirst({ 82 | where: { id: user.id }, 83 | }); 84 | expect(dbUser).not.toBeNull(); 85 | expect(dbUser?.deletedAt).not.toBeNull(); 86 | expect(dbUser?.deletedAt).toBeInstanceOf(Date); 87 | }); 88 | 89 | it("soft deletes when using deleteMany", async () => { 90 | await testClient.user.deleteMany({ 91 | where: { id: user.id }, 92 | }); 93 | 94 | const softDeletedUser = await testClient.user.findFirst({ 95 | where: { id: user.id }, 96 | }); 97 | expect(softDeletedUser).toBeNull(); 98 | 99 | const dbUser = await client.user.findFirst({ 100 | where: { id: user.id }, 101 | }); 102 | expect(dbUser).not.toBeNull(); 103 | expect(dbUser?.deletedAt).not.toBeNull(); 104 | expect(dbUser?.deletedAt).toBeInstanceOf(Date); 105 | }); 106 | 107 | it("excludes deleted when filtering with where", async () => { 108 | // soft delete user 109 | await client.user.update({ 110 | where: { id: user.id }, 111 | data: { deletedAt: new Date() }, 112 | }); 113 | 114 | const comment = await testClient.comment.findFirst({ 115 | where: { 116 | author: { 117 | id: user.id, 118 | }, 119 | }, 120 | }); 121 | 122 | expect(comment).toBeNull(); 123 | }); 124 | 125 | it("excludes deleted when filtering with where through 'some' modifier", async () => { 126 | // soft delete user 127 | await client.user.update({ 128 | where: { id: user.id }, 129 | data: { deletedAt: new Date() }, 130 | }); 131 | 132 | const userProfile = await testClient.profile.findFirst({ 133 | where: { 134 | users: { 135 | some: { 136 | id: user.id, 137 | }, 138 | }, 139 | }, 140 | }); 141 | 142 | expect(userProfile).toBeNull(); 143 | }); 144 | 145 | it("excludes deleted when filtering with where through 'every' modifier", async () => { 146 | // soft delete user 147 | await client.user.update({ 148 | where: { id: user.id }, 149 | data: { deletedAt: new Date() }, 150 | }); 151 | 152 | // add another user to profile 153 | await client.user.create({ 154 | data: { 155 | email: faker.internet.email(), 156 | name: faker.name.findName(), 157 | profileId: profile.id, 158 | }, 159 | }); 160 | 161 | const userProfile = await testClient.profile.findFirst({ 162 | where: { 163 | users: { 164 | every: { 165 | id: user.id, 166 | }, 167 | }, 168 | }, 169 | }); 170 | 171 | expect(userProfile).toBeNull(); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /test/e2e/nestedReads.test.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, User } from "@prisma/client"; 2 | import faker from "faker"; 3 | 4 | import { createSoftDeleteMiddleware } from "../../src"; 5 | import client from "./client"; 6 | 7 | describe("nested reads", () => { 8 | let testClient: PrismaClient; 9 | let user: User; 10 | 11 | beforeAll(async () => { 12 | testClient = new PrismaClient(); 13 | testClient.$use( 14 | createSoftDeleteMiddleware({ 15 | models: { 16 | Comment: true, 17 | // use truthy value for non-deleted to test that they are still filtered 18 | Profile: { 19 | field: "deletedAt", 20 | createValue: (deleted) => (deleted ? new Date() : new Date(0)), 21 | }, 22 | }, 23 | }) 24 | ); 25 | 26 | user = await client.user.create({ 27 | data: { 28 | email: faker.internet.email(), 29 | name: faker.name.findName(), 30 | profile: { create: { bio: "foo" } }, 31 | }, 32 | }); 33 | 34 | await client.comment.create({ 35 | data: { 36 | author: { connect: { id: user.id } }, 37 | content: "foo", 38 | replies: { 39 | create: [ 40 | { content: "baz" }, 41 | { content: "baz", deleted: true }, 42 | { content: "qux", deleted: true }, 43 | ], 44 | }, 45 | post: { 46 | create: { 47 | title: "foo-comment-post-title", 48 | authorId: user.id, 49 | author: { 50 | connect: { id: user.id }, 51 | }, 52 | }, 53 | }, 54 | }, 55 | }); 56 | 57 | await client.comment.create({ 58 | data: { 59 | author: { connect: { id: user.id } }, 60 | content: "bar", 61 | deleted: true, 62 | post: { 63 | create: { 64 | title: "bar-comment-post-title", 65 | authorId: user.id, 66 | author: { 67 | connect: { id: user.id }, 68 | }, 69 | }, 70 | }, 71 | }, 72 | }); 73 | }); 74 | afterEach(async () => { 75 | // restore soft deleted profile 76 | await client.profile.updateMany({ 77 | where: {}, 78 | data: { deletedAt: new Date(0) }, 79 | }); 80 | }); 81 | afterAll(async () => { 82 | await testClient.$disconnect(); 83 | 84 | await client.user.deleteMany({ where: {} }); 85 | await client.comment.deleteMany({ where: {} }); 86 | await client.profile.deleteMany({ where: {} }); 87 | }); 88 | 89 | describe("include", () => { 90 | it("excludes deleted when including toMany relation", async () => { 91 | const { comments } = await testClient.user.findUniqueOrThrow({ 92 | where: { id: user.id }, 93 | include: { 94 | comments: true, 95 | }, 96 | }); 97 | 98 | expect(comments).toHaveLength(1); 99 | expect(comments[0].content).toEqual("foo"); 100 | }); 101 | 102 | it("excludes deleted when including toOne relation", async () => { 103 | const { 104 | profile: nonDeletedProfile, 105 | } = await testClient.user.findUniqueOrThrow({ 106 | where: { id: user.id }, 107 | include: { 108 | profile: true, 109 | }, 110 | }); 111 | 112 | expect(nonDeletedProfile).not.toBeNull(); 113 | expect(nonDeletedProfile!.bio).toEqual("foo"); 114 | 115 | // soft delete profiles 116 | await client.profile.updateMany({ 117 | where: {}, 118 | data: { 119 | deletedAt: new Date(), 120 | }, 121 | }); 122 | 123 | const { 124 | profile: softDeletedProfile, 125 | } = await testClient.user.findUniqueOrThrow({ 126 | where: { id: user.id }, 127 | include: { 128 | profile: true, 129 | }, 130 | }); 131 | 132 | expect(softDeletedProfile).toBeNull(); 133 | }); 134 | 135 | it("excludes deleted when deeply including relations", async () => { 136 | const { 137 | comments: nonDeletedComments, 138 | } = await testClient.user.findUniqueOrThrow({ 139 | where: { id: user.id }, 140 | include: { 141 | comments: { 142 | include: { 143 | post: true, 144 | replies: true, 145 | }, 146 | }, 147 | }, 148 | }); 149 | 150 | expect(nonDeletedComments).toHaveLength(1); 151 | expect(nonDeletedComments[0].content).toEqual("foo"); 152 | 153 | expect(nonDeletedComments[0].replies).toHaveLength(1); 154 | expect(nonDeletedComments[0].replies[0].content).toEqual("baz"); 155 | }); 156 | 157 | it("excludes deleted when including fields with where", async () => { 158 | const { 159 | comments: nonDeletedComments, 160 | } = await testClient.user.findUniqueOrThrow({ 161 | where: { id: user.id }, 162 | include: { 163 | comments: { 164 | where: { 165 | content: "bar", 166 | }, 167 | }, 168 | }, 169 | }); 170 | 171 | expect(nonDeletedComments).toHaveLength(0); 172 | }); 173 | 174 | it("excludes deleted when including fields using where that targets soft-deleted model", async () => { 175 | const { posts } = await testClient.user.findUniqueOrThrow({ 176 | where: { id: user.id }, 177 | include: { 178 | posts: { 179 | where: { 180 | comments: { 181 | some: { 182 | content: { 183 | in: ["foo", "bar"], 184 | }, 185 | }, 186 | }, 187 | }, 188 | }, 189 | }, 190 | }); 191 | 192 | expect(posts).toHaveLength(1); 193 | }); 194 | }); 195 | 196 | describe("select", () => { 197 | it("excludes deleted when selecting toMany relation", async () => { 198 | const { comments } = await testClient.user.findUniqueOrThrow({ 199 | where: { id: user.id }, 200 | select: { 201 | comments: true, 202 | }, 203 | }); 204 | 205 | expect(comments).toHaveLength(1); 206 | expect(comments[0].content).toEqual("foo"); 207 | }); 208 | 209 | it("excludes deleted when selecting toOne relation", async () => { 210 | const { 211 | profile: nonDeletedProfile, 212 | } = await testClient.user.findUniqueOrThrow({ 213 | where: { id: user.id }, 214 | select: { 215 | profile: true, 216 | }, 217 | }); 218 | 219 | expect(nonDeletedProfile).not.toBeNull(); 220 | expect(nonDeletedProfile!.bio).toEqual("foo"); 221 | 222 | // soft delete profiles 223 | await client.profile.updateMany({ 224 | where: {}, 225 | data: { 226 | deletedAt: new Date(), 227 | }, 228 | }); 229 | 230 | const { 231 | profile: softDeletedProfile, 232 | } = await testClient.user.findUniqueOrThrow({ 233 | where: { id: user.id }, 234 | include: { 235 | profile: true, 236 | }, 237 | }); 238 | 239 | expect(softDeletedProfile).toBeNull(); 240 | }); 241 | 242 | it("excludes deleted when deeply selecting relations", async () => { 243 | const { 244 | comments: nonDeletedComments, 245 | } = await testClient.user.findUniqueOrThrow({ 246 | where: { id: user.id }, 247 | select: { 248 | comments: { 249 | select: { 250 | content: true, 251 | post: true, 252 | replies: true, 253 | }, 254 | }, 255 | }, 256 | }); 257 | 258 | expect(nonDeletedComments).toHaveLength(1); 259 | expect(nonDeletedComments[0].content).toEqual("foo"); 260 | 261 | expect(nonDeletedComments[0].replies).toHaveLength(1); 262 | expect(nonDeletedComments[0].replies[0].content).toEqual("baz"); 263 | }); 264 | 265 | it("excludes deleted when selecting fields through an include", async () => { 266 | const { 267 | comments: nonDeletedComments, 268 | } = await testClient.user.findUniqueOrThrow({ 269 | where: { id: user.id }, 270 | include: { 271 | comments: { 272 | select: { 273 | content: true, 274 | post: true, 275 | replies: true, 276 | }, 277 | }, 278 | }, 279 | }); 280 | 281 | expect(nonDeletedComments).toHaveLength(1); 282 | expect(nonDeletedComments[0].content).toEqual("foo"); 283 | 284 | expect(nonDeletedComments[0].replies).toHaveLength(1); 285 | expect(nonDeletedComments[0].replies[0].content).toEqual("baz"); 286 | }); 287 | 288 | it("excludes deleted when selecting fields with where", async () => { 289 | const { 290 | comments: nonDeletedComments, 291 | } = await testClient.user.findUniqueOrThrow({ 292 | where: { id: user.id }, 293 | select: { 294 | comments: { 295 | where: { 296 | content: "bar", 297 | }, 298 | }, 299 | }, 300 | }); 301 | 302 | expect(nonDeletedComments).toHaveLength(0); 303 | }); 304 | 305 | it("excludes deleted when selecting fields with where through an include", async () => { 306 | const { 307 | comments: nonDeletedComments, 308 | } = await testClient.user.findUniqueOrThrow({ 309 | where: { id: user.id }, 310 | include: { 311 | comments: { 312 | select: { 313 | replies: { 314 | where: { 315 | content: "qux", 316 | }, 317 | }, 318 | }, 319 | }, 320 | }, 321 | }); 322 | 323 | expect(nonDeletedComments).toHaveLength(1); 324 | expect(nonDeletedComments[0].replies).toHaveLength(0); 325 | }); 326 | 327 | it("excludes deleted when selecting fields using where that targets soft-deleted model", async () => { 328 | const { posts } = await testClient.user.findUniqueOrThrow({ 329 | where: { id: user.id }, 330 | include: { 331 | posts: { 332 | where: { 333 | comments: { 334 | some: { 335 | content: { 336 | in: ["foo", "bar"], 337 | }, 338 | }, 339 | }, 340 | }, 341 | }, 342 | }, 343 | }); 344 | 345 | expect(posts).toHaveLength(1); 346 | }); 347 | 348 | it("excludes deleted when including relation and filtering by every", async () => { 349 | const { comments } = await testClient.user.findUniqueOrThrow({ 350 | where: { id: user.id }, 351 | include: { 352 | comments: { 353 | where: { 354 | replies: { 355 | every: { 356 | content: { 357 | in: ["baz", "qux"], 358 | }, 359 | }, 360 | }, 361 | }, 362 | }, 363 | }, 364 | }); 365 | 366 | expect(comments).toHaveLength(1); 367 | }); 368 | 369 | it("excludes deleted when including relation and filtering by every in a NOT", async () => { 370 | const { comments } = await testClient.user.findUniqueOrThrow({ 371 | where: { id: user.id }, 372 | include: { 373 | comments: { 374 | where: { 375 | NOT: { 376 | replies: { 377 | every: { 378 | content: { 379 | in: ["baz", "qux"], 380 | }, 381 | }, 382 | }, 383 | }, 384 | }, 385 | }, 386 | }, 387 | }); 388 | 389 | expect(comments).toHaveLength(0); 390 | }); 391 | 392 | it("excludes deleted selected toOne relations even when deleted field not selected", async () => { 393 | const { profile } = await testClient.user.findUniqueOrThrow({ 394 | where: { id: user.id }, 395 | select: { 396 | profile: { 397 | select: { 398 | id: true, 399 | bio: true, 400 | }, 401 | }, 402 | }, 403 | }); 404 | 405 | expect(profile).not.toBeNull(); 406 | 407 | // @ts-expect-error deleted not on the type because it's not selected 408 | expect(profile!.deleted).toBeUndefined(); 409 | 410 | // soft delete profile 411 | await client.profile.update({ 412 | where: { id: profile!.id }, 413 | data: { 414 | deletedAt: new Date(), 415 | }, 416 | }); 417 | 418 | const { 419 | profile: softDeletedProfile, 420 | } = await testClient.user.findUniqueOrThrow({ 421 | where: { id: user.id }, 422 | select: { 423 | profile: { 424 | select: { 425 | id: true, 426 | bio: true, 427 | }, 428 | }, 429 | }, 430 | }); 431 | 432 | expect(softDeletedProfile).toBeNull(); 433 | }); 434 | }); 435 | }); 436 | -------------------------------------------------------------------------------- /test/e2e/queries.test.ts: -------------------------------------------------------------------------------- 1 | import { Comment, PrismaClient, Profile, User } from "@prisma/client"; 2 | import faker from "faker"; 3 | 4 | import { createSoftDeleteMiddleware } from "../../src"; 5 | import client from "./client"; 6 | 7 | describe("queries", () => { 8 | let testClient: PrismaClient; 9 | let profile: Profile; 10 | let firstUser: User; 11 | let secondUser: User; 12 | let deletedUser: User; 13 | let comment: Comment; 14 | 15 | beforeAll(async () => { 16 | testClient = new PrismaClient(); 17 | testClient.$use(createSoftDeleteMiddleware({ models: { User: true } })); 18 | 19 | profile = await client.profile.create({ 20 | data: { 21 | bio: faker.lorem.sentence(), 22 | }, 23 | }); 24 | firstUser = await client.user.create({ 25 | data: { 26 | email: faker.internet.email(), 27 | name: "Jack", 28 | profileId: profile.id, 29 | }, 30 | }); 31 | secondUser = await client.user.create({ 32 | data: { 33 | email: faker.internet.email(), 34 | name: "John", 35 | }, 36 | }); 37 | deletedUser = await client.user.create({ 38 | data: { 39 | email: faker.internet.email(), 40 | name: "Jill", 41 | deleted: true, 42 | profileId: profile.id, 43 | }, 44 | }); 45 | comment = await client.comment.create({ 46 | data: { 47 | content: faker.lorem.sentence(), 48 | authorId: firstUser.id, 49 | }, 50 | }); 51 | }); 52 | afterEach(async () => { 53 | await Promise.all([ 54 | // reset starting data 55 | client.profile.update({ where: { id: profile.id }, data: profile }), 56 | client.user.update({ where: { id: deletedUser.id }, data: deletedUser }), 57 | client.user.update({ where: { id: firstUser.id }, data: firstUser }), 58 | client.user.update({ where: { id: secondUser.id }, data: secondUser }), 59 | client.comment.update({ where: { id: comment.id }, data: comment }), 60 | 61 | // delete created models 62 | client.profile.deleteMany({ 63 | where: { id: { not: { in: [profile.id] } } }, 64 | }), 65 | client.user.deleteMany({ 66 | where: { 67 | id: { not: { in: [firstUser.id, secondUser.id, deletedUser.id] } }, 68 | }, 69 | }), 70 | client.comment.deleteMany({ 71 | where: { id: { not: { in: [comment.id] } } }, 72 | }), 73 | ]); 74 | }); 75 | afterAll(async () => { 76 | await testClient.$disconnect(); 77 | await client.user.deleteMany({ where: {} }); 78 | }); 79 | 80 | describe("delete", () => { 81 | it("delete soft deletes", async () => { 82 | const result = await testClient.user.delete({ 83 | where: { id: firstUser.id }, 84 | }); 85 | expect(result).not.toBeNull(); 86 | 87 | const dbUser = await client.user.findUnique({ 88 | where: { id: firstUser.id }, 89 | }); 90 | expect(dbUser).not.toBeNull(); 91 | expect(dbUser!.id).toEqual(firstUser.id); 92 | expect(dbUser?.deleted).toBe(true); 93 | }); 94 | 95 | it("nested delete soft deletes", async () => { 96 | const result = await testClient.profile.update({ 97 | where: { id: profile.id }, 98 | data: { 99 | users: { 100 | delete: { 101 | id: firstUser.id, 102 | }, 103 | }, 104 | }, 105 | }); 106 | expect(result).not.toBeNull(); 107 | 108 | const dbUser = await client.user.findUniqueOrThrow({ 109 | where: { id: firstUser.id }, 110 | }); 111 | expect(dbUser.deleted).toBe(true); 112 | }); 113 | }); 114 | 115 | describe("deleteMany", () => { 116 | it("deleteMany soft deletes", async () => { 117 | const result = await testClient.user.deleteMany({ 118 | where: { name: { contains: "J" } }, 119 | }); 120 | expect(result).not.toBeNull(); 121 | expect(result.count).toEqual(2); 122 | 123 | const dbUsers = await client.user.findMany({ 124 | where: { name: { contains: "J" } }, 125 | }); 126 | expect(dbUsers).toHaveLength(3); 127 | expect(dbUsers.map(({ id }) => id).sort()).toEqual( 128 | [firstUser.id, secondUser.id, deletedUser.id].sort() 129 | ); 130 | expect(dbUsers.every(({ deleted }) => deleted)).toBe(true); 131 | }); 132 | 133 | it("nested deleteMany soft deletes", async () => { 134 | const result = await testClient.profile.update({ 135 | where: { id: profile.id }, 136 | data: { 137 | users: { 138 | deleteMany: { 139 | name: { contains: "J" }, 140 | }, 141 | }, 142 | }, 143 | }); 144 | expect(result).not.toBeNull(); 145 | 146 | const dbUsers = await client.user.findMany({ 147 | where: { name: { contains: "J" }, deleted: true }, 148 | }); 149 | expect(dbUsers).toHaveLength(2); 150 | expect(dbUsers.map(({ id }) => id).sort()).toEqual( 151 | [firstUser.id, deletedUser.id].sort() 152 | ); 153 | }); 154 | }); 155 | 156 | describe("updateMany", () => { 157 | it("updateMany excludes soft deleted records", async () => { 158 | const result = await testClient.user.updateMany({ 159 | where: { name: { contains: "J" } }, 160 | data: { name: "Updated" }, 161 | }); 162 | expect(result).not.toBeNull(); 163 | expect(result.count).toEqual(2); 164 | 165 | const updatedDbUsers = await client.user.findMany({ 166 | where: { name: { contains: "Updated" } }, 167 | }); 168 | expect(updatedDbUsers).toHaveLength(2); 169 | expect(updatedDbUsers.map(({ id }) => id).sort()).toEqual( 170 | [firstUser.id, secondUser.id].sort() 171 | ); 172 | expect(updatedDbUsers.every(({ deleted }) => !deleted)).toBe(true); 173 | expect(updatedDbUsers.every(({ name }) => name === "Updated")).toBe(true); 174 | 175 | const deletedDbUser = await client.user.findUniqueOrThrow({ 176 | where: { id: deletedUser.id }, 177 | }); 178 | expect(deletedDbUser.name).toEqual(deletedUser.name); 179 | }); 180 | 181 | it("nested updateMany excludes soft deleted records", async () => { 182 | const result = await testClient.profile.update({ 183 | where: { id: profile.id }, 184 | data: { 185 | users: { 186 | updateMany: { 187 | where: { name: { contains: "J" } }, 188 | data: { name: "Updated" }, 189 | }, 190 | }, 191 | }, 192 | }); 193 | expect(result).not.toBeNull(); 194 | 195 | const dbUpdatedUser = await client.user.findUniqueOrThrow({ 196 | where: { id: firstUser.id }, 197 | }); 198 | expect(dbUpdatedUser.name).toEqual("Updated"); 199 | 200 | const dbDeletedUser = await client.user.findUniqueOrThrow({ 201 | where: { id: deletedUser.id }, 202 | }); 203 | expect(dbDeletedUser.name).toEqual(deletedUser.name); 204 | }); 205 | }); 206 | 207 | describe("update", () => { 208 | it("update does not exclude soft deleted records", async () => { 209 | const result = await testClient.user.update({ 210 | where: { id: deletedUser.id }, 211 | data: { name: "Updated Jill" }, 212 | }); 213 | expect(result).not.toBeNull(); 214 | expect(result.name).toEqual("Updated Jill"); 215 | 216 | const dbUser = await client.user.findUniqueOrThrow({ 217 | where: { id: deletedUser.id }, 218 | }); 219 | expect(dbUser.name).toEqual("Updated Jill"); 220 | }); 221 | 222 | it("nested toMany update does not exclude soft deleted records", async () => { 223 | const result = await testClient.user.update({ 224 | where: { id: firstUser.id }, 225 | data: { 226 | comments: { 227 | updateMany: { 228 | where: { id: comment.id }, 229 | data: { content: "Updated" }, 230 | }, 231 | }, 232 | }, 233 | }); 234 | expect(result).not.toBeNull(); 235 | 236 | const dbComment = await client.comment.findUniqueOrThrow({ 237 | where: { id: comment.id }, 238 | }); 239 | expect(dbComment.content).toEqual("Updated"); 240 | }); 241 | 242 | it("nested toOne update throws by default", async () => { 243 | await expect( 244 | testClient.comment.update({ 245 | where: { id: comment.id }, 246 | data: { 247 | author: { 248 | update: { 249 | name: "Updated", 250 | }, 251 | }, 252 | }, 253 | }) 254 | ).rejects.toThrowError( 255 | `prisma-soft-delete-middleware: update of model "User" through "Comment.author" found. Updates of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.` 256 | ); 257 | }); 258 | }); 259 | 260 | describe("upsert", () => { 261 | it("upsert does not exclude soft deleted records", async () => { 262 | const result = await testClient.user.upsert({ 263 | where: { id: deletedUser.id }, 264 | create: { email: faker.internet.email(), name: "New User" }, 265 | update: { name: "Updated" }, 266 | }); 267 | expect(result).not.toBeNull(); 268 | expect(result.name).toEqual("Updated"); 269 | 270 | const dbUser = await client.user.findUniqueOrThrow({ 271 | where: { id: deletedUser.id }, 272 | }); 273 | expect(dbUser.name).toEqual("Updated"); 274 | }); 275 | 276 | it("nested toMany upsert does not exclude soft deleted records", async () => { 277 | const result = await testClient.profile.update({ 278 | where: { id: profile.id }, 279 | data: { 280 | users: { 281 | upsert: { 282 | where: { id: deletedUser.id }, 283 | create: { email: faker.internet.email(), name: "New User" }, 284 | update: { name: "Updated" }, 285 | }, 286 | }, 287 | }, 288 | }); 289 | expect(result).not.toBeNull(); 290 | 291 | const dbUser = await client.user.findUniqueOrThrow({ 292 | where: { id: deletedUser.id }, 293 | }); 294 | expect(dbUser.name).toEqual("Updated"); 295 | }); 296 | 297 | it("nested toOne upsert throws by default", async () => { 298 | await expect( 299 | testClient.comment.update({ 300 | where: { id: comment.id }, 301 | data: { 302 | author: { 303 | upsert: { 304 | create: { email: faker.internet.email(), name: "New User" }, 305 | update: { name: "Updated" }, 306 | }, 307 | }, 308 | }, 309 | }) 310 | ).rejects.toThrowError( 311 | `prisma-soft-delete-middleware: upsert of model "User" through "Comment.author" found. Upserts of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.` 312 | ); 313 | }); 314 | }); 315 | 316 | describe("findFirst", () => { 317 | it("findFirst excludes soft deleted records", async () => { 318 | const foundUser = await testClient.user.findFirst({ 319 | where: { email: firstUser.email }, 320 | }); 321 | 322 | expect(foundUser).not.toBeNull(); 323 | expect(foundUser!.id).toEqual(firstUser.id); 324 | 325 | const notFoundUser = await testClient.user.findFirst({ 326 | where: { email: deletedUser.email }, 327 | }); 328 | expect(notFoundUser).toBeNull(); 329 | }); 330 | }); 331 | 332 | describe("findFirstOrThrow", () => { 333 | it("findFirstOrThrow throws for soft deleted records", async () => { 334 | const foundUser = await testClient.user.findFirstOrThrow({ 335 | where: { email: firstUser.email }, 336 | }); 337 | 338 | expect(foundUser).not.toBeNull(); 339 | expect(foundUser!.id).toEqual(firstUser.id); 340 | 341 | await expect(() => 342 | testClient.user.findFirstOrThrow({ 343 | where: { email: deletedUser.email }, 344 | }) 345 | ).rejects.toThrowError("No User found"); 346 | }); 347 | }); 348 | 349 | describe("findUnique", () => { 350 | it("findUnique excludes soft deleted records", async () => { 351 | const foundUser = await testClient.user.findUnique({ 352 | where: { id: firstUser.id }, 353 | }); 354 | expect(foundUser).not.toBeNull(); 355 | expect(foundUser!.id).toEqual(firstUser.id); 356 | 357 | const notFoundUser = await testClient.user.findUnique({ 358 | where: { id: deletedUser.id }, 359 | }); 360 | expect(notFoundUser).toBeNull(); 361 | }); 362 | 363 | it("throws a useful error when invalid where is passed", async () => { 364 | // throws useful error when no where is passed 365 | await expect(() => 366 | // @ts-expect-error intentionally incorrect args 367 | testClient.user.findUnique() 368 | ).rejects.toThrowError( 369 | "Invalid `testClient.user.findUnique()` invocation" 370 | ); 371 | 372 | // throws useful error when empty where is passed 373 | await expect(() => 374 | // @ts-expect-error intentionally incorrect args 375 | testClient.user.findUnique({}) 376 | ).rejects.toThrowError( 377 | "Invalid `testClient.user.findUnique()` invocation" 378 | ); 379 | 380 | // throws useful error when where is passed undefined unique fields 381 | await expect(() => 382 | testClient.user.findUnique({ 383 | where: { id: undefined }, 384 | }) 385 | ).rejects.toThrowError( 386 | "Invalid `testClient.user.findUnique()` invocation" 387 | ); 388 | 389 | // throws useful error when where has defined non-unique fields 390 | await expect(() => 391 | testClient.user.findUnique({ 392 | // @ts-expect-error intentionally incorrect args 393 | where: { name: firstUser.name }, 394 | }) 395 | ).rejects.toThrowError( 396 | "Invalid `testClient.user.findUnique()` invocation" 397 | ); 398 | 399 | // throws useful error when where has undefined compound unique index field 400 | await expect(() => 401 | testClient.user.findUnique({ 402 | where: { name_email: undefined }, 403 | }) 404 | ).rejects.toThrowError( 405 | "Invalid `testClient.user.findUnique()` invocation" 406 | ); 407 | 408 | // throws useful error when where has undefined unique field and defined non-unique field 409 | await expect(() => 410 | testClient.user.findUnique({ 411 | where: { id: undefined, name: firstUser.name }, 412 | }) 413 | ).rejects.toThrowError( 414 | "Invalid `testClient.user.findUnique()` invocation" 415 | ); 416 | }); 417 | 418 | // TODO:- enable this test when extendedWhereUnique is supported 419 | it.failing( 420 | "findUnique excludes soft-deleted records when using compound unique index fields", 421 | async () => { 422 | const notFoundUser = await testClient.user.findUnique({ 423 | where: { 424 | name_email: { 425 | name: deletedUser.name, 426 | email: deletedUser.email, 427 | }, 428 | }, 429 | }); 430 | expect(notFoundUser).toBeNull(); 431 | } 432 | ); 433 | }); 434 | 435 | describe("findUniqueOrThrow", () => { 436 | it("findUniqueOrThrow throws for soft deleted records", async () => { 437 | const foundUser = await testClient.user.findUniqueOrThrow({ 438 | where: { id: firstUser.id }, 439 | }); 440 | expect(foundUser).not.toBeNull(); 441 | expect(foundUser!.id).toEqual(firstUser.id); 442 | 443 | await expect(() => 444 | testClient.user.findUniqueOrThrow({ 445 | where: { id: deletedUser.id }, 446 | }) 447 | ).rejects.toThrowError( 448 | "No User found" 449 | ); 450 | }); 451 | 452 | it("throws a useful error when invalid where is passed", async () => { 453 | // throws useful error when no where is passed 454 | await expect(() => 455 | testClient.user.findUniqueOrThrow() 456 | ).rejects.toThrowError( 457 | "Invalid `testClient.user.findUniqueOrThrow()` invocation" 458 | ); 459 | 460 | // throws useful error when empty where is passed 461 | await expect(() => 462 | // @ts-expect-error intentionally incorrect args 463 | testClient.user.findUniqueOrThrow({}) 464 | ).rejects.toThrowError( 465 | "Invalid `testClient.user.findUniqueOrThrow()` invocation" 466 | ); 467 | 468 | // throws useful error when where is passed undefined unique fields 469 | await expect(() => 470 | testClient.user.findUniqueOrThrow({ 471 | where: { id: undefined }, 472 | }) 473 | ).rejects.toThrowError( 474 | "Invalid `testClient.user.findUniqueOrThrow()` invocation" 475 | ); 476 | 477 | // throws useful error when where has defined non-unique fields 478 | await expect(() => 479 | testClient.user.findUniqueOrThrow({ 480 | // @ts-expect-error intentionally incorrect args 481 | where: { name: firstUser.name }, 482 | }) 483 | ).rejects.toThrowError( 484 | "Invalid `testClient.user.findUniqueOrThrow()` invocation" 485 | ); 486 | 487 | // throws useful error when where has undefined compound unique index field 488 | await expect(() => 489 | testClient.user.findUniqueOrThrow({ 490 | where: { name_email: undefined }, 491 | }) 492 | ).rejects.toThrowError( 493 | "Invalid `testClient.user.findUniqueOrThrow()` invocation" 494 | ); 495 | 496 | // throws useful error when where has undefined unique field and defined non-unique field 497 | await expect(() => 498 | testClient.user.findUniqueOrThrow({ 499 | where: { id: undefined, name: firstUser.name }, 500 | }) 501 | ).rejects.toThrowError( 502 | "Invalid `testClient.user.findUniqueOrThrow()` invocation" 503 | ); 504 | }); 505 | 506 | // TODO:- enable this test when extendedWhereUnique is supported 507 | it.failing( 508 | "findUniqueOrThrow excludes soft-deleted records when using compound unique index fields", 509 | async () => { 510 | const notFoundUser = await testClient.user.findUniqueOrThrow({ 511 | where: { 512 | name_email: { 513 | name: deletedUser.name, 514 | email: deletedUser.email, 515 | }, 516 | }, 517 | }); 518 | expect(notFoundUser).toBeNull(); 519 | } 520 | ); 521 | }); 522 | 523 | describe("findMany", () => { 524 | it("findMany excludes soft deleted records", async () => { 525 | const foundUsers = await testClient.user.findMany({ 526 | where: { name: { contains: "J" } }, 527 | }); 528 | expect(foundUsers).toHaveLength(2); 529 | expect(foundUsers.map(({ id }) => id).sort()).toEqual( 530 | [firstUser.id, secondUser.id].sort() 531 | ); 532 | }); 533 | }); 534 | 535 | describe("count", () => { 536 | it("count excludes soft deleted records", async () => { 537 | const count = await testClient.user.count({ 538 | where: { name: { contains: "J" } }, 539 | }); 540 | expect(count).toEqual(2); 541 | }); 542 | }); 543 | 544 | describe("aggregate", () => { 545 | it("aggregate excludes soft deleted records", async () => { 546 | const aggregate = await testClient.user.aggregate({ 547 | where: { name: { contains: "J" } }, 548 | _sum: { 549 | id: true, 550 | }, 551 | }); 552 | expect(aggregate._sum.id).toEqual(firstUser.id + secondUser.id); 553 | }); 554 | }); 555 | 556 | describe("create", () => { 557 | it("does not prevent creating a record", async () => { 558 | const result = await testClient.user.create({ 559 | data: { email: faker.internet.email(), name: "New User" }, 560 | }); 561 | expect(result).not.toBeNull(); 562 | }); 563 | 564 | it("does not prevent creating a soft deleted record", async () => { 565 | const result = await testClient.user.create({ 566 | data: { 567 | email: faker.internet.email(), 568 | name: "New User", 569 | deleted: true, 570 | }, 571 | }); 572 | expect(result).not.toBeNull(); 573 | }); 574 | 575 | it("does not prevent creating a nested record", async () => { 576 | const result = await testClient.profile.update({ 577 | where: { id: profile.id }, 578 | data: { 579 | users: { 580 | create: { email: faker.internet.email(), name: "New User" }, 581 | }, 582 | }, 583 | include: { 584 | users: true, 585 | }, 586 | }); 587 | // should be first user and new user 588 | expect(result.users).toHaveLength(2); 589 | 590 | // first user should be there 591 | expect(result.users.find(({ id }) => id === firstUser.id)).not.toBeNull(); 592 | 593 | // other users should not be returned 594 | expect( 595 | result.users.find(({ id }) => id === secondUser.id) 596 | ).not.toBeDefined(); 597 | expect( 598 | result.users.find(({ id }) => id === deletedUser.id) 599 | ).not.toBeDefined(); 600 | }); 601 | 602 | it("does not prevent creating a nested soft deleted record", async () => { 603 | const result = await testClient.profile.update({ 604 | where: { id: profile.id }, 605 | data: { 606 | users: { 607 | create: { 608 | email: faker.internet.email(), 609 | name: "New User", 610 | deleted: true, 611 | }, 612 | }, 613 | }, 614 | include: { 615 | users: true, 616 | }, 617 | }); 618 | // should be first user and new user 619 | expect(result.users).toHaveLength(1); 620 | 621 | // only first user should be there 622 | expect(result.users[0].id).toEqual(firstUser.id); 623 | 624 | // user was created 625 | const dbUser = await client.user.findFirst({ 626 | where: { name: "New User" }, 627 | }); 628 | expect(dbUser).not.toBeNull(); 629 | }); 630 | }); 631 | 632 | describe("createMany", () => { 633 | it("createMany can create records and soft deleted records", async () => { 634 | const result = await testClient.user.createMany({ 635 | data: [ 636 | { email: faker.internet.email(), name: faker.name.findName() }, 637 | { 638 | email: faker.internet.email(), 639 | name: faker.name.findName(), 640 | deleted: true, 641 | }, 642 | ], 643 | }); 644 | expect(result).not.toBeNull(); 645 | expect(result.count).toEqual(2); 646 | }); 647 | 648 | it("nested createMany can create records and soft deleted records", async () => { 649 | const result = await testClient.user.create({ 650 | data: { 651 | email: faker.internet.email(), 652 | name: faker.name.findName(), 653 | comments: { 654 | createMany: { 655 | data: [ 656 | { content: faker.lorem.sentence() }, 657 | { 658 | content: faker.lorem.sentence(), 659 | deleted: true, 660 | }, 661 | ], 662 | }, 663 | }, 664 | }, 665 | }); 666 | expect(result).not.toBeNull(); 667 | 668 | const dbUser = await client.user.findUniqueOrThrow({ 669 | where: { id: result.id }, 670 | include: { comments: true }, 671 | }); 672 | expect(dbUser.comments).toHaveLength(2); 673 | expect(dbUser.comments.filter(({ deleted }) => deleted)).toHaveLength(1); 674 | expect(dbUser.comments.filter(({ deleted }) => !deleted)).toHaveLength(1); 675 | }); 676 | }); 677 | 678 | describe("connect", () => { 679 | it("connect connects soft deleted records", async () => { 680 | await testClient.user.update({ 681 | where: { id: firstUser.id }, 682 | data: { 683 | comments: { 684 | connect: { 685 | id: comment.id, 686 | }, 687 | }, 688 | }, 689 | }); 690 | 691 | const dbComment = await client.comment.findUniqueOrThrow({ 692 | where: { id: comment.id }, 693 | }); 694 | expect(dbComment.authorId).toEqual(firstUser.id); 695 | }); 696 | }); 697 | 698 | describe("connectOrCreate", () => { 699 | it("connectOrCreate connects soft deleted records", async () => { 700 | await testClient.user.update({ 701 | where: { id: firstUser.id }, 702 | data: { 703 | comments: { 704 | connectOrCreate: { 705 | where: { id: comment.id }, 706 | create: { content: "Updated" }, 707 | }, 708 | }, 709 | }, 710 | }); 711 | 712 | const dbComment = await client.comment.findUniqueOrThrow({ 713 | where: { id: comment.id }, 714 | }); 715 | expect(dbComment.authorId).toEqual(firstUser.id); 716 | }); 717 | }); 718 | 719 | describe("disconnect", () => { 720 | it("toMany disconnect can disconnect soft deleted records", async () => { 721 | await testClient.user.update({ 722 | where: { id: firstUser.id }, 723 | data: { 724 | comments: { 725 | disconnect: { 726 | id: comment.id, 727 | }, 728 | }, 729 | }, 730 | }); 731 | 732 | const dbComment = await client.comment.findUniqueOrThrow({ 733 | where: { id: comment.id }, 734 | }); 735 | expect(dbComment.authorId).toBeNull(); 736 | }); 737 | 738 | it("toOne disconnect can disconnect soft deleted records", async () => { 739 | await testClient.user.update({ 740 | where: { id: firstUser.id }, 741 | data: { 742 | profile: { 743 | disconnect: true, 744 | }, 745 | }, 746 | }); 747 | 748 | const dbFirstUser = await client.user.findUniqueOrThrow({ 749 | where: { id: firstUser.id }, 750 | }); 751 | expect(dbFirstUser.profileId).toBeNull(); 752 | }); 753 | }); 754 | }); 755 | -------------------------------------------------------------------------------- /test/e2e/where.test.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Profile, User } from "@prisma/client"; 2 | import faker from "faker"; 3 | 4 | import { createSoftDeleteMiddleware } from "../../src"; 5 | import client from "./client"; 6 | 7 | describe("where", () => { 8 | let testClient: PrismaClient; 9 | let profile: Profile; 10 | let user: User; 11 | 12 | beforeAll(async () => { 13 | testClient = new PrismaClient(); 14 | testClient.$use( 15 | createSoftDeleteMiddleware({ models: { Comment: true, Profile: true } }) 16 | ); 17 | 18 | profile = await client.profile.create({ 19 | data: { 20 | bio: "foo", 21 | }, 22 | }); 23 | user = await client.user.create({ 24 | data: { 25 | email: faker.internet.email(), 26 | name: faker.name.findName(), 27 | profileId: profile.id, 28 | comments: { 29 | create: [ 30 | { content: "foo" }, 31 | { content: "foo", deleted: true }, 32 | { content: "bar", deleted: true }, 33 | ], 34 | }, 35 | }, 36 | }); 37 | }); 38 | afterEach(async () => { 39 | // restore soft deleted profile 40 | await client.profile.update({ 41 | where: { id: profile.id }, 42 | data: { 43 | deleted: false, 44 | }, 45 | }); 46 | }); 47 | afterAll(async () => { 48 | // disconnect test client 49 | await testClient.$disconnect(); 50 | 51 | // delete user and related data 52 | await client.user.update({ 53 | where: { id: user.id }, 54 | data: { 55 | comments: { deleteMany: {} }, 56 | profile: { delete: true }, 57 | }, 58 | }); 59 | await client.user.delete({ where: { id: user.id } }); 60 | }); 61 | 62 | it("excludes deleted when filtering using 'is'", async () => { 63 | const foundUser = await testClient.user.findFirst({ 64 | where: { profile: { is: { bio: "foo" } } }, 65 | }); 66 | expect(foundUser).not.toBeNull(); 67 | expect(foundUser!.id).toEqual(user.id); 68 | 69 | // soft delete profile 70 | await testClient.profile.update({ 71 | where: { id: profile?.id }, 72 | data: { deleted: true }, 73 | }); 74 | 75 | const notFoundUser = await testClient.user.findFirst({ 76 | where: { profile: { is: { bio: "foo" } } }, 77 | }); 78 | expect(notFoundUser).toBeNull(); 79 | }); 80 | 81 | it("excludes deleted when filtering using 'some'", async () => { 82 | const foundUser = await testClient.user.findFirst({ 83 | where: { comments: { some: { content: "foo" } } }, 84 | }); 85 | expect(foundUser).not.toBeNull(); 86 | expect(foundUser!.id).toEqual(user.id); 87 | 88 | const notFoundUser = await testClient.user.findFirst({ 89 | where: { comments: { some: { content: "bar" } } }, 90 | }); 91 | expect(notFoundUser).toBeNull(); 92 | }); 93 | 94 | it("excludes deleted when filtering using 'every'", async () => { 95 | const foundUser = await testClient.user.findFirst({ 96 | where: { comments: { every: { content: "foo" } } }, 97 | }); 98 | expect(foundUser).not.toBeNull(); 99 | expect(foundUser!.id).toEqual(user.id); 100 | 101 | const notFoundUser = await testClient.user.findFirst({ 102 | where: { comments: { every: { content: "bar" } } }, 103 | }); 104 | expect(notFoundUser).toBeNull(); 105 | }); 106 | 107 | it("excludes deleted when filtering using 'none'", async () => { 108 | const foundUser = await testClient.user.findFirst({ 109 | where: { comments: { none: { content: "bar" } } }, 110 | }); 111 | expect(foundUser).not.toBeNull(); 112 | expect(foundUser!.id).toEqual(user.id); 113 | 114 | const notFoundUser = await testClient.user.findFirst({ 115 | where: { comments: { none: { content: "foo" } } }, 116 | }); 117 | expect(notFoundUser).toBeNull(); 118 | }); 119 | 120 | it("excludes deleted when using 'NOT' with 'some'", async () => { 121 | const foundUser = await testClient.user.findFirst({ 122 | where: { NOT: { comments: { some: { content: "bar" } } } }, 123 | }); 124 | expect(foundUser).not.toBeNull(); 125 | expect(foundUser!.id).toEqual(user.id); 126 | 127 | const notFoundUser = await testClient.user.findFirst({ 128 | where: { NOT: { comments: { some: { content: "foo" } } } }, 129 | }); 130 | expect(notFoundUser).toBeNull(); 131 | }); 132 | 133 | it("excludes deleted when using 'NOT' with 'every'", async () => { 134 | const foundUser = await testClient.user.findFirst({ 135 | where: { NOT: { comments: { every: { content: "bar" } } } }, 136 | }); 137 | expect(foundUser).not.toBeNull(); 138 | expect(foundUser!.id).toEqual(user.id); 139 | 140 | const notFoundUser = await testClient.user.findFirst({ 141 | where: { NOT: { comments: { every: { content: "foo" } } } }, 142 | }); 143 | expect(notFoundUser).toBeNull(); 144 | }); 145 | 146 | it("excludes deleted when using 'NOT' with 'none'", async () => { 147 | const foundUser = await testClient.user.findFirst({ 148 | where: { NOT: { comments: { none: { content: "foo" } } } }, 149 | }); 150 | expect(foundUser).not.toBeNull(); 151 | expect(foundUser!.id).toEqual(user.id); 152 | 153 | const notFoundUser = await testClient.user.findFirst({ 154 | where: { NOT: { comments: { none: { content: "bar" } } } }, 155 | }); 156 | expect(notFoundUser).toBeNull(); 157 | }); 158 | 159 | it("excludes deleted when using 'NOT' with 'isNot'", async () => { 160 | const foundUser = await testClient.user.findFirst({ 161 | where: { NOT: { profile: { isNot: { bio: "foo" } } } }, 162 | }); 163 | expect(foundUser).not.toBeNull(); 164 | expect(foundUser!.id).toEqual(user.id); 165 | 166 | // soft delete profile 167 | await client.profile.update({ 168 | where: { id: profile.id }, 169 | data: { 170 | deleted: true, 171 | }, 172 | }); 173 | 174 | const notFoundUser = await testClient.user.findFirst({ 175 | where: { NOT: { profile: { isNot: { bio: "foo" } } } }, 176 | }); 177 | expect(notFoundUser).toBeNull(); 178 | }); 179 | 180 | it("excludes deleted when using 'NOT' with 'is'", async () => { 181 | const notFoundUser = await testClient.user.findFirst({ 182 | where: { NOT: { profile: { is: { bio: "foo" } } } }, 183 | }); 184 | expect(notFoundUser).toBeNull(); 185 | 186 | // soft delete profile 187 | await client.profile.update({ 188 | where: { id: profile.id }, 189 | data: { 190 | deleted: true, 191 | }, 192 | }); 193 | 194 | const foundUser = await testClient.user.findFirst({ 195 | where: { NOT: { profile: { is: { bio: "foo" } } } }, 196 | }); 197 | expect(foundUser).not.toBeNull(); 198 | expect(foundUser!.id).toEqual(user.id); 199 | }); 200 | 201 | it("excludes deleted when using 'NOT' nested in a 'NOT'", async () => { 202 | const foundUser = await testClient.user.findFirst({ 203 | where: { NOT: { NOT: { profile: { bio: "foo" } } } }, 204 | }); 205 | expect(foundUser).not.toBeNull(); 206 | expect(foundUser!.id).toEqual(user.id); 207 | 208 | const notFoundUser = await testClient.user.findFirst({ 209 | where: { NOT: { NOT: { profile: { bio: "bar" } } } }, 210 | }); 211 | expect(notFoundUser).toBeNull(); 212 | }); 213 | 214 | it("excludes deleted when using 'NOT' nested in a 'NOT' with 'some'", async () => { 215 | const foundUser = await testClient.user.findFirst({ 216 | where: { NOT: { NOT: { comments: { some: { content: "foo" } } } } }, 217 | }); 218 | expect(foundUser).not.toBeNull(); 219 | expect(foundUser!.id).toEqual(user.id); 220 | 221 | const notFoundUser = await testClient.user.findFirst({ 222 | where: { NOT: { NOT: { comments: { some: { content: "bar" } } } } }, 223 | }); 224 | expect(notFoundUser).toBeNull(); 225 | }); 226 | 227 | it("excludes deleted when using 'NOT' nested in a 'NOT' with 'every'", async () => { 228 | const foundUser = await testClient.user.findFirst({ 229 | where: { NOT: { NOT: { comments: { every: { content: "foo" } } } } }, 230 | }); 231 | expect(foundUser).not.toBeNull(); 232 | expect(foundUser!.id).toEqual(user.id); 233 | 234 | const notFoundUser = await testClient.user.findFirst({ 235 | where: { NOT: { NOT: { comments: { every: { content: "bar" } } } } }, 236 | }); 237 | expect(notFoundUser).toBeNull(); 238 | }); 239 | 240 | it("excludes deleted when using 'NOT' nested in a 'NOT' with 'none'", async () => { 241 | const foundUser = await testClient.user.findFirst({ 242 | where: { NOT: { NOT: { comments: { none: { content: "bar" } } } } }, 243 | }); 244 | expect(foundUser).not.toBeNull(); 245 | expect(foundUser!.id).toEqual(user.id); 246 | 247 | const notFoundUser = await testClient.user.findFirst({ 248 | where: { NOT: { NOT: { comments: { none: { content: "foo" } } } } }, 249 | }); 250 | expect(notFoundUser).toBeNull(); 251 | }); 252 | 253 | it("excludes deleted when using 'NOT' nested in a 'NOT' with 'isNot'", async () => { 254 | const foundUser = await testClient.user.findFirst({ 255 | where: { NOT: { NOT: { profile: { isNot: { bio: "bar" } } } } }, 256 | }); 257 | expect(foundUser).not.toBeNull(); 258 | expect(foundUser!.id).toEqual(user.id); 259 | 260 | const notFoundUser = await testClient.user.findFirst({ 261 | where: { NOT: { NOT: { profile: { isNot: { bio: "foo" } } } } }, 262 | }); 263 | expect(notFoundUser).toBeNull(); 264 | }); 265 | 266 | it("excludes deleted when using 'NOT' nested in a 'NOT' with 'is'", async () => { 267 | const foundUser = await testClient.user.findFirst({ 268 | where: { NOT: { NOT: { profile: { is: { bio: "foo" } } } } }, 269 | }); 270 | expect(foundUser).not.toBeNull(); 271 | expect(foundUser!.id).toEqual(user.id); 272 | 273 | const notFoundUser = await testClient.user.findFirst({ 274 | where: { NOT: { NOT: { profile: { is: { bio: "bar" } } } } }, 275 | }); 276 | expect(notFoundUser).toBeNull(); 277 | }); 278 | 279 | it("excludes deleted when using 'NOT' nested in a relation nested in a 'NOT'", async () => { 280 | const foundUser = await testClient.user.findFirst({ 281 | where: { 282 | NOT: { profile: { NOT: { users: { some: { id: user.id } } } } }, 283 | }, 284 | }); 285 | expect(foundUser).not.toBeNull(); 286 | expect(foundUser!.id).toEqual(user.id); 287 | 288 | const notFoundUser = await testClient.user.findFirst({ 289 | where: { 290 | NOT: { profile: { NOT: { users: { none: { id: user.id } } } } }, 291 | }, 292 | }); 293 | expect(notFoundUser).toBeNull(); 294 | }); 295 | }); 296 | -------------------------------------------------------------------------------- /test/scripts/run-with-postgres.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export DATABASE_URL=postgres://postgres:123@localhost:5432/test 4 | 5 | trap "docker compose down" EXIT 6 | 7 | docker compose up -d && sleep 1 8 | npx prisma db push 9 | 10 | $@ 11 | -------------------------------------------------------------------------------- /test/unit/aggregate.test.ts: -------------------------------------------------------------------------------- 1 | import { set } from "lodash"; 2 | import { createSoftDeleteMiddleware } from "../../src"; 3 | import { createParams } from "./utils/createParams"; 4 | 5 | describe("aggregate", () => { 6 | it("does not change aggregate action if model is not in the list", async () => { 7 | const middleware = createSoftDeleteMiddleware({ models: {} }); 8 | 9 | const params = createParams("User", "aggregate", { 10 | where: { email: { contains: "test" } }, 11 | _sum: { id: true }, 12 | }); 13 | const next = jest.fn(() => Promise.resolve({})); 14 | 15 | await middleware(params, next); 16 | 17 | // params have not been modified 18 | expect(next).toHaveBeenCalledWith(params); 19 | }); 20 | 21 | it("excludes deleted records from aggregate with no where", async () => { 22 | const middleware = createSoftDeleteMiddleware({ 23 | models: { User: true }, 24 | }); 25 | 26 | const params = createParams("User", "aggregate", {}); 27 | const next = jest.fn(() => Promise.resolve({})); 28 | 29 | await middleware(params, next); 30 | 31 | // params have been modified 32 | expect(next).toHaveBeenCalledWith(set(params, "args.where.deleted", false)); 33 | }); 34 | 35 | it("excludes deleted record from aggregate with where", async () => { 36 | const middleware = createSoftDeleteMiddleware({ 37 | models: { User: true }, 38 | }); 39 | 40 | const params = createParams("User", "aggregate", { 41 | where: { email: { contains: "test" } }, 42 | }); 43 | const next = jest.fn(() => Promise.resolve({})); 44 | 45 | await middleware(params, next); 46 | 47 | // params have been modified 48 | expect(next).toHaveBeenCalledWith(set(params, "args.where.deleted", false)); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/unit/config.test.ts: -------------------------------------------------------------------------------- 1 | import faker from "faker"; 2 | import { set } from "lodash"; 3 | 4 | import { createSoftDeleteMiddleware } from "../../src"; 5 | import { createParams } from "./utils/createParams"; 6 | 7 | describe("config", () => { 8 | it('does not soft delete models where config is passed as "false"', async () => { 9 | const middleware = createSoftDeleteMiddleware({ 10 | models: { 11 | User: false, 12 | }, 13 | }); 14 | 15 | const params = createParams("Post", "update", { 16 | where: { id: 1 }, 17 | data: { 18 | author: { delete: true }, 19 | comments: { 20 | updateMany: { 21 | where: { content: faker.lorem.sentence() }, 22 | data: { content: faker.lorem.sentence() }, 23 | }, 24 | }, 25 | }, 26 | }); 27 | const next = jest.fn(() => Promise.resolve({})); 28 | 29 | await middleware(params, next); 30 | 31 | expect(next).toHaveBeenCalledWith(params); 32 | }); 33 | 34 | it("allows setting default config values", async () => { 35 | const deletedAt = new Date(); 36 | const middleware = createSoftDeleteMiddleware({ 37 | models: { 38 | Post: true, 39 | Comment: true, 40 | }, 41 | defaultConfig: { 42 | field: "deletedAt", 43 | createValue: () => deletedAt, 44 | }, 45 | }); 46 | 47 | const params = createParams("User", "update", { 48 | where: { id: 1 }, 49 | data: { 50 | posts: { 51 | delete: { id: 1 }, 52 | }, 53 | comments: { 54 | updateMany: { 55 | where: { content: faker.lorem.sentence() }, 56 | data: { content: faker.lorem.sentence() }, 57 | }, 58 | }, 59 | }, 60 | }); 61 | const next = jest.fn(() => Promise.resolve({})); 62 | 63 | await middleware(params, next); 64 | 65 | set(params, "args.data.posts", { 66 | update: { where: { id: 1 }, data: { deletedAt } }, 67 | }); 68 | set(params, "args.data.comments.updateMany.where.deletedAt", deletedAt); 69 | 70 | expect(next).toHaveBeenCalledWith(params); 71 | }); 72 | 73 | it('throws when default config does not have a "field" property', () => { 74 | expect(() => { 75 | createSoftDeleteMiddleware({ 76 | models: { 77 | Post: true, 78 | }, 79 | // @ts-expect-error - we are testing the error case 80 | defaultConfig: { 81 | createValue: () => new Date(), 82 | }, 83 | }); 84 | }).toThrowError( 85 | "prisma-soft-delete-middleware: defaultConfig.field is required" 86 | ); 87 | }); 88 | 89 | it('throws when default config does not have a "createValue" property', () => { 90 | expect(() => { 91 | createSoftDeleteMiddleware({ 92 | models: { 93 | Post: true, 94 | }, 95 | // @ts-expect-error - we are testing the error case 96 | defaultConfig: { 97 | field: "deletedAt", 98 | }, 99 | }); 100 | }).toThrowError( 101 | "prisma-soft-delete-middleware: defaultConfig.createValue is required" 102 | ); 103 | }); 104 | 105 | it("allows setting model specific config values", async () => { 106 | const deletedAt = new Date(); 107 | const middleware = createSoftDeleteMiddleware({ 108 | models: { 109 | Post: { 110 | field: "deletedAt", 111 | createValue: () => deletedAt, 112 | }, 113 | Comment: true, 114 | }, 115 | }); 116 | 117 | const params = createParams("User", "update", { 118 | where: { id: 1 }, 119 | data: { 120 | posts: { delete: { id: 1 } }, 121 | comments: { 122 | updateMany: { 123 | where: { content: faker.lorem.sentence() }, 124 | data: { content: faker.lorem.sentence() }, 125 | }, 126 | }, 127 | }, 128 | }); 129 | 130 | const next = jest.fn(() => Promise.resolve({})); 131 | 132 | await middleware(params, next); 133 | 134 | set(params, "args.data.posts", { 135 | update: { where: { id: 1 }, data: { deletedAt } }, 136 | }); 137 | set(params, "args.data.comments.updateMany.where.deleted", false); 138 | 139 | expect(next).toHaveBeenCalledWith(params); 140 | }); 141 | 142 | it("allows overriding default config values", async () => { 143 | const deletedAt = new Date(); 144 | const middleware = createSoftDeleteMiddleware({ 145 | models: { 146 | Post: true, 147 | Comment: { 148 | field: "deleted", 149 | createValue: Boolean, 150 | }, 151 | }, 152 | defaultConfig: { 153 | field: "deletedAt", 154 | createValue: (deleted) => { 155 | if (deleted) return deletedAt; 156 | return null; 157 | }, 158 | }, 159 | }); 160 | 161 | const params = createParams("User", "update", { 162 | where: { id: 1 }, 163 | data: { 164 | posts: { delete: { id: 1 } }, 165 | comments: { 166 | updateMany: { 167 | where: { content: faker.lorem.sentence() }, 168 | data: { content: faker.lorem.sentence() }, 169 | }, 170 | }, 171 | }, 172 | }); 173 | 174 | const next = jest.fn(() => Promise.resolve({})); 175 | 176 | await middleware(params, next); 177 | 178 | set(params, "args.data.posts", { 179 | update: { where: { id: 1 }, data: { deletedAt } }, 180 | }); 181 | set(params, "args.data.comments.updateMany.where.deleted", false); 182 | 183 | expect(next).toHaveBeenCalledWith(params); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /test/unit/count.test.ts: -------------------------------------------------------------------------------- 1 | import { set } from "lodash"; 2 | import { createSoftDeleteMiddleware } from "../../src"; 3 | import { createParams } from "./utils/createParams"; 4 | 5 | describe("count", () => { 6 | it("does not change count action if model is not in the list", async () => { 7 | const middleware = createSoftDeleteMiddleware({ models: {} }); 8 | 9 | const params = createParams("User", "count", {}); 10 | const next = jest.fn(() => Promise.resolve({})); 11 | 12 | await middleware(params, next); 13 | 14 | // params have not been modified 15 | expect(next).toHaveBeenCalledWith(params); 16 | }); 17 | 18 | it("excludes deleted records from count", async () => { 19 | const middleware = createSoftDeleteMiddleware({ 20 | models: { User: true }, 21 | }); 22 | 23 | const params = createParams("User", "count", undefined); 24 | const next = jest.fn(() => Promise.resolve({})); 25 | 26 | await middleware(params, next); 27 | 28 | // params have been modified 29 | expect(next).toHaveBeenCalledWith(set(params, "args.where.deleted", false)); 30 | }); 31 | 32 | it("excludes deleted records from count with empty args", async () => { 33 | const middleware = createSoftDeleteMiddleware({ 34 | models: { User: true }, 35 | }); 36 | 37 | const params = createParams("User", "count", {}); 38 | const next = jest.fn(() => Promise.resolve({})); 39 | 40 | await middleware(params, next); 41 | 42 | // params have been modified 43 | expect(next).toHaveBeenCalledWith(set(params, "args.where.deleted", false)); 44 | }); 45 | 46 | it("excludes deleted record from count with where", async () => { 47 | const middleware = createSoftDeleteMiddleware({ 48 | models: { User: true }, 49 | }); 50 | 51 | const params = createParams("User", "count", { 52 | where: { email: { contains: "test" } }, 53 | }); 54 | const next = jest.fn(() => Promise.resolve({})); 55 | 56 | await middleware(params, next); 57 | 58 | // params have been modified 59 | expect(next).toHaveBeenCalledWith(set(params, "args.where.deleted", false)); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/unit/delete.test.ts: -------------------------------------------------------------------------------- 1 | import { set } from "lodash"; 2 | import { createSoftDeleteMiddleware } from "../../src"; 3 | import { createParams } from "./utils/createParams"; 4 | 5 | describe("delete", () => { 6 | it("does not change delete action if model is not in the list", async () => { 7 | const middleware = createSoftDeleteMiddleware({ models: {} }); 8 | 9 | const params = createParams("User", "delete", { where: { id: 1 } }); 10 | const next = jest.fn(() => Promise.resolve({})); 11 | 12 | await middleware(params, next); 13 | 14 | // params have not been modified 15 | expect(next).toHaveBeenCalledWith(params); 16 | }); 17 | 18 | it("does not change nested delete action if model is not in the list", async () => { 19 | const middleware = createSoftDeleteMiddleware({ models: {} }); 20 | 21 | const params = createParams("User", "update", { 22 | where: { id: 1 }, 23 | data: { 24 | posts: { 25 | delete: { id: 1 }, 26 | }, 27 | }, 28 | }); 29 | const next = jest.fn(() => Promise.resolve({})); 30 | 31 | await middleware(params, next); 32 | 33 | // params have not been modified 34 | expect(next).toHaveBeenCalledWith(params); 35 | }); 36 | 37 | it("does not modify delete results", async () => { 38 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 39 | 40 | const params = createParams("User", "delete", { where: { id: 1 } }); 41 | const next = jest.fn(() => Promise.resolve({ id: 1, deleted: true })); 42 | 43 | expect(await middleware(params, next)).toEqual({ id: 1, deleted: true }); 44 | }); 45 | 46 | it("does not modify delete with no args", async () => { 47 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 48 | 49 | // @ts-expect-error - args are required 50 | const params = createParams("User", "delete", undefined); 51 | const next = jest.fn(() => Promise.resolve({})); 52 | 53 | await middleware(params, next); 54 | 55 | // params have not been modified 56 | expect(next).toHaveBeenCalledWith(params); 57 | }); 58 | 59 | it("does not modify delete with no where", async () => { 60 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 61 | 62 | // @ts-expect-error - where is required 63 | const params = createParams("User", "delete", {}); 64 | const next = jest.fn(() => Promise.resolve({})); 65 | 66 | await middleware(params, next); 67 | 68 | // params have not been modified 69 | expect(next).toHaveBeenCalledWith(params); 70 | }); 71 | 72 | it("changes delete action into an update to add deleted mark", async () => { 73 | const middleware = createSoftDeleteMiddleware({ 74 | models: { User: true }, 75 | }); 76 | 77 | const params = createParams("User", "delete", { where: { id: 1 } }); 78 | const next = jest.fn(() => Promise.resolve()); 79 | await middleware(params, next); 80 | 81 | // params are modified correctly 82 | expect(next).toHaveBeenCalledWith({ 83 | ...params, 84 | action: "update", 85 | args: { 86 | ...params.args, 87 | data: { deleted: true }, 88 | }, 89 | }); 90 | }); 91 | 92 | it("does not change nested delete false action", async () => { 93 | const middleware = createSoftDeleteMiddleware({ 94 | models: { Profile: true }, 95 | }); 96 | 97 | const next = jest.fn(() => Promise.resolve({})); 98 | const params = createParams("User", "update", { 99 | where: { id: 1 }, 100 | data: { 101 | profile: { delete: false }, 102 | }, 103 | }); 104 | 105 | await middleware(params, next); 106 | 107 | // params have not been modified 108 | expect(next).toHaveBeenCalledWith(params); 109 | }); 110 | 111 | it("changes nested delete true action into an update that adds deleted mark", async () => { 112 | const middleware = createSoftDeleteMiddleware({ 113 | models: { Profile: true }, 114 | }); 115 | 116 | const next = jest.fn(() => Promise.resolve({})); 117 | const params = createParams("User", "update", { 118 | where: { id: 1 }, 119 | data: { 120 | profile: { 121 | delete: true, 122 | }, 123 | }, 124 | }); 125 | 126 | await middleware(params, next); 127 | 128 | // params are modified correctly 129 | expect(next).toHaveBeenCalledWith( 130 | set(params, "args.data.profile", { 131 | update: { deleted: true }, 132 | }) 133 | ); 134 | }); 135 | 136 | it("changes nested delete action on a toMany relation into an update that adds deleted mark", async () => { 137 | const middleware = createSoftDeleteMiddleware({ 138 | models: { Post: true }, 139 | }); 140 | 141 | const next = jest.fn(() => Promise.resolve({})); 142 | const params = createParams("User", "update", { 143 | where: { id: 1 }, 144 | data: { 145 | posts: { 146 | delete: { id: 1 }, 147 | }, 148 | }, 149 | }); 150 | 151 | await middleware(params, next); 152 | 153 | // params are modified correctly 154 | expect(next).toHaveBeenCalledWith({ 155 | ...params, 156 | action: "update", 157 | args: { 158 | ...params.args, 159 | data: { 160 | posts: { 161 | update: { 162 | where: { id: 1 }, 163 | data: { deleted: true }, 164 | }, 165 | }, 166 | }, 167 | }, 168 | }); 169 | }); 170 | 171 | it("changes nested list of delete actions into a nested list of update actions", async () => { 172 | const middleware = createSoftDeleteMiddleware({ 173 | models: { Post: true }, 174 | }); 175 | 176 | const next = jest.fn(() => Promise.resolve({})); 177 | const params = createParams("User", "update", { 178 | where: { id: 1 }, 179 | data: { 180 | posts: { 181 | delete: [{ id: 1 }, { id: 2 }, { id: 3 }], 182 | }, 183 | }, 184 | }); 185 | 186 | await middleware(params, next); 187 | 188 | // params are modified correctly 189 | expect(next).toHaveBeenCalledWith({ 190 | ...params, 191 | action: "update", 192 | args: { 193 | ...params.args, 194 | data: { 195 | posts: { 196 | update: [ 197 | { where: { id: 1 }, data: { deleted: true } }, 198 | { where: { id: 2 }, data: { deleted: true } }, 199 | { where: { id: 3 }, data: { deleted: true } }, 200 | ], 201 | }, 202 | }, 203 | }, 204 | }); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /test/unit/deleteMany.test.ts: -------------------------------------------------------------------------------- 1 | import { createSoftDeleteMiddleware } from "../../src"; 2 | import { createParams } from "./utils/createParams"; 3 | 4 | describe("deleteMany", () => { 5 | it("does not change deleteMany action if model is not in the list", async () => { 6 | const middleware = createSoftDeleteMiddleware({ models: {} }); 7 | 8 | const params = createParams("User", "deleteMany", { where: { id: 1 } }); 9 | const next = jest.fn(() => Promise.resolve({})); 10 | 11 | await middleware(params, next); 12 | 13 | // params have not been modified 14 | expect(next).toHaveBeenCalledWith(params); 15 | }); 16 | 17 | it("does not change nested deleteMany action if model is not in the list", async () => { 18 | const middleware = createSoftDeleteMiddleware({ models: {} }); 19 | 20 | const params = createParams("User", "update", { 21 | where: { id: 1 }, 22 | data: { 23 | posts: { 24 | deleteMany: { 25 | id: 1, 26 | }, 27 | }, 28 | }, 29 | }); 30 | const next = jest.fn(() => Promise.resolve({})); 31 | 32 | await middleware(params, next); 33 | 34 | // params have not been modified 35 | expect(next).toHaveBeenCalledWith(params); 36 | }); 37 | 38 | it("does not modify deleteMany results", async () => { 39 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 40 | 41 | const params = createParams("User", "deleteMany", { where: { id: 1 } }); 42 | const next = jest.fn(() => Promise.resolve({ count: 1 })); 43 | 44 | expect(await middleware(params, next)).toEqual({ count: 1 }); 45 | }); 46 | 47 | it("changes deleteMany action into an updateMany that adds deleted mark", async () => { 48 | const middleware = createSoftDeleteMiddleware({ 49 | models: { User: true }, 50 | }); 51 | 52 | const next = jest.fn(() => Promise.resolve({})); 53 | const params = createParams("User", "deleteMany", { where: { id: 1 } }); 54 | await middleware(params, next); 55 | 56 | // params are modified correctly 57 | expect(next).toHaveBeenCalledWith({ 58 | ...params, 59 | action: "updateMany", 60 | args: { 61 | where: { ...params.args.where, deleted: false }, 62 | data: { deleted: true }, 63 | }, 64 | }); 65 | }); 66 | 67 | it("changes deleteMany action with no args into an updateMany that adds deleted mark", async () => { 68 | const middleware = createSoftDeleteMiddleware({ 69 | models: { User: true }, 70 | }); 71 | 72 | const next = jest.fn(() => Promise.resolve({})); 73 | const params = createParams("User", "deleteMany", undefined); 74 | await middleware(params, next); 75 | 76 | // params are modified correctly 77 | expect(next).toHaveBeenCalledWith({ 78 | ...params, 79 | action: "updateMany", 80 | args: { 81 | where: { deleted: false }, 82 | data: { deleted: true }, 83 | }, 84 | }); 85 | }); 86 | 87 | it("changes deleteMany action with no where into an updateMany that adds deleted mark", async () => { 88 | const middleware = createSoftDeleteMiddleware({ 89 | models: { User: true }, 90 | }); 91 | 92 | const next = jest.fn(() => Promise.resolve({})); 93 | const params = createParams("User", "deleteMany", {}); 94 | await middleware(params, next); 95 | 96 | // params are modified correctly 97 | expect(next).toHaveBeenCalledWith({ 98 | ...params, 99 | action: "updateMany", 100 | args: { 101 | where: { deleted: false }, 102 | data: { deleted: true }, 103 | }, 104 | }); 105 | }); 106 | 107 | it("changes nested deleteMany action into an updateMany that adds deleted mark", async () => { 108 | const middleware = createSoftDeleteMiddleware({ 109 | models: { Post: true }, 110 | }); 111 | 112 | const next = jest.fn(() => Promise.resolve({})); 113 | const params = createParams("User", "update", { 114 | where: { id: 1 }, 115 | data: { 116 | posts: { 117 | deleteMany: { 118 | id: 1, 119 | }, 120 | }, 121 | }, 122 | }); 123 | 124 | await middleware(params, next); 125 | 126 | // params are modified correctly 127 | expect(next).toHaveBeenCalledWith({ 128 | ...params, 129 | action: "update", 130 | args: { 131 | ...params.args, 132 | data: { 133 | posts: { 134 | updateMany: { 135 | where: { id: 1, deleted: false }, 136 | data: { deleted: true }, 137 | }, 138 | }, 139 | }, 140 | }, 141 | }); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /test/unit/findFirst.test.ts: -------------------------------------------------------------------------------- 1 | import { createSoftDeleteMiddleware } from "../../src"; 2 | import { createParams } from "./utils/createParams"; 3 | 4 | describe("findFirst", () => { 5 | it("does not change findFirst params if model is not in the list", async () => { 6 | const middleware = createSoftDeleteMiddleware({ models: {} }); 7 | 8 | const params = createParams("User", "findFirst", { where: { id: 1 } }); 9 | const next = jest.fn(() => Promise.resolve({})); 10 | 11 | await middleware(params, next); 12 | 13 | // params have not been modified 14 | expect(next).toHaveBeenCalledWith(params); 15 | }); 16 | 17 | it("does not modify findFirst results", async () => { 18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 19 | 20 | const params = createParams("User", "findFirst", { where: { id: 1 } }); 21 | const next = jest.fn(() => Promise.resolve({ id: 1, deleted: true })); 22 | 23 | expect(await middleware(params, next)).toEqual({ id: 1, deleted: true }); 24 | }); 25 | 26 | it("excludes deleted records from findFirst", async () => { 27 | const middleware = createSoftDeleteMiddleware({ 28 | models: { User: true }, 29 | }); 30 | 31 | const params = createParams("User", "findFirst", { where: { id: 1 } }); 32 | const next = jest.fn(() => Promise.resolve({})); 33 | 34 | await middleware(params, next); 35 | 36 | // params have been modified 37 | expect(next).toHaveBeenCalledWith({ 38 | ...params, 39 | action: "findFirst", 40 | args: { 41 | where: { 42 | id: 1, 43 | deleted: false, 44 | }, 45 | }, 46 | }); 47 | }); 48 | 49 | it("excludes deleted records from findFirst with no args", async () => { 50 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 51 | 52 | const params = createParams("User", "findFirst", undefined); 53 | const next = jest.fn(() => Promise.resolve({})); 54 | 55 | await middleware(params, next); 56 | 57 | // params have been modified 58 | expect(next).toHaveBeenCalledWith({ 59 | ...params, 60 | action: "findFirst", 61 | args: { 62 | where: { 63 | deleted: false, 64 | }, 65 | }, 66 | }); 67 | }); 68 | 69 | it("excludes deleted records from findFirst with empty args", async () => { 70 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 71 | 72 | const params = createParams("User", "findFirst", {}); 73 | const next = jest.fn(() => Promise.resolve({})); 74 | 75 | await middleware(params, next); 76 | 77 | // params have been modified 78 | expect(next).toHaveBeenCalledWith({ 79 | ...params, 80 | action: "findFirst", 81 | args: { 82 | where: { 83 | deleted: false, 84 | }, 85 | }, 86 | }); 87 | }); 88 | 89 | it("allows explicitly querying for deleted records using findFirst", async () => { 90 | const middleware = createSoftDeleteMiddleware({ 91 | models: { User: true }, 92 | }); 93 | 94 | const params = createParams("User", "findFirst", { 95 | where: { id: 1, deleted: true }, 96 | }); 97 | const next = jest.fn(() => Promise.resolve({})); 98 | 99 | await middleware(params, next); 100 | 101 | // params have not been modified 102 | expect(next).toHaveBeenCalledWith(params); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /test/unit/findFirstOrThrow.test.ts: -------------------------------------------------------------------------------- 1 | import { createSoftDeleteMiddleware } from "../../src"; 2 | import { createParams } from "./utils/createParams"; 3 | 4 | describe("findFirstOrThrow", () => { 5 | it("does not change findFirstOrThrow params if model is not in the list", async () => { 6 | const middleware = createSoftDeleteMiddleware({ models: {} }); 7 | 8 | const params = createParams("User", "findFirstOrThrow", { where: { id: 1 } }); 9 | const next = jest.fn(() => Promise.resolve({})); 10 | 11 | await middleware(params, next); 12 | 13 | // params have not been modified 14 | expect(next).toHaveBeenCalledWith(params); 15 | }); 16 | 17 | it("does not modify findFirstOrThrow results", async () => { 18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 19 | 20 | const params = createParams("User", "findFirstOrThrow", { where: { id: 1 } }); 21 | const next = jest.fn(() => Promise.resolve({ id: 1, deleted: true })); 22 | 23 | expect(await middleware(params, next)).toEqual({ id: 1, deleted: true }); 24 | }); 25 | 26 | it("excludes deleted records from findFirstOrThrow", async () => { 27 | const middleware = createSoftDeleteMiddleware({ 28 | models: { User: true }, 29 | }); 30 | 31 | const params = createParams("User", "findFirstOrThrow", { where: { id: 1 } }); 32 | const next = jest.fn(() => Promise.resolve({})); 33 | 34 | await middleware(params, next); 35 | 36 | // params have been modified 37 | expect(next).toHaveBeenCalledWith({ 38 | ...params, 39 | action: "findFirstOrThrow", 40 | args: { 41 | where: { 42 | id: 1, 43 | deleted: false, 44 | }, 45 | }, 46 | }); 47 | }); 48 | 49 | it("excludes deleted records from findFirstOrThrow with no args", async () => { 50 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 51 | 52 | const params = createParams("User", "findFirstOrThrow", undefined); 53 | const next = jest.fn(() => Promise.resolve({})); 54 | 55 | await middleware(params, next); 56 | 57 | // params have been modified 58 | expect(next).toHaveBeenCalledWith({ 59 | ...params, 60 | action: "findFirstOrThrow", 61 | args: { 62 | where: { 63 | deleted: false, 64 | }, 65 | }, 66 | }); 67 | }); 68 | 69 | it("excludes deleted records from findFirstOrThrow with empty args", async () => { 70 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 71 | 72 | const params = createParams("User", "findFirstOrThrow", {}); 73 | const next = jest.fn(() => Promise.resolve({})); 74 | 75 | await middleware(params, next); 76 | 77 | // params have been modified 78 | expect(next).toHaveBeenCalledWith({ 79 | ...params, 80 | action: "findFirstOrThrow", 81 | args: { 82 | where: { 83 | deleted: false, 84 | }, 85 | }, 86 | }); 87 | }); 88 | 89 | it("allows explicitly querying for deleted records using findFirstOrThrow", async () => { 90 | const middleware = createSoftDeleteMiddleware({ 91 | models: { User: true }, 92 | }); 93 | 94 | const params = createParams("User", "findFirstOrThrow", { 95 | where: { id: 1, deleted: true }, 96 | }); 97 | const next = jest.fn(() => Promise.resolve({})); 98 | 99 | await middleware(params, next); 100 | 101 | // params have not been modified 102 | expect(next).toHaveBeenCalledWith(params); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /test/unit/findMany.test.ts: -------------------------------------------------------------------------------- 1 | import { createSoftDeleteMiddleware } from "../../src"; 2 | import { createParams } from "./utils/createParams"; 3 | 4 | describe("findMany", () => { 5 | it("does not change findMany params if model is not in the list", async () => { 6 | const middleware = createSoftDeleteMiddleware({ models: {} }); 7 | 8 | const params = createParams("User", "findMany", { where: { id: 1 } }); 9 | const next = jest.fn(() => Promise.resolve({})); 10 | 11 | await middleware(params, next); 12 | 13 | // params have not been modified 14 | expect(next).toHaveBeenCalledWith(params); 15 | }); 16 | 17 | it("does not modify findMany results", async () => { 18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 19 | 20 | const params = createParams("User", "findMany", { where: { id: 1 } }); 21 | const next = jest.fn(() => Promise.resolve([{ id: 1, deleted: true }])); 22 | 23 | expect(await middleware(params, next)).toEqual([{ id: 1, deleted: true }]); 24 | }); 25 | 26 | it("excludes deleted records from findMany", async () => { 27 | const middleware = createSoftDeleteMiddleware({ 28 | models: { User: true }, 29 | }); 30 | 31 | const params = createParams("User", "findMany", { where: { id: 1 } }); 32 | const next = jest.fn(() => Promise.resolve({})); 33 | 34 | await middleware(params, next); 35 | 36 | // params have been modified 37 | expect(next).toHaveBeenCalledWith({ 38 | ...params, 39 | action: "findMany", 40 | args: { 41 | where: { 42 | id: 1, 43 | deleted: false, 44 | }, 45 | }, 46 | }); 47 | }); 48 | 49 | it("excludes deleted records from findMany with no args", async () => { 50 | const middleware = createSoftDeleteMiddleware({ 51 | models: { User: true }, 52 | }); 53 | 54 | const params = createParams("User", "findMany", undefined); 55 | const next = jest.fn(() => Promise.resolve({})); 56 | 57 | await middleware(params, next); 58 | 59 | // params have been modified 60 | expect(next).toHaveBeenCalledWith({ 61 | ...params, 62 | action: "findMany", 63 | args: { 64 | where: { 65 | deleted: false, 66 | }, 67 | }, 68 | }); 69 | }); 70 | 71 | it("excludes deleted records from findMany with empty args", async () => { 72 | const middleware = createSoftDeleteMiddleware({ 73 | models: { User: true }, 74 | }); 75 | 76 | const params = createParams("User", "findMany", {}); 77 | const next = jest.fn(() => Promise.resolve({})); 78 | 79 | await middleware(params, next); 80 | 81 | // params have been modified 82 | expect(next).toHaveBeenCalledWith({ 83 | ...params, 84 | action: "findMany", 85 | args: { 86 | where: { 87 | deleted: false, 88 | }, 89 | }, 90 | }); 91 | }); 92 | 93 | it("allows explicitly querying for deleted records using findMany", async () => { 94 | const middleware = createSoftDeleteMiddleware({ 95 | models: { User: true }, 96 | }); 97 | 98 | const params = createParams("User", "findMany", { 99 | where: { id: 1, deleted: true }, 100 | }); 101 | const next = jest.fn(() => Promise.resolve({})); 102 | 103 | await middleware(params, next); 104 | 105 | // params have not been modified 106 | expect(next).toHaveBeenCalledWith(params); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /test/unit/findUnique.test.ts: -------------------------------------------------------------------------------- 1 | import { createSoftDeleteMiddleware } from "../../src"; 2 | import { createParams } from "./utils/createParams"; 3 | 4 | describe("findUnique", () => { 5 | it("does not change findUnique params if model is not in the list", async () => { 6 | const middleware = createSoftDeleteMiddleware({ models: {} }); 7 | 8 | const params = createParams("User", "findUnique", { where: { id: 1 } }); 9 | const next = jest.fn(() => Promise.resolve({})); 10 | 11 | await middleware(params, next); 12 | 13 | // params have not been modified 14 | expect(next).toHaveBeenCalledWith(params); 15 | }); 16 | 17 | it("does not modify findUnique results", async () => { 18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 19 | 20 | const params = createParams("User", "findUnique", { where: { id: 1 } }); 21 | const next = jest.fn(() => Promise.resolve({ id: 1, deleted: true })); 22 | 23 | expect(await middleware(params, next)).toEqual({ id: 1, deleted: true }); 24 | }); 25 | 26 | it("changes findUnique into findFirst and excludes deleted records", async () => { 27 | const middleware = createSoftDeleteMiddleware({ 28 | models: { User: true }, 29 | }); 30 | 31 | const params = createParams("User", "findUnique", { where: { id: 1 } }); 32 | const next = jest.fn(() => Promise.resolve({})); 33 | 34 | await middleware(params, next); 35 | 36 | // params have been modified 37 | expect(next).toHaveBeenCalledWith({ 38 | ...params, 39 | action: "findFirst", 40 | args: { 41 | where: { 42 | id: 1, 43 | deleted: false, 44 | }, 45 | }, 46 | }); 47 | }); 48 | 49 | it("throws when trying to pass a findUnique where with a compound unique index field", async () => { 50 | const middleware = createSoftDeleteMiddleware({ 51 | models: { User: true }, 52 | }); 53 | 54 | const params = createParams("User", "findUnique", { 55 | where: { 56 | name_email: { 57 | name: "test", 58 | email: "test@test.com", 59 | }, 60 | }, 61 | }); 62 | 63 | const next = () => Promise.resolve({}); 64 | 65 | await expect(middleware(params, next)).rejects.toThrowError( 66 | `prisma-soft-delete-middleware: query of model "User" through compound unique index field "name_email" found. Queries of soft deleted models through a unique index are not supported. Set "allowCompoundUniqueIndexWhere" to true to override this behaviour.` 67 | ); 68 | }); 69 | 70 | it('does not modify findUnique when compound unique index field used and "allowCompoundUniqueIndexWhere" is set to true', async () => { 71 | const middleware = createSoftDeleteMiddleware({ 72 | models: { 73 | User: { 74 | field: "deleted", 75 | createValue: Boolean, 76 | allowCompoundUniqueIndexWhere: true, 77 | }, 78 | }, 79 | }); 80 | 81 | const params = createParams("User", "findUnique", { 82 | where: { 83 | name_email: { 84 | name: "test", 85 | email: "test@test.com", 86 | }, 87 | }, 88 | }); 89 | const next = jest.fn(() => Promise.resolve({})); 90 | 91 | await middleware(params, next); 92 | 93 | // params have not been modified 94 | expect(next).toHaveBeenCalledWith(params); 95 | }); 96 | 97 | it("does not modify findUnique to be a findFirst when no args passed", async () => { 98 | const middleware = createSoftDeleteMiddleware({ 99 | models: { User: true }, 100 | }); 101 | 102 | // @ts-expect-error testing if user doesn't pass args accidentally 103 | const params = createParams("User", "findUnique", undefined); 104 | const next = jest.fn(() => Promise.resolve({})); 105 | 106 | await middleware(params, next); 107 | 108 | // params have not been modified 109 | expect(next).toHaveBeenCalledWith(params); 110 | }); 111 | 112 | it("does not modify findUnique to be a findFirst when invalid where passed", async () => { 113 | const middleware = createSoftDeleteMiddleware({ 114 | models: { User: true }, 115 | }); 116 | 117 | // @ts-expect-error testing if user doesn't pass where accidentally 118 | let params = createParams("User", "findUnique", {}); 119 | let next = jest.fn(() => Promise.resolve({})); 120 | await middleware(params, next); 121 | expect(next).toHaveBeenCalledWith(params); 122 | 123 | // expect empty where not to modify params 124 | // @ts-expect-error testing if user passes where without unique field 125 | params = createParams("User", "findUnique", { where: {} }); 126 | next = jest.fn(() => Promise.resolve({})); 127 | await middleware(params, next); 128 | expect(next).toHaveBeenCalledWith(params); 129 | 130 | // expect where with undefined id field not to modify params 131 | params = createParams("User", "findUnique", { where: { id: undefined } }); 132 | next = jest.fn(() => Promise.resolve({})); 133 | await middleware(params, next); 134 | expect(next).toHaveBeenCalledWith(params); 135 | 136 | // expect where with undefined unique field not to modify params 137 | params = createParams("User", "findUnique", { 138 | where: { email: undefined }, 139 | }); 140 | next = jest.fn(() => Promise.resolve({})); 141 | await middleware(params, next); 142 | expect(next).toHaveBeenCalledWith(params); 143 | 144 | // expect where with undefined unique index field not to modify params 145 | params = createParams("User", "findUnique", { 146 | where: { name_email: undefined }, 147 | }); 148 | next = jest.fn(() => Promise.resolve({})); 149 | await middleware(params, next); 150 | expect(next).toHaveBeenCalledWith(params); 151 | 152 | // expect where with defined non-unique field 153 | params = createParams("User", "findUnique", { 154 | // @ts-expect-error intentionally incorrect where 155 | where: { name: "test" }, 156 | }); 157 | next = jest.fn(() => Promise.resolve({})); 158 | await middleware(params, next); 159 | expect(next).toHaveBeenCalledWith(params); 160 | 161 | // expect where with defined non-unique field and undefined id field not to modify params 162 | params = createParams("User", "findUnique", { 163 | where: { id: undefined, name: "test" }, 164 | }); 165 | next = jest.fn(() => Promise.resolve({})); 166 | await middleware(params, next); 167 | expect(next).toHaveBeenCalledWith(params); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /test/unit/findUniqueOrThrow.ts: -------------------------------------------------------------------------------- 1 | import { createSoftDeleteMiddleware } from "../../src"; 2 | import { createParams } from "./utils/createParams"; 3 | 4 | describe("findUniqueOrThrow", () => { 5 | it("does not change findUniqueOrThrow params if model is not in the list", async () => { 6 | const middleware = createSoftDeleteMiddleware({ models: {} }); 7 | 8 | const params = createParams("User", "findUniqueOrThrow", { where: { id: 1 } }); 9 | const next = jest.fn(() => Promise.resolve({})); 10 | 11 | await middleware(params, next); 12 | 13 | // params have not been modified 14 | expect(next).toHaveBeenCalledWith(params); 15 | }); 16 | 17 | it("does not modify findUniqueOrThrow results", async () => { 18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 19 | 20 | const params = createParams("User", "findUniqueOrThrow", { where: { id: 1 } }); 21 | const next = jest.fn(() => Promise.resolve({ id: 1, deleted: true })); 22 | 23 | expect(await middleware(params, next)).toEqual({ id: 1, deleted: true }); 24 | }); 25 | 26 | it("changes findUniqueOrThrow into findFirst and excludes deleted records", async () => { 27 | const middleware = createSoftDeleteMiddleware({ 28 | models: { User: true }, 29 | }); 30 | 31 | const params = createParams("User", "findUniqueOrThrow", { where: { id: 1 } }); 32 | const next = jest.fn(() => Promise.resolve({})); 33 | 34 | await middleware(params, next); 35 | 36 | // params have been modified 37 | expect(next).toHaveBeenCalledWith({ 38 | ...params, 39 | action: "findFirst", 40 | args: { 41 | where: { 42 | id: 1, 43 | deleted: false, 44 | }, 45 | }, 46 | }); 47 | }); 48 | 49 | it("does not modify findUniqueOrThrow to be a findFirst when no args passed", async () => { 50 | const middleware = createSoftDeleteMiddleware({ 51 | models: { User: true }, 52 | }); 53 | 54 | // @ts-expect-error testing if user doesn't pass args accidentally 55 | const params = createParams("User", "findUniqueOrThrow", undefined); 56 | const next = jest.fn(() => Promise.resolve({})); 57 | 58 | await middleware(params, next); 59 | 60 | // params have not been modified 61 | expect(next).toHaveBeenCalledWith(params); 62 | }); 63 | 64 | it("does not modify findUniqueOrThrow to be a findFirst when invalid where passed", async () => { 65 | const middleware = createSoftDeleteMiddleware({ 66 | models: { User: true }, 67 | }); 68 | 69 | // @ts-expect-error testing if user doesn't pass where accidentally 70 | let params = createParams("User", "findUniqueOrThrow", {}); 71 | let next = jest.fn(() => Promise.resolve({})); 72 | await middleware(params, next); 73 | expect(next).toHaveBeenCalledWith(params); 74 | 75 | // expect empty where not to modify params 76 | // @ts-expect-error testing if user passes where without unique field 77 | params = createParams("User", "findUniqueOrThrow", { where: {} }); 78 | next = jest.fn(() => Promise.resolve({})); 79 | await middleware(params, next); 80 | expect(next).toHaveBeenCalledWith(params); 81 | 82 | // expect where with undefined id field not to modify params 83 | params = createParams("User", "findUniqueOrThrow", { where: { id: undefined } }); 84 | next = jest.fn(() => Promise.resolve({})); 85 | await middleware(params, next); 86 | expect(next).toHaveBeenCalledWith(params); 87 | 88 | // expect where with undefined unique field not to modify params 89 | params = createParams("User", "findUniqueOrThrow", { 90 | where: { email: undefined }, 91 | }); 92 | next = jest.fn(() => Promise.resolve({})); 93 | await middleware(params, next); 94 | expect(next).toHaveBeenCalledWith(params); 95 | 96 | // expect where with undefined unique index field not to modify params 97 | params = createParams("User", "findUniqueOrThrow", { 98 | where: { name_email: undefined }, 99 | }); 100 | next = jest.fn(() => Promise.resolve({})); 101 | await middleware(params, next); 102 | expect(next).toHaveBeenCalledWith(params); 103 | 104 | // expect where with defined non-unique field 105 | params = createParams("User", "findUniqueOrThrow", { 106 | // @ts-expect-error intentionally incorrect where 107 | where: { name: "test" }, 108 | }); 109 | next = jest.fn(() => Promise.resolve({})); 110 | await middleware(params, next); 111 | expect(next).toHaveBeenCalledWith(params); 112 | 113 | // expect where with defined non-unique field and undefined id field not to modify params 114 | params = createParams("User", "findUniqueOrThrow", { 115 | where: { id: undefined, name: "test" }, 116 | }); 117 | next = jest.fn(() => Promise.resolve({})); 118 | await middleware(params, next); 119 | expect(next).toHaveBeenCalledWith(params); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/unit/groupBy.test.ts: -------------------------------------------------------------------------------- 1 | import { createSoftDeleteMiddleware } from "../../src"; 2 | import { createParams } from "./utils/createParams"; 3 | 4 | describe("groupBy", () => { 5 | 6 | //group by must always have by and order by, else we get an error, 7 | it("does not change groupBy action if model is not in the list", async () => { 8 | const middleware = createSoftDeleteMiddleware({ models: {} }); 9 | 10 | const params = createParams("User", "groupBy", { where: { id: 1 }, by: ['id'], orderBy: {}, }); 11 | const next = jest.fn(() => Promise.resolve({})); 12 | await middleware(params, next); 13 | // params have not been modified 14 | expect(next).toHaveBeenCalledWith(params); 15 | }); 16 | 17 | it("does not modify groupBy results", async () => { 18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 19 | const params = createParams("User", "groupBy", { where: { id: 1 }, by: ['id'], orderBy: {}, }); 20 | const next = jest.fn(() => Promise.resolve([{ id: 1, deleted: true }])); 21 | expect(await middleware(params, next)).toEqual([{ id: 1, deleted: true }]); 22 | }); 23 | 24 | it("excludes deleted records from groupBy", async () => { 25 | const middleware = createSoftDeleteMiddleware({ 26 | models: { User: true }, 27 | }); 28 | 29 | const params = createParams("User", "groupBy", { where: { id: 1 },by: ['id'], orderBy: {} }); 30 | const next = jest.fn(() => Promise.resolve({})); 31 | 32 | await middleware(params, next); 33 | 34 | // params have been modified 35 | expect(next).toHaveBeenCalledWith({ 36 | ...params, 37 | action: "groupBy", 38 | args: { 39 | by: ['id'], 40 | orderBy: { }, 41 | where: { 42 | id: 1, 43 | deleted: false, 44 | }, 45 | }, 46 | }); 47 | }); 48 | 49 | 50 | it("allows explicitly querying for deleted records using groupBy", async () => { 51 | const middleware = createSoftDeleteMiddleware({ 52 | models: { User: true }, 53 | }); 54 | 55 | const params = createParams("User", "groupBy", { 56 | where: { id: 1, deleted: true }, by: ['id'], orderBy: {} 57 | }); 58 | const next = jest.fn(() => Promise.resolve({})); 59 | 60 | await middleware(params, next); 61 | 62 | // params have not been modified 63 | expect(next).toHaveBeenCalledWith(params); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/unit/include.test.ts: -------------------------------------------------------------------------------- 1 | import { set } from "lodash"; 2 | import faker from "faker"; 3 | 4 | import { createSoftDeleteMiddleware } from "../../src"; 5 | import { createParams, ActionByModel } from "./utils/createParams"; 6 | 7 | describe("include", () => { 8 | it("does not change include params if model is not in the list", async () => { 9 | const middleware = createSoftDeleteMiddleware({ models: {} }); 10 | 11 | const params = createParams("User", "update", { 12 | where: { id: 1 }, 13 | data: { email: "test@test.com" }, 14 | include: { comments: true }, 15 | }); 16 | 17 | const next = jest.fn(() => Promise.resolve({})); 18 | await middleware(params, next); 19 | 20 | // params have not been modified 21 | expect(next).toHaveBeenCalledWith(params); 22 | }); 23 | 24 | it.each([ 25 | "delete", 26 | "update", 27 | "upsert", 28 | "findFirst", 29 | "findFirstOrThrow", 30 | "findUnique", 31 | "findUniqueOrThrow", 32 | "findMany", 33 | ] as Array>)( 34 | "can include records for configured models in %s", 35 | async (action) => { 36 | const middleware = createSoftDeleteMiddleware({ 37 | models: { User: true }, 38 | }); 39 | 40 | const params = createParams("User", action, { 41 | where: { id: 1 }, 42 | include: { 43 | comments: true, 44 | }, 45 | }); 46 | 47 | const next = jest.fn(() => 48 | Promise.resolve({ 49 | comments: [{ deleted: true }, { deleted: false }], 50 | }) 51 | ); 52 | 53 | await middleware(params, next); 54 | 55 | // @ts-expect-error - ts doesn't know there has been a call 56 | expect(next.mock.calls[0][0]?.args?.include).toEqual({ 57 | comments: true, 58 | }); 59 | } 60 | ); 61 | 62 | it("uses params to exclude deleted records from toMany includes", async () => { 63 | const middleware = createSoftDeleteMiddleware({ 64 | models: { Comment: true }, 65 | }); 66 | 67 | const params = createParams("User", "update", { 68 | where: { id: 1 }, 69 | data: { email: "test@test.com" }, 70 | include: { 71 | comments: true, 72 | }, 73 | }); 74 | 75 | const next = jest.fn(() => Promise.resolve({})); 76 | await middleware(params, next); 77 | 78 | // params have been modified 79 | expect(next).toHaveBeenCalledWith( 80 | set(params, "args.include.comments", { 81 | where: { 82 | deleted: false, 83 | }, 84 | }) 85 | ); 86 | }); 87 | 88 | it("uses params to exclude deleted records from toMany includes with where", async () => { 89 | const middleware = createSoftDeleteMiddleware({ 90 | models: { Comment: true }, 91 | }); 92 | 93 | const params = createParams("User", "update", { 94 | where: { id: 1 }, 95 | data: { email: "test@test.com" }, 96 | include: { 97 | comments: { 98 | where: { 99 | content: faker.lorem.sentence(), 100 | }, 101 | }, 102 | }, 103 | }); 104 | 105 | const next = jest.fn(() => Promise.resolve({})); 106 | await middleware(params, next); 107 | 108 | // params have been modified 109 | expect(next).toHaveBeenCalledWith( 110 | set(params, "args.include.comments.where.deleted", false) 111 | ); 112 | }); 113 | 114 | it("manually excludes deleted records from boolean toOne include", async () => { 115 | const middleware = createSoftDeleteMiddleware({ 116 | models: { User: true }, 117 | }); 118 | 119 | const params = createParams("Post", "update", { 120 | where: { id: 1 }, 121 | data: { content: "foo" }, 122 | include: { 123 | author: true, 124 | }, 125 | }); 126 | 127 | const next = jest.fn(() => Promise.resolve({ author: { deleted: true } })); 128 | const result = await middleware(params, next); 129 | 130 | expect(next).toHaveBeenCalledWith(params); 131 | expect(result).toEqual({ author: null }); 132 | }); 133 | 134 | it("does not manually exclude non-deleted records from boolean toOne include", async () => { 135 | const middleware = createSoftDeleteMiddleware({ 136 | models: { User: true }, 137 | }); 138 | 139 | const params = createParams("Post", "update", { 140 | where: { id: 1 }, 141 | data: { content: "foo" }, 142 | include: { 143 | author: true, 144 | }, 145 | }); 146 | 147 | const next = jest.fn(() => Promise.resolve({ author: { deleted: false } })); 148 | const result = await middleware(params, next); 149 | 150 | expect(next).toHaveBeenCalledWith(params); 151 | expect(result).toEqual({ author: { deleted: false } }); 152 | }); 153 | 154 | it("manually excludes deleted records from toOne include with nested includes", async () => { 155 | const middleware = createSoftDeleteMiddleware({ 156 | models: { User: true }, 157 | }); 158 | 159 | const params = createParams("Post", "update", { 160 | where: { id: 1 }, 161 | data: { content: "foo" }, 162 | include: { 163 | author: { 164 | include: { 165 | comments: true, 166 | }, 167 | }, 168 | }, 169 | }); 170 | 171 | const next = jest.fn(() => 172 | Promise.resolve({ author: { deleted: true, comments: [] } }) 173 | ); 174 | const result = await middleware(params, next); 175 | 176 | expect(next).toHaveBeenCalledWith(params); 177 | expect(result).toEqual({ author: null }); 178 | }); 179 | 180 | it("does not manually exclude non-deleted records from toOne include with nested includes", async () => { 181 | const middleware = createSoftDeleteMiddleware({ 182 | models: { User: true }, 183 | }); 184 | 185 | const params = createParams("Post", "update", { 186 | where: { id: 1 }, 187 | data: { content: "foo" }, 188 | include: { 189 | author: { 190 | include: { 191 | comments: true, 192 | }, 193 | }, 194 | }, 195 | }); 196 | 197 | const next = jest.fn(() => 198 | Promise.resolve({ 199 | author: { 200 | deleted: false, 201 | comments: [], 202 | }, 203 | }) 204 | ); 205 | 206 | const result = await middleware(params, next); 207 | 208 | expect(result).toEqual({ 209 | author: { 210 | deleted: false, 211 | comments: [], 212 | }, 213 | }); 214 | }); 215 | 216 | it("manually excludes deleted records from toOne include nested in toMany include", async () => { 217 | const middleware = createSoftDeleteMiddleware({ 218 | models: { User: true }, 219 | }); 220 | 221 | const params = createParams("User", "findFirst", { 222 | where: { id: 1, deleted: false }, 223 | include: { 224 | posts: { 225 | include: { 226 | author: true, 227 | }, 228 | }, 229 | }, 230 | }); 231 | 232 | const next = jest.fn(() => 233 | Promise.resolve({ 234 | posts: [ 235 | { author: null }, 236 | { author: { deleted: true } }, 237 | { author: { deleted: false } }, 238 | ], 239 | }) 240 | ); 241 | 242 | const result = await middleware(params, next); 243 | 244 | expect(next).toHaveBeenCalledWith(params); 245 | expect(result).toEqual({ 246 | posts: [ 247 | { author: null }, 248 | { author: null }, 249 | { author: { deleted: false } }, 250 | ], 251 | }); 252 | }); 253 | 254 | it("allows explicitly including deleted records using include", async () => { 255 | const middleware = createSoftDeleteMiddleware({ 256 | models: { Comment: true }, 257 | }); 258 | 259 | const params = createParams("User", "update", { 260 | where: { id: 1 }, 261 | data: { email: "test@test.com" }, 262 | include: { 263 | comments: { 264 | where: { 265 | deleted: true, 266 | }, 267 | }, 268 | }, 269 | }); 270 | const next = jest.fn(() => 271 | Promise.resolve({ 272 | comments: [{ deleted: true }, { deleted: true }], 273 | }) 274 | ); 275 | 276 | const result = await middleware(params, next); 277 | 278 | // params have not been modified 279 | expect(next).toHaveBeenCalledWith(params); 280 | expect(result).toEqual({ 281 | comments: [{ deleted: true }, { deleted: true }], 282 | }); 283 | }); 284 | }); 285 | -------------------------------------------------------------------------------- /test/unit/select.test.ts: -------------------------------------------------------------------------------- 1 | import { set } from "lodash"; 2 | import faker from "faker"; 3 | 4 | import { createSoftDeleteMiddleware } from "../../src"; 5 | import { ActionByModel, createParams } from "./utils/createParams"; 6 | 7 | describe("select", () => { 8 | it("does not change select params if model is not in the list", async () => { 9 | const middleware = createSoftDeleteMiddleware({ models: {} }); 10 | 11 | const params = createParams("User", "update", { 12 | where: { id: 1 }, 13 | data: { email: "test@test.com" }, 14 | select: { comments: true }, 15 | }); 16 | const next = jest.fn(() => Promise.resolve({})); 17 | 18 | await middleware(params, next); 19 | 20 | // params have not been modified 21 | expect(next).toHaveBeenCalledWith(params); 22 | }); 23 | 24 | it.each([ 25 | "delete", 26 | "update", 27 | "upsert", 28 | "findFirst", 29 | "findFirstOrThrow", 30 | "findUnique", 31 | "findUniqueOrThrow", 32 | "findMany", 33 | ] as Array>)( 34 | "can select records for configured models in %s", 35 | async (action) => { 36 | const middleware = createSoftDeleteMiddleware({ 37 | models: { User: true }, 38 | }); 39 | 40 | const params = createParams("User", action, { 41 | where: { id: 1 }, 42 | select: { 43 | comments: true, 44 | }, 45 | }); 46 | 47 | const next = jest.fn(() => 48 | Promise.resolve({ 49 | comments: [{ deleted: true }, { deleted: false }], 50 | }) 51 | ); 52 | 53 | await middleware(params, next); 54 | 55 | // @ts-expect-error - ts doesn't know there has been a call 56 | expect(next.mock.calls[0][0]?.args?.select).toEqual({ 57 | comments: true, 58 | }); 59 | } 60 | ); 61 | 62 | it("excludes deleted records from selects", async () => { 63 | const middleware = createSoftDeleteMiddleware({ 64 | models: { Comment: true }, 65 | }); 66 | 67 | const params = createParams("User", "update", { 68 | where: { id: 1 }, 69 | data: { email: "test@test.com" }, 70 | select: { 71 | comments: true, 72 | }, 73 | }); 74 | const next = jest.fn(() => Promise.resolve({})); 75 | 76 | await middleware(params, next); 77 | 78 | // params have been modified 79 | expect(next).toHaveBeenCalledWith( 80 | set(params, "args.select.comments", { 81 | where: { 82 | deleted: false, 83 | }, 84 | }) 85 | ); 86 | }); 87 | 88 | it("excludes deleted records from selects using where", async () => { 89 | const middleware = createSoftDeleteMiddleware({ 90 | models: { Comment: true }, 91 | }); 92 | 93 | const params = createParams("User", "update", { 94 | where: { id: 1 }, 95 | data: { email: "test@test.com" }, 96 | select: { 97 | comments: { 98 | where: { 99 | content: faker.lorem.sentence(), 100 | }, 101 | }, 102 | }, 103 | }); 104 | const next = jest.fn(() => Promise.resolve({})); 105 | 106 | await middleware(params, next); 107 | 108 | // params have been modified 109 | expect(next).toHaveBeenCalledWith( 110 | set(params, "args.select.comments.where.deleted", false) 111 | ); 112 | }); 113 | 114 | it("excludes deleted records from include with select", async () => { 115 | const middleware = createSoftDeleteMiddleware({ 116 | models: { Comment: true }, 117 | }); 118 | 119 | const params = createParams("User", "update", { 120 | where: { id: 1 }, 121 | data: { email: "test@test.com" }, 122 | include: { 123 | comments: { 124 | select: { 125 | id: true, 126 | }, 127 | }, 128 | }, 129 | }); 130 | const next = jest.fn(() => Promise.resolve({})); 131 | 132 | await middleware(params, next); 133 | 134 | // params have not been modified 135 | expect(next).toHaveBeenCalledWith( 136 | set(params, "args.include.comments", { 137 | where: { deleted: false }, 138 | select: { id: true }, 139 | }) 140 | ); 141 | }); 142 | 143 | it("allows explicitly selecting deleted records using select", async () => { 144 | const middleware = createSoftDeleteMiddleware({ 145 | models: { Comment: true }, 146 | }); 147 | 148 | const params = createParams("User", "update", { 149 | where: { id: 1 }, 150 | data: { email: "test@test.com" }, 151 | select: { 152 | comments: { 153 | where: { 154 | deleted: true, 155 | }, 156 | }, 157 | }, 158 | }); 159 | const next = jest.fn(() => Promise.resolve({})); 160 | 161 | await middleware(params, next); 162 | 163 | // params have not been modified 164 | expect(next).toHaveBeenCalledWith(params); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /test/unit/update.test.ts: -------------------------------------------------------------------------------- 1 | import { createSoftDeleteMiddleware } from "../../src"; 2 | import { createParams } from "./utils/createParams"; 3 | 4 | describe("update", () => { 5 | it("does not change update action if model is not in the list", async () => { 6 | const middleware = createSoftDeleteMiddleware({ models: {} }); 7 | 8 | const params = createParams("User", "update", { 9 | where: { id: 1 }, 10 | data: { email: "test@test.com" }, 11 | }); 12 | const next = jest.fn(() => Promise.resolve({})); 13 | 14 | await middleware(params, next); 15 | 16 | // params have not been modified 17 | expect(next).toHaveBeenCalledWith(params); 18 | }); 19 | 20 | it("does not modify update results", async () => { 21 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 22 | 23 | const params = createParams("User", "update", { 24 | where: { id: 1 }, 25 | data: { name: "John" }, 26 | }); 27 | const next = jest.fn(() => Promise.resolve({ id: 1, name: "John" })); 28 | 29 | expect(await middleware(params, next)).toEqual({ id: 1, name: "John" }); 30 | }); 31 | 32 | it("throws when trying to update a model configured for soft delete through a toOne relation", async () => { 33 | const middleware = createSoftDeleteMiddleware({ 34 | models: { User: true }, 35 | }); 36 | 37 | const params = createParams("Post", "update", { 38 | where: { id: 1 }, 39 | data: { 40 | author: { 41 | update: { 42 | email: "test@test.com", 43 | }, 44 | }, 45 | }, 46 | }); 47 | 48 | const next = () => Promise.resolve({}); 49 | 50 | await expect(middleware(params, next)).rejects.toThrowError( 51 | 'prisma-soft-delete-middleware: update of model "User" through "Post.author" found. Updates of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.' 52 | ); 53 | }); 54 | 55 | it("does nothing to nested update actions for toOne relations when allowToOneUpdates is true", async () => { 56 | const middleware = createSoftDeleteMiddleware({ 57 | models: { User: true }, 58 | defaultConfig: { 59 | field: "deleted", 60 | createValue: Boolean, 61 | allowToOneUpdates: true, 62 | }, 63 | }); 64 | 65 | const params = createParams("Post", "update", { 66 | where: { id: 1 }, 67 | data: { 68 | author: { 69 | update: { 70 | email: "blah", 71 | }, 72 | }, 73 | }, 74 | }); 75 | const next = jest.fn(() => Promise.resolve({})); 76 | 77 | await middleware(params, next); 78 | 79 | // params have been modified 80 | expect(next).toHaveBeenCalledWith(params); 81 | }); 82 | 83 | it("does nothing to nested update actions for toMany relations", async () => { 84 | const middleware = createSoftDeleteMiddleware({ 85 | models: { User: true }, 86 | }); 87 | 88 | const params = createParams("Post", "update", { 89 | where: { id: 1 }, 90 | data: { 91 | comments: { 92 | update: { 93 | where: { 94 | id: 2, 95 | }, 96 | data: { 97 | content: "content", 98 | }, 99 | }, 100 | }, 101 | }, 102 | }); 103 | const next = jest.fn(() => Promise.resolve({})); 104 | 105 | await middleware(params, next); 106 | 107 | // params have not been modified 108 | expect(next).toHaveBeenCalledWith(params); 109 | }); 110 | 111 | it("does not modify update when no args are passed", async () => { 112 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 113 | 114 | // @ts-expect-error - args are required 115 | const params = createParams("User", "update", undefined); 116 | const next = jest.fn(() => Promise.resolve({})); 117 | 118 | await middleware(params, next); 119 | 120 | // params have not been modified 121 | expect(next).toHaveBeenCalledWith(params); 122 | }); 123 | 124 | it("does not modify update when no where is passed", async () => { 125 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 126 | 127 | // @ts-expect-error - where is required 128 | const params = createParams("User", "update", {}); 129 | const next = jest.fn(() => Promise.resolve({})); 130 | 131 | await middleware(params, next); 132 | 133 | // params have not been modified 134 | expect(next).toHaveBeenCalledWith(params); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /test/unit/updateMany.test.ts: -------------------------------------------------------------------------------- 1 | import { set } from "lodash"; 2 | import { createSoftDeleteMiddleware } from "../../src"; 3 | import { createParams } from "./utils/createParams"; 4 | 5 | describe("updateMany", () => { 6 | it("does not change updateMany action if model is not in the list", async () => { 7 | const middleware = createSoftDeleteMiddleware({ models: {} }); 8 | 9 | const params = createParams("User", "updateMany", { 10 | where: { id: { in: [1, 2] } }, 11 | data: { email: "test@test.com" }, 12 | }); 13 | const next = jest.fn(() => Promise.resolve({})); 14 | 15 | await middleware(params, next); 16 | 17 | // params have not been modified 18 | expect(next).toHaveBeenCalledWith(params); 19 | }); 20 | 21 | it("does not modify updateMany results", async () => { 22 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 23 | 24 | const params = createParams("User", "updateMany", { 25 | where: { id: 1 }, 26 | data: { name: "John" }, 27 | }); 28 | const next = jest.fn(() => Promise.resolve({ count: 1 })); 29 | 30 | expect(await middleware(params, next)).toEqual({ count: 1 }); 31 | }); 32 | 33 | it("does not change updateMany action if args not passed", async () => { 34 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 35 | 36 | // @ts-expect-error - args are required 37 | const params = createParams("User", "updateMany", undefined); 38 | const next = jest.fn(() => Promise.resolve({})); 39 | 40 | await middleware(params, next); 41 | 42 | // params have not been modified 43 | expect(next).toHaveBeenCalledWith(params); 44 | }); 45 | 46 | it("excludes deleted records from root updateMany action", async () => { 47 | const middleware = createSoftDeleteMiddleware({ 48 | models: { User: true }, 49 | }); 50 | 51 | const params = createParams("User", "updateMany", { 52 | where: { id: 1 }, 53 | data: { email: "test@test.com" }, 54 | }); 55 | const next = jest.fn(() => Promise.resolve({})); 56 | 57 | await middleware(params, next); 58 | 59 | // params have been modified 60 | expect(next).toHaveBeenCalledWith({ 61 | ...params, 62 | args: { 63 | ...params.args, 64 | where: { 65 | ...params.args.where, 66 | deleted: false, 67 | }, 68 | }, 69 | }); 70 | }); 71 | 72 | it("excludes deleted records from root updateMany action when where not passed", async () => { 73 | const middleware = createSoftDeleteMiddleware({ 74 | models: { User: true }, 75 | }); 76 | 77 | const params = createParams("User", "updateMany", { 78 | data: { name: "John" }, 79 | }); 80 | const next = jest.fn(() => Promise.resolve({})); 81 | 82 | await middleware(params, next); 83 | 84 | // params have been modified 85 | expect(next).toHaveBeenCalledWith({ 86 | ...params, 87 | args: { 88 | ...params.args, 89 | where: { 90 | ...params.args.where, 91 | deleted: false, 92 | }, 93 | }, 94 | }); 95 | }); 96 | 97 | it("excludes deleted record from nested updateMany action", async () => { 98 | const middleware = createSoftDeleteMiddleware({ 99 | models: { Comment: true }, 100 | }); 101 | 102 | const params = createParams("User", "update", { 103 | where: { id: 1 }, 104 | data: { 105 | comments: { 106 | updateMany: { 107 | where: { 108 | content: "foo", 109 | OR: [{ authorId: 1 }, { authorId: 2 }], 110 | AND: [ 111 | { createdAt: { gt: new Date() } }, 112 | { createdAt: { lt: new Date() } }, 113 | ], 114 | NOT: { content: "bar" }, 115 | }, 116 | data: { content: "bar" }, 117 | }, 118 | }, 119 | }, 120 | }); 121 | const next = jest.fn(() => Promise.resolve({})); 122 | 123 | await middleware(params, next); 124 | 125 | // params have been modified 126 | expect(next).toHaveBeenCalledWith( 127 | set(params, "args.data.comments.updateMany.where.deleted", false) 128 | ); 129 | }); 130 | 131 | it("allows explicitly updating deleted records", async () => { 132 | const middleware = createSoftDeleteMiddleware({ 133 | models: { 134 | User: true, 135 | }, 136 | }); 137 | 138 | const params = createParams("User", "updateMany", { 139 | where: { id: { in: [1, 2] }, deleted: true }, 140 | data: { email: "test@test.com" }, 141 | }); 142 | const next = jest.fn(() => Promise.resolve({})); 143 | 144 | await middleware(params, next); 145 | 146 | // params have not been modified 147 | expect(next).toHaveBeenCalledWith(params); 148 | }); 149 | 150 | it("allows explicitly updating deleted records when using custom deletedAt field", async () => { 151 | const middleware = createSoftDeleteMiddleware({ 152 | models: { 153 | User: { 154 | field: "deletedAt", 155 | createValue: (deleted) => { 156 | if (deleted) return new Date(); 157 | return null; 158 | }, 159 | }, 160 | }, 161 | }); 162 | 163 | const params = createParams("User", "updateMany", { 164 | where: { id: { in: [1, 2] }, deletedAt: { not: null } }, 165 | data: { email: "test@test.com" }, 166 | }); 167 | const next = jest.fn(() => Promise.resolve({})); 168 | 169 | await middleware(params, next); 170 | 171 | // params have not been modified 172 | expect(next).toHaveBeenCalledWith(params); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /test/unit/upsert.test.ts: -------------------------------------------------------------------------------- 1 | import { createSoftDeleteMiddleware } from "../../src"; 2 | import { createParams } from "./utils/createParams"; 3 | 4 | describe("upsert", () => { 5 | it("does not modify upsert results", async () => { 6 | const middleware = createSoftDeleteMiddleware({ models: { User: true } }); 7 | 8 | const params = createParams("User", "upsert", { 9 | where: { id: 1 }, 10 | create: { name: "John", email: "John@test.com" }, 11 | update: { name: "John" }, 12 | }); 13 | const next = jest.fn(() => 14 | Promise.resolve({ id: 1, name: "John", email: "John@test.com" }) 15 | ); 16 | 17 | expect(await middleware(params, next)).toEqual({ 18 | id: 1, 19 | name: "John", 20 | email: "John@test.com", 21 | }); 22 | }); 23 | 24 | it("does nothing to root upsert action", async () => { 25 | const middleware = createSoftDeleteMiddleware({ 26 | models: { User: true }, 27 | }); 28 | 29 | const params = createParams("User", "upsert", { 30 | where: { id: 1 }, 31 | create: { name: "John", email: "john@test.com" }, 32 | update: { name: "John" }, 33 | }); 34 | const next = jest.fn(() => Promise.resolve({})); 35 | 36 | await middleware(params, next); 37 | 38 | expect(next).toHaveBeenCalledWith(params); 39 | }); 40 | 41 | it("does nothing to nested toMany upsert actions", async () => { 42 | const middleware = createSoftDeleteMiddleware({ 43 | models: { User: true }, 44 | }); 45 | 46 | const params = createParams("Post", "update", { 47 | where: { id: 1 }, 48 | data: { 49 | comments: { 50 | upsert: { 51 | where: { id: 1 }, 52 | create: { content: "Hello", authorId: 1 }, 53 | update: { content: "Hello" }, 54 | }, 55 | }, 56 | }, 57 | }); 58 | const next = jest.fn(() => Promise.resolve({})); 59 | 60 | await middleware(params, next); 61 | 62 | expect(next).toHaveBeenCalledWith(params); 63 | }); 64 | 65 | it("throws when trying to upsert a model configured for soft delete through a toOne relation", async () => { 66 | const middleware = createSoftDeleteMiddleware({ 67 | models: { User: true }, 68 | }); 69 | 70 | const params = createParams("Post", "update", { 71 | where: { id: 1 }, 72 | data: { 73 | author: { 74 | upsert: { 75 | create: { 76 | name: "test", 77 | email: "test@test.com", 78 | }, 79 | update: { 80 | email: "test@test.com", 81 | }, 82 | }, 83 | }, 84 | }, 85 | }); 86 | 87 | const next = () => Promise.resolve({}); 88 | 89 | await expect(middleware(params, next)).rejects.toThrowError( 90 | 'prisma-soft-delete-middleware: upsert of model "User" through "Post.author" found. Upserts of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.' 91 | ); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /test/unit/utils/createParams.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from "@prisma/client"; 2 | 3 | type DelegateByModel = Model extends "User" 4 | ? Prisma.UserDelegate 5 | : Model extends "Post" 6 | ? Prisma.PostDelegate 7 | : Model extends "Profile" 8 | ? Prisma.ProfileDelegate 9 | : Model extends "Comment" 10 | ? Prisma.CommentDelegate 11 | : never; 12 | 13 | type SelectByModel = Model extends "User" 14 | ? Prisma.UserSelect 15 | : Model extends "Post" 16 | ? Prisma.PostSelect 17 | : Model extends "Profile" 18 | ? Prisma.ProfileSelect 19 | : Model extends "Comment" 20 | ? Prisma.CommentSelect 21 | : never; 22 | 23 | type IncludeByModel = Model extends "User" 24 | ? Prisma.UserInclude 25 | : Model extends "Post" 26 | ? Prisma.PostInclude 27 | : Model extends "Profile" 28 | ? Prisma.ProfileInclude 29 | : Model extends "Comment" 30 | ? Prisma.CommentInclude 31 | : never; 32 | 33 | export type ActionByModel = 34 | | keyof DelegateByModel 35 | | "connectOrCreate" 36 | | "select" 37 | | "include"; 38 | 39 | type ArgsByAction< 40 | Model extends Prisma.ModelName, 41 | Action extends ActionByModel 42 | > = Action extends "create" 43 | ? Parameters["create"]>[0] 44 | : Action extends "update" 45 | ? Parameters["update"]>[0] 46 | : Action extends "upsert" 47 | ? Parameters["upsert"]>[0] 48 | : Action extends "delete" 49 | ? Parameters["delete"]>[0] 50 | : Action extends "deleteMany" 51 | ? Parameters["deleteMany"]>[0] 52 | : Action extends "updateMany" 53 | ? Parameters["updateMany"]>[0] 54 | : Action extends "findUnique" 55 | ? Parameters["findUnique"]>[0] 56 | : Action extends "findUniqueOrThrow" 57 | ? Parameters["findUnique"]>[0] 58 | : Action extends "groupBy" 59 | ? Parameters["groupBy"]>[0] 60 | : Action extends "findFirst" 61 | ? Parameters["findFirst"]>[0] 62 | : Action extends "findFirstOrThrow" 63 | ? Parameters["findFirstOrThrow"]>[0] 64 | : Action extends "findMany" 65 | ? Parameters["findMany"]>[0] 66 | : Action extends "count" 67 | ? Parameters["count"]>[0] 68 | : Action extends "aggregate" 69 | ? Parameters["aggregate"]>[0] 70 | : Action extends "connectOrCreate" 71 | ? { 72 | where: Parameters["findUnique"]>[0]; 73 | create: Parameters["create"]>[0]; 74 | } 75 | : Action extends "select" 76 | ? SelectByModel 77 | : Action extends "include" 78 | ? IncludeByModel 79 | : never; 80 | 81 | /** 82 | * Creates params objects with strict typing of the `args` object to ensure it 83 | * is valid for the `model` and `action` passed. 84 | */ 85 | export const createParams = < 86 | Model extends Prisma.ModelName, 87 | Action extends ActionByModel = ActionByModel 88 | >( 89 | model: Model, 90 | action: Action, 91 | args: ArgsByAction, 92 | dataPath: string[] = [], 93 | runInTransaction: boolean = false 94 | ): Prisma.MiddlewareParams => ({ 95 | model, 96 | action: action as Prisma.PrismaAction, 97 | args, 98 | dataPath, 99 | runInTransaction, 100 | }); 101 | -------------------------------------------------------------------------------- /test/unit/where.test.ts: -------------------------------------------------------------------------------- 1 | import faker from "faker"; 2 | import { set } from "lodash"; 3 | 4 | import { createSoftDeleteMiddleware } from "../../src"; 5 | import { createParams } from "./utils/createParams"; 6 | 7 | describe("where", () => { 8 | it("does not change where action if model is not in the list", async () => { 9 | const middleware = createSoftDeleteMiddleware({ models: {} }); 10 | 11 | const params = createParams("User", "deleteMany", { 12 | where: { 13 | email: faker.internet.email(), 14 | comments: { 15 | some: { 16 | content: faker.lorem.sentence(), 17 | }, 18 | }, 19 | }, 20 | }); 21 | const next = jest.fn(() => Promise.resolve({})); 22 | 23 | await middleware(params, next); 24 | 25 | // params have not been modified 26 | expect(next).toHaveBeenCalledWith(params); 27 | }); 28 | 29 | it("changes root where correctly when model is nested", async () => { 30 | const middleware = createSoftDeleteMiddleware({ 31 | models: { Comment: true }, 32 | }); 33 | 34 | const params = createParams("User", "deleteMany", { 35 | where: { 36 | email: faker.internet.email(), 37 | comments: { 38 | some: { 39 | AND: [ 40 | { createdAt: { gt: faker.date.past() } }, 41 | { createdAt: { lt: faker.date.future() } }, 42 | ], 43 | OR: [ 44 | { post: { content: faker.lorem.sentence() } }, 45 | { post: { content: faker.lorem.sentence() } }, 46 | ], 47 | NOT: { post: { is: { authorName: faker.name.findName() } } }, 48 | content: faker.lorem.sentence(), 49 | post: { 50 | isNot: { 51 | content: faker.lorem.sentence(), 52 | }, 53 | }, 54 | }, 55 | }, 56 | }, 57 | }); 58 | const next = jest.fn(() => Promise.resolve({})); 59 | 60 | await middleware(params, next); 61 | 62 | set(params, "args.where.comments.some.deleted", false); 63 | 64 | // params have not been modified 65 | expect(next).toHaveBeenCalledWith(params); 66 | }); 67 | 68 | it("handles where with modifiers correctly", async () => { 69 | const middleware = createSoftDeleteMiddleware({ 70 | models: { Post: true, Comment: true, User: true }, 71 | }); 72 | 73 | const params = createParams("Comment", "findMany", { 74 | where: { 75 | content: faker.lorem.sentence(), 76 | post: { 77 | is: { 78 | content: "foo", 79 | }, 80 | }, 81 | author: { 82 | isNot: { 83 | name: "Jack", 84 | }, 85 | }, 86 | replies: { 87 | some: { 88 | content: "foo", 89 | }, 90 | every: { 91 | content: "bar", 92 | }, 93 | none: { 94 | content: "baz", 95 | }, 96 | }, 97 | }, 98 | }); 99 | const next = jest.fn(() => Promise.resolve({})); 100 | 101 | await middleware(params, next); 102 | 103 | set(params, "args.where.deleted", false); 104 | set(params, "args.where.post.is.deleted", false); 105 | set(params, "args.where.author.isNot.deleted", false); 106 | set(params, "args.where.replies.some.deleted", false); 107 | set(params, "args.where.replies.every", { 108 | OR: [{ deleted: { not: false } }, params.args.where.replies.every], 109 | }); 110 | set(params, "args.where.replies.none.deleted", false); 111 | 112 | expect(next).toHaveBeenCalledWith(params); 113 | }); 114 | 115 | it("changes root where correctly when model is deeply nested", async () => { 116 | const middleware = createSoftDeleteMiddleware({ 117 | models: { Post: true }, 118 | }); 119 | 120 | const params = createParams("User", "deleteMany", { 121 | where: { 122 | email: faker.internet.email(), 123 | comments: { 124 | some: { 125 | AND: [ 126 | { createdAt: { gt: faker.date.past() } }, 127 | { post: { content: faker.lorem.sentence() } }, 128 | ], 129 | OR: [ 130 | { post: { content: faker.lorem.sentence() } }, 131 | { createdAt: { lt: faker.date.future() } }, 132 | ], 133 | NOT: { 134 | post: { 135 | is: { 136 | authorName: faker.name.findName(), 137 | }, 138 | }, 139 | }, 140 | post: { 141 | isNot: { 142 | content: faker.lorem.sentence(), 143 | }, 144 | }, 145 | }, 146 | }, 147 | }, 148 | }); 149 | const next = jest.fn(() => Promise.resolve({})); 150 | 151 | await middleware(params, next); 152 | 153 | set(params, "args.where.comments.some.AND.1.post.deleted", false); 154 | set(params, "args.where.comments.some.OR.0.post.deleted", false); 155 | set(params, "args.where.comments.some.NOT.post.is.deleted", false); 156 | set(params, "args.where.comments.some.post.isNot.deleted", false); 157 | 158 | // params have not been modified 159 | expect(next).toHaveBeenCalledWith(params); 160 | }); 161 | 162 | it("change root where correctly when multiple models passed", async () => { 163 | const middleware = createSoftDeleteMiddleware({ 164 | models: { 165 | Comment: true, 166 | Post: true, 167 | }, 168 | }); 169 | 170 | const params = createParams("User", "deleteMany", { 171 | where: { 172 | email: faker.internet.email(), 173 | comments: { 174 | some: { 175 | AND: [ 176 | { createdAt: { gt: faker.date.past() } }, 177 | { createdAt: { lt: faker.date.future() } }, 178 | ], 179 | OR: [ 180 | { post: { content: faker.lorem.sentence() } }, 181 | { post: { content: faker.lorem.sentence() } }, 182 | ], 183 | NOT: { post: { is: { authorName: faker.name.findName() } } }, 184 | content: faker.lorem.sentence(), 185 | post: { 186 | isNot: { 187 | content: faker.lorem.sentence(), 188 | }, 189 | }, 190 | }, 191 | }, 192 | }, 193 | }); 194 | const next = jest.fn(() => Promise.resolve({})); 195 | 196 | await middleware(params, next); 197 | 198 | set(params, "args.where.comments.some.deleted", false); 199 | set(params, "args.where.comments.some.NOT.post.is.deleted", false); 200 | set(params, "args.where.comments.some.OR.0.post.deleted", false); 201 | set(params, "args.where.comments.some.OR.1.post.deleted", false); 202 | set(params, "args.where.comments.some.post.isNot.deleted", false); 203 | 204 | // params have not been modified 205 | expect(next).toHaveBeenCalledWith( 206 | set(params, "args.where.comments.some.deleted", false) 207 | ); 208 | }); 209 | 210 | it("allows checking for deleted records explicitly", async () => { 211 | const middleware = createSoftDeleteMiddleware({ 212 | models: { 213 | Comment: true, 214 | Post: true, 215 | }, 216 | }); 217 | 218 | const params = createParams("User", "deleteMany", { 219 | where: { 220 | email: faker.internet.email(), 221 | comments: { 222 | some: { 223 | deleted: true, 224 | AND: [ 225 | { createdAt: { gt: faker.date.past() } }, 226 | { createdAt: { lt: faker.date.future() } }, 227 | ], 228 | OR: [ 229 | { post: { deleted: true, content: faker.lorem.sentence() } }, 230 | { post: { content: faker.lorem.sentence() } }, 231 | ], 232 | NOT: { 233 | post: { 234 | is: { deleted: true, authorName: faker.name.findName() }, 235 | }, 236 | }, 237 | content: faker.lorem.sentence(), 238 | post: { 239 | isNot: { 240 | content: faker.lorem.sentence(), 241 | }, 242 | }, 243 | replies: { 244 | some: { 245 | content: "foo", 246 | deleted: true, 247 | }, 248 | every: { 249 | content: "bar", 250 | deleted: true, 251 | }, 252 | none: { 253 | content: "baz", 254 | deleted: true, 255 | }, 256 | }, 257 | }, 258 | }, 259 | }, 260 | }); 261 | const next = jest.fn(() => Promise.resolve({})); 262 | 263 | await middleware(params, next); 264 | 265 | set(params, "args.where.comments.some.deleted", true); 266 | set(params, "args.where.comments.some.OR.0.post.deleted", true); 267 | set(params, "args.where.comments.some.OR.1.post.deleted", false); 268 | set(params, "args.where.comments.some.NOT.post.is.deleted", true); 269 | set(params, "args.where.comments.some.post.isNot.deleted", false); 270 | set(params, "args.where.comments.some.replies.some.deleted", true); 271 | set(params, "args.where.comments.some.replies.every.deleted", true); 272 | set(params, "args.where.comments.some.replies.none.deleted", true); 273 | 274 | expect(next).toHaveBeenCalledWith(params); 275 | }); 276 | 277 | it("excludes deleted from include where with nested relations", async () => { 278 | const middleware = createSoftDeleteMiddleware({ 279 | models: { 280 | Comment: true, 281 | }, 282 | }); 283 | 284 | const params = createParams("User", "findMany", { 285 | include: { 286 | posts: { 287 | where: { 288 | comments: { 289 | some: { 290 | content: faker.lorem.sentence(), 291 | }, 292 | }, 293 | }, 294 | }, 295 | }, 296 | }); 297 | 298 | const next = jest.fn(() => Promise.resolve({})); 299 | 300 | await middleware(params, next); 301 | 302 | set(params, "args.include.posts.where.comments.some.deleted", false); 303 | 304 | expect(next).toHaveBeenCalledWith(params); 305 | }); 306 | 307 | it("excludes deleted from select where with nested relations", async () => { 308 | const middleware = createSoftDeleteMiddleware({ 309 | models: { 310 | Comment: true, 311 | }, 312 | }); 313 | 314 | const params = createParams("User", "findMany", { 315 | select: { 316 | posts: { 317 | where: { 318 | comments: { 319 | some: { 320 | content: faker.lorem.sentence(), 321 | }, 322 | }, 323 | }, 324 | }, 325 | }, 326 | }); 327 | 328 | const next = jest.fn(() => Promise.resolve({})); 329 | 330 | await middleware(params, next); 331 | 332 | set(params, "args.select.posts.where.comments.some.deleted", false); 333 | 334 | expect(next).toHaveBeenCalledWith(params); 335 | }); 336 | 337 | it("excludes deleted from include where with nested relations and multiple models", async () => { 338 | const middleware = createSoftDeleteMiddleware({ 339 | models: { 340 | Comment: true, 341 | Post: true, 342 | }, 343 | }); 344 | 345 | const params = createParams("User", "findMany", { 346 | include: { 347 | comments: { 348 | where: { 349 | post: { 350 | content: faker.lorem.sentence(), 351 | comments: { 352 | some: { 353 | content: faker.lorem.sentence(), 354 | }, 355 | }, 356 | }, 357 | }, 358 | }, 359 | }, 360 | }); 361 | 362 | const next = jest.fn(() => Promise.resolve({})); 363 | 364 | await middleware(params, next); 365 | 366 | set(params, "args.include.comments.where.deleted", false); 367 | set(params, "args.include.comments.where.post.deleted", false); 368 | set( 369 | params, 370 | "args.include.comments.where.post.comments.some.deleted", 371 | false 372 | ); 373 | 374 | expect(next).toHaveBeenCalledWith(params); 375 | }); 376 | 377 | it("excludes deleted from select where with nested relations and multiple models", async () => { 378 | const middleware = createSoftDeleteMiddleware({ 379 | models: { 380 | Comment: true, 381 | Post: true, 382 | }, 383 | }); 384 | 385 | const params = createParams("User", "findMany", { 386 | select: { 387 | comments: { 388 | where: { 389 | post: { 390 | content: faker.lorem.sentence(), 391 | comments: { 392 | some: { 393 | content: faker.lorem.sentence(), 394 | }, 395 | }, 396 | }, 397 | }, 398 | }, 399 | }, 400 | }); 401 | 402 | const next = jest.fn(() => Promise.resolve({})); 403 | 404 | await middleware(params, next); 405 | 406 | set(params, "args.select.comments.where.deleted", false); 407 | set(params, "args.select.comments.where.post.deleted", false); 408 | set(params, "args.select.comments.where.post.comments.some.deleted", false); 409 | 410 | expect(next).toHaveBeenCalledWith(params); 411 | }); 412 | }); 413 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "noEmit": false 6 | }, 7 | "exclude": ["test", "dist"] 8 | } 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------