From cce92c6ecb3d089c8149c4c0707f5800843b0c86 Mon Sep 17 00:00:00 2001 From: Olivier Wilkinson Date: Thu, 2 Nov 2023 17:24:46 -0400 Subject: [PATCH] feat: initial release This is based on the prisma-soft-delete-middleware package: https://github.com/olivierwilkinson/prisma-soft-delete-middleware --- .eslintrc | 29 + .../prisma-extension-soft-delete.yml | 40 + .gitignore | 11 + .npmrc | 1 + LICENSE | 201 ++++ README.md | 994 ++++++++++++++++++ docker-compose.yml | 13 + jest.config.e2e.js | 6 + jest.config.js | 7 + jest.config.unit.js | 6 + package.json | 77 ++ prisma/schema.prisma | 59 ++ src/index.ts | 3 + src/lib/createSoftDeleteExtension.ts | 169 +++ src/lib/helpers/createParams.ts | 448 ++++++++ src/lib/helpers/modifyResult.ts | 33 + src/lib/types.ts | 14 + src/lib/utils/nestedReads.ts | 36 + src/lib/utils/resultFiltering.ts | 27 + test/e2e/client.ts | 3 + test/e2e/deletedAt.test.ts | 175 +++ test/e2e/nestedReads.test.ts | 429 ++++++++ test/e2e/queries.test.ts | 747 +++++++++++++ test/e2e/where.test.ts | 295 ++++++ test/scripts/run-with-postgres.sh | 10 + test/setup.ts | 5 + test/unit/aggregate.test.ts | 66 ++ test/unit/config.test.ts | 184 ++++ test/unit/count.test.ts | 62 ++ test/unit/delete.test.ts | 221 ++++ test/unit/deleteMany.test.ts | 152 +++ test/unit/findFirst.test.ts | 113 ++ test/unit/findFirstOrThrow.test.ts | 113 ++ test/unit/findMany.test.ts | 117 +++ test/unit/findUnique.test.ts | 193 ++++ test/unit/findUniqueOrThrow.test.ts | 144 +++ test/unit/groupBy.test.ts | 83 ++ test/unit/include.test.ts | 316 ++++++ test/unit/select.test.ts | 129 +++ test/unit/update.test.ts | 138 +++ test/unit/updateMany.test.ts | 187 ++++ test/unit/upsert.test.ts | 95 ++ test/unit/utils/createParams.ts | 98 ++ test/unit/utils/mockClient.ts | 60 ++ test/unit/where.test.ts | 429 ++++++++ tsconfig.build.json | 8 + tsconfig.esm.json | 11 + tsconfig.json | 14 + 48 files changed, 6771 insertions(+) create mode 100644 .eslintrc create mode 100644 .github/workflows/prisma-extension-soft-delete.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 jest.config.e2e.js create mode 100644 jest.config.js create mode 100644 jest.config.unit.js create mode 100644 package.json create mode 100644 prisma/schema.prisma create mode 100644 src/index.ts create mode 100644 src/lib/createSoftDeleteExtension.ts create mode 100644 src/lib/helpers/createParams.ts create mode 100644 src/lib/helpers/modifyResult.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/utils/nestedReads.ts create mode 100644 src/lib/utils/resultFiltering.ts create mode 100644 test/e2e/client.ts create mode 100644 test/e2e/deletedAt.test.ts create mode 100644 test/e2e/nestedReads.test.ts create mode 100644 test/e2e/queries.test.ts create mode 100644 test/e2e/where.test.ts create mode 100755 test/scripts/run-with-postgres.sh create mode 100644 test/setup.ts create mode 100644 test/unit/aggregate.test.ts create mode 100644 test/unit/config.test.ts create mode 100644 test/unit/count.test.ts create mode 100644 test/unit/delete.test.ts create mode 100644 test/unit/deleteMany.test.ts create mode 100644 test/unit/findFirst.test.ts create mode 100644 test/unit/findFirstOrThrow.test.ts create mode 100644 test/unit/findMany.test.ts create mode 100644 test/unit/findUnique.test.ts create mode 100644 test/unit/findUniqueOrThrow.test.ts create mode 100644 test/unit/groupBy.test.ts create mode 100644 test/unit/include.test.ts create mode 100644 test/unit/select.test.ts create mode 100644 test/unit/update.test.ts create mode 100644 test/unit/updateMany.test.ts create mode 100644 test/unit/upsert.test.ts create mode 100644 test/unit/utils/createParams.ts create mode 100644 test/unit/utils/mockClient.ts create mode 100644 test/unit/where.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.esm.json create mode 100644 tsconfig.json diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..55d2bb0 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,29 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": [ + "./node_modules/kcd-scripts/eslint.js", + "plugin:import/typescript" + ], + "plugins": ["@typescript-eslint"], + "rules": { + "babel/new-cap": "off", + "func-names": "off", + "babel/no-unused-expressions": "off", + "prefer-arrow-callback": "off", + "testing-library/no-await-sync-query": "off", + "testing-library/no-dom-import": "off", + "testing-library/prefer-screen-queries": "off", + "no-undef": "off", + "no-use-before-define": "off", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" } + ], + "max-lines-per-function": "off", + "consistent-return": "off", + "jest/no-if": "off", + "one-var": "off", + "babel/camelcase": "off" + } +} diff --git a/.github/workflows/prisma-extension-soft-delete.yml b/.github/workflows/prisma-extension-soft-delete.yml new file mode 100644 index 0000000..a9b4ac6 --- /dev/null +++ b/.github/workflows/prisma-extension-soft-delete.yml @@ -0,0 +1,40 @@ +name: prisma-extensions-soft-delete +on: + push: + branches: + - 'main' + - 'next' + pull_request: + +jobs: + test: + name: 'node ${{ matrix.node }} chrome ${{ matrix.os }} ' + runs-on: '${{ matrix.os }}' + strategy: + matrix: + os: [ubuntu-latest] + node: [16] + steps: + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + - uses: actions/checkout@v2 + - run: npm install + - run: npm run validate + env: + CI: true + release: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/setup-node@v2 + with: + node-version: 16 + - uses: actions/checkout@v2 + - run: npm install + - run: npm run build + - run: ls -asl dist + - run: npx semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fcc6256 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Dependency directories +node_modules/ + +# Build output directory +dist + +# Test coverage directory +coverage + +# Mac +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..afbd003 --- /dev/null +++ b/README.md @@ -0,0 +1,994 @@ +
+

Prisma Extension Soft Delete

+ +

Prisma extension for soft deleting records.

+ +

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

