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"]
+}