+ +
+ +
+ +[![Build Status][build-badge]][build] +[![version][version-badge]][package] +[![MIT License][license-badge]][license] +[![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) +[![PRs Welcome][prs-badge]][prs] + +## Table of Contents + + + + +- [Disclaimer](#disclaimer) +- [Installation](#installation) +- [Usage](#usage) + - [Middleware Setup](#middleware-setup) + - [Prisma Schema Setup](#prisma-schema-setup) +- [Behaviour](#behaviour) + - [Deleting Records](#deleting-records) + - [Deleting a Single Record](#deleting-a-single-record) + - [Deleting Multiple Records](#deleting-multiple-records) + - [Deleting Through a relationship](#deleting-through-a-relationship) + - [Hard Deletes](#hard-deletes) + - [Excluding Soft Deleted Records](#excluding-soft-deleted-records) + - [Excluding Soft Deleted Records in a `findFirst` Operation](#excluding-soft-deleted-records-in-a-findfirst-operation) + - [Excluding Soft Deleted Records in a `findMany` Operation](#excluding-soft-deleted-records-in-a-findmany-operation) + - [Excluding Soft Deleted Records in a `findUnique` Operation](#excluding-soft-deleted-records-in-a-findunique-operation) + - [Updating Records](#updating-records) + - [Explicitly Updating Many Soft Deleted Records](#explicitly-updating-many-soft-deleted-records) + - [Where objects](#where-objects) + - [Explicitly Querying Soft Deleted Records](#explicitly-querying-soft-deleted-records) + - [Including or Selecting Soft Deleted Records](#including-or-selecting-soft-deleted-records) + - [Including or Selecting toMany Relations](#including-or-selecting-tomany-relations) + - [Including or Selecting toOne Relations](#including-or-selecting-toone-relations) + - [Explicitly Including Soft Deleted Records in toMany Relations](#explicitly-including-soft-deleted-records-in-tomany-relations) +- [LICENSE](#license) + + + +## Installation + +This module is distributed via [npm][npm] and should be installed as one of your +project's dependencies: + +``` +npm install prisma-extensions-soft-delete +``` + +`@prisma/client` is a peer dependency of this library, so you will need to +install it if you haven't already: + +``` +npm install @prisma/client +``` + +## Usage + +### Extension Setup + +To add soft delete functionality to your Prisma client create the extension using the `createSoftDeleteExtension` +function and pass it to `client.$extends`. + +The `createSoftDeleteExtension` function takes a config object where you can define the models you want to use soft +delete with. + +```typescript +import { PrismaClient } from "@prisma/client"; + +const client = new PrismaClient(); + +const extendedClient = client.$extends( + createSoftDeleteExtension({ + models: { + Comment: true, + }, + }) +); +``` + +By default the extension will use a `deleted` field of type `Boolean` on the model. If you want to use a custom field +name or value you can pass a config object for the model. For example to use a `deletedAt` field where the value is null +by default and a `DateTime` when the record is deleted you would pass the following: + +```typescript +const extendedClient = client.$extends( + createSoftDeleteExtension({ + models: { + Comment: { + field: "deletedAt", + createValue: (deleted) => { + if (deleted) return new Date(); + return null; + }, + }, + }, + }) +); +``` + +The `field` property is the name of the field to use for soft delete, and the `createValue` property is a function that +takes a deleted argument and returns the value for whether the record is soft deleted or not. The `createValue` method +must return a falsy value if the record is not deleted and a truthy value if it is deleted. + +It is possible to setup soft delete for multiple models at once by passing a config for each model in the `models` +object: + +```typescript +const extendedClient = client.$extends( + createSoftDeleteExtension({ + models: { + Comment: true, + Post: true, + }, + }) +); +``` + +To modify the default field and type for all models you can pass a `defaultConfig`: + +```typescript +const extendedClient = client.$extends( + createSoftDeleteExtension({ + models: { + Comment: true, + Post: true, + }, + defaultConfig: { + field: "deletedAt", + createValue: (deleted) => { + if (deleted) return new Date(); + return null; + }, + }, + }) +); +``` + +When using the default config you can also override the default config for a specific model by passing a config object +for that model: + +```typescript +const extendedClient = client.$extends( + createSoftDeleteExtension({ + models: { + Comment: true, + Post: { + field: "deleted", + createValue: Boolean, + }, + }, + defaultConfig: { + field: "deletedAt", + createValue: (deleted) => { + if (deleted) return new Date(); + return null; + }, + }, + }) +); +``` + +The config object also has a `allowToOneUpdates` option that can be used to allow updates to toOne relationships through +nested updates. By default this is set to `false` and will throw an error if you try to update a toOne relationship +through a nested update. If you want to allow this you can set `allowToOneUpdates` to `true`: + +```typescript +const extendedClient = client.$extends( + createSoftDeleteExtension({ + models: { + Comment: { + field: "deleted", + createValue: Boolean, + allowToOneUpdates: true, + }, + }, + }) +); +``` + +For more information for why updating through toOne relationship is disabled by default see the +[Updating Records](#updating-records) section. + +Similarly to `allowToOneUpdates` there is an `allowCompoundUniqueIndexWhere` option that can be used to allow using +where objects with compound unique index fields when using `findUnique` queries. By default this is set to `false` and +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 +`allowCompoundUniqueIndexWhere` to `true`: + +```typescript +const extendedClient = client.$extends( + createSoftDeleteExtension({ + models: { + Comment: { + field: "deleted", + createValue: Boolean, + allowCompoundUniqueIndexWhere: true, + }, + }, + }) +); +``` + +For more information for why updating through toOne relationship is disabled by default see the +[Excluding Soft Deleted Records in a `findUnique` Operation](#excluding-soft-deleted-records-in-a-findunique-operation) section. + + +To allow to one updates or compound unique index fields globally you can use the `defaultConfig` to do so: + +```typescript +const extendedClient = client.$extends( + createSoftDeleteExtension({ + models: { + User: true, + Comment: true, + }, + defaultConfig: { + field: "deleted", + createValue: Boolean, + allowToOneUpdates: true, + allowCompoundUniqueIndexWhere: true, + }, + }) +); +``` + +### Prisma Schema Setup + +The Prisma schema must be updated to include the soft delete field for each model you want to use soft delete with. + +For models configured to use the default field and type you must add the `deleted` field to your Prisma schema manually. +Using the Comment model configured in [Extension Setup](#extension-setup) you would need add the following to the +Prisma schema: + +```prisma +model Comment { + deleted Boolean @default(false) + [other fields] +} +``` + +If the Comment model was configured to use a `deletedAt` field where the value is null by default and a `DateTime` when +the record is deleted you would need to add the following to your Prisma schema: + +```prisma +model Comment { + deletedAt DateTime? + [other fields] +} +``` + +Models configured to use soft delete that are related to other models through a toOne relationship must have this +relationship defined as optional. This is because the extension will exclude soft deleted records when the relationship +is included or selected. If the relationship is not optional the types for the relation will be incorrect and you may +get runtime errors. + +For example if you have an `author` relationship on the Comment model and the User model is configured to use soft +delete you would need to change the relationship to be optional: + +```prisma +model Comment { + authorId Int? + author User? @relation(fields: [authorId], references: [id]) + [other fields] +} +``` + +`@unique` fields on models that are configured to use soft deletes may cause problems due to the records not actually +being deleted. If a record is soft deleted and then a new record is created with the same value for the unique field, +the new record will not be created. + +## Behaviour + +The main behaviour of the extension is to replace delete operations with update operations that set the soft delete +field to the deleted value. + +The extension also prevents accidentally fetching or updating soft deleted records by excluding soft deleted records +from find queries, includes, selects and bulk updates. The extension does allow explicit queries for soft deleted +records and allows updates through unique fields such is it's id. The reason it allows updates through unique fields is +because soft deleted records can only be fetched explicitly so updates through a unique fields should be intentional. + +### Deleting Records + +When deleting a record using the `delete` or `deleteMany` operations the extension will change the operation to an +`update` operation and set the soft delete field to be the deleted value defined in the config for that model. + +For example if the Comment model was configured to use the default `deleted` field of type `Boolean` the extension +would change the `delete` operation to an `update` operation and set the `deleted` field to `true`. + +#### Deleting a Single Record + +When deleting a single record using the `delete` operation: + +```typescript +await client.comment.delete({ + where: { + id: 1, + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.comment.update({ + where: { + id: 1, + }, + data: { + deleted: true, + }, +}); +``` + +#### Deleting Multiple Records + +When deleting multiple records using the `deleteMany` operation: + +```typescript +await client.comment.deleteMany({ + where: { + id: { + in: [1, 2, 3], + }, + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.comment.updateMany({ + where: { + id: { + in: [1, 2, 3], + }, + }, + data: { + deleted: true, + }, +}); +``` + +#### Deleting Through a relationship + +When using a nested delete through a relationship the extension will change the nested delete operation to an update +operation: + +```typescript +await client.post.update({ + where: { + id: 1, + }, + data: { + comments: { + delete: { + where: { + id: 2, + }, + }, + }, + author: { + delete: true, + }, + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.post.update({ + where: { + id: 1, + }, + data: { + comments: { + update: { + where: { + id: 2, + }, + data: { + deleted: true, + }, + }, + }, + author: { + update: { + deleted: true, + }, + }, + }, +}); +``` + +The same behaviour applies when using a nested `deleteMany` with a toMany relationship. + +#### Hard Deletes + +Hard deletes are not currently supported by this extension, when the `extendedWhereUnique` feature is supported +it will be possible to explicitly hard delete a soft deleted record. In the meantime you can use the `executeRaw` +operation to perform hard deletes. + +### Excluding Soft Deleted Records + +When using the `findUnique`, `findFirst` and `findMany` operations the extension will modify the `where` object passed +to exclude soft deleted records. It does this by adding an additional condition to the `where` object that excludes +records where the soft delete field is set to the deleted value defined in the config for that model. + +#### Excluding Soft Deleted Records in a `findFirst` Operation + +When using a `findFirst` operation the extension will modify the `where` object to exclude soft deleted records, so for: + +```typescript +await client.comment.findFirst({ + where: { + id: 1, + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.comment.findFirst({ + where: { + id: 1, + deleted: false, + }, +}); +``` + +#### Excluding Soft Deleted Records in a `findMany` Operation + +When using a `findMany` operation the extension will modify the `where` object to exclude soft deleted records, so for: + +```typescript +await client.comment.findMany({ + where: { + id: 1, + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.comment.findMany({ + where: { + id: 1, + deleted: false, + }, +}); +``` + +#### Excluding Soft Deleted Records in a `findUnique` Operation + +When using a `findUnique` operation the extension will change the query to use `findFirst` so that it can modify the +`where` object to exclude soft deleted records, so for: + +```typescript +await client.comment.findUnique({ + where: { + id: 1, + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.comment.findFirst({ + where: { + id: 1, + deleted: false, + }, +}); +``` + +When querying using a compound unique index in the where object the extension will throw an error by default. This +is because it is not possible to use these types of where object with `findFirst` and it is not possible to exclude +soft-deleted records when using `findUnique`. For example take the following query: + +```typescript +await client.user.findUnique({ + where: { + name_email: { + name: "foo", + email: "bar", + }, + }, +}); +``` + +Since the compound unique index `@@unique([name, email])` is being queried through the `name_email` field of the where +object the extension will throw to avoid accidentally returning a soft deleted record. + +It is possible to override this behaviour by setting `allowCompoundUniqueIndexWhere` to `true` in the model config. + +### Updating Records + +Updating records is split into three categories, updating a single record using a root operation, updating a single +record through a relation and updating multiple records either through a root operation or a relation. + +When updating a single record using a root operation such as `update` or `upsert` the extension will not modify the +operation. This is because unless explicitly queried for soft deleted records should not be returned from queries, +so if these operations are updating a soft deleted record it should be intentional. + +When updating a single record through a relation the extension will throw an error by default. This is because it is +not possible to filter out soft deleted records for nested toOne relations. For example take the following query: + +```typescript +await client.post.update({ + where: { + id: 1, + }, + data: { + author: { + update: { + name: "foo", + }, + }, + }, +}); +``` + +Since the `author` field is a toOne relation it does not support a where object. This means that if the `author` field +is a soft deleted record it will be updated accidentally. + +It is possible to override this behaviour by setting `allowToOneUpdates` to `true` in the extension config. + +When updating multiple records using `updateMany` the extension will modify the `where` object passed to exclude soft +deleted records. For example take the following query: + +```typescript +await client.comment.updateMany({ + where: { + id: 1, + }, + data: { + content: "foo", + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.comment.updateMany({ + where: { + id: 1, + deleted: false, + }, + data: { + content: "foo", + }, +}); +``` + +This also works when a toMany relation is updated: + +```typescript +await client.post.update({ + where: { + id: 1, + }, + data: { + comments: { + updateMany: { + where: { + id: 1, + }, + data: { + content: "foo", + }, + }, + }, + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.post.update({ + where: { + id: 1, + }, + data: { + comments: { + updateMany: { + where: { + id: 1, + deleted: false, + }, + data: { + content: "foo", + }, + }, + }, + }, +}); +``` + +#### Explicitly Updating Many Soft Deleted Records + +When using the `updateMany` operation it is possible to explicitly update many soft deleted records by setting the +deleted field to the deleted value defined in the config for that model. An example that would update soft deleted +records would be: + +```typescript +await client.comment.updateMany({ + where: { + content: "foo", + deleted: true, + }, + data: { + content: "bar", + }, +}); +``` + +### Where objects + +When using a `where` query it is possible to reference models configured to use soft deletes. In this case the +extension will modify the `where` object to exclude soft deleted records from the query, so for: + +```typescript +await client.post.findMany({ + where: { + id: 1, + comments: { + some: { + content: "foo", + }, + }, + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.post.findMany({ + where: { + id: 1, + comments: { + some: { + content: "foo", + deleted: false, + }, + }, + }, +}); +``` + +This also works when the where object includes logical operators: + +```typescript +await client.post.findMany({ + where: { + id: 1, + OR: [ + { + comments: { + some: { + author: { + name: "Jack", + }, + }, + }, + }, + { + comments: { + none: { + author: { + name: "Jill", + }, + }, + }, + }, + ], + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.post.findMany({ + where: { + id: 1, + OR: [ + { + comments: { + some: { + deleted: false, + author: { + name: "Jack", + }, + }, + }, + }, + { + comments: { + none: { + deleted: false, + author: { + name: "Jill", + }, + }, + }, + }, + ], + }, +}); +``` + +When using the `every` modifier the extension will modify the `where` object to exclude soft deleted records from the +query in a different way, so for: + +```typescript +await client.post.findMany({ + where: { + id: 1, + comments: { + every: { + content: "foo", + }, + }, + }, +}); +``` + +The extension would change the operation to: + +```typescript +await client.post.findMany({ + where: { + id: 1, + comments: { + every: { + OR: [{ deleted: { not: false } }, { content: "foo" }], + }, + }, + }, +}); +``` + +This is because if the same logic that is used for `some` and `none` were to be used with `every` then the query would +fail for cases where there are deleted models. + +The deleted case uses the `not` operator to ensure that the query works for custom fields and types. For example if the +field was configured to be `deletedAt` where the type is `DateTime` when deleted and `null` when not deleted then the +query would be: + +```typescript +await client.post.findMany({ + where: { + id: 1, + comments: { + every: { + OR: [{ deletedAt: { not: null } }, { content: "foo" }], + }, + }, + }, +}); +``` + +#### Explicitly Querying Soft Deleted Records + +It is possible to explicitly query soft deleted records by setting the configured field in the `where` object. For +example the following will include deleted records in the results: + +```typescript +await client.comment.findMany({ + where: { + deleted: true, + }, +}); +``` + +It is also possible to explicitly query soft deleted records through relationships in the `where` object. For example +the following will also not be modified: + +```typescript +await client.post.findMany({ + where: { + comments: { + some: { + deleted: true, + }, + }, + }, +}); +``` + +### Including or Selecting Soft Deleted Records + +When using `include` or `select` the extension will modify the `include` and `select` objects passed to exclude soft +deleted records. + +#### Including or Selecting toMany Relations + +When using `include` or `select` on a toMany relationship the extension will modify the where object to exclude soft +deleted records from the query, so for: + +```typescript +await client.post.findMany({ + where: { + id: 1, + }, + include: { + comments: true, + }, +}); +``` + +If the Comment model was configured to be soft deleted the extension would modify the `include` action where object to +exclude soft deleted records, so the query would be: + +```typescript +await client.post.findMany({ + where: { + id: 1, + }, + include: { + comments: { + where: { + deleted: false, + }, + }, + }, +}); +``` + +The same applies for `select`: + +```typescript +await client.post.findMany({ + where: { + id: 1, + }, + select: { + comments: true, + }, +}); +``` + +This also works for nested includes and selects: + +```typescript +await client.user.findMany({ + where: { + id: 1, + }, + include: { + posts: { + select: { + comments: { + where: { + content: "foo", + }, + }, + }, + }, + }, +}); +``` + +The extension would modify the query to: + +```typescript +await client.user.findMany({ + where: { + id: 1, + }, + include: { + posts: { + select: { + comments: { + where: { + deleted: false, + content: "foo", + }, + }, + }, + }, + }, +}); +``` + +#### Including or Selecting toOne Relations + +Records included through a toOne relation are also excluded, however there is no way to explicitly include them. For +example the following query: + +```typescript +await client.post.findFirst({ + where: { + id: 1, + }, + include: { + author: true, + }, +}); +``` + +The extension would not modify the query since toOne relations do not support where clauses. Instead the extension +will manually filter results based on the configured deleted field. + +So if the author of the Post was soft deleted the extension would filter the results and remove the author from the +results: + +```typescript +{ + id: 1, + title: "foo", + author: null +} +``` + +When selecting specific fields on a toOne relation the extension will manually add the configured deleted field to the +select object, filter the results and finally strip the deleted field from the results before returning them. + +For example the following query would behave that way: + +```typescript +await client.post.findMany({ + where: { + id: 1, + }, + select: { + author: { + select: { + name: true, + }, + }, + }, +}); +``` + +#### Explicitly Including Soft Deleted Records in toMany Relations + +It is possible to explicitly include soft deleted records in toMany relations by adding the configured deleted field to +the `where` object. For example the following will include deleted records in the results: + +```typescript +await client.post.findMany({ + where: { + id: 1, + }, + include: { + comments: { + where: { + deleted: true, + }, + }, + }, +}); +``` + +## LICENSE + +Apache 2.0 + +[npm]: https://www.npmjs.com/ +[node]: https://nodejs.org +[build-badge]: https://github.com/olivierwilkinson/prisma-extension-soft-delete/workflows/prisma-soft-delete-middleware/badge.svg +[build]: https://github.com/olivierwilkinson/prisma-extension-soft-delete/actions?query=branch%3Amaster+workflow%3Aprisma-extension-soft-delete +[version-badge]: https://img.shields.io/npm/v/prisma-soft-delete-middleware.svg?style=flat-square +[package]: https://www.npmjs.com/package/prisma-soft-delete-middleware +[downloads-badge]: https://img.shields.io/npm/dm/prisma-soft-delete-middleware.svg?style=flat-square +[npmtrends]: http://www.npmtrends.com/prisma-soft-delete-middleware +[license-badge]: https://img.shields.io/npm/l/prisma-soft-delete-middleware.svg?style=flat-square +[license]: https://github.com/olivierwilkinson/prisma-extension-soft-delete/blob/main/LICENSE +[prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square +[prs]: http://makeapullrequest.com +[coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square +[coc]: https://github.com/olivierwilkinson/prisma-extension-soft-delete/blob/main/other/CODE_OF_CONDUCT.md diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8f2a4d1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +version: '3.7' + +services: + postgres: + image: "postgres:latest" + hostname: postgres + user: postgres + restart: always + environment: + - POSTGRES_DATABASE=test + - POSTGRES_PASSWORD=123 + ports: + - '5432:5432' diff --git a/jest.config.e2e.js b/jest.config.e2e.js new file mode 100644 index 0000000..337cd69 --- /dev/null +++ b/jest.config.e2e.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testRegex: "test/e2e/.+\\.test\\.ts$", +}; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..5b4379b --- /dev/null +++ b/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testRegex: ".+\\.test\\.ts$", + setupFilesAfterEnv: ["/test/setup.ts"], +}; diff --git a/jest.config.unit.js b/jest.config.unit.js new file mode 100644 index 0000000..dc8720c --- /dev/null +++ b/jest.config.unit.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testRegex: "test/unit/.+\\.test\\.ts$", +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..c06a287 --- /dev/null +++ b/package.json @@ -0,0 +1,77 @@ +{ + "name": "prisma-extension-soft-delete", + "version": "1.0.0-semantically-released", + "description": "Prisma extension for soft deleting records", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "module": "dist/esm/index.js", + "scripts": { + "build": "npm-run-all build:cjs build:esm", + "build:cjs": "tsc -p tsconfig.build.json", + "build:esm": "tsc -p tsconfig.esm.json", + "test:unit": "prisma generate && jest --config jest.config.unit.js", + "test:e2e": "./test/scripts/run-with-postgres.sh jest --config jest.config.e2e.js --runInBand", + "test": "./test/scripts/run-with-postgres.sh jest --runInBand", + "lint": "eslint ./src --fix --ext .ts", + "typecheck": "npm run build:cjs -- --noEmit && npm run build:esm -- --noEmit", + "validate": "kcd-scripts validate lint,typecheck,test", + "semantic-release": "semantic-release", + "doctoc": "doctoc ." + }, + "files": [ + "dist" + ], + "keywords": [ + "prisma", + "client", + "middleware" + ], + "author": "Olivier Wilkinson", + "license": "Apache-2.0", + "dependencies": { + "prisma-extension-nested-operations": "^1.0.1" + }, + "peerDependencies": { + "@prisma/client": "*" + }, + "devDependencies": { + "@prisma/client": "^5.4.2", + "@types/faker": "^5.5.9", + "@types/jest": "^29.2.5", + "@types/lodash": "^4.14.192", + "@typescript-eslint/eslint-plugin": "^4.14.0", + "@typescript-eslint/parser": "^4.14.0", + "doctoc": "^2.2.0", + "eslint": "^7.6.0", + "faker": "^5.0.0", + "jest": "^29.3.1", + "kcd-scripts": "^5.0.0", + "lodash": "^4.17.21", + "npm-run-all": "^4.1.5", + "prisma": "^5.4.2", + "semantic-release": "^17.0.2", + "ts-jest": "^29.0.3", + "ts-node": "^9.1.1", + "typescript": "^4.1.3" + }, + "repository": { + "type": "git", + "url": "https://github.com/olivierwilkinson/prisma-extension-soft-delete.git" + }, + "release": { + "branches": [ + "main", + { + "name": "prerelease", + "prerelease": true + }, + { + "name": "next", + "prerelease": true + } + ] + }, + "publishConfig": { + "access": "public" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..928adaa --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,59 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + name String + posts Post[] + profileId Int? + profile Profile? @relation(fields: [profileId], references: [id]) + comments Comment[] + deleted Boolean @default(false) + deletedAt DateTime? + + @@unique([name, email]) +} + +model Post { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + published Boolean @default(false) + title String + content String? + author User? @relation(fields: [authorName, authorEmail], references: [name, email]) + authorName String? + authorEmail String? + authorId Int + comments Comment[] + deleted Boolean @default(false) +} + +model Comment { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + content String + author User? @relation(fields: [authorId], references: [id]) + authorId Int? + post Post? @relation(fields: [postId], references: [id]) + postId Int? + repliedTo Comment? @relation("replies", fields: [repliedToId], references: [id]) + repliedToId Int? + replies Comment[] @relation("replies") + deleted Boolean @default(false) +} + +model Profile { + id Int @id @default(autoincrement()) + bio String? + deleted Boolean @default(false) + users User[] +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a906ba5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export * from "./lib/types"; + +export { createSoftDeleteExtension } from "./lib/createSoftDeleteExtension"; diff --git a/src/lib/createSoftDeleteExtension.ts b/src/lib/createSoftDeleteExtension.ts new file mode 100644 index 0000000..c25fe86 --- /dev/null +++ b/src/lib/createSoftDeleteExtension.ts @@ -0,0 +1,169 @@ +import { Prisma } from "@prisma/client"; +import { Prisma as PrismaExtensions } from "@prisma/client/extension"; +import { + NestedOperation, + withNestedOperations, +} from "prisma-extension-nested-operations"; +import { + createAggregateParams, + createCountParams, + createDeleteManyParams, + createDeleteParams, + createFindFirstParams, + createFindFirstOrThrowParams, + createFindManyParams, + createFindUniqueParams, + createFindUniqueOrThrowParams, + createIncludeParams, + createSelectParams, + createUpdateManyParams, + createUpdateParams, + createUpsertParams, + createWhereParams, + createGroupByParams, + CreateParams, +} from "./helpers/createParams"; + +import { Config, ModelConfig } from "./types"; +import { ModifyResult, modifyReadResult } from "./helpers/modifyResult"; + +type ConfigBound = F extends (x: ModelConfig, ...args: infer P) => infer R + ? (...args: P) => R + : never; + +export function createSoftDeleteExtension({ + models, + defaultConfig = { + field: "deleted", + createValue: Boolean, + allowToOneUpdates: false, + allowCompoundUniqueIndexWhere: false, + }, +}: Config) { + if (!defaultConfig.field) { + throw new Error( + "prisma-extension-soft-delete: defaultConfig.field is required" + ); + } + if (!defaultConfig.createValue) { + throw new Error( + "prisma-extension-soft-delete: defaultConfig.createValue is required" + ); + } + + const modelConfig: Partial> = {}; + + Object.keys(models).forEach((model) => { + const modelName = model as Prisma.ModelName; + const config = models[modelName]; + if (config) { + modelConfig[modelName] = + typeof config === "boolean" && config ? defaultConfig : config; + } + }); + + const createParamsByModel = Object.keys(modelConfig).reduce< + Record | undefined>> + >((acc, model) => { + const config = modelConfig[model as Prisma.ModelName]!; + return { + ...acc, + [model]: { + delete: createDeleteParams.bind(null, config), + deleteMany: createDeleteManyParams.bind(null, config), + update: createUpdateParams.bind(null, config), + updateMany: createUpdateManyParams.bind(null, config), + upsert: createUpsertParams.bind(null, config), + findFirst: createFindFirstParams.bind(null, config), + findFirstOrThrow: createFindFirstOrThrowParams.bind(null, config), + findUnique: createFindUniqueParams.bind(null, config), + findUniqueOrThrow: createFindUniqueOrThrowParams.bind(null, config), + findMany: createFindManyParams.bind(null, config), + count: createCountParams.bind(null, config), + aggregate: createAggregateParams.bind(null, config), + where: createWhereParams.bind(null, config), + include: createIncludeParams.bind(null, config), + select: createSelectParams.bind(null, config), + groupBy: createGroupByParams.bind(null, config), + }, + }; + }, {}); + + const modifyResultByModel = Object.keys(modelConfig).reduce< + Record | undefined>> + >((acc, model) => { + const config = modelConfig[model as Prisma.ModelName]!; + return { + ...acc, + [model]: { + include: modifyReadResult.bind(null, config), + select: modifyReadResult.bind(null, config), + }, + }; + }, {}); + + // before handling root params generate deleted value so it is consistent + // for the query. Add it to root params and get it from scope? + + return PrismaExtensions.defineExtension((client) => { + return client.$extends({ + query: { + $allModels: { + // @ts-expect-error - we don't know what the client is + $allOperations: withNestedOperations({ + async $rootOperation(initialParams) { + const createParams = + createParamsByModel[initialParams.model || ""]?.[ + initialParams.operation + ]; + + if (!createParams) return initialParams.query(initialParams.args); + + const { params, ctx } = createParams(initialParams); + const { model } = params; + + const operationChanged = + params.operation !== initialParams.operation; + + const result = operationChanged + ? // @ts-expect-error - we don't know what the client is + await client[model[0].toLowerCase() + model.slice(1)][ + params.operation + ](params.args) + : await params.query(params.args); + + const modifyResult = + modifyResultByModel[params.model || ""]?.[params.operation]; + + if (!modifyResult) return result; + + return modifyResult(result, params, ctx); + }, + async $allNestedOperations(initialParams) { + const createParams = + createParamsByModel[initialParams.model || ""]?.[ + initialParams.operation + ]; + + if (!createParams) return initialParams.query(initialParams.args); + + const { params, ctx } = createParams(initialParams); + + const result = await params.query( + params.args, + params.operation as NestedOperation + ); + + const modifyResult = + modifyResultByModel[params.model || ""]?.[params.operation]; + + if (!modifyResult) return result; + + return modifyResult(result, params, ctx); + }, + }), + }, + }, + }); + }); +} diff --git a/src/lib/helpers/createParams.ts b/src/lib/helpers/createParams.ts new file mode 100644 index 0000000..2dc152b --- /dev/null +++ b/src/lib/helpers/createParams.ts @@ -0,0 +1,448 @@ +import { Prisma } from "@prisma/client"; +import { NestedParams } from "prisma-extension-nested-operations"; + +import { ModelConfig } from "../types"; +import { addDeletedToSelect } from "../utils/nestedReads"; + +const uniqueFieldsByModel: Record = {}; +const uniqueIndexFieldsByModel: Record = {}; + +Prisma.dmmf.datamodel.models.forEach((model) => { + // add unique fields derived from indexes + const uniqueIndexFields: string[] = []; + model.uniqueFields.forEach((field) => { + uniqueIndexFields.push(field.join("_")); + }); + uniqueIndexFieldsByModel[model.name] = uniqueIndexFields; + + // add id field and unique fields from @unique decorator + const uniqueFields: string[] = []; + model.fields.forEach((field) => { + if (field.isId || field.isUnique) { + uniqueFields.push(field.name); + } + }); + uniqueFieldsByModel[model.name] = uniqueFields; +}); + +export type Params = Omit, "operation"> & { + operation: string; +}; + +export type CreateParamsReturn = { + params: Params; + ctx?: any; +}; + +export type CreateParams = ( + config: ModelConfig, + params: Params +) => CreateParamsReturn; + +export const createDeleteParams: CreateParams = ( + { field, createValue }, + params +) => { + if ( + !params.model || + // do nothing for delete: false + (typeof params.args === "boolean" && !params.args) || + // do nothing for root delete without where to allow Prisma to throw + (!params.scope && !params.args?.where) + ) { + return { + params, + }; + } + + if (typeof params.args === "boolean") { + return { + params: { + ...params, + operation: "update", + args: { + __passUpdateThrough: true, + [field]: createValue(true), + }, + }, + }; + } + + return { + params: { + ...params, + operation: "update", + args: { + where: params.args?.where || params.args, + data: { + [field]: createValue(true), + }, + }, + }, + }; +}; + +export const createDeleteManyParams: CreateParams = (config, params) => { + if (!params.model) return { params }; + + const where = params.args?.where || params.args; + + return { + params: { + ...params, + operation: "updateMany", + args: { + where: { + ...where, + [config.field]: config.createValue(false), + }, + data: { + [config.field]: config.createValue(true), + }, + }, + }, + }; +}; + +export const createUpdateParams: CreateParams = (config, params) => { + if ( + params.scope?.relations && + !params.scope.relations.to.isList && + !config.allowToOneUpdates && + !params.args?.__passUpdateThrough + ) { + throw new Error( + `prisma-extension-soft-delete: 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.` + ); + } + + // remove __passUpdateThrough from args + if (params.args?.__passUpdateThrough) { + delete params.args.__passUpdateThrough; + } + + return { params }; +}; + +export const createUpdateManyParams: CreateParams = (config, params) => { + // do nothing if args are not defined to allow Prisma to throw an error + if (!params.args) return { params }; + + return { + params: { + ...params, + args: { + ...params.args, + where: { + ...params.args?.where, + // allow overriding the deleted field in where + [config.field]: + params.args?.where?.[config.field] || config.createValue(false), + }, + }, + }, + }; +}; + +export const createUpsertParams: CreateParams = (_, params) => { + if (params.scope?.relations && !params.scope.relations.to.isList) { + throw new Error( + `prisma-extension-soft-delete: 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.` + ); + } + + return { params }; +}; + +function validateFindUniqueParams( + params: Params, + config: ModelConfig +): void { + const uniqueIndexFields = uniqueIndexFieldsByModel[params.model || ""] || []; + const uniqueIndexField = Object.keys(params.args?.where || {}).find((key) => + uniqueIndexFields.includes(key) + ); + + // when unique index field is found it is not possible to use findFirst. + // Instead warn the user that soft-deleted models will not be excluded from + // this query unless warnForUniqueIndexes is false. + if (uniqueIndexField && !config.allowCompoundUniqueIndexWhere) { + throw new Error( + `prisma-extension-soft-delete: 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.` + ); + } +} + +function shouldPassFindUniqueParamsThrough( + params: Params, + config: ModelConfig +): boolean { + const uniqueFields = uniqueFieldsByModel[params.model || ""] || []; + const uniqueIndexFields = uniqueIndexFieldsByModel[params.model || ""] || []; + const uniqueIndexField = Object.keys(params.args?.where || {}).find((key) => + uniqueIndexFields.includes(key) + ); + + // pass through invalid args so Prisma throws an error + return ( + // findUnique must have a where object + !params.args?.where || + typeof params.args.where !== "object" || + // where object must have at least one defined unique field + !Object.entries(params.args.where).some( + ([key, val]) => + (uniqueFields.includes(key) || uniqueIndexFields.includes(key)) && + typeof val !== "undefined" + ) || + // pass through if where object has a unique index field and allowCompoundUniqueIndexWhere is true + !!(uniqueIndexField && config.allowCompoundUniqueIndexWhere) + ); +} + +export const createFindUniqueParams: CreateParams = (config, params) => { + if (shouldPassFindUniqueParamsThrough(params, config)) { + return { params }; + } + + validateFindUniqueParams(params, config); + + return { + params: { + ...params, + operation: "findFirst", + args: { + ...params.args, + where: { + ...params.args?.where, + [config.field]: config.createValue(false), + }, + }, + }, + }; +}; + +export const createFindUniqueOrThrowParams: CreateParams = (config, params) => { + if (shouldPassFindUniqueParamsThrough(params, config)) { + return { params }; + } + + validateFindUniqueParams(params, config); + + return { + params: { + ...params, + operation: "findFirstOrThrow", + args: { + ...params.args, + where: { + ...params.args?.where, + [config.field]: config.createValue(false), + }, + }, + }, + }; +}; + +export const createFindFirstParams: CreateParams = (config, params) => { + return { + params: { + ...params, + operation: "findFirst", + args: { + ...params.args, + where: { + ...params.args?.where, + // allow overriding the deleted field in where + [config.field]: + params.args?.where?.[config.field] || config.createValue(false), + }, + }, + }, + }; +}; + +export const createFindFirstOrThrowParams: CreateParams = (config, params) => { + return { + params: { + ...params, + operation: "findFirstOrThrow", + args: { + ...params.args, + where: { + ...params.args?.where, + // allow overriding the deleted field in where + [config.field]: + params.args?.where?.[config.field] || config.createValue(false), + }, + }, + }, + }; +}; + +export const createFindManyParams: CreateParams = (config, params) => { + return { + params: { + ...params, + operation: "findMany", + args: { + ...params.args, + where: { + ...params.args?.where, + // allow overriding the deleted field in where + [config.field]: + params.args?.where?.[config.field] || config.createValue(false), + }, + }, + }, + }; +}; + +/*GroupBy */ +export const createGroupByParams: CreateParams = (config, params) => { + return { + params: { + ...params, + operation: "groupBy", + args: { + ...params.args, + where: { + ...params.args?.where, + // allow overriding the deleted field in where + [config.field]: + params.args?.where?.[config.field] || config.createValue(false), + }, + }, + }, + }; +}; + +export const createCountParams: CreateParams = (config, params) => { + const args = params.args || {}; + const where = args.where || {}; + + return { + params: { + ...params, + args: { + ...args, + where: { + ...where, + // allow overriding the deleted field in where + [config.field]: where[config.field] || config.createValue(false), + }, + }, + }, + }; +}; + +export const createAggregateParams: CreateParams = (config, params) => { + const args = params.args || {}; + const where = args.where || {}; + + return { + params: { + ...params, + args: { + ...args, + where: { + ...where, + // allow overriding the deleted field in where + [config.field]: where[config.field] || config.createValue(false), + }, + }, + }, + }; +}; + +export const createWhereParams: CreateParams = (config, params) => { + if (!params.scope) return { params }; + + // customise list queries with every modifier unless the deleted field is set + if (params.scope?.modifier === "every" && !params.args[config.field]) { + return { + params: { + ...params, + args: { + OR: [ + { [config.field]: { not: config.createValue(false) } }, + params.args, + ], + }, + }, + }; + } + + return { + params: { + ...params, + args: { + ...params.args, + [config.field]: params.args[config.field] || config.createValue(false), + }, + }, + }; +}; + +export const createIncludeParams: CreateParams = (config, params) => { + // includes of toOne relation cannot filter deleted records using params + // instead ensure that the deleted field is selected and filter the results + if (params.scope?.relations?.to.isList === false) { + if (params.args?.select && !params.args?.select[config.field]) { + return { + params: addDeletedToSelect(params, config), + ctx: { deletedFieldAdded: true }, + }; + } + + return { params }; + } + + return { + params: { + ...params, + args: { + ...params.args, + where: { + ...params.args?.where, + // allow overriding the deleted field in where + [config.field]: + params.args?.where?.[config.field] || config.createValue(false), + }, + }, + }, + }; +}; + +export const createSelectParams: CreateParams = (config, params) => { + // selects in includes are handled by createIncludeParams + if (params.scope?.parentParams.operation === "include") { + return { params }; + } + + // selects of toOne relation cannot filter deleted records using params + if (params.scope?.relations?.to.isList === false) { + if (params.args?.select && !params.args.select[config.field]) { + return { + params: addDeletedToSelect(params, config), + ctx: { deletedFieldAdded: true }, + }; + } + + return { params }; + } + + return { + params: { + ...params, + args: { + ...params.args, + where: { + ...params.args?.where, + // allow overriding the deleted field in where + [config.field]: + params.args?.where?.[config.field] || config.createValue(false), + }, + }, + }, + }; +}; diff --git a/src/lib/helpers/modifyResult.ts b/src/lib/helpers/modifyResult.ts new file mode 100644 index 0000000..feffe1f --- /dev/null +++ b/src/lib/helpers/modifyResult.ts @@ -0,0 +1,33 @@ +import { ModelConfig } from "../types"; +import { stripDeletedFieldFromResults } from "../utils/nestedReads"; +import { + filterSoftDeletedResults, + shouldFilterDeletedFromReadResult, +} from "../utils/resultFiltering"; +import { CreateParamsReturn } from "./createParams"; + +export type ModifyResult = ( + config: ModelConfig, + result: any, + params: CreateParamsReturn["params"], + ctx?: any +) => any; + +export function modifyReadResult( + config: ModelConfig, + result: any, + params: CreateParamsReturn["params"], + ctx?: any +): CreateParamsReturn { + if (shouldFilterDeletedFromReadResult(params, config)) { + const filteredResults = filterSoftDeletedResults(result, config); + + if (ctx?.deletedFieldAdded) { + stripDeletedFieldFromResults(filteredResults, config); + } + + return filteredResults; + } + + return result; +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..d9cc571 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,14 @@ +import { Prisma } from "@prisma/client"; + +export type ModelConfig = { + field: string; + createValue: (deleted: boolean) => any; + allowToOneUpdates?: boolean; + allowCompoundUniqueIndexWhere?: boolean; +}; + +export type Config = { + models: Partial>; + defaultConfig?: ModelConfig; + client?: any; +}; \ No newline at end of file diff --git a/src/lib/utils/nestedReads.ts b/src/lib/utils/nestedReads.ts new file mode 100644 index 0000000..61b0e97 --- /dev/null +++ b/src/lib/utils/nestedReads.ts @@ -0,0 +1,36 @@ +import { ModelConfig } from "../types"; + +export function addDeletedToSelect( + params: T, + config: ModelConfig +): T { + if (params.args.select && !params.args.select[config.field]) { + return { + ...params, + args: { + ...params.args, + select: { + ...params.args.select, + [config.field]: true, + }, + }, + }; + } + + return params; +} + +export function stripDeletedFieldFromResults( + results: any, + config: ModelConfig +) { + if (Array.isArray(results)) { + results?.forEach((item: any) => { + delete item[config.field]; + }); + } else if (results) { + delete results[config.field]; + } + + return results; +} diff --git a/src/lib/utils/resultFiltering.ts b/src/lib/utils/resultFiltering.ts new file mode 100644 index 0000000..7131cef --- /dev/null +++ b/src/lib/utils/resultFiltering.ts @@ -0,0 +1,27 @@ +import { ModelConfig } from "../types"; + +// Maybe this should return true for non-list relations only? +export function shouldFilterDeletedFromReadResult( + params: { args: any }, + config: ModelConfig +): boolean { + return ( + !params.args.where || + typeof params.args.where[config.field] === "undefined" || + !params.args.where[config.field] + ); +} + +export function filterSoftDeletedResults(result: any, config: ModelConfig) { + // filter out deleted records from array results + if (result && Array.isArray(result)) { + return result.filter((item) => !item[config.field]); + } + + // if the result is deleted return null + if (result && result[config.field]) { + return null; + } + + return result; +} diff --git a/test/e2e/client.ts b/test/e2e/client.ts new file mode 100644 index 0000000..2c3e98f --- /dev/null +++ b/test/e2e/client.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from "@prisma/client"; + +export default new PrismaClient(); diff --git a/test/e2e/deletedAt.test.ts b/test/e2e/deletedAt.test.ts new file mode 100644 index 0000000..5bdd320 --- /dev/null +++ b/test/e2e/deletedAt.test.ts @@ -0,0 +1,175 @@ +import { PrismaClient, Profile, User } from "@prisma/client"; +import faker from "faker"; + +import { createSoftDeleteExtension } from "../../src"; +import client from "./client"; + +describe("deletedAt", () => { + let testClient: any; + let profile: Profile; + let user: User; + + beforeAll(async () => { + testClient = new PrismaClient(); + testClient = testClient.$extends( + createSoftDeleteExtension( + { + models: { + User: { + field: "deletedAt", + createValue: (deleted) => { + return deleted ? new Date() : null; + }, + }, + }, + }, + ) + ); + + profile = await client.profile.create({ + data: { + bio: "foo", + }, + }); + user = await client.user.create({ + data: { + email: faker.internet.email(), + name: faker.name.findName(), + profileId: profile.id, + comments: { + create: [ + { content: "foo" }, + { content: "foo", deleted: true }, + { content: "bar", deleted: true }, + ], + }, + }, + }); + }); + afterEach(async () => { + // restore soft deleted user + await client.user.update({ + where: { id: user.id }, + data: { + deletedAt: null, + }, + }); + }); + afterAll(async () => { + // disconnect test client + await testClient.$disconnect(); + + // delete user and related data + await client.user.update({ + where: { id: user.id }, + data: { + comments: { deleteMany: {} }, + profile: { delete: true }, + }, + }); + await client.user.deleteMany({ where: {} }); + }); + + it("soft deletes when using delete", async () => { + await testClient.user.delete({ + where: { id: user.id }, + }); + + const softDeletedUser = await testClient.user.findFirst({ + where: { id: user.id }, + }); + expect(softDeletedUser).toBeNull(); + + const dbUser = await client.user.findFirst({ + where: { id: user.id }, + }); + expect(dbUser).not.toBeNull(); + expect(dbUser?.deletedAt).not.toBeNull(); + expect(dbUser?.deletedAt).toBeInstanceOf(Date); + }); + + it("soft deletes when using deleteMany", async () => { + await testClient.user.deleteMany({ + where: { id: user.id }, + }); + + const softDeletedUser = await testClient.user.findFirst({ + where: { id: user.id }, + }); + expect(softDeletedUser).toBeNull(); + + const dbUser = await client.user.findFirst({ + where: { id: user.id }, + }); + expect(dbUser).not.toBeNull(); + expect(dbUser?.deletedAt).not.toBeNull(); + expect(dbUser?.deletedAt).toBeInstanceOf(Date); + }); + + it("excludes deleted when filtering with where", async () => { + // soft delete user + await client.user.update({ + where: { id: user.id }, + data: { deletedAt: new Date() }, + }); + + const comment = await testClient.comment.findFirst({ + where: { + author: { + id: user.id, + }, + }, + }); + + expect(comment).toBeNull(); + }); + + it("excludes deleted when filtering with where through 'some' modifier", async () => { + // soft delete user + await client.user.update({ + where: { id: user.id }, + data: { deletedAt: new Date() }, + }); + + const userProfile = await testClient.profile.findFirst({ + where: { + users: { + some: { + id: user.id, + }, + }, + }, + }); + + expect(userProfile).toBeNull(); + }); + + it("excludes deleted when filtering with where through 'every' modifier", async () => { + // soft delete user + await client.user.update({ + where: { id: user.id }, + data: { deletedAt: new Date() }, + }); + + // add another user to profile + await client.user.create({ + data: { + email: faker.internet.email(), + name: faker.name.findName(), + profileId: profile.id, + }, + }); + + const userProfile = await testClient.profile.findFirst({ + where: { + users: { + every: { + id: user.id, + }, + }, + }, + }); + + expect(userProfile).toBeNull(); + }); +}); diff --git a/test/e2e/nestedReads.test.ts b/test/e2e/nestedReads.test.ts new file mode 100644 index 0000000..a36065d --- /dev/null +++ b/test/e2e/nestedReads.test.ts @@ -0,0 +1,429 @@ +import { PrismaClient, User } from "@prisma/client"; +import faker from "faker"; + +import { createSoftDeleteExtension } from "../../src"; +import client from "./client"; + +describe("nested reads", () => { + let testClient: any; + let user: User; + + beforeAll(async () => { + testClient = new PrismaClient(); + testClient = testClient.$extends( + createSoftDeleteExtension( + { models: { Comment: true, Profile: true } }, + // @ts-expect-error - we don't know what the client is + { client: testClient } + ) + ); + + user = await client.user.create({ + data: { + email: faker.internet.email(), + name: faker.name.findName(), + profile: { create: { bio: "foo" } }, + }, + }); + + await client.comment.create({ + data: { + author: { connect: { id: user.id } }, + content: "foo", + replies: { + create: [ + { content: "baz" }, + { content: "baz", deleted: true }, + { content: "qux", deleted: true }, + ], + }, + post: { + create: { + title: "foo-comment-post-title", + authorId: user.id, + author: { + connect: { id: user.id }, + }, + }, + }, + }, + }); + + await client.comment.create({ + data: { + author: { connect: { id: user.id } }, + content: "bar", + deleted: true, + post: { + create: { + title: "bar-comment-post-title", + authorId: user.id, + author: { + connect: { id: user.id }, + }, + }, + }, + }, + }); + }); + afterEach(async () => { + // restore soft deleted profile + await client.profile.updateMany({ + where: {}, + data: { deleted: false }, + }); + }); + afterAll(async () => { + await testClient.$disconnect(); + + await client.user.deleteMany({ where: {} }); + await client.comment.deleteMany({ where: {} }); + await client.profile.deleteMany({ where: {} }); + }); + + describe("include", () => { + it("excludes deleted when including toMany relation", async () => { + const { comments } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + comments: true, + }, + }); + + expect(comments).toHaveLength(1); + expect(comments[0].content).toEqual("foo"); + }); + + it("excludes deleted when including toOne relation", async () => { + const { + profile: nonDeletedProfile, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + profile: true, + }, + }); + + expect(nonDeletedProfile).not.toBeNull(); + expect(nonDeletedProfile!.bio).toEqual("foo"); + + // soft delete profiles + await client.profile.updateMany({ + where: {}, + data: { + deleted: true, + }, + }); + + const { + profile: softDeletedProfile, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + profile: true, + }, + }); + + expect(softDeletedProfile).toBeNull(); + }); + + it("excludes deleted when deeply including relations", async () => { + const { + comments: nonDeletedComments, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + comments: { + include: { + post: true, + replies: true, + }, + }, + }, + }); + + expect(nonDeletedComments).toHaveLength(1); + expect(nonDeletedComments[0].content).toEqual("foo"); + + expect(nonDeletedComments[0].replies).toHaveLength(1); + expect(nonDeletedComments[0].replies[0].content).toEqual("baz"); + }); + + it("excludes deleted when including fields with where", async () => { + const { + comments: nonDeletedComments, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + comments: { + where: { + content: "bar", + }, + }, + }, + }); + + expect(nonDeletedComments).toHaveLength(0); + }); + + it("excludes deleted when including fields using where that targets soft-deleted model", async () => { + const { posts } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + posts: { + where: { + comments: { + some: { + content: { + in: ["foo", "bar"], + }, + }, + }, + }, + }, + }, + }); + + expect(posts).toHaveLength(1); + }); + }); + + describe("select", () => { + it("excludes deleted when selecting toMany relation", async () => { + const { comments } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { + comments: true, + }, + }); + + expect(comments).toHaveLength(1); + expect(comments[0].content).toEqual("foo"); + }); + + it("excludes deleted when selecting toOne relation", async () => { + const { + profile: nonDeletedProfile, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { + profile: true, + }, + }); + + expect(nonDeletedProfile).not.toBeNull(); + expect(nonDeletedProfile!.bio).toEqual("foo"); + + // soft delete profiles + await client.profile.updateMany({ + where: {}, + data: { + deleted: true, + }, + }); + + const { + profile: softDeletedProfile, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + profile: true, + }, + }); + + expect(softDeletedProfile).toBeNull(); + }); + + it("excludes deleted when deeply including relations", async () => { + const { + comments: nonDeletedComments, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { + comments: { + select: { + content: true, + post: true, + replies: true, + }, + }, + }, + }); + + expect(nonDeletedComments).toHaveLength(1); + expect(nonDeletedComments[0].content).toEqual("foo"); + + expect(nonDeletedComments[0].replies).toHaveLength(1); + expect(nonDeletedComments[0].replies[0].content).toEqual("baz"); + }); + + it("excludes deleted when selecting fields through an include", async () => { + const { + comments: nonDeletedComments, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + comments: { + select: { + content: true, + post: true, + replies: true, + }, + }, + }, + }); + + expect(nonDeletedComments).toHaveLength(1); + expect(nonDeletedComments[0].content).toEqual("foo"); + + expect(nonDeletedComments[0].replies).toHaveLength(1); + expect(nonDeletedComments[0].replies[0].content).toEqual("baz"); + }); + + it("excludes deleted when selecting fields with where", async () => { + const { + comments: nonDeletedComments, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { + comments: { + where: { + content: "bar", + }, + }, + }, + }); + + expect(nonDeletedComments).toHaveLength(0); + }); + + it("excludes deleted when selecting fields with where through an include", async () => { + const { + comments: nonDeletedComments, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + comments: { + select: { + replies: { + where: { + content: "qux", + }, + }, + }, + }, + }, + }); + + expect(nonDeletedComments).toHaveLength(1); + expect(nonDeletedComments[0].replies).toHaveLength(0); + }); + + it("excludes deleted when selecting fields using where that targets soft-deleted model", async () => { + const { posts } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + posts: { + where: { + comments: { + some: { + content: { + in: ["foo", "bar"], + }, + }, + }, + }, + }, + }, + }); + + expect(posts).toHaveLength(1); + }); + + it("excludes deleted when including relation and filtering by every", async () => { + const { comments } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + comments: { + where: { + replies: { + every: { + content: { + in: ["baz", "qux"], + }, + }, + }, + }, + }, + }, + }); + + expect(comments).toHaveLength(1); + }); + + it("excludes deleted when including relation and filtering by every in a NOT", async () => { + const { comments } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + include: { + comments: { + where: { + NOT: { + replies: { + every: { + content: { + in: ["baz", "qux"], + }, + }, + }, + }, + }, + }, + }, + }); + + expect(comments).toHaveLength(0); + }); + + it("excludes deleted selected toOne relations even when deleted field not selected", async () => { + const { profile } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { + profile: { + select: { + id: true, + bio: true, + }, + }, + }, + }); + + expect(profile).not.toBeNull(); + + expect(profile!.deleted).toBeUndefined(); + + // soft delete profile + await client.profile.update({ + where: { id: profile!.id }, + data: { + deleted: true, + }, + }); + + const { + profile: softDeletedProfile, + } = await testClient.user.findUniqueOrThrow({ + where: { id: user.id }, + select: { + profile: { + select: { + id: true, + bio: true, + }, + }, + }, + }); + + expect(softDeletedProfile).toBeNull(); + }); + }); +}); diff --git a/test/e2e/queries.test.ts b/test/e2e/queries.test.ts new file mode 100644 index 0000000..df5a9e1 --- /dev/null +++ b/test/e2e/queries.test.ts @@ -0,0 +1,747 @@ +import { Comment, PrismaClient, Profile, User } from "@prisma/client"; +import faker from "faker"; + +import { createSoftDeleteExtension } from "../../src"; +import client from "./client"; + +describe("queries", () => { + let testClient: any; + let profile: Profile; + let firstUser: User; + let secondUser: User; + let deletedUser: User; + let comment: Comment; + + beforeAll(async () => { + testClient = new PrismaClient(); + testClient = testClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + profile = await client.profile.create({ + data: { + bio: faker.lorem.sentence(), + }, + }); + firstUser = await client.user.create({ + data: { + email: faker.internet.email(), + name: "Jack", + profileId: profile.id, + }, + }); + secondUser = await client.user.create({ + data: { + email: faker.internet.email(), + name: "John", + }, + }); + deletedUser = await client.user.create({ + data: { + email: faker.internet.email(), + name: "Jill", + deleted: true, + profileId: profile.id, + }, + }); + comment = await client.comment.create({ + data: { + content: faker.lorem.sentence(), + authorId: firstUser.id, + }, + }); + }); + afterEach(async () => { + await Promise.all([ + // reset starting data + client.profile.update({ where: { id: profile.id }, data: profile }), + client.user.update({ where: { id: deletedUser.id }, data: deletedUser }), + client.user.update({ where: { id: firstUser.id }, data: firstUser }), + client.user.update({ where: { id: secondUser.id }, data: secondUser }), + client.comment.update({ where: { id: comment.id }, data: comment }), + + // delete created models + client.profile.deleteMany({ + where: { id: { not: { in: [profile.id] } } }, + }), + client.user.deleteMany({ + where: { + id: { not: { in: [firstUser.id, secondUser.id, deletedUser.id] } }, + }, + }), + client.comment.deleteMany({ + where: { id: { not: { in: [comment.id] } } }, + }), + ]); + }); + afterAll(async () => { + await testClient.$disconnect(); + await client.user.deleteMany({ where: {} }); + }); + + describe("delete", () => { + it("delete soft deletes", async () => { + const result = await testClient.user.delete({ + where: { id: firstUser.id }, + }); + expect(result).not.toBeNull(); + + const dbUser = await client.user.findUnique({ + where: { id: firstUser.id }, + }); + expect(dbUser).not.toBeNull(); + expect(dbUser!.id).toEqual(firstUser.id); + expect(dbUser?.deleted).toBe(true); + }); + + it("nested delete soft deletes", async () => { + const result = await testClient.profile.update({ + where: { id: profile.id }, + data: { + users: { + delete: { + id: firstUser.id, + }, + }, + }, + }); + expect(result).not.toBeNull(); + + const dbUser = await client.user.findUniqueOrThrow({ + where: { id: firstUser.id }, + }); + expect(dbUser.deleted).toBe(true); + }); + }); + + describe("deleteMany", () => { + it("deleteMany soft deletes", async () => { + const result = await testClient.user.deleteMany({ + where: { name: { contains: "J" } }, + }); + expect(result).not.toBeNull(); + expect(result.count).toEqual(2); + + const dbUsers = await client.user.findMany({ + where: { name: { contains: "J" } }, + }); + expect(dbUsers).toHaveLength(3); + expect(dbUsers.map(({ id }) => id).sort()).toEqual( + [firstUser.id, secondUser.id, deletedUser.id].sort() + ); + expect(dbUsers.every(({ deleted }) => deleted)).toBe(true); + }); + + it("nested deleteMany soft deletes", async () => { + const result = await testClient.profile.update({ + where: { id: profile.id }, + data: { + users: { + deleteMany: { + name: { contains: "J" }, + }, + }, + }, + }); + expect(result).not.toBeNull(); + + const dbUsers = await client.user.findMany({ + where: { name: { contains: "J" }, deleted: true }, + }); + expect(dbUsers).toHaveLength(2); + expect(dbUsers.map(({ id }) => id).sort()).toEqual( + [firstUser.id, deletedUser.id].sort() + ); + }); + }); + + describe("updateMany", () => { + it("updateMany excludes soft deleted records", async () => { + const result = await testClient.user.updateMany({ + where: { name: { contains: "J" } }, + data: { name: "Updated" }, + }); + expect(result).not.toBeNull(); + expect(result.count).toEqual(2); + + const updatedDbUsers = await client.user.findMany({ + where: { name: { contains: "Updated" } }, + }); + expect(updatedDbUsers).toHaveLength(2); + expect(updatedDbUsers.map(({ id }) => id).sort()).toEqual( + [firstUser.id, secondUser.id].sort() + ); + expect(updatedDbUsers.every(({ deleted }) => !deleted)).toBe(true); + expect(updatedDbUsers.every(({ name }) => name === "Updated")).toBe(true); + + const deletedDbUser = await client.user.findUniqueOrThrow({ + where: { id: deletedUser.id }, + }); + expect(deletedDbUser.name).toEqual(deletedUser.name); + }); + + it("nested updateMany excludes soft deleted records", async () => { + const result = await testClient.profile.update({ + where: { id: profile.id }, + data: { + users: { + updateMany: { + where: { name: { contains: "J" } }, + data: { name: "Updated" }, + }, + }, + }, + }); + expect(result).not.toBeNull(); + + const dbUpdatedUser = await client.user.findUniqueOrThrow({ + where: { id: firstUser.id }, + }); + expect(dbUpdatedUser.name).toEqual("Updated"); + + const dbDeletedUser = await client.user.findUniqueOrThrow({ + where: { id: deletedUser.id }, + }); + expect(dbDeletedUser.name).toEqual(deletedUser.name); + }); + }); + + describe("update", () => { + it("update does not exclude soft deleted records", async () => { + const result = await testClient.user.update({ + where: { id: deletedUser.id }, + data: { name: "Updated Jill" }, + }); + expect(result).not.toBeNull(); + expect(result.name).toEqual("Updated Jill"); + + const dbUser = await client.user.findUniqueOrThrow({ + where: { id: deletedUser.id }, + }); + expect(dbUser.name).toEqual("Updated Jill"); + }); + + it("nested toMany update does not exclude soft deleted records", async () => { + const result = await testClient.user.update({ + where: { id: firstUser.id }, + data: { + comments: { + updateMany: { + where: { id: comment.id }, + data: { content: "Updated" }, + }, + }, + }, + }); + expect(result).not.toBeNull(); + + const dbComment = await client.comment.findUniqueOrThrow({ + where: { id: comment.id }, + }); + expect(dbComment.content).toEqual("Updated"); + }); + + it("nested toOne update throws by default", async () => { + await expect( + testClient.comment.update({ + where: { id: comment.id }, + data: { + author: { + update: { + name: "Updated", + }, + }, + }, + }) + ).rejects.toThrowError( + `prisma-extension-soft-delete: 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.` + ); + }); + }); + + describe("upsert", () => { + it("upsert does not exclude soft deleted records", async () => { + const result = await testClient.user.upsert({ + where: { id: deletedUser.id }, + create: { email: faker.internet.email(), name: "New User" }, + update: { name: "Updated" }, + }); + expect(result).not.toBeNull(); + expect(result.name).toEqual("Updated"); + + const dbUser = await client.user.findUniqueOrThrow({ + where: { id: deletedUser.id }, + }); + expect(dbUser.name).toEqual("Updated"); + }); + + it("nested toMany upsert does not exclude soft deleted records", async () => { + const result = await testClient.profile.update({ + where: { id: profile.id }, + data: { + users: { + upsert: { + where: { id: deletedUser.id }, + create: { email: faker.internet.email(), name: "New User" }, + update: { name: "Updated" }, + }, + }, + }, + }); + expect(result).not.toBeNull(); + + const dbUser = await client.user.findUniqueOrThrow({ + where: { id: deletedUser.id }, + }); + expect(dbUser.name).toEqual("Updated"); + }); + + it("nested toOne upsert throws by default", async () => { + await expect( + testClient.comment.update({ + where: { id: comment.id }, + data: { + author: { + upsert: { + create: { email: faker.internet.email(), name: "New User" }, + update: { name: "Updated" }, + }, + }, + }, + }) + ).rejects.toThrowError( + `prisma-extension-soft-delete: 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.` + ); + }); + }); + + describe("findFirst", () => { + it("findFirst excludes soft deleted records", async () => { + const foundUser = await testClient.user.findFirst({ + where: { email: firstUser.email }, + }); + + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(firstUser.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { email: deletedUser.email }, + }); + expect(notFoundUser).toBeNull(); + }); + }); + + describe("findFirstOrThrow", () => { + it("findFirstOrThrow throws for soft deleted records", async () => { + const foundUser = await testClient.user.findFirstOrThrow({ + where: { email: firstUser.email }, + }); + + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(firstUser.id); + + await expect(() => + testClient.user.findFirstOrThrow({ + where: { email: deletedUser.email }, + }) + ).rejects.toThrowError("No User found"); + }); + }); + + describe("findUnique", () => { + it("findUnique excludes soft deleted records", async () => { + const foundUser = await testClient.user.findUnique({ + where: { id: firstUser.id }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(firstUser.id); + + const notFoundUser = await testClient.user.findUnique({ + where: { id: deletedUser.id }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("throws a useful error when invalid where is passed", async () => { + // throws useful error when no where is passed + await expect(() => testClient.user.findUnique()).rejects.toThrowError( + "Invalid `testClient.user.findUnique()` invocation" + ); + + // throws useful error when empty where is passed + await expect(() => testClient.user.findUnique({})).rejects.toThrowError( + "Invalid `testClient.user.findUnique()` invocation" + ); + + // throws useful error when where is passed undefined unique fields + await expect(() => + testClient.user.findUnique({ + where: { id: undefined }, + }) + ).rejects.toThrowError( + "Invalid `testClient.user.findUnique()` invocation" + ); + + // throws useful error when where has defined non-unique fields + await expect(() => + testClient.user.findUnique({ + where: { name: firstUser.name }, + }) + ).rejects.toThrowError( + "Invalid `testClient.user.findUnique()` invocation" + ); + + // throws useful error when where has undefined compound unique index field + await expect(() => + testClient.user.findUnique({ + where: { name_email: undefined }, + }) + ).rejects.toThrowError( + "Invalid `testClient.user.findUnique()` invocation" + ); + + // throws useful error when where has undefined unique field and defined non-unique field + await expect(() => + testClient.user.findUnique({ + where: { id: undefined, name: firstUser.name }, + }) + ).rejects.toThrowError( + "Invalid `testClient.user.findUnique()` invocation" + ); + }); + + // TODO:- enable this test when extendedWhereUnique is supported + it.failing( + "findUnique excludes soft-deleted records when using compound unique index fields", + async () => { + const notFoundUser = await testClient.user.findUnique({ + where: { + name_email: { + name: deletedUser.name, + email: deletedUser.email, + }, + }, + }); + expect(notFoundUser).toBeNull(); + } + ); + }); + + describe("findUniqueOrThrow", () => { + it("findUniqueOrThrow throws for soft deleted records", async () => { + const foundUser = await testClient.user.findUniqueOrThrow({ + where: { id: firstUser.id }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(firstUser.id); + + await expect(() => + testClient.user.findUniqueOrThrow({ + where: { id: deletedUser.id }, + }) + ).rejects.toThrowError("No User found"); + }); + + it("throws a useful error when invalid where is passed", async () => { + // throws useful error when no where is passed + await expect(() => + testClient.user.findUniqueOrThrow() + ).rejects.toThrowError( + "Invalid `testClient.user.findUniqueOrThrow()` invocation" + ); + + // throws useful error when empty where is passed + await expect(() => + testClient.user.findUniqueOrThrow({}) + ).rejects.toThrowError( + "Invalid `testClient.user.findUniqueOrThrow()` invocation" + ); + + // throws useful error when where is passed undefined unique fields + await expect(() => + testClient.user.findUniqueOrThrow({ + where: { id: undefined }, + }) + ).rejects.toThrowError( + "Invalid `testClient.user.findUniqueOrThrow()` invocation" + ); + + // throws useful error when where has defined non-unique fields + await expect(() => + testClient.user.findUniqueOrThrow({ + where: { name: firstUser.name }, + }) + ).rejects.toThrowError( + "Invalid `testClient.user.findUniqueOrThrow()` invocation" + ); + + // throws useful error when where has undefined compound unique index field + await expect(() => + testClient.user.findUniqueOrThrow({ + where: { name_email: undefined }, + }) + ).rejects.toThrowError( + "Invalid `testClient.user.findUniqueOrThrow()` invocation" + ); + + // throws useful error when where has undefined unique field and defined non-unique field + await expect(() => + testClient.user.findUniqueOrThrow({ + where: { id: undefined, name: firstUser.name }, + }) + ).rejects.toThrowError( + "Invalid `testClient.user.findUniqueOrThrow()` invocation" + ); + }); + + // TODO:- enable this test when extendedWhereUnique is supported + it.failing( + "findUniqueOrThrow excludes soft-deleted records when using compound unique index fields", + async () => { + const notFoundUser = await testClient.user.findUniqueOrThrow({ + where: { + name_email: { + name: deletedUser.name, + email: deletedUser.email, + }, + }, + }); + expect(notFoundUser).toBeNull(); + } + ); + }); + + describe("findMany", () => { + it("findMany excludes soft deleted records", async () => { + const foundUsers = await testClient.user.findMany({ + where: { name: { contains: "J" } }, + }); + expect(foundUsers).toHaveLength(2); + expect(foundUsers.map(({ id }: any) => id).sort()).toEqual( + [firstUser.id, secondUser.id].sort() + ); + }); + }); + + describe("count", () => { + it("count excludes soft deleted records", async () => { + const count = await testClient.user.count({ + where: { name: { contains: "J" } }, + }); + expect(count).toEqual(2); + }); + }); + + describe("aggregate", () => { + it("aggregate excludes soft deleted records", async () => { + const aggregate = await testClient.user.aggregate({ + where: { name: { contains: "J" } }, + _sum: { + id: true, + }, + }); + expect(aggregate._sum.id).toEqual(firstUser.id + secondUser.id); + }); + }); + + describe("create", () => { + it("does not prevent creating a record", async () => { + const result = await testClient.user.create({ + data: { email: faker.internet.email(), name: "New User" }, + }); + expect(result).not.toBeNull(); + }); + + it("does not prevent creating a soft deleted record", async () => { + const result = await testClient.user.create({ + data: { + email: faker.internet.email(), + name: "New User", + deleted: true, + }, + }); + expect(result).not.toBeNull(); + }); + + it("does not prevent creating a nested record", async () => { + const result = await testClient.profile.update({ + where: { id: profile.id }, + data: { + users: { + create: { email: faker.internet.email(), name: "New User" }, + }, + }, + include: { + users: true, + }, + }); + // should be first user and new user + expect(result.users).toHaveLength(2); + + // first user should be there + expect( + result.users.find(({ id }: any) => id === firstUser.id) + ).not.toBeNull(); + + // other users should not be returned + expect( + result.users.find(({ id }: any) => id === secondUser.id) + ).not.toBeDefined(); + expect( + result.users.find(({ id }: any) => id === deletedUser.id) + ).not.toBeDefined(); + }); + + it("does not prevent creating a nested soft deleted record", async () => { + const result = await testClient.profile.update({ + where: { id: profile.id }, + data: { + users: { + create: { + email: faker.internet.email(), + name: "New User", + deleted: true, + }, + }, + }, + include: { + users: true, + }, + }); + // should be first user and new user + expect(result.users).toHaveLength(1); + + // only first user should be there + expect(result.users[0].id).toEqual(firstUser.id); + + // user was created + const dbUser = await client.user.findFirst({ + where: { name: "New User" }, + }); + expect(dbUser).not.toBeNull(); + }); + }); + + describe("createMany", () => { + it("createMany can create records and soft deleted records", async () => { + const result = await testClient.user.createMany({ + data: [ + { email: faker.internet.email(), name: faker.name.findName() }, + { + email: faker.internet.email(), + name: faker.name.findName(), + deleted: true, + }, + ], + }); + expect(result).not.toBeNull(); + expect(result.count).toEqual(2); + }); + + it("nested createMany can create records and soft deleted records", async () => { + const result = await testClient.user.create({ + data: { + email: faker.internet.email(), + name: faker.name.findName(), + comments: { + createMany: { + data: [ + { content: faker.lorem.sentence() }, + { + content: faker.lorem.sentence(), + deleted: true, + }, + ], + }, + }, + }, + }); + expect(result).not.toBeNull(); + + const dbUser = await client.user.findUniqueOrThrow({ + where: { id: result.id }, + include: { comments: true }, + }); + expect(dbUser.comments).toHaveLength(2); + expect(dbUser.comments.filter(({ deleted }) => deleted)).toHaveLength(1); + expect(dbUser.comments.filter(({ deleted }) => !deleted)).toHaveLength(1); + }); + }); + + describe("connect", () => { + it("connect connects soft deleted records", async () => { + await testClient.user.update({ + where: { id: firstUser.id }, + data: { + comments: { + connect: { + id: comment.id, + }, + }, + }, + }); + + const dbComment = await client.comment.findUniqueOrThrow({ + where: { id: comment.id }, + }); + expect(dbComment.authorId).toEqual(firstUser.id); + }); + }); + + describe("connectOrCreate", () => { + it("connectOrCreate connects soft deleted records", async () => { + await testClient.user.update({ + where: { id: firstUser.id }, + data: { + comments: { + connectOrCreate: { + where: { id: comment.id }, + create: { content: "Updated" }, + }, + }, + }, + }); + + const dbComment = await client.comment.findUniqueOrThrow({ + where: { id: comment.id }, + }); + expect(dbComment.authorId).toEqual(firstUser.id); + }); + }); + + describe("disconnect", () => { + it("toMany disconnect can disconnect soft deleted records", async () => { + await testClient.user.update({ + where: { id: firstUser.id }, + data: { + comments: { + disconnect: { + id: comment.id, + }, + }, + }, + }); + + const dbComment = await client.comment.findUniqueOrThrow({ + where: { id: comment.id }, + }); + expect(dbComment.authorId).toBeNull(); + }); + + it("toOne disconnect can disconnect soft deleted records", async () => { + await testClient.user.update({ + where: { id: firstUser.id }, + data: { + profile: { + disconnect: true, + }, + }, + }); + + const dbFirstUser = await client.user.findUniqueOrThrow({ + where: { id: firstUser.id }, + }); + expect(dbFirstUser.profileId).toBeNull(); + }); + }); +}); diff --git a/test/e2e/where.test.ts b/test/e2e/where.test.ts new file mode 100644 index 0000000..9d8d209 --- /dev/null +++ b/test/e2e/where.test.ts @@ -0,0 +1,295 @@ +import { PrismaClient, Profile, User } from "@prisma/client"; +import faker from "faker"; + +import { createSoftDeleteExtension } from "../../src"; +import client from "./client"; + +describe("where", () => { + let testClient: any; + let profile: Profile; + let user: User; + + beforeAll(async () => { + testClient = new PrismaClient(); + testClient = testClient.$extends( + createSoftDeleteExtension({ models: { Comment: true, Profile: true } }) + ); + + profile = await client.profile.create({ + data: { + bio: "foo", + }, + }); + user = await client.user.create({ + data: { + email: faker.internet.email(), + name: faker.name.findName(), + profileId: profile.id, + comments: { + create: [ + { content: "foo" }, + { content: "foo", deleted: true }, + { content: "bar", deleted: true }, + ], + }, + }, + }); + }); + afterEach(async () => { + // restore soft deleted profile + await client.profile.update({ + where: { id: profile.id }, + data: { + deleted: false, + }, + }); + }); + afterAll(async () => { + // disconnect test client + await testClient.$disconnect(); + + // delete user and related data + await client.user.update({ + where: { id: user.id }, + data: { + comments: { deleteMany: {} }, + profile: { delete: true }, + }, + }); + await client.user.delete({ where: { id: user.id } }); + }); + + it("excludes deleted when filtering using 'is'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { profile: { is: { bio: "foo" } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + // soft delete profile + await testClient.profile.update({ + where: { id: profile?.id }, + data: { deleted: true }, + }); + + const notFoundUser = await testClient.user.findFirst({ + where: { profile: { is: { bio: "foo" } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when filtering using 'some'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { comments: { some: { content: "foo" } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { comments: { some: { content: "bar" } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when filtering using 'every'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { comments: { every: { content: "foo" } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { comments: { every: { content: "bar" } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when filtering using 'none'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { comments: { none: { content: "bar" } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { comments: { none: { content: "foo" } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' with 'some'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { NOT: { comments: { some: { content: "bar" } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { comments: { some: { content: "foo" } } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' with 'every'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { NOT: { comments: { every: { content: "bar" } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { comments: { every: { content: "foo" } } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' with 'none'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { NOT: { comments: { none: { content: "foo" } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { comments: { none: { content: "bar" } } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' with 'isNot'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { NOT: { profile: { isNot: { bio: "foo" } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + // soft delete profile + await client.profile.update({ + where: { id: profile.id }, + data: { + deleted: true, + }, + }); + + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { profile: { isNot: { bio: "foo" } } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' with 'is'", async () => { + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { profile: { is: { bio: "foo" } } } }, + }); + expect(notFoundUser).toBeNull(); + + // soft delete profile + await client.profile.update({ + where: { id: profile.id }, + data: { + deleted: true, + }, + }); + + const foundUser = await testClient.user.findFirst({ + where: { NOT: { profile: { is: { bio: "foo" } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + }); + + it("excludes deleted when using 'NOT' nested in a 'NOT'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { profile: { bio: "foo" } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { profile: { bio: "bar" } } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' nested in a 'NOT' with 'some'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { comments: { some: { content: "foo" } } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { comments: { some: { content: "bar" } } } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' nested in a 'NOT' with 'every'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { comments: { every: { content: "foo" } } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { comments: { every: { content: "bar" } } } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' nested in a 'NOT' with 'none'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { comments: { none: { content: "bar" } } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { comments: { none: { content: "foo" } } } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' nested in a 'NOT' with 'isNot'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { profile: { isNot: { bio: "bar" } } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { profile: { isNot: { bio: "foo" } } } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' nested in a 'NOT' with 'is'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { profile: { is: { bio: "foo" } } } } }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { NOT: { NOT: { profile: { is: { bio: "bar" } } } } }, + }); + expect(notFoundUser).toBeNull(); + }); + + it("excludes deleted when using 'NOT' nested in a relation nested in a 'NOT'", async () => { + const foundUser = await testClient.user.findFirst({ + where: { + NOT: { profile: { NOT: { users: { some: { id: user.id } } } } }, + }, + }); + expect(foundUser).not.toBeNull(); + expect(foundUser!.id).toEqual(user.id); + + const notFoundUser = await testClient.user.findFirst({ + where: { + NOT: { profile: { NOT: { users: { none: { id: user.id } } } } }, + }, + }); + expect(notFoundUser).toBeNull(); + }); +}); diff --git a/test/scripts/run-with-postgres.sh b/test/scripts/run-with-postgres.sh new file mode 100755 index 0000000..d70f5ce --- /dev/null +++ b/test/scripts/run-with-postgres.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +export DATABASE_URL=postgres://postgres:123@localhost:5432/test + +trap "docker compose down" EXIT + +docker compose up -d && sleep 1 +npx prisma db push + +$@ diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..99b1753 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,5 @@ +import mockClient from './unit/utils/mockClient' + +afterEach(() => { + mockClient.reset() +}) \ No newline at end of file diff --git a/test/unit/aggregate.test.ts b/test/unit/aggregate.test.ts new file mode 100644 index 0000000..c53e2f5 --- /dev/null +++ b/test/unit/aggregate.test.ts @@ -0,0 +1,66 @@ +import { set } from "lodash"; + +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("aggregate", () => { + it("does not change aggregate action if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "aggregate", { + where: { email: { contains: "test" } }, + _sum: { id: true }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("excludes deleted records from aggregate with no where", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + User: true, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "aggregate", {}); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith( + set(params, "args.where.deleted", false).args + ); + }); + + it("excludes deleted record from aggregate with where", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + User: true, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "aggregate", { + where: { email: { contains: "test" } }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith( + set(params, "args.where.deleted", false).args + ); + }); +}); diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts new file mode 100644 index 0000000..3e01566 --- /dev/null +++ b/test/unit/config.test.ts @@ -0,0 +1,184 @@ +import faker from "faker"; +import { set } from "lodash"; + +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("config", () => { + it('does not soft delete models where config is passed as "false"', async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { + User: false, + }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "Post", "update", { + where: { id: 1 }, + data: { + author: { delete: true }, + comments: { + updateMany: { + where: { content: faker.lorem.sentence() }, + data: { content: faker.lorem.sentence() }, + }, + }, + }, + }); + + await $allOperations(params); + + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("allows setting default config values", async () => { + const deletedAt = new Date(); + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { + Post: true, + Comment: true, + }, + defaultConfig: { + field: "deletedAt", + createValue: () => deletedAt, + }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + posts: { + delete: { id: 1 }, + }, + comments: { + updateMany: { + where: { content: faker.lorem.sentence() }, + data: { content: faker.lorem.sentence() }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.data.posts", { + update: { where: { id: 1 }, data: { deletedAt } }, + }); + set(params, "args.data.comments.updateMany.where.deletedAt", deletedAt); + + expect(query).toHaveBeenCalledWith(params.args); + }); + + it('throws when default config does not have a "field" property', () => { + expect(() => { + createSoftDeleteExtension({ + models: { + Post: true, + }, + // @ts-expect-error - we are testing the error case + defaultConfig: { + createValue: () => new Date(), + }, + }); + }).toThrowError( + "prisma-extension-soft-delete: defaultConfig.field is required" + ); + }); + + it('throws when default config does not have a "createValue" property', () => { + expect(() => { + createSoftDeleteExtension({ + models: { + Post: true, + }, + // @ts-expect-error - we are testing the error case + defaultConfig: { + field: "deletedAt", + }, + }); + }).toThrowError( + "prisma-extension-soft-delete: defaultConfig.createValue is required" + ); + }); + + it("allows setting model specific config values", async () => { + const deletedAt = new Date(); + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { + Post: { + field: "deletedAt", + createValue: () => deletedAt, + }, + Comment: true, + }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + posts: { delete: { id: 1 } }, + comments: { + updateMany: { + where: { content: faker.lorem.sentence() }, + data: { content: faker.lorem.sentence() }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.data.posts", { + update: { where: { id: 1 }, data: { deletedAt } }, + }); + set(params, "args.data.comments.updateMany.where.deleted", false); + + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("allows overriding default config values", async () => { + const deletedAt = new Date(); + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { + Post: true, + Comment: { + field: "deleted", + createValue: Boolean, + }, + }, + defaultConfig: { + field: "deletedAt", + createValue: (deleted) => { + if (deleted) return deletedAt; + return null; + }, + }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + posts: { delete: { id: 1 } }, + comments: { + updateMany: { + where: { content: faker.lorem.sentence() }, + data: { content: faker.lorem.sentence() }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.data.posts", { + update: { where: { id: 1 }, data: { deletedAt } }, + }); + set(params, "args.data.comments.updateMany.where.deleted", false); + + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/test/unit/count.test.ts b/test/unit/count.test.ts new file mode 100644 index 0000000..ad6b6b1 --- /dev/null +++ b/test/unit/count.test.ts @@ -0,0 +1,62 @@ +import { set } from "lodash"; +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("count", () => { + it("does not change count action if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ models: {} })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "count", {}); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("excludes deleted records from count", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { User: true }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "count", undefined); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith(set(params, "args.where.deleted", false).args); + }); + + it("excludes deleted records from count with empty args", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { User: true }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "count", {}); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith(set(params, "args.where.deleted", false).args); + }); + + it("excludes deleted record from count with where", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { User: true }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "count", { + where: { email: { contains: "test" } }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith(set(params, "args.where.deleted", false).args); + }); +}); diff --git a/test/unit/delete.test.ts b/test/unit/delete.test.ts new file mode 100644 index 0000000..10c3b97 --- /dev/null +++ b/test/unit/delete.test.ts @@ -0,0 +1,221 @@ +import { set } from "lodash"; +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("delete", () => { + it("does not change delete action if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "delete", { where: { id: 1 } }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not change nested delete action if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + posts: { + delete: { id: 1 }, + }, + }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify delete results", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const queryResult = { id: 1, deleted: true }; + const query = jest.fn(() => Promise.resolve(queryResult)); + mockClient.user.update.mockImplementation(() => queryResult); + + const params = createParams(query, "User", "delete", { where: { id: 1 } }); + + const result = await $allOperations(params); + expect(result).toEqual({ id: 1, deleted: true }); + }); + + it("does not modify delete with no args", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error - args are required + const params = createParams(query, "User", "delete", undefined); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify delete with no where", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error - where is required + const params = createParams(query, "User", "delete", {}); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("changes delete action into an update to add deleted mark", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve()); + const params = createParams(query, "User", "delete", { where: { id: 1 } }); + await $allOperations(params); + + // params are modified correctly + expect(mockClient.user.update).toHaveBeenCalledWith({ + ...params.args, + data: { deleted: true }, + }); + + expect(query).not.toHaveBeenCalled(); + }); + + it("does not change nested delete false action", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Profile: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + profile: { delete: false }, + }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("changes nested delete true action into an update that adds deleted mark", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Profile: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + profile: { + delete: true, + }, + }, + }); + + await $allOperations(params); + + // params are modified correctly + expect(query).toHaveBeenCalledWith( + set(params, "args.data.profile", { + update: { deleted: true }, + }).args + ); + }); + + it("changes nested delete action on a toMany relation into an update that adds deleted mark", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Post: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + posts: { + delete: { id: 1 }, + }, + }, + }); + + await $allOperations(params); + + // params are modified correctly + expect(query).toHaveBeenCalledWith({ + ...params.args, + data: { + posts: { + update: { + where: { id: 1 }, + data: { deleted: true }, + }, + }, + }, + }); + }); + + it("changes nested list of delete actions into a nested list of update actions", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Post: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + posts: { + delete: [{ id: 1 }, { id: 2 }, { id: 3 }], + }, + }, + }); + + await $allOperations(params); + + // params are modified correctly + expect(query).toHaveBeenCalledWith({ + ...params.args, + data: { + posts: { + update: [ + { where: { id: 1 }, data: { deleted: true } }, + { where: { id: 2 }, data: { deleted: true } }, + { where: { id: 3 }, data: { deleted: true } }, + ], + }, + }, + }); + }); +}); diff --git a/test/unit/deleteMany.test.ts b/test/unit/deleteMany.test.ts new file mode 100644 index 0000000..e4b5093 --- /dev/null +++ b/test/unit/deleteMany.test.ts @@ -0,0 +1,152 @@ +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("deleteMany", () => { + it("does not change deleteMany action if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "deleteMany", { + where: { id: 1 }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not change nested deleteMany action if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + posts: { + deleteMany: { + id: 1, + }, + }, + }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify deleteMany results", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const queryResult = { count: 1 }; + const query = jest.fn(() => Promise.resolve(queryResult)); + mockClient.user.updateMany.mockImplementation(() => queryResult); + + const params = createParams(query, "User", "deleteMany", { + where: { id: 1 }, + }); + + const result = await $allOperations(params); + expect(result).toEqual({ count: 1 }); + }); + + it("changes deleteMany action into an updateMany that adds deleted mark", async () => { + const client = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "deleteMany", { + where: { id: 1 }, + }); + await client.$allOperations(params); + + // params are modified correctly + expect(mockClient.user.updateMany).toHaveBeenCalledWith({ + where: { ...params.args.where, deleted: false }, + data: { deleted: true }, + }); + }); + + it("changes deleteMany action with no args into an updateMany that adds deleted mark", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "deleteMany", undefined); + await $allOperations(params); + + // params are modified correctly + expect(mockClient.user.updateMany).toHaveBeenCalledWith({ + where: { deleted: false }, + data: { deleted: true }, + }); + }); + + it("changes deleteMany action with no where into an updateMany that adds deleted mark", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "deleteMany", {}); + await $allOperations(params); + + // params are modified correctly + expect(mockClient.user.updateMany).toHaveBeenCalledWith({ + where: { deleted: false }, + data: { deleted: true }, + }); + }); + + it("changes nested deleteMany action into an updateMany that adds deleted mark", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Post: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + posts: { + deleteMany: { + id: 1, + }, + }, + }, + }); + + await $allOperations(params); + + // params are modified correctly + expect(query).toHaveBeenCalledWith({ + ...params.args, + data: { + posts: { + updateMany: { + where: { id: 1, deleted: false }, + data: { deleted: true }, + }, + }, + }, + }); + }); +}); diff --git a/test/unit/findFirst.test.ts b/test/unit/findFirst.test.ts new file mode 100644 index 0000000..ebd50a2 --- /dev/null +++ b/test/unit/findFirst.test.ts @@ -0,0 +1,113 @@ +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("findFirst", () => { + it("does not change findFirst params if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findFirst", { + where: { id: 1 }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify findFirst results", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve({ id: 1, deleted: true })); + const params = createParams(query, "User", "findFirst", { + where: { id: 1 }, + }); + + const result = await $allOperations(params); + + expect(result).toEqual({ id: 1, deleted: true }); + }); + + it("excludes deleted records from findFirst", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findFirst", { + where: { id: 1 }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + where: { + id: 1, + deleted: false, + }, + }); + }); + + it("excludes deleted records from findFirst with no args", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findFirst", undefined); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + where: { + deleted: false, + }, + }); + }); + + it("excludes deleted records from findFirst with empty args", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findFirst", {}); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + where: { + deleted: false, + }, + }); + }); + + it("allows explicitly querying for deleted records using findFirst", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findFirst", { + where: { id: 1, deleted: true }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/test/unit/findFirstOrThrow.test.ts b/test/unit/findFirstOrThrow.test.ts new file mode 100644 index 0000000..c15c731 --- /dev/null +++ b/test/unit/findFirstOrThrow.test.ts @@ -0,0 +1,113 @@ +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("findFirstOrThrow", () => { + it("does not change findFirstOrThrow params if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findFirstOrThrow", { + where: { id: 1 }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify findFirstOrThrow results", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve({ id: 1, deleted: true })); + const params = createParams(query, "User", "findFirstOrThrow", { + where: { id: 1 }, + }); + + const result = await $allOperations(params); + + expect(result).toEqual({ id: 1, deleted: true }); + }); + + it("excludes deleted records from findFirstOrThrow", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findFirstOrThrow", { + where: { id: 1 }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + where: { + id: 1, + deleted: false, + }, + }); + }); + + it("excludes deleted records from findFirstOrThrow with no args", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findFirstOrThrow", undefined); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + where: { + deleted: false, + }, + }); + }); + + it("excludes deleted records from findFirstOrThrow with empty args", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findFirstOrThrow", {}); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + where: { + deleted: false, + }, + }); + }); + + it("allows explicitly querying for deleted records using findFirstOrThrow", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findFirstOrThrow", { + where: { id: 1, deleted: true }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/test/unit/findMany.test.ts b/test/unit/findMany.test.ts new file mode 100644 index 0000000..9b01f0f --- /dev/null +++ b/test/unit/findMany.test.ts @@ -0,0 +1,117 @@ +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("findMany", () => { + it("does not change findMany params if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findMany", { + where: { id: 1 }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify findMany results", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve([{ id: 1, deleted: true }])); + const params = createParams(query, "User", "findMany", { + where: { id: 1 }, + }); + + const result = await $allOperations(params); + + expect(result).toEqual([{ id: 1, deleted: true }]); + }); + + it("excludes deleted records from findMany", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findMany", { + where: { id: 1 }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + where: { + id: 1, + deleted: false, + }, + }); + }); + + it("excludes deleted records from findMany with no args", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findMany", undefined); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + where: { + deleted: false, + }, + }); + }); + + it("excludes deleted records from findMany with empty args", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findMany", {}); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + where: { + deleted: false, + }, + }); + }); + + it("allows explicitly querying for deleted records using findMany", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findMany", { + where: { id: 1, deleted: true }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/test/unit/findUnique.test.ts b/test/unit/findUnique.test.ts new file mode 100644 index 0000000..6b03110 --- /dev/null +++ b/test/unit/findUnique.test.ts @@ -0,0 +1,193 @@ +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("findUnique", () => { + it("does not change findUnique params if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findUnique", { + where: { id: 1 }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify findUnique results", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const queryResult = { id: 1, deleted: true }; + const query = jest.fn(() => Promise.resolve(queryResult)); + mockClient.user.findFirst.mockImplementation(() => queryResult); + + const params = createParams(query, "User", "findUnique", { + where: { id: 1 }, + }); + + const result = await $allOperations(params); + expect(result).toEqual(queryResult); + }); + + it("changes findUnique into findFirst and excludes deleted records", async () => { + const client = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findUnique", { + where: { id: 1 }, + }); + + await client.$allOperations(params); + + // params have been modified + expect(mockClient.user.findFirst).toHaveBeenCalledWith({ + where: { + id: 1, + deleted: false, + }, + }); + + expect(query).not.toHaveBeenCalled(); + }); + + it("throws when trying to pass a findUnique where with a compound unique index field", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = () => Promise.resolve({}); + const params = createParams(query, "User", "findUnique", { + where: { + name_email: { + name: "test", + email: "test@test.com", + }, + }, + }); + + await expect($allOperations(params)).rejects.toThrowError( + `prisma-extension-soft-delete: 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.` + ); + }); + + it('does not modify findUnique when compound unique index field used and "allowCompoundUniqueIndexWhere" is set to true', async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + User: { + field: "deleted", + createValue: Boolean, + allowCompoundUniqueIndexWhere: true, + }, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findUnique", { + where: { + name_email: { + name: "test", + email: "test@test.com", + }, + }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify findUnique to be a findFirst when no args passed", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error testing if user doesn't pass args accidentally + const params = createParams(query, "User", "findUnique", undefined); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify findUnique to be a findFirst when invalid where passed", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + let query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error testing if user doesn't pass where accidentally + let params = createParams(query, "User", "findUnique", {}); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect empty where not to modify params + query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error testing if user passes where without unique field + params = createParams(query, "User", "findUnique", { where: {} }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect where with undefined id field not to modify params + query = jest.fn(() => Promise.resolve({})); + params = createParams(query, "User", "findUnique", { + where: { id: undefined }, + }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect where with undefined unique field not to modify params + query = jest.fn(() => Promise.resolve({})); + params = createParams(query, "User", "findUnique", { + where: { email: undefined }, + }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect where with undefined unique index field not to modify params + query = jest.fn(() => Promise.resolve({})); + params = createParams(query, "User", "findUnique", { + where: { name_email: undefined }, + }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect where with defined non-unique field + query = jest.fn(() => Promise.resolve({})); + params = createParams(query, "User", "findUnique", { + // @ts-expect-error intentionally incorrect where + where: { name: "test" }, + }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect where with defined non-unique field and undefined id field not to modify params + query = jest.fn(() => Promise.resolve({})); + params = createParams(query, "User", "findUnique", { + where: { id: undefined, name: "test" }, + }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/test/unit/findUniqueOrThrow.test.ts b/test/unit/findUniqueOrThrow.test.ts new file mode 100644 index 0000000..ff4e501 --- /dev/null +++ b/test/unit/findUniqueOrThrow.test.ts @@ -0,0 +1,144 @@ +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("findUniqueOrThrow", () => { + it("does not change findUniqueOrThrow params if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findUniqueOrThrow", { + where: { id: 1 }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify findUniqueOrThrow results", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const queryResult = { id: 1, deleted: true }; + const query = jest.fn(() => Promise.resolve(queryResult)); + mockClient.user.findFirstOrThrow.mockImplementation(() => queryResult); + + const params = createParams(query, "User", "findUniqueOrThrow", { + where: { id: 1 }, + }); + + const result = await $allOperations(params); + + expect(result).toEqual({ id: 1, deleted: true }); + }); + + it("changes findUniqueOrThrow into findFirstOrThrow and excludes deleted records", async () => { + const client = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findUniqueOrThrow", { + where: { id: 1 }, + }); + + await client.$allOperations(params); + + // params have been modified + expect(mockClient.user.findFirstOrThrow).toHaveBeenCalledWith({ + where: { + id: 1, + deleted: false, + }, + }); + + // query has not been called + expect(query).not.toHaveBeenCalled(); + }); + + it("does not modify findUniqueOrThrow to be a findFirstOrThrow when no args passed", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error testing if user doesn't pass args accidentally + const params = createParams(query, "User", "findUniqueOrThrow", undefined); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify findUniqueOrThrow to be a findFirst when invalid where passed", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + let query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error testing if user doesn't pass where accidentally + let params = createParams(query, "User", "findUniqueOrThrow", {}); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect empty where not to modify params + query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error testing if user passes where without unique field + params = createParams(query, "User", "findUniqueOrThrow", { where: {} }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect where with undefined id field not to modify params + query = jest.fn(() => Promise.resolve({})); + params = createParams(query, "User", "findUniqueOrThrow", { + where: { id: undefined }, + }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect where with undefined unique field not to modify params + query = jest.fn(() => Promise.resolve({})); + params = createParams(query, "User", "findUniqueOrThrow", { + where: { email: undefined }, + }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect where with undefined unique index field not to modify params + query = jest.fn(() => Promise.resolve({})); + params = createParams(query, "User", "findUniqueOrThrow", { + where: { name_email: undefined }, + }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect where with defined non-unique field + query = jest.fn(() => Promise.resolve({})); + params = createParams(query, "User", "findUniqueOrThrow", { + // @ts-expect-error intentionally incorrect where + where: { name: "test" }, + }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + + // expect where with defined non-unique field and undefined id field not to modify params + query = jest.fn(() => Promise.resolve({})); + params = createParams(query, "User", "findUniqueOrThrow", { + where: { id: undefined, name: "test" }, + }); + await $allOperations(params); + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/test/unit/groupBy.test.ts b/test/unit/groupBy.test.ts new file mode 100644 index 0000000..63dc7f7 --- /dev/null +++ b/test/unit/groupBy.test.ts @@ -0,0 +1,83 @@ +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("groupBy", () => { + //group by must always have by and order by, else we get an error, + it("does not change groupBy action if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "groupBy", { + where: { id: 1 }, + by: ["id"], + orderBy: {}, + }); + await $allOperations(params); + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify groupBy results", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + const query = jest.fn(() => Promise.resolve([{ id: 1, deleted: true }])); + const params = createParams(query, "User", "groupBy", { + where: { id: 1 }, + by: ["id"], + orderBy: {}, + }); + const result = await $allOperations(params); + expect(result).toEqual([{ id: 1, deleted: true }]); + }); + + it("excludes deleted records from groupBy", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "groupBy", { + where: { id: 1 }, + by: ["id"], + orderBy: {}, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + by: ["id"], + orderBy: {}, + where: { + id: 1, + deleted: false, + }, + }); + }); + + it("allows explicitly querying for deleted records using groupBy", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "groupBy", { + where: { id: 1, deleted: true }, + by: ["id"], + orderBy: {}, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/test/unit/include.test.ts b/test/unit/include.test.ts new file mode 100644 index 0000000..e983037 --- /dev/null +++ b/test/unit/include.test.ts @@ -0,0 +1,316 @@ +import { set } from "lodash"; +import faker from "faker"; + +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("include", () => { + it("does not change include params if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { email: "test@test.com" }, + include: { comments: true }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("uses params to exclude deleted records from toMany includes", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Comment: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { email: "test@test.com" }, + include: { + comments: true, + }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith( + set(params, "args.include.comments", { + where: { + deleted: false, + }, + }).args + ); + }); + + it("uses params to exclude deleted records from toMany includes with where", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Comment: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { email: "test@test.com" }, + include: { + comments: { + where: { + content: faker.lorem.sentence(), + }, + }, + }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith( + set(params, "args.include.comments.where.deleted", false).args + ); + }); + + it("manually excludes deleted records from boolean toOne include", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({ author: { deleted: true } })); + const params = createParams(query, "Post", "update", { + where: { id: 1 }, + data: { content: "foo" }, + include: { + author: true, + }, + }); + + const result = await $allOperations(params); + + expect(query).toHaveBeenCalledWith(params.args); + expect(result).toEqual({ author: null }); + }); + + it("does not manually exclude non-deleted records from boolean toOne include", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => + Promise.resolve({ author: { deleted: false } }) + ); + const params = createParams(query, "Post", "update", { + where: { id: 1 }, + data: { content: "foo" }, + include: { + author: true, + }, + }); + + const result = await $allOperations(params); + + expect(query).toHaveBeenCalledWith(params.args); + expect(result).toEqual({ author: { deleted: false } }); + }); + + it("manually excludes deleted records from toOne include with nested includes", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => + Promise.resolve({ author: { deleted: true, comments: [] } }) + ); + const params = createParams(query, "Post", "update", { + where: { id: 1 }, + data: { content: "foo" }, + include: { + author: { + include: { + comments: true, + }, + }, + }, + }); + + const result = await $allOperations(params); + + expect(query).toHaveBeenCalledWith(params.args); + expect(result).toEqual({ author: null }); + }); + + it("does not manually exclude non-deleted records from toOne include with nested includes", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => + Promise.resolve({ + author: { + deleted: false, + comments: [], + }, + }) + ); + const params = createParams(query, "Post", "update", { + where: { id: 1 }, + data: { content: "foo" }, + include: { + author: { + include: { + comments: true, + }, + }, + }, + }); + + const result = await $allOperations(params); + + expect(result).toEqual({ + author: { + deleted: false, + comments: [], + }, + }); + }); + + it("excludes deleted records from toMany include nested in toMany include", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Comment: true }, + }) + ); + + const query = jest.fn(() => + Promise.resolve({ + posts: [ + { + comments: [{ deleted: true }, { deleted: false }], + }, + { + comments: [ + { deleted: false }, + { deleted: false }, + { deleted: true }, + ], + }, + ], + }) + ); + const params = createParams(query, "User", "findFirst", { + where: { id: 1 }, + include: { + posts: { + include: { + comments: true, + }, + }, + }, + }); + + const result = await $allOperations(params); + + expect(query).toHaveBeenCalledWith( + set(params, "args.include.posts.include.comments", { + where: { + deleted: false, + }, + }).args + ); + expect(result).toEqual({ + posts: [ + { comments: [{ deleted: false }] }, + { comments: [{ deleted: false }, { deleted: false }] }, + ], + }); + }); + + it("manually excludes deleted records from toOne include nested in toMany include", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => + Promise.resolve({ + posts: [ + { author: null }, + { author: { deleted: true } }, + { author: { deleted: false } }, + ], + }) + ); + const params = createParams(query, "User", "findFirst", { + where: { id: 1, deleted: false }, + include: { + posts: { + include: { + author: true, + }, + }, + }, + }); + + const result = await $allOperations(params); + + expect(query).toHaveBeenCalledWith(params.args); + expect(result).toEqual({ + posts: [ + { author: null }, + { author: null }, + { author: { deleted: false } }, + ], + }); + }); + + it("allows explicitly including deleted records using include", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Comment: true }, + }) + ); + + const query = jest.fn(() => + Promise.resolve({ + comments: [{ deleted: true }, { deleted: true }], + }) + ); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { email: "test@test.com" }, + include: { + comments: { + where: { + deleted: true, + }, + }, + }, + }); + + const result = await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + expect(result).toEqual({ + comments: [{ deleted: true }, { deleted: true }], + }); + }); +}); diff --git a/test/unit/select.test.ts b/test/unit/select.test.ts new file mode 100644 index 0000000..5a24c6e --- /dev/null +++ b/test/unit/select.test.ts @@ -0,0 +1,129 @@ +import { set } from "lodash"; +import faker from "faker"; + +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("select", () => { + it("does not change select params if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ models: {} })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { email: "test@test.com" }, + select: { comments: true }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("excludes deleted records from selects", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { Comment: true }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { email: "test@test.com" }, + select: { + comments: true, + }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith( + set(params, "args.select.comments", { + where: { + deleted: false, + }, + }).args + ); + }); + + it("excludes deleted records from selects using where", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { Comment: true }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { email: "test@test.com" }, + select: { + comments: { + where: { + content: faker.lorem.sentence(), + }, + }, + }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith( + set(params, "args.select.comments.where.deleted", false).args + ); + }); + + it("excludes deleted records from include with select", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { Comment: true }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { email: "test@test.com" }, + include: { + comments: { + select: { + id: true, + }, + }, + }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith( + set(params, "args.include.comments", { + where: { deleted: false }, + select: { id: true }, + }).args + ); + }); + + it("allows explicitly selecting deleted records using select", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { Comment: true }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { email: "test@test.com" }, + select: { + comments: { + where: { + deleted: true, + }, + }, + }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/test/unit/update.test.ts b/test/unit/update.test.ts new file mode 100644 index 0000000..bb73d0d --- /dev/null +++ b/test/unit/update.test.ts @@ -0,0 +1,138 @@ +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("update", () => { + it("does not change update action if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ models: {} })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { email: "test@test.com" }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify update results", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ models: { User: true } })); + + const query = jest.fn(() => Promise.resolve({ id: 1, name: "John" })); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { name: "John" }, + }); + + const result = await $allOperations(params); + + expect(result).toEqual({ id: 1, name: "John" }); + }); + + it("throws when trying to update a model configured for soft delete through a toOne relation", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { User: true }, + })); + + const query = () => Promise.resolve({}); + const params = createParams(query, "Post", "update", { + where: { id: 1 }, + data: { + author: { + update: { + email: "test@test.com", + }, + }, + }, + }); + + await expect($allOperations(params)).rejects.toThrowError( + 'prisma-extension-soft-delete: 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.' + ); + }); + + it("does nothing to nested update actions for toOne relations when allowToOneUpdates is true", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { User: true }, + defaultConfig: { + field: "deleted", + createValue: Boolean, + allowToOneUpdates: true, + }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "Post", "update", { + where: { id: 1 }, + data: { + author: { + update: { + email: "blah", + }, + }, + }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does nothing to nested update actions for toMany relations", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { User: true }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "Post", "update", { + where: { id: 1 }, + data: { + comments: { + update: { + where: { + id: 2, + }, + data: { + content: "content", + }, + }, + }, + }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify update when no args are passed", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ models: { User: true } })); + + const query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error - args are required + const params = createParams(query, "User", "update", undefined); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify update when no where is passed", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ models: { User: true } })); + + const query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error - where is required + const params = createParams(query, "User", "update", {}); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/test/unit/updateMany.test.ts b/test/unit/updateMany.test.ts new file mode 100644 index 0000000..9b973cc --- /dev/null +++ b/test/unit/updateMany.test.ts @@ -0,0 +1,187 @@ +import { set } from "lodash"; +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("updateMany", () => { + it("does not change updateMany action if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "updateMany", { + where: { id: { in: [1, 2] } }, + data: { email: "test@test.com" }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does not modify updateMany results", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve({ count: 1 })); + const params = createParams(query, "User", "updateMany", { + where: { id: 1 }, + data: { name: "John" }, + }); + + const result = await $allOperations(params); + + expect(result).toEqual({ count: 1 }); + }); + + it("does not change updateMany action if args not passed", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: { User: true } }) + ); + + const query = jest.fn(() => Promise.resolve({})); + // @ts-expect-error - args are required + const params = createParams(query, "User", "updateMany", undefined); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("excludes deleted records from root updateMany action", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "updateMany", { + where: { id: 1 }, + data: { email: "test@test.com" }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + ...params.args, + where: { + ...params.args.where, + deleted: false, + }, + }); + }); + + it("excludes deleted records from root updateMany action when where not passed", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "updateMany", { + data: { name: "John" }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith({ + ...params.args, + where: { + ...params.args.where, + deleted: false, + }, + }); + }); + + it("excludes deleted record from nested updateMany action", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Comment: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "update", { + where: { id: 1 }, + data: { + comments: { + updateMany: { + where: { + content: "foo", + OR: [{ authorId: 1 }, { authorId: 2 }], + AND: [ + { createdAt: { gt: new Date() } }, + { createdAt: { lt: new Date() } }, + ], + NOT: { content: "bar" }, + }, + data: { content: "bar" }, + }, + }, + }, + }); + + await $allOperations(params); + + // params have been modified + expect(query).toHaveBeenCalledWith( + set(params, "args.data.comments.updateMany.where.deleted", false).args + ); + }); + + it("allows explicitly updating deleted records", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + User: true, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "updateMany", { + where: { id: { in: [1, 2] }, deleted: true }, + data: { email: "test@test.com" }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("allows explicitly updating deleted records when using custom deletedAt field", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + User: { + field: "deletedAt", + createValue: (deleted) => { + if (deleted) return new Date(); + return null; + }, + }, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "updateMany", { + where: { id: { in: [1, 2] }, deletedAt: { not: null } }, + data: { email: "test@test.com" }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/test/unit/upsert.test.ts b/test/unit/upsert.test.ts new file mode 100644 index 0000000..51fd895 --- /dev/null +++ b/test/unit/upsert.test.ts @@ -0,0 +1,95 @@ +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("upsert", () => { + it("does not modify upsert results", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ models: { User: true } })); + + const query = jest.fn(() => + Promise.resolve({ id: 1, name: "John", email: "John@test.com" }) + ); + const params = createParams(query, "User", "upsert", { + where: { id: 1 }, + create: { name: "John", email: "John@test.com" }, + update: { name: "John" }, + }); + + const result = await $allOperations(params); + expect(result).toEqual({ + id: 1, + name: "John", + email: "John@test.com", + }); + }); + + it("does nothing to root upsert action", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { User: true }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "upsert", { + where: { id: 1 }, + create: { name: "John", email: "john@test.com" }, + update: { name: "John" }, + }); + + await $allOperations(params); + + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("does nothing to nested toMany upsert actions", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { User: true }, + })); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "Post", "update", { + where: { id: 1 }, + data: { + comments: { + upsert: { + where: { id: 1 }, + create: { content: "Hello", authorId: 1 }, + update: { content: "Hello" }, + }, + }, + }, + }); + + await $allOperations(params); + + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("throws when trying to upsert a model configured for soft delete through a toOne relation", async () => { + const { $allOperations } = mockClient.$extends(createSoftDeleteExtension({ + models: { User: true }, + })); + + const query = () => Promise.resolve({}); + const params = createParams(query, "Post", "update", { + where: { id: 1 }, + data: { + author: { + upsert: { + create: { + name: "test", + email: "test@test.com", + }, + update: { + email: "test@test.com", + }, + }, + }, + }, + }); + + + await expect($allOperations(params)).rejects.toThrowError( + 'prisma-extension-soft-delete: 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.' + ); + }); +}); diff --git a/test/unit/utils/createParams.ts b/test/unit/utils/createParams.ts new file mode 100644 index 0000000..8a128a6 --- /dev/null +++ b/test/unit/utils/createParams.ts @@ -0,0 +1,98 @@ +import { Prisma } from "@prisma/client"; + +type DelegateByModel = Model extends "User" + ? Prisma.UserDelegate + : Model extends "Post" + ? Prisma.PostDelegate + : Model extends "Profile" + ? Prisma.ProfileDelegate + : Model extends "Comment" + ? Prisma.CommentDelegate + : never; + +type SelectByModel = Model extends "User" + ? Prisma.UserSelect + : Model extends "Post" + ? Prisma.PostSelect + : Model extends "Profile" + ? Prisma.ProfileSelect + : Model extends "Comment" + ? Prisma.CommentSelect + : never; + +type IncludeByModel = Model extends "User" + ? Prisma.UserInclude + : Model extends "Post" + ? Prisma.PostInclude + : Model extends "Profile" + ? Prisma.ProfileInclude + : Model extends "Comment" + ? Prisma.CommentInclude + : never; + +type ActionByModel = + | keyof DelegateByModel + | "connectOrCreate" + | "select" + | "include"; + +type ArgsByAction< + Model extends Prisma.ModelName, + Action extends ActionByModel +> = Action extends "create" + ? Parameters["create"]>[0] + : Action extends "update" + ? Parameters["update"]>[0] + : Action extends "upsert" + ? Parameters["upsert"]>[0] + : Action extends "delete" + ? Parameters["delete"]>[0] + : Action extends "deleteMany" + ? Parameters["deleteMany"]>[0] + : Action extends "updateMany" + ? Parameters["updateMany"]>[0] + : Action extends "findUnique" + ? Parameters["findUnique"]>[0] + : Action extends "findUniqueOrThrow" + ? Parameters["findUnique"]>[0] + : Action extends "groupBy" + ? Parameters["groupBy"]>[0] + : Action extends "findFirst" + ? Parameters["findFirst"]>[0] + : Action extends "findFirstOrThrow" + ? Parameters["findFirstOrThrow"]>[0] + : Action extends "findMany" + ? Parameters["findMany"]>[0] + : Action extends "count" + ? Parameters["count"]>[0] + : Action extends "aggregate" + ? Parameters["aggregate"]>[0] + : Action extends "connectOrCreate" + ? { + where: Parameters["findUnique"]>[0]; + create: Parameters["create"]>[0]; + } + : Action extends "select" + ? SelectByModel + : Action extends "include" + ? IncludeByModel + : never; + +/** + * Creates params objects with strict typing of the `args` object to ensure it + * is valid for the `model` and `action` passed. + */ +export const createParams = < + Model extends Prisma.ModelName, + Action extends ActionByModel = ActionByModel, +>( + query: (args: any) => Promise, + model: Model, + operation: Action, + args: ArgsByAction, +) => ({ + query: query as any, + model, + operation: operation as any, + args: args as any, +}); diff --git a/test/unit/utils/mockClient.ts b/test/unit/utils/mockClient.ts new file mode 100644 index 0000000..36937b8 --- /dev/null +++ b/test/unit/utils/mockClient.ts @@ -0,0 +1,60 @@ +const operations = [ + "findUnique", + "findUniqueOrThrow", + "findFirst", + "findFirstOrThrow", + "findMany", + "aggregate", + "create", + "createMany", + "delete", + "deleteMany", + "update", + "updateMany", + "upsert", +]; + +class MockClient { + $allOperations: any; + user: any; + + constructor() { + this.user = operations.reduce((acc, operation) => { + acc[operation] = jest.fn(() => null); + return acc; + }, {}); + } + + $extends(extension: any) { + const ext = typeof extension === "function" ? extension(this) : extension; + const $allOperations = + ext instanceof MockClient + ? ext.$allOperations + : ext.query.$allModels.$allOperations; + + const newClient = new MockClient(); + newClient.$allOperations = $allOperations.bind(this); + + newClient.user = operations.reduce((acc, operation) => { + acc[operation] = jest.fn((params: any) => { + // @ts-ignore + return this.$allOperations({ + model: "User", + action: operation, + args: params, + }); + }); + return acc; + }, {}); + + return newClient; + } + + reset() { + operations.forEach((operation) => { + this.user[operation].mockReset(); + }); + } +} + +export default new MockClient(); diff --git a/test/unit/where.test.ts b/test/unit/where.test.ts new file mode 100644 index 0000000..b1a7e2b --- /dev/null +++ b/test/unit/where.test.ts @@ -0,0 +1,429 @@ +import faker from "faker"; +import { set } from "lodash"; + +import { createSoftDeleteExtension } from "../../src"; +import { createParams } from "./utils/createParams"; +import mockClient from "./utils/mockClient"; + +describe("where", () => { + it("does not change where action if model is not in the list", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ models: {} }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "deleteMany", { + where: { + email: faker.internet.email(), + comments: { + some: { + content: faker.lorem.sentence(), + }, + }, + }, + }); + + await $allOperations(params); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("changes root where correctly when model is nested", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Comment: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "deleteMany", { + where: { + email: faker.internet.email(), + comments: { + some: { + AND: [ + { createdAt: { gt: faker.date.past() } }, + { createdAt: { lt: faker.date.future() } }, + ], + OR: [ + { post: { content: faker.lorem.sentence() } }, + { post: { content: faker.lorem.sentence() } }, + ], + NOT: { post: { is: { authorName: faker.name.findName() } } }, + content: faker.lorem.sentence(), + post: { + isNot: { + content: faker.lorem.sentence(), + }, + }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.where.comments.some.deleted", false); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("handles where with modifiers correctly", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Post: true, Comment: true, User: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "Comment", "findMany", { + where: { + content: faker.lorem.sentence(), + post: { + is: { + content: "foo", + }, + }, + author: { + isNot: { + name: "Jack", + }, + }, + replies: { + some: { + content: "foo", + }, + every: { + content: "bar", + }, + none: { + content: "baz", + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.where.deleted", false); + set(params, "args.where.post.is.deleted", false); + set(params, "args.where.author.isNot.deleted", false); + set(params, "args.where.replies.some.deleted", false); + set(params, "args.where.replies.every", { + OR: [{ deleted: { not: false } }, params.args.where.replies.every], + }); + set(params, "args.where.replies.none.deleted", false); + + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("changes root where correctly when model is deeply nested", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { Post: true }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "deleteMany", { + where: { + email: faker.internet.email(), + comments: { + some: { + AND: [ + { createdAt: { gt: faker.date.past() } }, + { post: { content: faker.lorem.sentence() } }, + ], + OR: [ + { post: { content: faker.lorem.sentence() } }, + { createdAt: { lt: faker.date.future() } }, + ], + NOT: { + post: { + is: { + authorName: faker.name.findName(), + }, + }, + }, + post: { + isNot: { + content: faker.lorem.sentence(), + }, + }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.where.comments.some.AND.1.post.deleted", false); + set(params, "args.where.comments.some.OR.0.post.deleted", false); + set(params, "args.where.comments.some.NOT.post.is.deleted", false); + set(params, "args.where.comments.some.post.isNot.deleted", false); + + // params have not been modified + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("change root where correctly when multiple models passed", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + Comment: true, + Post: true, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "deleteMany", { + where: { + email: faker.internet.email(), + comments: { + some: { + AND: [ + { createdAt: { gt: faker.date.past() } }, + { createdAt: { lt: faker.date.future() } }, + ], + OR: [ + { post: { content: faker.lorem.sentence() } }, + { post: { content: faker.lorem.sentence() } }, + ], + NOT: { post: { is: { authorName: faker.name.findName() } } }, + content: faker.lorem.sentence(), + post: { + isNot: { + content: faker.lorem.sentence(), + }, + }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.where.comments.some.deleted", false); + set(params, "args.where.comments.some.NOT.post.is.deleted", false); + set(params, "args.where.comments.some.OR.0.post.deleted", false); + set(params, "args.where.comments.some.OR.1.post.deleted", false); + set(params, "args.where.comments.some.post.isNot.deleted", false); + + // params have not been modified + expect(query).toHaveBeenCalledWith( + set(params, "args.where.comments.some.deleted", false).args + ); + }); + + it("allows checking for deleted records explicitly", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + Comment: true, + Post: true, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "deleteMany", { + where: { + email: faker.internet.email(), + comments: { + some: { + deleted: true, + AND: [ + { createdAt: { gt: faker.date.past() } }, + { createdAt: { lt: faker.date.future() } }, + ], + OR: [ + { post: { deleted: true, content: faker.lorem.sentence() } }, + { post: { content: faker.lorem.sentence() } }, + ], + NOT: { + post: { + is: { deleted: true, authorName: faker.name.findName() }, + }, + }, + content: faker.lorem.sentence(), + post: { + isNot: { + content: faker.lorem.sentence(), + }, + }, + replies: { + some: { + content: "foo", + deleted: true, + }, + every: { + content: "bar", + deleted: true, + }, + none: { + content: "baz", + deleted: true, + }, + }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.where.comments.some.deleted", true); + set(params, "args.where.comments.some.OR.0.post.deleted", true); + set(params, "args.where.comments.some.OR.1.post.deleted", false); + set(params, "args.where.comments.some.NOT.post.is.deleted", true); + set(params, "args.where.comments.some.post.isNot.deleted", false); + set(params, "args.where.comments.some.replies.some.deleted", true); + set(params, "args.where.comments.some.replies.every.deleted", true); + set(params, "args.where.comments.some.replies.none.deleted", true); + + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("excludes deleted from include where with nested relations", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + Comment: true, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findMany", { + include: { + posts: { + where: { + comments: { + some: { + content: faker.lorem.sentence(), + }, + }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.include.posts.where.comments.some.deleted", false); + + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("excludes deleted from select where with nested relations", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + Comment: true, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findMany", { + select: { + posts: { + where: { + comments: { + some: { + content: faker.lorem.sentence(), + }, + }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.select.posts.where.comments.some.deleted", false); + + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("excludes deleted from include where with nested relations and multiple models", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + Comment: true, + Post: true, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findMany", { + include: { + comments: { + where: { + post: { + content: faker.lorem.sentence(), + comments: { + some: { + content: faker.lorem.sentence(), + }, + }, + }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.include.comments.where.deleted", false); + set(params, "args.include.comments.where.post.deleted", false); + set( + params, + "args.include.comments.where.post.comments.some.deleted", + false + ); + + expect(query).toHaveBeenCalledWith(params.args); + }); + + it("excludes deleted from select where with nested relations and multiple models", async () => { + const { $allOperations } = mockClient.$extends( + createSoftDeleteExtension({ + models: { + Comment: true, + Post: true, + }, + }) + ); + + const query = jest.fn(() => Promise.resolve({})); + const params = createParams(query, "User", "findMany", { + select: { + comments: { + where: { + post: { + content: faker.lorem.sentence(), + comments: { + some: { + content: faker.lorem.sentence(), + }, + }, + }, + }, + }, + }, + }); + + await $allOperations(params); + + set(params, "args.select.comments.where.deleted", false); + set(params, "args.select.comments.where.post.deleted", false); + set(params, "args.select.comments.where.post.comments.some.deleted", false); + + expect(query).toHaveBeenCalledWith(params.args); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..c4bf43d --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "noEmit": false + }, + "exclude": ["test", "dist"] +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..c83902e --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "esnext", + "outDir": "./dist/esm", + "moduleResolution": "node", + "declaration": true, + "noEmit": false + }, + "exclude": ["test", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2e66c3e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "es2019", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "declaration": true, + "outDir": "./dist", + "skipLibCheck": true, + "noEmit": true + }, + "exclude": ["test"] +}