├── .eslintrc
├── .github
└── workflows
│ └── prisma-soft-delete-middleware.yml
├── .gitignore
├── .npmrc
├── LICENSE
├── README.md
├── docker-compose.yml
├── jest.config.e2e.js
├── jest.config.js
├── jest.config.unit.js
├── package.json
├── prisma
└── schema.prisma
├── src
├── index.ts
└── lib
│ ├── actionMiddleware.ts
│ ├── createSoftDeleteMiddleware.ts
│ ├── types.ts
│ └── utils
│ ├── nestedReads.ts
│ └── resultFiltering.ts
├── test
├── e2e
│ ├── client.ts
│ ├── deletedAt.test.ts
│ ├── nestedReads.test.ts
│ ├── queries.test.ts
│ └── where.test.ts
├── scripts
│ └── run-with-postgres.sh
└── unit
│ ├── aggregate.test.ts
│ ├── config.test.ts
│ ├── count.test.ts
│ ├── delete.test.ts
│ ├── deleteMany.test.ts
│ ├── findFirst.test.ts
│ ├── findFirstOrThrow.test.ts
│ ├── findMany.test.ts
│ ├── findUnique.test.ts
│ ├── findUniqueOrThrow.ts
│ ├── groupBy.test.ts
│ ├── include.test.ts
│ ├── select.test.ts
│ ├── update.test.ts
│ ├── updateMany.test.ts
│ ├── upsert.test.ts
│ ├── utils
│ └── createParams.ts
│ └── where.test.ts
├── tsconfig.build.json
├── tsconfig.esm.json
└── tsconfig.json
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "extends": [
4 | "./node_modules/kcd-scripts/eslint.js",
5 | "plugin:import/typescript"
6 | ],
7 | "plugins": ["@typescript-eslint"],
8 | "rules": {
9 | "babel/new-cap": "off",
10 | "func-names": "off",
11 | "babel/no-unused-expressions": "off",
12 | "prefer-arrow-callback": "off",
13 | "testing-library/no-await-sync-query": "off",
14 | "testing-library/no-dom-import": "off",
15 | "testing-library/prefer-screen-queries": "off",
16 | "no-undef": "off",
17 | "no-use-before-define": "off",
18 | "no-unused-vars": "off",
19 | "@typescript-eslint/no-unused-vars": [
20 | "error",
21 | { "varsIgnorePattern": "^_", "argsIgnorePattern": "^_" }
22 | ],
23 | "max-lines-per-function": "off",
24 | "consistent-return": "off",
25 | "jest/no-if": "off",
26 | "one-var": "off",
27 | "babel/camelcase": "off"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/.github/workflows/prisma-soft-delete-middleware.yml:
--------------------------------------------------------------------------------
1 | name: prisma-soft-delete-middleware
2 | on:
3 | push:
4 | branches:
5 | - 'main'
6 | - 'next'
7 | pull_request:
8 |
9 | jobs:
10 | test:
11 | name: 'node ${{ matrix.node }} chrome ${{ matrix.os }} '
12 | runs-on: '${{ matrix.os }}'
13 | strategy:
14 | matrix:
15 | os: [ubuntu-latest]
16 | node: [16]
17 | steps:
18 | - uses: actions/setup-node@v2
19 | with:
20 | node-version: ${{ matrix.node }}
21 | - uses: actions/checkout@v2
22 | - run: npm install
23 | - run: npm run validate
24 | env:
25 | CI: true
26 | release:
27 | runs-on: ubuntu-latest
28 | needs: test
29 | steps:
30 | - uses: actions/setup-node@v2
31 | with:
32 | node-version: 16
33 | - uses: actions/checkout@v2
34 | - run: npm install
35 | - run: npm run build
36 | - run: ls -asl dist
37 | - run: npx semantic-release
38 | env:
39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependency directories
2 | node_modules/
3 |
4 | # Build output directory
5 | dist
6 |
7 | # Test coverage directory
8 | coverage
9 |
10 | # Mac
11 | .DS_Store
12 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
Prisma Soft Delete Middleware
3 |
4 |
Prisma middleware for soft deleting records.
5 |
6 |
7 | Soft deleting records is a common pattern in many applications. This library provides middleware for Prisma that
8 | allows you to soft delete records and exclude them from queries. It handles deleting records through relations and
9 | excluding soft deleted records when including relations or referencing them in where objects. It does this by using
10 | the prisma-nested-middleware library to
11 | handle nested relations.
12 |
13 |
14 |
15 |
16 |
17 |
18 | [![Build Status][build-badge]][build]
19 | [![version][version-badge]][package]
20 | [![MIT License][license-badge]][license]
21 | [](https://github.com/semantic-release/semantic-release)
22 | [![PRs Welcome][prs-badge]][prs]
23 |
24 | ## Table of Contents
25 |
26 |
27 |
28 |
29 | - [Prisma Middleware Deprecation](#prisma-middleware-deprecation)
30 | - [Installation](#installation)
31 | - [Usage](#usage)
32 | - [Middleware Setup](#middleware-setup)
33 | - [Prisma Schema Setup](#prisma-schema-setup)
34 | - [Behaviour](#behaviour)
35 | - [Deleting Records](#deleting-records)
36 | - [Deleting a Single Record](#deleting-a-single-record)
37 | - [Deleting Multiple Records](#deleting-multiple-records)
38 | - [Deleting Through a relationship](#deleting-through-a-relationship)
39 | - [Hard Deletes](#hard-deletes)
40 | - [Excluding Soft Deleted Records](#excluding-soft-deleted-records)
41 | - [Excluding Soft Deleted Records in a `findFirst` Operation](#excluding-soft-deleted-records-in-a-findfirst-operation)
42 | - [Excluding Soft Deleted Records in a `findMany` Operation](#excluding-soft-deleted-records-in-a-findmany-operation)
43 | - [Excluding Soft Deleted Records in a `findUnique` Operation](#excluding-soft-deleted-records-in-a-findunique-operation)
44 | - [Updating Records](#updating-records)
45 | - [Explicitly Updating Many Soft Deleted Records](#explicitly-updating-many-soft-deleted-records)
46 | - [Where objects](#where-objects)
47 | - [Explicitly Querying Soft Deleted Records](#explicitly-querying-soft-deleted-records)
48 | - [Including or Selecting Soft Deleted Records](#including-or-selecting-soft-deleted-records)
49 | - [Including or Selecting toMany Relations](#including-or-selecting-tomany-relations)
50 | - [Including or Selecting toOne Relations](#including-or-selecting-toone-relations)
51 | - [Explicitly Including Soft Deleted Records in toMany Relations](#explicitly-including-soft-deleted-records-in-tomany-relations)
52 | - [LICENSE](#license)
53 |
54 |
55 |
56 | ## Prisma Middleware Deprecation
57 |
58 | Since Prisma middleware is deprecated this library has been ported to an extension:
59 | [prisma-extension-soft-delete](https://github.com/olivierwilkinson/prisma-extension-soft-delete).
60 |
61 | While middleware is still supported this library will continue to be maintained, however it is recommended to use the
62 | extension instead.
63 |
64 | ## Installation
65 |
66 | This module is distributed via [npm][npm] and should be installed as one of your
67 | project's dependencies:
68 |
69 | ```
70 | npm install --save prisma-soft-delete-middleware
71 | ```
72 |
73 | `@prisma/client` is a peer dependency of this library, so you will need to
74 | install it if you haven't already:
75 |
76 | ```
77 | npm install --save @prisma/client
78 | ```
79 |
80 | ## Usage
81 |
82 | ### Middleware Setup
83 |
84 | To add soft delete functionality to your Prisma client create the middleware using the `createSoftDeleteMiddleware`
85 | function and `$use` it with your client.
86 |
87 | The `createSoftDeleteMiddleware` function takes a config object where you can define the models you want to use soft
88 | delete with.
89 |
90 | ```typescript
91 | import { PrismaClient } from "@prisma/client";
92 |
93 | const client = new PrismaClient();
94 |
95 | client.$use(
96 | createSoftDeleteMiddleware({
97 | models: {
98 | Comment: true,
99 | },
100 | })
101 | );
102 | ```
103 |
104 | By default the middleware will use a `deleted` field of type `Boolean` on the model. If you want to use a custom field
105 | name or value you can pass a config object for the model. For example to use a `deletedAt` field where the value is null
106 | by default and a `DateTime` when the record is deleted you would pass the following:
107 |
108 | ```typescript
109 | client.$use(
110 | createSoftDeleteMiddleware({
111 | models: {
112 | Comment: {
113 | field: "deletedAt",
114 | createValue: (deleted) => {
115 | if (deleted) return new Date();
116 | return null;
117 | },
118 | },
119 | },
120 | })
121 | );
122 | ```
123 |
124 | The `field` property is the name of the field to use for soft delete, and the `createValue` property is a function that
125 | takes a deleted argument and returns the value for whether the record is soft deleted or not.
126 |
127 | The `createValue` method must return a deterministic value if the record is not deleted. This is because the middleware
128 | uses the value returned by `createValue` to modify the `where` object in the query, or to manually filter the results
129 | when including or selecting a toOne relationship. If the value for the field can have multiple values when the record is
130 | not deleted then this filtering will fail. Examples for good values to use when the `deleted` arg in `createValue` is
131 | false are `false`, `null`, `0` or `Date(0)`.
132 |
133 | It is possible to setup soft delete for multiple models at once by passing a config for each model in the `models`
134 | object:
135 |
136 | ```typescript
137 | client.$use(
138 | createSoftDeleteMiddleware({
139 | models: {
140 | Comment: true,
141 | Post: true,
142 | },
143 | })
144 | );
145 | ```
146 |
147 | To modify the default field and type for all models you can pass a `defaultConfig`:
148 |
149 | ```typescript
150 | client.$use(
151 | createSoftDeleteMiddleware({
152 | models: {
153 | Comment: true,
154 | Post: true,
155 | },
156 | defaultConfig: {
157 | field: "deletedAt",
158 | createValue: (deleted) => {
159 | if (deleted) return new Date();
160 | return null;
161 | },
162 | },
163 | })
164 | );
165 | ```
166 |
167 | When using the default config you can also override the default config for a specific model by passing a config object
168 | for that model:
169 |
170 | ```typescript
171 | client.$use(
172 | createSoftDeleteMiddleware({
173 | models: {
174 | Comment: true,
175 | Post: {
176 | field: "deleted",
177 | createValue: Boolean,
178 | },
179 | },
180 | defaultConfig: {
181 | field: "deletedAt",
182 | createValue: (deleted) => {
183 | if (deleted) return new Date();
184 | return null;
185 | },
186 | },
187 | })
188 | );
189 | ```
190 |
191 | The config object also has a `allowToOneUpdates` option that can be used to allow updates to toOne relationships through
192 | nested updates. By default this is set to `false` and will throw an error if you try to update a toOne relationship
193 | through a nested update. If you want to allow this you can set `allowToOneUpdates` to `true`:
194 |
195 | ```typescript
196 | client.$use(
197 | createSoftDeleteMiddleware({
198 | models: {
199 | Comment: {
200 | field: "deleted",
201 | createValue: Boolean,
202 | allowToOneUpdates: true,
203 | },
204 | },
205 | })
206 | );
207 | ```
208 |
209 | For more information for why updating through toOne relationship is disabled by default see the
210 | [Updating Records](#updating-records) section.
211 |
212 | Similarly to `allowToOneUpdates` there is an `allowCompoundUniqueIndexWhere` option that can be used to allow using
213 | where objects with compound unique index fields when using `findUnique` queries. By default this is set to `false` and
214 | will throw an error if you try to use a where with compound unique index fields. If you want to allow this you can set
215 | `allowCompoundUniqueIndexWhere` to `true`:
216 |
217 | ```typescript
218 | client.$use(
219 | createSoftDeleteMiddleware({
220 | models: {
221 | Comment: {
222 | field: "deleted",
223 | createValue: Boolean,
224 | allowCompoundUniqueIndexWhere: true,
225 | },
226 | },
227 | })
228 | );
229 | ```
230 |
231 | For more information for why updating through toOne relationship is disabled by default see the
232 | [Excluding Soft Deleted Records in a `findUnique` Operation](#excluding-soft-deleted-records-in-a-findunique-operation) section.
233 |
234 | To allow to one updates or compound unique index fields globally you can use the `defaultConfig` to do so:
235 |
236 | ```typescript
237 | client.$use(
238 | createSoftDeleteMiddleware({
239 | models: {
240 | User: true,
241 | Comment: true,
242 | },
243 | defaultConfig: {
244 | field: "deleted",
245 | createValue: Boolean,
246 | allowToOneUpdates: true,
247 | allowCompoundUniqueIndexWhere: true,
248 | },
249 | })
250 | );
251 | ```
252 |
253 | ### Prisma Schema Setup
254 |
255 | The Prisma schema must be updated to include the soft delete field for each model you want to use soft delete with.
256 |
257 | For models configured to use the default field and type you must add the `deleted` field to your Prisma schema manually.
258 | Using the Comment model configured in [Middleware Setup](#middleware-setup) you would need add the following to the
259 | Prisma schema:
260 |
261 | ```prisma
262 | model Comment {
263 | deleted Boolean @default(false)
264 | [other fields]
265 | }
266 | ```
267 |
268 | If the Comment model was configured to use a `deletedAt` field where the value is null by default and a `DateTime` when
269 | the record is deleted you would need to add the following to your Prisma schema:
270 |
271 | ```prisma
272 | model Comment {
273 | deletedAt DateTime?
274 | [other fields]
275 | }
276 | ```
277 |
278 | If the Comment model was configured to use a `deletedAt` field where the value is `Date(0)` by default and a `Date()`
279 | when the record is deleted you would need to add the following to your Prisma schema:
280 |
281 | ```prisma
282 | model Comment {
283 | deletedAt DateTime @default(dbgenerated("to_timestamp(0)"))
284 | // Note that the example above is for PostgreSQL, you will need to use the appropriate default function for your db.
285 | [other fields]
286 | }
287 | ```
288 |
289 | Models configured to use soft delete that are related to other models through a toOne relationship must have this
290 | relationship defined as optional. This is because the middleware will exclude soft deleted records when the relationship
291 | is included or selected. If the relationship is not optional the types for the relation will be incorrect and you may
292 | get runtime errors.
293 |
294 | For example if you have an `author` relationship on the Comment model and the User model is configured to use soft
295 | delete you would need to change the relationship to be optional:
296 |
297 | ```prisma
298 | model Comment {
299 | authorId Int?
300 | author User? @relation(fields: [authorId], references: [id])
301 | [other fields]
302 | }
303 | ```
304 |
305 | `@unique` fields on models that are configured to use soft deletes may cause problems due to the records not actually
306 | being deleted. If a record is soft deleted and then a new record is created with the same value for the unique field,
307 | the new record will not be created.
308 |
309 | ## Behaviour
310 |
311 | The main behaviour of the middleware is to replace delete operations with update operations that set the soft delete
312 | field to the deleted value.
313 |
314 | The middleware also prevents accidentally fetching or updating soft deleted records by excluding soft deleted records
315 | from find queries, includes, selects and bulk updates. The middleware does allow explicit queries for soft deleted
316 | records and allows updates through unique fields such is it's id. The reason it allows updates through unique fields is
317 | because soft deleted records can only be fetched explicitly so updates through a unique fields should be intentional.
318 |
319 | ### Deleting Records
320 |
321 | When deleting a record using the `delete` or `deleteMany` operations the middleware will change the operation to an
322 | `update` operation and set the soft delete field to be the deleted value defined in the config for that model.
323 |
324 | For example if the Comment model was configured to use the default `deleted` field of type `Boolean` the middleware
325 | would change the `delete` operation to an `update` operation and set the `deleted` field to `true`.
326 |
327 | #### Deleting a Single Record
328 |
329 | When deleting a single record using the `delete` operation:
330 |
331 | ```typescript
332 | await client.comment.delete({
333 | where: {
334 | id: 1,
335 | },
336 | });
337 | ```
338 |
339 | The middleware would change the operation to:
340 |
341 | ```typescript
342 | await client.comment.update({
343 | where: {
344 | id: 1,
345 | },
346 | data: {
347 | deleted: true,
348 | },
349 | });
350 | ```
351 |
352 | #### Deleting Multiple Records
353 |
354 | When deleting multiple records using the `deleteMany` operation:
355 |
356 | ```typescript
357 | await client.comment.deleteMany({
358 | where: {
359 | id: {
360 | in: [1, 2, 3],
361 | },
362 | },
363 | });
364 | ```
365 |
366 | The middleware would change the operation to:
367 |
368 | ```typescript
369 | await client.comment.updateMany({
370 | where: {
371 | id: {
372 | in: [1, 2, 3],
373 | },
374 | },
375 | data: {
376 | deleted: true,
377 | },
378 | });
379 | ```
380 |
381 | #### Deleting Through a relationship
382 |
383 | When using a nested delete through a relationship the middleware will change the nested delete operation to an update
384 | operation:
385 |
386 | ```typescript
387 | await client.post.update({
388 | where: {
389 | id: 1,
390 | },
391 | data: {
392 | comments: {
393 | delete: {
394 | where: {
395 | id: 2,
396 | },
397 | },
398 | },
399 | author: {
400 | delete: true,
401 | },
402 | },
403 | });
404 | ```
405 |
406 | The middleware would change the operation to:
407 |
408 | ```typescript
409 | await client.post.update({
410 | where: {
411 | id: 1,
412 | },
413 | data: {
414 | comments: {
415 | update: {
416 | where: {
417 | id: 2,
418 | },
419 | data: {
420 | deleted: true,
421 | },
422 | },
423 | },
424 | author: {
425 | update: {
426 | deleted: true,
427 | },
428 | },
429 | },
430 | });
431 | ```
432 |
433 | The same behaviour applies when using a nested `deleteMany` with a toMany relationship.
434 |
435 | #### Hard Deletes
436 |
437 | Hard deletes are not currently supported by this middleware, when the `extendedWhereUnique` feature is supported
438 | it will be possible to explicitly hard delete a soft deleted record. In the meantime you can use the `executeRaw`
439 | operation to perform hard deletes.
440 |
441 | ### Excluding Soft Deleted Records
442 |
443 | When using the `findUnique`, `findFirst` and `findMany` operations the middleware will modify the `where` object passed
444 | to exclude soft deleted records. It does this by adding an additional condition to the `where` object that excludes
445 | records where the soft delete field is set to the deleted value defined in the config for that model.
446 |
447 | #### Excluding Soft Deleted Records in a `findFirst` Operation
448 |
449 | When using a `findFirst` operation the middleware will modify the `where` object to exclude soft deleted records, so for:
450 |
451 | ```typescript
452 | await client.comment.findFirst({
453 | where: {
454 | id: 1,
455 | },
456 | });
457 | ```
458 |
459 | The middleware would change the operation to:
460 |
461 | ```typescript
462 | await client.comment.findFirst({
463 | where: {
464 | id: 1,
465 | deleted: false,
466 | },
467 | });
468 | ```
469 |
470 | #### Excluding Soft Deleted Records in a `findMany` Operation
471 |
472 | When using a `findMany` operation the middleware will modify the `where` object to exclude soft deleted records, so for:
473 |
474 | ```typescript
475 | await client.comment.findMany({
476 | where: {
477 | id: 1,
478 | },
479 | });
480 | ```
481 |
482 | The middleware would change the operation to:
483 |
484 | ```typescript
485 | await client.comment.findMany({
486 | where: {
487 | id: 1,
488 | deleted: false,
489 | },
490 | });
491 | ```
492 |
493 | #### Excluding Soft Deleted Records in a `findUnique` Operation
494 |
495 | When using a `findUnique` operation the middleware will change the query to use `findFirst` so that it can modify the
496 | `where` object to exclude soft deleted records, so for:
497 |
498 | ```typescript
499 | await client.comment.findUnique({
500 | where: {
501 | id: 1,
502 | },
503 | });
504 | ```
505 |
506 | The middleware would change the operation to:
507 |
508 | ```typescript
509 | await client.comment.findFirst({
510 | where: {
511 | id: 1,
512 | deleted: false,
513 | },
514 | });
515 | ```
516 |
517 | When querying using a compound unique index in the where object the middleware will throw an error by default. This
518 | is because it is not possible to use these types of where object with `findFirst` and it is not possible to exclude
519 | soft-deleted records when using `findUnique`. For example take the following query:
520 |
521 | ```typescript
522 | await client.user.findUnique({
523 | where: {
524 | name_email: {
525 | name: "foo",
526 | email: "bar",
527 | },
528 | },
529 | });
530 | ```
531 |
532 | Since the compound unique index `@@unique([name, email])` is being queried through the `name_email` field of the where
533 | object the middleware will throw to avoid accidentally returning a soft deleted record.
534 |
535 | It is possible to override this behaviour by setting `allowCompoundUniqueIndexWhere` to `true` in the model config.
536 |
537 | ### Updating Records
538 |
539 | Updating records is split into three categories, updating a single record using a root operation, updating a single
540 | record through a relation and updating multiple records either through a root operation or a relation.
541 |
542 | When updating a single record using a root operation such as `update` or `upsert` the middleware will not modify the
543 | operation. This is because unless explicitly queried for soft deleted records should not be returned from queries,
544 | so if these operations are updating a soft deleted record it should be intentional.
545 |
546 | When updating a single record through a relation the middleware will throw an error by default. This is because it is
547 | not possible to filter out soft deleted records for nested toOne relations. For example take the following query:
548 |
549 | ```typescript
550 | await client.post.update({
551 | where: {
552 | id: 1,
553 | },
554 | data: {
555 | author: {
556 | update: {
557 | name: "foo",
558 | },
559 | },
560 | },
561 | });
562 | ```
563 |
564 | Since the `author` field is a toOne relation it does not support a where object. This means that if the `author` field
565 | is a soft deleted record it will be updated accidentally.
566 |
567 | It is possible to override this behaviour by setting `allowToOneUpdates` to `true` in the middleware config.
568 |
569 | When updating multiple records using `updateMany` the middleware will modify the `where` object passed to exclude soft
570 | deleted records. For example take the following query:
571 |
572 | ```typescript
573 | await client.comment.updateMany({
574 | where: {
575 | id: 1,
576 | },
577 | data: {
578 | content: "foo",
579 | },
580 | });
581 | ```
582 |
583 | The middleware would change the operation to:
584 |
585 | ```typescript
586 | await client.comment.updateMany({
587 | where: {
588 | id: 1,
589 | deleted: false,
590 | },
591 | data: {
592 | content: "foo",
593 | },
594 | });
595 | ```
596 |
597 | This also works when a toMany relation is updated:
598 |
599 | ```typescript
600 | await client.post.update({
601 | where: {
602 | id: 1,
603 | },
604 | data: {
605 | comments: {
606 | updateMany: {
607 | where: {
608 | id: 1,
609 | },
610 | data: {
611 | content: "foo",
612 | },
613 | },
614 | },
615 | },
616 | });
617 | ```
618 |
619 | The middleware would change the operation to:
620 |
621 | ```typescript
622 | await client.post.update({
623 | where: {
624 | id: 1,
625 | },
626 | data: {
627 | comments: {
628 | updateMany: {
629 | where: {
630 | id: 1,
631 | deleted: false,
632 | },
633 | data: {
634 | content: "foo",
635 | },
636 | },
637 | },
638 | },
639 | });
640 | ```
641 |
642 | #### Explicitly Updating Many Soft Deleted Records
643 |
644 | When using the `updateMany` operation it is possible to explicitly update many soft deleted records by setting the
645 | deleted field to the deleted value defined in the config for that model. An example that would update soft deleted
646 | records would be:
647 |
648 | ```typescript
649 | await client.comment.updateMany({
650 | where: {
651 | content: "foo",
652 | deleted: true,
653 | },
654 | data: {
655 | content: "bar",
656 | },
657 | });
658 | ```
659 |
660 | ### Where objects
661 |
662 | When using a `where` query it is possible to reference models configured to use soft deletes. In this case the
663 | middleware will modify the `where` object to exclude soft deleted records from the query, so for:
664 |
665 | ```typescript
666 | await client.post.findMany({
667 | where: {
668 | id: 1,
669 | comments: {
670 | some: {
671 | content: "foo",
672 | },
673 | },
674 | },
675 | });
676 | ```
677 |
678 | The middleware would change the operation to:
679 |
680 | ```typescript
681 | await client.post.findMany({
682 | where: {
683 | id: 1,
684 | comments: {
685 | some: {
686 | content: "foo",
687 | deleted: false,
688 | },
689 | },
690 | },
691 | });
692 | ```
693 |
694 | This also works when the where object includes logical operators:
695 |
696 | ```typescript
697 | await client.post.findMany({
698 | where: {
699 | id: 1,
700 | OR: [
701 | {
702 | comments: {
703 | some: {
704 | author: {
705 | name: "Jack",
706 | },
707 | },
708 | },
709 | },
710 | {
711 | comments: {
712 | none: {
713 | author: {
714 | name: "Jill",
715 | },
716 | },
717 | },
718 | },
719 | ],
720 | },
721 | });
722 | ```
723 |
724 | The middleware would change the operation to:
725 |
726 | ```typescript
727 | await client.post.findMany({
728 | where: {
729 | id: 1,
730 | OR: [
731 | {
732 | comments: {
733 | some: {
734 | deleted: false,
735 | author: {
736 | name: "Jack",
737 | },
738 | },
739 | },
740 | },
741 | {
742 | comments: {
743 | none: {
744 | deleted: false,
745 | author: {
746 | name: "Jill",
747 | },
748 | },
749 | },
750 | },
751 | ],
752 | },
753 | });
754 | ```
755 |
756 | When using the `every` modifier the middleware will modify the `where` object to exclude soft deleted records from the
757 | query in a different way, so for:
758 |
759 | ```typescript
760 | await client.post.findMany({
761 | where: {
762 | id: 1,
763 | comments: {
764 | every: {
765 | content: "foo",
766 | },
767 | },
768 | },
769 | });
770 | ```
771 |
772 | The middleware would change the operation to:
773 |
774 | ```typescript
775 | await client.post.findMany({
776 | where: {
777 | id: 1,
778 | comments: {
779 | every: {
780 | OR: [{ deleted: { not: false } }, { content: "foo" }],
781 | },
782 | },
783 | },
784 | });
785 | ```
786 |
787 | This is because if the same logic that is used for `some` and `none` were to be used with `every` then the query would
788 | fail for cases where there are deleted models.
789 |
790 | The deleted case uses the `not` operator to ensure that the query works for custom fields and types. For example if the
791 | field was configured to be `deletedAt` where the type is `DateTime` when deleted and `null` when not deleted then the
792 | query would be:
793 |
794 | ```typescript
795 | await client.post.findMany({
796 | where: {
797 | id: 1,
798 | comments: {
799 | every: {
800 | OR: [{ deletedAt: { not: null } }, { content: "foo" }],
801 | },
802 | },
803 | },
804 | });
805 | ```
806 |
807 | #### Explicitly Querying Soft Deleted Records
808 |
809 | It is possible to explicitly query soft deleted records by setting the configured field in the `where` object. For
810 | example the following will include deleted records in the results:
811 |
812 | ```typescript
813 | await client.comment.findMany({
814 | where: {
815 | deleted: true,
816 | },
817 | });
818 | ```
819 |
820 | It is also possible to explicitly query soft deleted records through relationships in the `where` object. For example
821 | the following will also not be modified:
822 |
823 | ```typescript
824 | await client.post.findMany({
825 | where: {
826 | comments: {
827 | some: {
828 | deleted: true,
829 | },
830 | },
831 | },
832 | });
833 | ```
834 |
835 | ### Including or Selecting Soft Deleted Records
836 |
837 | When using `include` or `select` the middleware will modify the `include` and `select` objects passed to exclude soft
838 | deleted records.
839 |
840 | #### Including or Selecting toMany Relations
841 |
842 | When using `include` or `select` on a toMany relationship the middleware will modify the where object to exclude soft
843 | deleted records from the query, so for:
844 |
845 | ```typescript
846 | await client.post.findMany({
847 | where: {
848 | id: 1,
849 | },
850 | include: {
851 | comments: true,
852 | },
853 | });
854 | ```
855 |
856 | If the Comment model was configured to be soft deleted the middleware would modify the `include` action where object to
857 | exclude soft deleted records, so the query would be:
858 |
859 | ```typescript
860 | await client.post.findMany({
861 | where: {
862 | id: 1,
863 | },
864 | include: {
865 | comments: {
866 | where: {
867 | deleted: false,
868 | },
869 | },
870 | },
871 | });
872 | ```
873 |
874 | The same applies for `select`:
875 |
876 | ```typescript
877 | await client.post.findMany({
878 | where: {
879 | id: 1,
880 | },
881 | select: {
882 | comments: true,
883 | },
884 | });
885 | ```
886 |
887 | This also works for nested includes and selects:
888 |
889 | ```typescript
890 | await client.user.findMany({
891 | where: {
892 | id: 1,
893 | },
894 | include: {
895 | posts: {
896 | select: {
897 | comments: {
898 | where: {
899 | content: "foo",
900 | },
901 | },
902 | },
903 | },
904 | },
905 | });
906 | ```
907 |
908 | The middleware would modify the query to:
909 |
910 | ```typescript
911 | await client.user.findMany({
912 | where: {
913 | id: 1,
914 | },
915 | include: {
916 | posts: {
917 | select: {
918 | comments: {
919 | where: {
920 | deleted: false,
921 | content: "foo",
922 | },
923 | },
924 | },
925 | },
926 | },
927 | });
928 | ```
929 |
930 | #### Including or Selecting toOne Relations
931 |
932 | Records included through a toOne relation are also excluded, however there is no way to explicitly include them. For
933 | example the following query:
934 |
935 | ```typescript
936 | await client.post.findFirst({
937 | where: {
938 | id: 1,
939 | },
940 | include: {
941 | author: true,
942 | },
943 | });
944 | ```
945 |
946 | The middleware would not modify the query since toOne relations do not support where clauses. Instead the middleware
947 | will manually filter results based on the configured deleted field.
948 |
949 | So if the author of the Post was soft deleted the middleware would filter the results and remove the author from the
950 | results:
951 |
952 | ```typescript
953 | {
954 | id: 1,
955 | title: "foo",
956 | author: null
957 | }
958 | ```
959 |
960 | When selecting specific fields on a toOne relation the middleware will manually add the configured deleted field to the
961 | select object, filter the results and finally strip the deleted field from the results before returning them.
962 |
963 | For example the following query would behave that way:
964 |
965 | ```typescript
966 | await client.post.findMany({
967 | where: {
968 | id: 1,
969 | },
970 | select: {
971 | author: {
972 | select: {
973 | name: true,
974 | },
975 | },
976 | },
977 | });
978 | ```
979 |
980 | #### Explicitly Including Soft Deleted Records in toMany Relations
981 |
982 | It is possible to explicitly include soft deleted records in toMany relations by adding the configured deleted field to
983 | the `where` object. For example the following will include deleted records in the results:
984 |
985 | ```typescript
986 | await client.post.findMany({
987 | where: {
988 | id: 1,
989 | },
990 | include: {
991 | comments: {
992 | where: {
993 | deleted: true,
994 | },
995 | },
996 | },
997 | });
998 | ```
999 |
1000 | ## LICENSE
1001 |
1002 | Apache 2.0
1003 |
1004 | [npm]: https://www.npmjs.com/
1005 | [node]: https://nodejs.org
1006 | [build-badge]: https://github.com/olivierwilkinson/prisma-soft-delete-middleware/workflows/prisma-soft-delete-middleware/badge.svg
1007 | [build]: https://github.com/olivierwilkinson/prisma-soft-delete-middleware/actions?query=branch%3Amaster+workflow%3Aprisma-soft-delete-middleware
1008 | [version-badge]: https://img.shields.io/npm/v/prisma-soft-delete-middleware.svg?style=flat-square
1009 | [package]: https://www.npmjs.com/package/prisma-soft-delete-middleware
1010 | [downloads-badge]: https://img.shields.io/npm/dm/prisma-soft-delete-middleware.svg?style=flat-square
1011 | [npmtrends]: http://www.npmtrends.com/prisma-soft-delete-middleware
1012 | [license-badge]: https://img.shields.io/npm/l/prisma-soft-delete-middleware.svg?style=flat-square
1013 | [license]: https://github.com/olivierwilkinson/prisma-soft-delete-middleware/blob/main/LICENSE
1014 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square
1015 | [prs]: http://makeapullrequest.com
1016 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square
1017 | [coc]: https://github.com/olivierwilkinson/prisma-soft-delete-middleware/blob/main/other/CODE_OF_CONDUCT.md
1018 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.7'
2 |
3 | services:
4 | postgres:
5 | image: "postgres:latest"
6 | hostname: postgres
7 | user: postgres
8 | restart: always
9 | environment:
10 | - POSTGRES_DATABASE=test
11 | - POSTGRES_PASSWORD=123
12 | ports:
13 | - '5432:5432'
14 |
--------------------------------------------------------------------------------
/jest.config.e2e.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | module.exports = {
3 | preset: "ts-jest",
4 | testEnvironment: "node",
5 | testRegex: "test/e2e/.+\\.test\\.ts$",
6 | };
7 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | module.exports = {
3 | preset: "ts-jest",
4 | testEnvironment: "node",
5 | testRegex: ".+\\.test\\.ts$",
6 | };
7 |
--------------------------------------------------------------------------------
/jest.config.unit.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | module.exports = {
3 | preset: "ts-jest",
4 | testEnvironment: "node",
5 | testRegex: "test/unit/.+\\.test\\.ts$",
6 | };
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "prisma-soft-delete-middleware",
3 | "version": "1.0.0-semantically-released",
4 | "description": "Prisma middleware for soft deleting records",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "module": "dist/esm/index.js",
8 | "scripts": {
9 | "build": "npm-run-all build:cjs build:esm",
10 | "build:cjs": "tsc -p tsconfig.build.json",
11 | "build:esm": "tsc -p tsconfig.esm.json",
12 | "test:unit": "prisma generate && jest --config jest.config.unit.js",
13 | "test:e2e": "./test/scripts/run-with-postgres.sh jest --config jest.config.e2e.js --runInBand",
14 | "test": "./test/scripts/run-with-postgres.sh jest --runInBand",
15 | "lint": "eslint ./src --fix --ext .ts",
16 | "typecheck": "npm run build:cjs -- --noEmit && npm run build:esm -- --noEmit",
17 | "validate": "kcd-scripts validate lint,typecheck,test",
18 | "semantic-release": "semantic-release",
19 | "doctoc": "doctoc ."
20 | },
21 | "files": [
22 | "dist"
23 | ],
24 | "keywords": [
25 | "prisma",
26 | "client",
27 | "middleware"
28 | ],
29 | "author": "Olivier Wilkinson",
30 | "license": "Apache-2.0",
31 | "dependencies": {
32 | "prisma-nested-middleware": "^4.0.0"
33 | },
34 | "peerDependencies": {
35 | "@prisma/client": "*"
36 | },
37 | "devDependencies": {
38 | "@prisma/client": "^5.4.2",
39 | "@types/faker": "^5.5.9",
40 | "@types/jest": "^29.2.5",
41 | "@types/lodash": "^4.14.192",
42 | "@typescript-eslint/eslint-plugin": "^4.14.0",
43 | "@typescript-eslint/parser": "^4.14.0",
44 | "doctoc": "^2.2.0",
45 | "eslint": "^7.6.0",
46 | "faker": "^5.0.0",
47 | "jest": "^29.3.1",
48 | "kcd-scripts": "^5.0.0",
49 | "lodash": "^4.17.21",
50 | "npm-run-all": "^4.1.5",
51 | "prisma": "^5.4.2",
52 | "semantic-release": "^17.0.2",
53 | "ts-jest": "^29.0.3",
54 | "ts-node": "^9.1.1",
55 | "typescript": "^4.1.3"
56 | },
57 | "repository": {
58 | "type": "git",
59 | "url": "https://github.com/olivierwilkinson/prisma-soft-delete-middleware.git"
60 | },
61 | "release": {
62 | "branches": [
63 | "main",
64 | {
65 | "name": "prerelease",
66 | "prerelease": true
67 | },
68 | {
69 | "name": "next",
70 | "prerelease": true
71 | }
72 | ]
73 | },
74 | "publishConfig": {
75 | "access": "public"
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("DATABASE_URL")
8 | }
9 |
10 | model User {
11 | id Int @id @default(autoincrement())
12 | email String @unique
13 | name String
14 | posts Post[]
15 | profileId Int?
16 | profile Profile? @relation(fields: [profileId], references: [id])
17 | comments Comment[]
18 | deleted Boolean @default(false)
19 | deletedAt DateTime?
20 |
21 | @@unique([name, email])
22 | }
23 |
24 | model Post {
25 | id Int @id @default(autoincrement())
26 | createdAt DateTime @default(now())
27 | updatedAt DateTime @updatedAt
28 | published Boolean @default(false)
29 | title String
30 | content String?
31 | author User? @relation(fields: [authorName, authorEmail], references: [name, email])
32 | authorName String?
33 | authorEmail String?
34 | authorId Int
35 | comments Comment[]
36 | deleted Boolean @default(false)
37 | }
38 |
39 | model Comment {
40 | id Int @id @default(autoincrement())
41 | createdAt DateTime @default(now())
42 | updatedAt DateTime @updatedAt
43 | content String
44 | author User? @relation(fields: [authorId], references: [id])
45 | authorId Int?
46 | post Post? @relation(fields: [postId], references: [id])
47 | postId Int?
48 | repliedTo Comment? @relation("replies", fields: [repliedToId], references: [id])
49 | repliedToId Int?
50 | replies Comment[] @relation("replies")
51 | deleted Boolean @default(false)
52 | }
53 |
54 | model Profile {
55 | id Int @id @default(autoincrement())
56 | bio String?
57 | deleted Boolean @default(false)
58 | deletedAt DateTime @default(dbgenerated("to_timestamp(0)"))
59 | users User[]
60 | }
61 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./lib/types";
2 |
3 | export { createSoftDeleteMiddleware } from "./lib/createSoftDeleteMiddleware";
4 |
--------------------------------------------------------------------------------
/src/lib/actionMiddleware.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "@prisma/client";
2 | import { NestedParams, NestedMiddleware } from "prisma-nested-middleware";
3 |
4 | import { ModelConfig } from "./types";
5 | import {
6 | addDeletedToSelect,
7 | stripDeletedFieldFromResults,
8 | } from "./utils/nestedReads";
9 | import {
10 | filterSoftDeletedResults,
11 | shouldFilterDeletedFromReadResult,
12 | } from "./utils/resultFiltering";
13 |
14 | const uniqueFieldsByModel: Record = {};
15 | const uniqueIndexFieldsByModel: Record = {};
16 |
17 | Prisma.dmmf.datamodel.models.forEach((model) => {
18 | // add unique fields derived from indexes
19 | const uniqueIndexFields: string[] = [];
20 | model.uniqueFields.forEach((field) => {
21 | uniqueIndexFields.push(field.join("_"));
22 | });
23 | uniqueIndexFieldsByModel[model.name] = uniqueIndexFields;
24 |
25 | // add id field and unique fields from @unique decorator
26 | const uniqueFields: string[] = [];
27 | model.fields.forEach((field) => {
28 | if (field.isId || field.isUnique) {
29 | uniqueFields.push(field.name);
30 | }
31 | });
32 | uniqueFieldsByModel[model.name] = uniqueFields;
33 | });
34 |
35 | /* Delete middleware */
36 |
37 | function createDeleteParams(
38 | params: NestedParams,
39 | { field, createValue }: ModelConfig
40 | ): NestedParams | NestedParams[] {
41 | if (
42 | !params.model ||
43 | // do nothing for delete: false
44 | (typeof params.args === "boolean" && !params.args) ||
45 | // do nothing for root delete without where to allow Prisma to throw
46 | (!params.scope && !params.args?.where)
47 | ) {
48 | return params;
49 | }
50 |
51 | if (typeof params.args === "boolean") {
52 | return {
53 | ...params,
54 | action: "update",
55 | args: {
56 | __passUpdateThrough: true,
57 | [field]: createValue(true),
58 | },
59 | };
60 | }
61 |
62 | if (!!params.scope) {
63 | return {
64 | ...params,
65 | action: "update",
66 | args: {
67 | where: params.args,
68 | data: {
69 | [field]: createValue(true),
70 | },
71 | },
72 | };
73 | }
74 |
75 | return {
76 | ...params,
77 | action: "update",
78 | args: {
79 | ...params.args,
80 | data: {
81 | [field]: createValue(true),
82 | },
83 | },
84 | };
85 | }
86 |
87 | export function createDeleteMiddleware(config: ModelConfig): NestedMiddleware {
88 | return function deleteMiddleware(params, next) {
89 | return next(createDeleteParams(params, config));
90 | };
91 | }
92 |
93 | /* DeleteMany middleware */
94 |
95 | function createDeleteManyParams(
96 | params: NestedParams,
97 | config: ModelConfig
98 | ): NestedParams {
99 | if (!params.model) return params;
100 |
101 | const where = params.args?.where || params.args;
102 |
103 | return {
104 | ...params,
105 | action: "updateMany",
106 | args: {
107 | where: {
108 | ...where,
109 | [config.field]: config.createValue(false),
110 | },
111 | data: {
112 | [config.field]: config.createValue(true),
113 | },
114 | },
115 | };
116 | }
117 |
118 | export function createDeleteManyMiddleware(
119 | config: ModelConfig
120 | ): NestedMiddleware {
121 | return function deleteManyMiddleware(params, next) {
122 | return next(createDeleteManyParams(params, config));
123 | };
124 | }
125 |
126 | /* Update middleware */
127 |
128 | function createUpdateParams(
129 | params: NestedParams,
130 | config: ModelConfig
131 | ): NestedParams {
132 | if (
133 | params.scope?.relations &&
134 | !params.scope.relations.to.isList &&
135 | !config.allowToOneUpdates &&
136 | !params.args?.__passUpdateThrough
137 | ) {
138 | throw new Error(
139 | `prisma-soft-delete-middleware: update of model "${params.model}" through "${params.scope?.parentParams.model}.${params.scope.relations.to.name}" found. Updates of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.`
140 | );
141 | }
142 |
143 | // remove __passUpdateThrough from args
144 | if (params.args?.__passUpdateThrough) {
145 | delete params.args.__passUpdateThrough;
146 | }
147 |
148 | return params;
149 | }
150 |
151 | export function createUpdateMiddleware(config: ModelConfig): NestedMiddleware {
152 | return function updateMiddleware(params, next) {
153 | return next(createUpdateParams(params, config));
154 | };
155 | }
156 |
157 | /* UpdateMany middleware */
158 |
159 | function createUpdateManyParams(
160 | params: NestedParams,
161 | config: ModelConfig
162 | ): NestedParams {
163 | // do nothing if args are not defined to allow Prisma to throw an error
164 | if (!params.args) return params;
165 |
166 | return {
167 | ...params,
168 | args: {
169 | ...params.args,
170 | where: {
171 | ...params.args?.where,
172 | // allow overriding the deleted field in where
173 | [config.field]:
174 | params.args?.where?.[config.field] || config.createValue(false),
175 | },
176 | },
177 | };
178 | }
179 |
180 | export function createUpdateManyMiddleware(
181 | config: ModelConfig
182 | ): NestedMiddleware {
183 | return function updateManyMiddleware(params, next) {
184 | return next(createUpdateManyParams(params, config));
185 | };
186 | }
187 |
188 | /* Upsert middleware */
189 |
190 | export function createUpsertMiddleware(_: ModelConfig): NestedMiddleware {
191 | return function upsertMiddleware(params, next) {
192 | if (params.scope?.relations && !params.scope.relations.to.isList) {
193 | throw new Error(
194 | `prisma-soft-delete-middleware: upsert of model "${params.model}" through "${params.scope?.parentParams.model}.${params.scope.relations.to.name}" found. Upserts of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.`
195 | );
196 | }
197 |
198 | return next(params);
199 | };
200 | }
201 |
202 | /* FindUnique middleware helpers */
203 |
204 | function validateFindUniqueParams(
205 | params: NestedParams,
206 | config: ModelConfig
207 | ): void {
208 | const uniqueIndexFields = uniqueIndexFieldsByModel[params.model || ""] || [];
209 | const uniqueIndexField = Object.keys(params.args?.where || {}).find((key) =>
210 | uniqueIndexFields.includes(key)
211 | );
212 |
213 | // when unique index field is found it is not possible to use findFirst.
214 | // Instead warn the user that soft-deleted models will not be excluded from
215 | // this query unless warnForUniqueIndexes is false.
216 | if (uniqueIndexField && !config.allowCompoundUniqueIndexWhere) {
217 | throw new Error(
218 | `prisma-soft-delete-middleware: query of model "${params.model}" through compound unique index field "${uniqueIndexField}" found. Queries of soft deleted models through a unique index are not supported. Set "allowCompoundUniqueIndexWhere" to true to override this behaviour.`
219 | );
220 | }
221 | }
222 |
223 | function shouldPassFindUniqueParamsThrough(
224 | params: NestedParams,
225 | config: ModelConfig
226 | ): boolean {
227 | const uniqueFields = uniqueFieldsByModel[params.model || ""] || [];
228 | const uniqueIndexFields = uniqueIndexFieldsByModel[params.model || ""] || [];
229 | const uniqueIndexField = Object.keys(params.args?.where || {}).find((key) =>
230 | uniqueIndexFields.includes(key)
231 | );
232 |
233 | // pass through invalid args so Prisma throws an error
234 | return (
235 | // findUnique must have a where object
236 | !params.args?.where ||
237 | typeof params.args.where !== "object" ||
238 | // where object must have at least one defined unique field
239 | !Object.entries(params.args.where).some(
240 | ([key, val]) =>
241 | (uniqueFields.includes(key) || uniqueIndexFields.includes(key)) &&
242 | typeof val !== "undefined"
243 | ) ||
244 | // pass through if where object has a unique index field and allowCompoundUniqueIndexWhere is true
245 | !!(uniqueIndexField && config.allowCompoundUniqueIndexWhere)
246 | );
247 | }
248 |
249 | /* FindUnique middleware */
250 |
251 | function createFindUniqueParams(
252 | params: NestedParams,
253 | config: ModelConfig
254 | ): NestedParams {
255 | if (shouldPassFindUniqueParamsThrough(params, config)) {
256 | return params;
257 | }
258 |
259 | validateFindUniqueParams(params, config);
260 |
261 | return {
262 | ...params,
263 | action: "findFirst",
264 | args: {
265 | ...params.args,
266 | where: {
267 | ...params.args?.where,
268 | [config.field]: config.createValue(false),
269 | },
270 | },
271 | };
272 | }
273 |
274 | export function createFindUniqueMiddleware(
275 | config: ModelConfig
276 | ): NestedMiddleware {
277 | return function findUniqueMiddleware(params, next) {
278 | return next(createFindUniqueParams(params, config));
279 | };
280 | }
281 |
282 | /* FindUniqueOrThrow middleware */
283 |
284 | function createFindUniqueOrThrowParams(
285 | params: NestedParams,
286 | config: ModelConfig
287 | ): NestedParams {
288 | if (shouldPassFindUniqueParamsThrough(params, config)) {
289 | return params;
290 | }
291 |
292 | validateFindUniqueParams(params, config);
293 |
294 | return {
295 | ...params,
296 | action: "findFirstOrThrow",
297 | args: {
298 | ...params.args,
299 | where: {
300 | ...params.args?.where,
301 | [config.field]: config.createValue(false),
302 | },
303 | },
304 | };
305 | }
306 |
307 | export function createFindUniqueOrThrowMiddleware(
308 | config: ModelConfig
309 | ): NestedMiddleware {
310 | return function findUniqueMiddleware(params, next) {
311 | return next(createFindUniqueOrThrowParams(params, config));
312 | };
313 | }
314 |
315 | /* FindFirst middleware */
316 |
317 | function createFindFirstParams(
318 | params: NestedParams,
319 | config: ModelConfig
320 | ): NestedParams {
321 | return {
322 | ...params,
323 | action: "findFirst",
324 | args: {
325 | ...params.args,
326 | where: {
327 | ...params.args?.where,
328 | // allow overriding the deleted field in where
329 | [config.field]:
330 | params.args?.where?.[config.field] || config.createValue(false),
331 | },
332 | },
333 | };
334 | }
335 |
336 | export function createFindFirstMiddleware(
337 | config: ModelConfig
338 | ): NestedMiddleware {
339 | return function findFirst(params, next) {
340 | return next(createFindFirstParams(params, config));
341 | };
342 | }
343 |
344 | /* FindFirst middleware */
345 |
346 | function createFindFirstOrThrowParams(
347 | params: NestedParams,
348 | config: ModelConfig
349 | ): NestedParams {
350 | return {
351 | ...params,
352 | action: "findFirstOrThrow",
353 | args: {
354 | ...params.args,
355 | where: {
356 | ...params.args?.where,
357 | // allow overriding the deleted field in where
358 | [config.field]:
359 | params.args?.where?.[config.field] || config.createValue(false),
360 | },
361 | },
362 | };
363 | }
364 |
365 | export function createFindFirstOrThrowMiddleware(
366 | config: ModelConfig
367 | ): NestedMiddleware {
368 | return function findFirstOrThrow(params, next) {
369 | return next(createFindFirstOrThrowParams(params, config));
370 | };
371 | }
372 |
373 | /* FindMany middleware */
374 |
375 | function createFindManyParams(
376 | params: NestedParams,
377 | config: ModelConfig
378 | ): NestedParams {
379 | return {
380 | ...params,
381 | action: "findMany",
382 | args: {
383 | ...params.args,
384 | where: {
385 | ...params.args?.where,
386 | // allow overriding the deleted field in where
387 | [config.field]:
388 | params.args?.where?.[config.field] || config.createValue(false),
389 | },
390 | },
391 | };
392 | }
393 |
394 | export function createFindManyMiddleware(
395 | config: ModelConfig
396 | ): NestedMiddleware {
397 | return function findManyMiddleware(params, next) {
398 | return next(createFindManyParams(params, config));
399 | };
400 | }
401 |
402 | /*GroupBy middleware */
403 | function createGroupByParams(
404 | params: NestedParams,
405 | config: ModelConfig
406 | ): NestedParams {
407 | return {
408 | ...params,
409 | action: "groupBy",
410 | args: {
411 | ...params.args,
412 | where: {
413 | ...params.args?.where,
414 | // allow overriding the deleted field in where
415 | [config.field]:
416 | params.args?.where?.[config.field] || config.createValue(false),
417 | },
418 | },
419 | };
420 | }
421 |
422 | export function createGroupByMiddleware(config: ModelConfig): NestedMiddleware {
423 | return function groupByMiddleware(params, next) {
424 | return next(createGroupByParams(params, config));
425 | };
426 | }
427 |
428 | /* Count middleware */
429 |
430 | function createCountParams(
431 | params: NestedParams,
432 | config: ModelConfig
433 | ): NestedParams {
434 | const args = params.args || {};
435 | const where = args.where || {};
436 |
437 | return {
438 | ...params,
439 | args: {
440 | ...args,
441 | where: {
442 | ...where,
443 | // allow overriding the deleted field in where
444 | [config.field]: where[config.field] || config.createValue(false),
445 | },
446 | },
447 | };
448 | }
449 |
450 | export function createCountMiddleware(config: ModelConfig): NestedMiddleware {
451 | return function countMiddleware(params, next) {
452 | return next(createCountParams(params, config));
453 | };
454 | }
455 |
456 | /* Aggregate middleware */
457 |
458 | function createAggregateParams(
459 | params: NestedParams,
460 | config: ModelConfig
461 | ): NestedParams {
462 | const args = params.args || {};
463 | const where = args.where || {};
464 |
465 | return {
466 | ...params,
467 | args: {
468 | ...args,
469 | where: {
470 | ...where,
471 | // allow overriding the deleted field in where
472 | [config.field]: where[config.field] || config.createValue(false),
473 | },
474 | },
475 | };
476 | }
477 |
478 | export function createAggregateMiddleware(
479 | config: ModelConfig
480 | ): NestedMiddleware {
481 | return function aggregateMiddleware(params, next) {
482 | return next(createAggregateParams(params, config));
483 | };
484 | }
485 |
486 | /* Where middleware */
487 |
488 | function createWhereParams(params: NestedParams, config: ModelConfig) {
489 | // customise list queries with every modifier unless the deleted field is set
490 | if (params.scope?.modifier === "every" && !params.args[config.field]) {
491 | return {
492 | ...params,
493 | args: {
494 | OR: [
495 | { [config.field]: { not: config.createValue(false) } },
496 | params.args,
497 | ],
498 | },
499 | };
500 | }
501 |
502 | return {
503 | ...params,
504 | args: {
505 | ...params.args,
506 | [config.field]: params.args[config.field] || config.createValue(false),
507 | },
508 | };
509 | }
510 |
511 | export function createWhereMiddleware(config: ModelConfig): NestedMiddleware {
512 | return function whereMiddleware(params, next) {
513 | if (!params.scope) return next(params);
514 | return next(createWhereParams(params, config));
515 | };
516 | }
517 |
518 | /* Include middleware */
519 |
520 | function createIncludeParams(
521 | params: NestedParams,
522 | config: ModelConfig
523 | ): NestedParams {
524 | // includes of toOne relation cannot filter deleted records using params
525 | // instead ensure that the deleted field is selected and filter the results
526 | if (params.scope?.relations?.to.isList === false) {
527 | if (params.args?.select && !params.args?.select[config.field]) {
528 | return addDeletedToSelect(params, config);
529 | }
530 |
531 | return params;
532 | }
533 |
534 | return {
535 | ...params,
536 | args: {
537 | ...params.args,
538 | where: {
539 | ...params.args?.where,
540 | // allow overriding the deleted field in where
541 | [config.field]:
542 | params.args?.where?.[config.field] || config.createValue(false),
543 | },
544 | },
545 | };
546 | }
547 |
548 | export function createIncludeMiddleware(config: ModelConfig): NestedMiddleware {
549 | return async function includeMiddleware(params, next) {
550 | const updatedParams = createIncludeParams(params, config);
551 |
552 | const deletedFieldAdded =
553 | typeof updatedParams.args === "object" &&
554 | updatedParams.args?.__deletedFieldAdded;
555 |
556 | if (deletedFieldAdded) {
557 | delete updatedParams.args.__deletedFieldAdded;
558 | }
559 |
560 | const result = await next(updatedParams);
561 |
562 | if (shouldFilterDeletedFromReadResult(params, config)) {
563 | const filteredResults = filterSoftDeletedResults(result, config);
564 |
565 | if (deletedFieldAdded) {
566 | stripDeletedFieldFromResults(filteredResults, config);
567 | }
568 |
569 | return filteredResults;
570 | }
571 |
572 | return result;
573 | };
574 | }
575 |
576 | /* Select middleware */
577 |
578 | function createSelectParams(
579 | params: NestedParams,
580 | config: ModelConfig
581 | ): NestedParams {
582 | // selects of toOne relation cannot filter deleted records using params
583 | if (params.scope?.relations?.to.isList === false) {
584 | if (params.args?.select && !params.args.select[config.field]) {
585 | return addDeletedToSelect(params, config);
586 | }
587 |
588 | return params;
589 | }
590 |
591 | return {
592 | ...params,
593 | args: {
594 | ...params.args,
595 | where: {
596 | ...params.args?.where,
597 | // allow overriding the deleted field in where
598 | [config.field]:
599 | params.args?.where?.[config.field] || config.createValue(false),
600 | },
601 | },
602 | };
603 | }
604 |
605 | export function createSelectMiddleware(config: ModelConfig): NestedMiddleware {
606 | return async function selectMiddleware(params, next) {
607 | const updatedParams = createSelectParams(params, config);
608 |
609 | const deletedFieldAdded =
610 | typeof updatedParams.args === "object" &&
611 | updatedParams.args?.__deletedFieldAdded;
612 |
613 | if (deletedFieldAdded) {
614 | delete updatedParams.args.__deletedFieldAdded;
615 | }
616 |
617 | const result = await next(updatedParams);
618 |
619 | if (shouldFilterDeletedFromReadResult(params, config)) {
620 | const filteredResults = filterSoftDeletedResults(result, config);
621 |
622 | if (deletedFieldAdded) {
623 | stripDeletedFieldFromResults(filteredResults, config);
624 | }
625 |
626 | return filteredResults;
627 | }
628 |
629 | return result;
630 | };
631 | }
632 |
--------------------------------------------------------------------------------
/src/lib/createSoftDeleteMiddleware.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "@prisma/client";
2 | import {
3 | createNestedMiddleware,
4 | NestedMiddleware,
5 | } from "prisma-nested-middleware";
6 | import {
7 | createAggregateMiddleware,
8 | createCountMiddleware,
9 | createDeleteManyMiddleware,
10 | createDeleteMiddleware,
11 | createFindFirstMiddleware,
12 | createFindFirstOrThrowMiddleware,
13 | createFindManyMiddleware,
14 | createFindUniqueMiddleware,
15 | createFindUniqueOrThrowMiddleware,
16 | createIncludeMiddleware,
17 | createSelectMiddleware,
18 | createUpdateManyMiddleware,
19 | createUpdateMiddleware,
20 | createUpsertMiddleware,
21 | createWhereMiddleware,
22 | createGroupByMiddleware,
23 | } from "./actionMiddleware";
24 |
25 | import { Config, ModelConfig } from "./types";
26 |
27 | export function createSoftDeleteMiddleware({
28 | models,
29 | defaultConfig = {
30 | field: "deleted",
31 | createValue: Boolean,
32 | allowToOneUpdates: false,
33 | allowCompoundUniqueIndexWhere: false,
34 | },
35 | }: Config) {
36 | if (!defaultConfig.field) {
37 | throw new Error(
38 | "prisma-soft-delete-middleware: defaultConfig.field is required"
39 | );
40 | }
41 | if (!defaultConfig.createValue) {
42 | throw new Error(
43 | "prisma-soft-delete-middleware: defaultConfig.createValue is required"
44 | );
45 | }
46 |
47 | const modelConfig: Partial> = {};
48 |
49 | Object.keys(models).forEach((model) => {
50 | const modelName = model as Prisma.ModelName;
51 | const config = models[modelName];
52 | if (config) {
53 | modelConfig[modelName] =
54 | typeof config === "boolean" && config ? defaultConfig : config;
55 | }
56 | });
57 |
58 | const middlewareByModel = Object.keys(modelConfig).reduce<
59 | Record>
60 | >((acc, model) => {
61 | const config = modelConfig[model as Prisma.ModelName]!;
62 | return {
63 | ...acc,
64 | [model]: {
65 | delete: createDeleteMiddleware(config),
66 | deleteMany: createDeleteManyMiddleware(config),
67 | update: createUpdateMiddleware(config),
68 | updateMany: createUpdateManyMiddleware(config),
69 | upsert: createUpsertMiddleware(config),
70 | findFirst: createFindFirstMiddleware(config),
71 | findFirstOrThrow: createFindFirstOrThrowMiddleware(config),
72 | findUnique: createFindUniqueMiddleware(config),
73 | findUniqueOrThrow: createFindUniqueOrThrowMiddleware(config),
74 | findMany: createFindManyMiddleware(config),
75 | count: createCountMiddleware(config),
76 | aggregate: createAggregateMiddleware(config),
77 | where: createWhereMiddleware(config),
78 | include: createIncludeMiddleware(config),
79 | select: createSelectMiddleware(config),
80 | groupBy: createGroupByMiddleware(config),
81 | },
82 | };
83 | }, {});
84 |
85 | // before handling root params generate deleted value so it is consistent
86 | // for the query. Add it to root params and get it from scope?
87 |
88 | return createNestedMiddleware((params, next) => {
89 | const middleware = middlewareByModel[params.model || ""]?.[params.action];
90 |
91 | // apply middleware if it is found for model and action
92 | if (middleware) return middleware(params, next);
93 |
94 | return next(params);
95 | });
96 | }
97 |
--------------------------------------------------------------------------------
/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "@prisma/client";
2 |
3 | export type ModelConfig = {
4 | field: string;
5 | createValue: (deleted: boolean) => any;
6 | allowToOneUpdates?: boolean;
7 | allowCompoundUniqueIndexWhere?: boolean;
8 | };
9 |
10 | export type Config = {
11 | models: Partial>;
12 | defaultConfig?: ModelConfig;
13 | };
--------------------------------------------------------------------------------
/src/lib/utils/nestedReads.ts:
--------------------------------------------------------------------------------
1 | import { NestedParams } from "prisma-nested-middleware";
2 |
3 | import { ModelConfig } from "../types";
4 |
5 | export function addDeletedToSelect(params: NestedParams, config: ModelConfig) {
6 | if (params.args.select && !params.args.select[config.field]) {
7 | return {
8 | ...params,
9 | args: {
10 | __deletedFieldAdded: true,
11 | ...params.args,
12 | select: {
13 | ...params.args.select,
14 | [config.field]: true,
15 | },
16 | },
17 | };
18 | }
19 |
20 | return params;
21 | }
22 |
23 | export function stripDeletedFieldFromResults(
24 | results: any,
25 | config: ModelConfig
26 | ) {
27 | if (Array.isArray(results)) {
28 | results?.forEach((item: any) => {
29 | delete item[config.field];
30 | });
31 | } else if (results) {
32 | delete results[config.field];
33 | }
34 |
35 | return results;
36 | }
37 |
--------------------------------------------------------------------------------
/src/lib/utils/resultFiltering.ts:
--------------------------------------------------------------------------------
1 | import { NestedParams } from "prisma-nested-middleware";
2 | import isEqual from "lodash/isEqual";
3 |
4 | import { ModelConfig } from "../types";
5 |
6 | export function shouldFilterDeletedFromReadResult(
7 | params: NestedParams,
8 | config: ModelConfig
9 | ): boolean {
10 | return (
11 | !params.scope?.relations.to.isList &&
12 | (!params.args.where ||
13 | typeof params.args.where[config.field] === "undefined" ||
14 | isEqual(params.args.where[config.field], config.createValue(false)))
15 | );
16 | }
17 |
18 | export function filterSoftDeletedResults(result: any, config: ModelConfig) {
19 | // filter out deleted records from array results
20 | if (result && Array.isArray(result)) {
21 | return result.filter((item) =>
22 | isEqual(item[config.field], config.createValue(false))
23 | );
24 | }
25 |
26 | // if the result is deleted return null
27 | if (result && !isEqual(result[config.field], config.createValue(false))) {
28 | return null;
29 | }
30 |
31 | return result;
32 | }
33 |
--------------------------------------------------------------------------------
/test/e2e/client.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | export default new PrismaClient();
4 |
--------------------------------------------------------------------------------
/test/e2e/deletedAt.test.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient, Profile, User } from "@prisma/client";
2 | import faker from "faker";
3 |
4 | import { createSoftDeleteMiddleware } from "../../src";
5 | import client from "./client";
6 |
7 | describe("deletedAt", () => {
8 | let testClient: PrismaClient;
9 | let profile: Profile;
10 | let user: User;
11 |
12 | beforeAll(async () => {
13 | testClient = new PrismaClient();
14 | testClient.$use(
15 | createSoftDeleteMiddleware({
16 | models: {
17 | User: {
18 | field: "deletedAt",
19 | createValue: (deleted) => {
20 | return deleted ? new Date() : null;
21 | },
22 | },
23 | },
24 | })
25 | );
26 |
27 | profile = await client.profile.create({
28 | data: {
29 | bio: "foo",
30 | },
31 | });
32 | user = await client.user.create({
33 | data: {
34 | email: faker.internet.email(),
35 | name: faker.name.findName(),
36 | profileId: profile.id,
37 | comments: {
38 | create: [
39 | { content: "foo" },
40 | { content: "foo", deleted: true },
41 | { content: "bar", deleted: true },
42 | ],
43 | },
44 | },
45 | });
46 | });
47 | afterEach(async () => {
48 | // restore soft deleted user
49 | await client.user.update({
50 | where: { id: user.id },
51 | data: {
52 | deletedAt: null,
53 | },
54 | });
55 | });
56 | afterAll(async () => {
57 | // disconnect test client
58 | await testClient.$disconnect();
59 |
60 | // delete user and related data
61 | await client.user.update({
62 | where: { id: user.id },
63 | data: {
64 | comments: { deleteMany: {} },
65 | profile: { delete: true },
66 | },
67 | });
68 | await client.user.deleteMany({ where: {} })
69 | });
70 |
71 | it("soft deletes when using delete", async () => {
72 | await testClient.user.delete({
73 | where: { id: user.id },
74 | });
75 |
76 | const softDeletedUser = await testClient.user.findFirst({
77 | where: { id: user.id },
78 | });
79 | expect(softDeletedUser).toBeNull();
80 |
81 | const dbUser = await client.user.findFirst({
82 | where: { id: user.id },
83 | });
84 | expect(dbUser).not.toBeNull();
85 | expect(dbUser?.deletedAt).not.toBeNull();
86 | expect(dbUser?.deletedAt).toBeInstanceOf(Date);
87 | });
88 |
89 | it("soft deletes when using deleteMany", async () => {
90 | await testClient.user.deleteMany({
91 | where: { id: user.id },
92 | });
93 |
94 | const softDeletedUser = await testClient.user.findFirst({
95 | where: { id: user.id },
96 | });
97 | expect(softDeletedUser).toBeNull();
98 |
99 | const dbUser = await client.user.findFirst({
100 | where: { id: user.id },
101 | });
102 | expect(dbUser).not.toBeNull();
103 | expect(dbUser?.deletedAt).not.toBeNull();
104 | expect(dbUser?.deletedAt).toBeInstanceOf(Date);
105 | });
106 |
107 | it("excludes deleted when filtering with where", async () => {
108 | // soft delete user
109 | await client.user.update({
110 | where: { id: user.id },
111 | data: { deletedAt: new Date() },
112 | });
113 |
114 | const comment = await testClient.comment.findFirst({
115 | where: {
116 | author: {
117 | id: user.id,
118 | },
119 | },
120 | });
121 |
122 | expect(comment).toBeNull();
123 | });
124 |
125 | it("excludes deleted when filtering with where through 'some' modifier", async () => {
126 | // soft delete user
127 | await client.user.update({
128 | where: { id: user.id },
129 | data: { deletedAt: new Date() },
130 | });
131 |
132 | const userProfile = await testClient.profile.findFirst({
133 | where: {
134 | users: {
135 | some: {
136 | id: user.id,
137 | },
138 | },
139 | },
140 | });
141 |
142 | expect(userProfile).toBeNull();
143 | });
144 |
145 | it("excludes deleted when filtering with where through 'every' modifier", async () => {
146 | // soft delete user
147 | await client.user.update({
148 | where: { id: user.id },
149 | data: { deletedAt: new Date() },
150 | });
151 |
152 | // add another user to profile
153 | await client.user.create({
154 | data: {
155 | email: faker.internet.email(),
156 | name: faker.name.findName(),
157 | profileId: profile.id,
158 | },
159 | });
160 |
161 | const userProfile = await testClient.profile.findFirst({
162 | where: {
163 | users: {
164 | every: {
165 | id: user.id,
166 | },
167 | },
168 | },
169 | });
170 |
171 | expect(userProfile).toBeNull();
172 | });
173 | });
174 |
--------------------------------------------------------------------------------
/test/e2e/nestedReads.test.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient, User } from "@prisma/client";
2 | import faker from "faker";
3 |
4 | import { createSoftDeleteMiddleware } from "../../src";
5 | import client from "./client";
6 |
7 | describe("nested reads", () => {
8 | let testClient: PrismaClient;
9 | let user: User;
10 |
11 | beforeAll(async () => {
12 | testClient = new PrismaClient();
13 | testClient.$use(
14 | createSoftDeleteMiddleware({
15 | models: {
16 | Comment: true,
17 | // use truthy value for non-deleted to test that they are still filtered
18 | Profile: {
19 | field: "deletedAt",
20 | createValue: (deleted) => (deleted ? new Date() : new Date(0)),
21 | },
22 | },
23 | })
24 | );
25 |
26 | user = await client.user.create({
27 | data: {
28 | email: faker.internet.email(),
29 | name: faker.name.findName(),
30 | profile: { create: { bio: "foo" } },
31 | },
32 | });
33 |
34 | await client.comment.create({
35 | data: {
36 | author: { connect: { id: user.id } },
37 | content: "foo",
38 | replies: {
39 | create: [
40 | { content: "baz" },
41 | { content: "baz", deleted: true },
42 | { content: "qux", deleted: true },
43 | ],
44 | },
45 | post: {
46 | create: {
47 | title: "foo-comment-post-title",
48 | authorId: user.id,
49 | author: {
50 | connect: { id: user.id },
51 | },
52 | },
53 | },
54 | },
55 | });
56 |
57 | await client.comment.create({
58 | data: {
59 | author: { connect: { id: user.id } },
60 | content: "bar",
61 | deleted: true,
62 | post: {
63 | create: {
64 | title: "bar-comment-post-title",
65 | authorId: user.id,
66 | author: {
67 | connect: { id: user.id },
68 | },
69 | },
70 | },
71 | },
72 | });
73 | });
74 | afterEach(async () => {
75 | // restore soft deleted profile
76 | await client.profile.updateMany({
77 | where: {},
78 | data: { deletedAt: new Date(0) },
79 | });
80 | });
81 | afterAll(async () => {
82 | await testClient.$disconnect();
83 |
84 | await client.user.deleteMany({ where: {} });
85 | await client.comment.deleteMany({ where: {} });
86 | await client.profile.deleteMany({ where: {} });
87 | });
88 |
89 | describe("include", () => {
90 | it("excludes deleted when including toMany relation", async () => {
91 | const { comments } = await testClient.user.findUniqueOrThrow({
92 | where: { id: user.id },
93 | include: {
94 | comments: true,
95 | },
96 | });
97 |
98 | expect(comments).toHaveLength(1);
99 | expect(comments[0].content).toEqual("foo");
100 | });
101 |
102 | it("excludes deleted when including toOne relation", async () => {
103 | const {
104 | profile: nonDeletedProfile,
105 | } = await testClient.user.findUniqueOrThrow({
106 | where: { id: user.id },
107 | include: {
108 | profile: true,
109 | },
110 | });
111 |
112 | expect(nonDeletedProfile).not.toBeNull();
113 | expect(nonDeletedProfile!.bio).toEqual("foo");
114 |
115 | // soft delete profiles
116 | await client.profile.updateMany({
117 | where: {},
118 | data: {
119 | deletedAt: new Date(),
120 | },
121 | });
122 |
123 | const {
124 | profile: softDeletedProfile,
125 | } = await testClient.user.findUniqueOrThrow({
126 | where: { id: user.id },
127 | include: {
128 | profile: true,
129 | },
130 | });
131 |
132 | expect(softDeletedProfile).toBeNull();
133 | });
134 |
135 | it("excludes deleted when deeply including relations", async () => {
136 | const {
137 | comments: nonDeletedComments,
138 | } = await testClient.user.findUniqueOrThrow({
139 | where: { id: user.id },
140 | include: {
141 | comments: {
142 | include: {
143 | post: true,
144 | replies: true,
145 | },
146 | },
147 | },
148 | });
149 |
150 | expect(nonDeletedComments).toHaveLength(1);
151 | expect(nonDeletedComments[0].content).toEqual("foo");
152 |
153 | expect(nonDeletedComments[0].replies).toHaveLength(1);
154 | expect(nonDeletedComments[0].replies[0].content).toEqual("baz");
155 | });
156 |
157 | it("excludes deleted when including fields with where", async () => {
158 | const {
159 | comments: nonDeletedComments,
160 | } = await testClient.user.findUniqueOrThrow({
161 | where: { id: user.id },
162 | include: {
163 | comments: {
164 | where: {
165 | content: "bar",
166 | },
167 | },
168 | },
169 | });
170 |
171 | expect(nonDeletedComments).toHaveLength(0);
172 | });
173 |
174 | it("excludes deleted when including fields using where that targets soft-deleted model", async () => {
175 | const { posts } = await testClient.user.findUniqueOrThrow({
176 | where: { id: user.id },
177 | include: {
178 | posts: {
179 | where: {
180 | comments: {
181 | some: {
182 | content: {
183 | in: ["foo", "bar"],
184 | },
185 | },
186 | },
187 | },
188 | },
189 | },
190 | });
191 |
192 | expect(posts).toHaveLength(1);
193 | });
194 | });
195 |
196 | describe("select", () => {
197 | it("excludes deleted when selecting toMany relation", async () => {
198 | const { comments } = await testClient.user.findUniqueOrThrow({
199 | where: { id: user.id },
200 | select: {
201 | comments: true,
202 | },
203 | });
204 |
205 | expect(comments).toHaveLength(1);
206 | expect(comments[0].content).toEqual("foo");
207 | });
208 |
209 | it("excludes deleted when selecting toOne relation", async () => {
210 | const {
211 | profile: nonDeletedProfile,
212 | } = await testClient.user.findUniqueOrThrow({
213 | where: { id: user.id },
214 | select: {
215 | profile: true,
216 | },
217 | });
218 |
219 | expect(nonDeletedProfile).not.toBeNull();
220 | expect(nonDeletedProfile!.bio).toEqual("foo");
221 |
222 | // soft delete profiles
223 | await client.profile.updateMany({
224 | where: {},
225 | data: {
226 | deletedAt: new Date(),
227 | },
228 | });
229 |
230 | const {
231 | profile: softDeletedProfile,
232 | } = await testClient.user.findUniqueOrThrow({
233 | where: { id: user.id },
234 | include: {
235 | profile: true,
236 | },
237 | });
238 |
239 | expect(softDeletedProfile).toBeNull();
240 | });
241 |
242 | it("excludes deleted when deeply selecting relations", async () => {
243 | const {
244 | comments: nonDeletedComments,
245 | } = await testClient.user.findUniqueOrThrow({
246 | where: { id: user.id },
247 | select: {
248 | comments: {
249 | select: {
250 | content: true,
251 | post: true,
252 | replies: true,
253 | },
254 | },
255 | },
256 | });
257 |
258 | expect(nonDeletedComments).toHaveLength(1);
259 | expect(nonDeletedComments[0].content).toEqual("foo");
260 |
261 | expect(nonDeletedComments[0].replies).toHaveLength(1);
262 | expect(nonDeletedComments[0].replies[0].content).toEqual("baz");
263 | });
264 |
265 | it("excludes deleted when selecting fields through an include", async () => {
266 | const {
267 | comments: nonDeletedComments,
268 | } = await testClient.user.findUniqueOrThrow({
269 | where: { id: user.id },
270 | include: {
271 | comments: {
272 | select: {
273 | content: true,
274 | post: true,
275 | replies: true,
276 | },
277 | },
278 | },
279 | });
280 |
281 | expect(nonDeletedComments).toHaveLength(1);
282 | expect(nonDeletedComments[0].content).toEqual("foo");
283 |
284 | expect(nonDeletedComments[0].replies).toHaveLength(1);
285 | expect(nonDeletedComments[0].replies[0].content).toEqual("baz");
286 | });
287 |
288 | it("excludes deleted when selecting fields with where", async () => {
289 | const {
290 | comments: nonDeletedComments,
291 | } = await testClient.user.findUniqueOrThrow({
292 | where: { id: user.id },
293 | select: {
294 | comments: {
295 | where: {
296 | content: "bar",
297 | },
298 | },
299 | },
300 | });
301 |
302 | expect(nonDeletedComments).toHaveLength(0);
303 | });
304 |
305 | it("excludes deleted when selecting fields with where through an include", async () => {
306 | const {
307 | comments: nonDeletedComments,
308 | } = await testClient.user.findUniqueOrThrow({
309 | where: { id: user.id },
310 | include: {
311 | comments: {
312 | select: {
313 | replies: {
314 | where: {
315 | content: "qux",
316 | },
317 | },
318 | },
319 | },
320 | },
321 | });
322 |
323 | expect(nonDeletedComments).toHaveLength(1);
324 | expect(nonDeletedComments[0].replies).toHaveLength(0);
325 | });
326 |
327 | it("excludes deleted when selecting fields using where that targets soft-deleted model", async () => {
328 | const { posts } = await testClient.user.findUniqueOrThrow({
329 | where: { id: user.id },
330 | include: {
331 | posts: {
332 | where: {
333 | comments: {
334 | some: {
335 | content: {
336 | in: ["foo", "bar"],
337 | },
338 | },
339 | },
340 | },
341 | },
342 | },
343 | });
344 |
345 | expect(posts).toHaveLength(1);
346 | });
347 |
348 | it("excludes deleted when including relation and filtering by every", async () => {
349 | const { comments } = await testClient.user.findUniqueOrThrow({
350 | where: { id: user.id },
351 | include: {
352 | comments: {
353 | where: {
354 | replies: {
355 | every: {
356 | content: {
357 | in: ["baz", "qux"],
358 | },
359 | },
360 | },
361 | },
362 | },
363 | },
364 | });
365 |
366 | expect(comments).toHaveLength(1);
367 | });
368 |
369 | it("excludes deleted when including relation and filtering by every in a NOT", async () => {
370 | const { comments } = await testClient.user.findUniqueOrThrow({
371 | where: { id: user.id },
372 | include: {
373 | comments: {
374 | where: {
375 | NOT: {
376 | replies: {
377 | every: {
378 | content: {
379 | in: ["baz", "qux"],
380 | },
381 | },
382 | },
383 | },
384 | },
385 | },
386 | },
387 | });
388 |
389 | expect(comments).toHaveLength(0);
390 | });
391 |
392 | it("excludes deleted selected toOne relations even when deleted field not selected", async () => {
393 | const { profile } = await testClient.user.findUniqueOrThrow({
394 | where: { id: user.id },
395 | select: {
396 | profile: {
397 | select: {
398 | id: true,
399 | bio: true,
400 | },
401 | },
402 | },
403 | });
404 |
405 | expect(profile).not.toBeNull();
406 |
407 | // @ts-expect-error deleted not on the type because it's not selected
408 | expect(profile!.deleted).toBeUndefined();
409 |
410 | // soft delete profile
411 | await client.profile.update({
412 | where: { id: profile!.id },
413 | data: {
414 | deletedAt: new Date(),
415 | },
416 | });
417 |
418 | const {
419 | profile: softDeletedProfile,
420 | } = await testClient.user.findUniqueOrThrow({
421 | where: { id: user.id },
422 | select: {
423 | profile: {
424 | select: {
425 | id: true,
426 | bio: true,
427 | },
428 | },
429 | },
430 | });
431 |
432 | expect(softDeletedProfile).toBeNull();
433 | });
434 | });
435 | });
436 |
--------------------------------------------------------------------------------
/test/e2e/queries.test.ts:
--------------------------------------------------------------------------------
1 | import { Comment, PrismaClient, Profile, User } from "@prisma/client";
2 | import faker from "faker";
3 |
4 | import { createSoftDeleteMiddleware } from "../../src";
5 | import client from "./client";
6 |
7 | describe("queries", () => {
8 | let testClient: PrismaClient;
9 | let profile: Profile;
10 | let firstUser: User;
11 | let secondUser: User;
12 | let deletedUser: User;
13 | let comment: Comment;
14 |
15 | beforeAll(async () => {
16 | testClient = new PrismaClient();
17 | testClient.$use(createSoftDeleteMiddleware({ models: { User: true } }));
18 |
19 | profile = await client.profile.create({
20 | data: {
21 | bio: faker.lorem.sentence(),
22 | },
23 | });
24 | firstUser = await client.user.create({
25 | data: {
26 | email: faker.internet.email(),
27 | name: "Jack",
28 | profileId: profile.id,
29 | },
30 | });
31 | secondUser = await client.user.create({
32 | data: {
33 | email: faker.internet.email(),
34 | name: "John",
35 | },
36 | });
37 | deletedUser = await client.user.create({
38 | data: {
39 | email: faker.internet.email(),
40 | name: "Jill",
41 | deleted: true,
42 | profileId: profile.id,
43 | },
44 | });
45 | comment = await client.comment.create({
46 | data: {
47 | content: faker.lorem.sentence(),
48 | authorId: firstUser.id,
49 | },
50 | });
51 | });
52 | afterEach(async () => {
53 | await Promise.all([
54 | // reset starting data
55 | client.profile.update({ where: { id: profile.id }, data: profile }),
56 | client.user.update({ where: { id: deletedUser.id }, data: deletedUser }),
57 | client.user.update({ where: { id: firstUser.id }, data: firstUser }),
58 | client.user.update({ where: { id: secondUser.id }, data: secondUser }),
59 | client.comment.update({ where: { id: comment.id }, data: comment }),
60 |
61 | // delete created models
62 | client.profile.deleteMany({
63 | where: { id: { not: { in: [profile.id] } } },
64 | }),
65 | client.user.deleteMany({
66 | where: {
67 | id: { not: { in: [firstUser.id, secondUser.id, deletedUser.id] } },
68 | },
69 | }),
70 | client.comment.deleteMany({
71 | where: { id: { not: { in: [comment.id] } } },
72 | }),
73 | ]);
74 | });
75 | afterAll(async () => {
76 | await testClient.$disconnect();
77 | await client.user.deleteMany({ where: {} });
78 | });
79 |
80 | describe("delete", () => {
81 | it("delete soft deletes", async () => {
82 | const result = await testClient.user.delete({
83 | where: { id: firstUser.id },
84 | });
85 | expect(result).not.toBeNull();
86 |
87 | const dbUser = await client.user.findUnique({
88 | where: { id: firstUser.id },
89 | });
90 | expect(dbUser).not.toBeNull();
91 | expect(dbUser!.id).toEqual(firstUser.id);
92 | expect(dbUser?.deleted).toBe(true);
93 | });
94 |
95 | it("nested delete soft deletes", async () => {
96 | const result = await testClient.profile.update({
97 | where: { id: profile.id },
98 | data: {
99 | users: {
100 | delete: {
101 | id: firstUser.id,
102 | },
103 | },
104 | },
105 | });
106 | expect(result).not.toBeNull();
107 |
108 | const dbUser = await client.user.findUniqueOrThrow({
109 | where: { id: firstUser.id },
110 | });
111 | expect(dbUser.deleted).toBe(true);
112 | });
113 | });
114 |
115 | describe("deleteMany", () => {
116 | it("deleteMany soft deletes", async () => {
117 | const result = await testClient.user.deleteMany({
118 | where: { name: { contains: "J" } },
119 | });
120 | expect(result).not.toBeNull();
121 | expect(result.count).toEqual(2);
122 |
123 | const dbUsers = await client.user.findMany({
124 | where: { name: { contains: "J" } },
125 | });
126 | expect(dbUsers).toHaveLength(3);
127 | expect(dbUsers.map(({ id }) => id).sort()).toEqual(
128 | [firstUser.id, secondUser.id, deletedUser.id].sort()
129 | );
130 | expect(dbUsers.every(({ deleted }) => deleted)).toBe(true);
131 | });
132 |
133 | it("nested deleteMany soft deletes", async () => {
134 | const result = await testClient.profile.update({
135 | where: { id: profile.id },
136 | data: {
137 | users: {
138 | deleteMany: {
139 | name: { contains: "J" },
140 | },
141 | },
142 | },
143 | });
144 | expect(result).not.toBeNull();
145 |
146 | const dbUsers = await client.user.findMany({
147 | where: { name: { contains: "J" }, deleted: true },
148 | });
149 | expect(dbUsers).toHaveLength(2);
150 | expect(dbUsers.map(({ id }) => id).sort()).toEqual(
151 | [firstUser.id, deletedUser.id].sort()
152 | );
153 | });
154 | });
155 |
156 | describe("updateMany", () => {
157 | it("updateMany excludes soft deleted records", async () => {
158 | const result = await testClient.user.updateMany({
159 | where: { name: { contains: "J" } },
160 | data: { name: "Updated" },
161 | });
162 | expect(result).not.toBeNull();
163 | expect(result.count).toEqual(2);
164 |
165 | const updatedDbUsers = await client.user.findMany({
166 | where: { name: { contains: "Updated" } },
167 | });
168 | expect(updatedDbUsers).toHaveLength(2);
169 | expect(updatedDbUsers.map(({ id }) => id).sort()).toEqual(
170 | [firstUser.id, secondUser.id].sort()
171 | );
172 | expect(updatedDbUsers.every(({ deleted }) => !deleted)).toBe(true);
173 | expect(updatedDbUsers.every(({ name }) => name === "Updated")).toBe(true);
174 |
175 | const deletedDbUser = await client.user.findUniqueOrThrow({
176 | where: { id: deletedUser.id },
177 | });
178 | expect(deletedDbUser.name).toEqual(deletedUser.name);
179 | });
180 |
181 | it("nested updateMany excludes soft deleted records", async () => {
182 | const result = await testClient.profile.update({
183 | where: { id: profile.id },
184 | data: {
185 | users: {
186 | updateMany: {
187 | where: { name: { contains: "J" } },
188 | data: { name: "Updated" },
189 | },
190 | },
191 | },
192 | });
193 | expect(result).not.toBeNull();
194 |
195 | const dbUpdatedUser = await client.user.findUniqueOrThrow({
196 | where: { id: firstUser.id },
197 | });
198 | expect(dbUpdatedUser.name).toEqual("Updated");
199 |
200 | const dbDeletedUser = await client.user.findUniqueOrThrow({
201 | where: { id: deletedUser.id },
202 | });
203 | expect(dbDeletedUser.name).toEqual(deletedUser.name);
204 | });
205 | });
206 |
207 | describe("update", () => {
208 | it("update does not exclude soft deleted records", async () => {
209 | const result = await testClient.user.update({
210 | where: { id: deletedUser.id },
211 | data: { name: "Updated Jill" },
212 | });
213 | expect(result).not.toBeNull();
214 | expect(result.name).toEqual("Updated Jill");
215 |
216 | const dbUser = await client.user.findUniqueOrThrow({
217 | where: { id: deletedUser.id },
218 | });
219 | expect(dbUser.name).toEqual("Updated Jill");
220 | });
221 |
222 | it("nested toMany update does not exclude soft deleted records", async () => {
223 | const result = await testClient.user.update({
224 | where: { id: firstUser.id },
225 | data: {
226 | comments: {
227 | updateMany: {
228 | where: { id: comment.id },
229 | data: { content: "Updated" },
230 | },
231 | },
232 | },
233 | });
234 | expect(result).not.toBeNull();
235 |
236 | const dbComment = await client.comment.findUniqueOrThrow({
237 | where: { id: comment.id },
238 | });
239 | expect(dbComment.content).toEqual("Updated");
240 | });
241 |
242 | it("nested toOne update throws by default", async () => {
243 | await expect(
244 | testClient.comment.update({
245 | where: { id: comment.id },
246 | data: {
247 | author: {
248 | update: {
249 | name: "Updated",
250 | },
251 | },
252 | },
253 | })
254 | ).rejects.toThrowError(
255 | `prisma-soft-delete-middleware: update of model "User" through "Comment.author" found. Updates of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.`
256 | );
257 | });
258 | });
259 |
260 | describe("upsert", () => {
261 | it("upsert does not exclude soft deleted records", async () => {
262 | const result = await testClient.user.upsert({
263 | where: { id: deletedUser.id },
264 | create: { email: faker.internet.email(), name: "New User" },
265 | update: { name: "Updated" },
266 | });
267 | expect(result).not.toBeNull();
268 | expect(result.name).toEqual("Updated");
269 |
270 | const dbUser = await client.user.findUniqueOrThrow({
271 | where: { id: deletedUser.id },
272 | });
273 | expect(dbUser.name).toEqual("Updated");
274 | });
275 |
276 | it("nested toMany upsert does not exclude soft deleted records", async () => {
277 | const result = await testClient.profile.update({
278 | where: { id: profile.id },
279 | data: {
280 | users: {
281 | upsert: {
282 | where: { id: deletedUser.id },
283 | create: { email: faker.internet.email(), name: "New User" },
284 | update: { name: "Updated" },
285 | },
286 | },
287 | },
288 | });
289 | expect(result).not.toBeNull();
290 |
291 | const dbUser = await client.user.findUniqueOrThrow({
292 | where: { id: deletedUser.id },
293 | });
294 | expect(dbUser.name).toEqual("Updated");
295 | });
296 |
297 | it("nested toOne upsert throws by default", async () => {
298 | await expect(
299 | testClient.comment.update({
300 | where: { id: comment.id },
301 | data: {
302 | author: {
303 | upsert: {
304 | create: { email: faker.internet.email(), name: "New User" },
305 | update: { name: "Updated" },
306 | },
307 | },
308 | },
309 | })
310 | ).rejects.toThrowError(
311 | `prisma-soft-delete-middleware: upsert of model "User" through "Comment.author" found. Upserts of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.`
312 | );
313 | });
314 | });
315 |
316 | describe("findFirst", () => {
317 | it("findFirst excludes soft deleted records", async () => {
318 | const foundUser = await testClient.user.findFirst({
319 | where: { email: firstUser.email },
320 | });
321 |
322 | expect(foundUser).not.toBeNull();
323 | expect(foundUser!.id).toEqual(firstUser.id);
324 |
325 | const notFoundUser = await testClient.user.findFirst({
326 | where: { email: deletedUser.email },
327 | });
328 | expect(notFoundUser).toBeNull();
329 | });
330 | });
331 |
332 | describe("findFirstOrThrow", () => {
333 | it("findFirstOrThrow throws for soft deleted records", async () => {
334 | const foundUser = await testClient.user.findFirstOrThrow({
335 | where: { email: firstUser.email },
336 | });
337 |
338 | expect(foundUser).not.toBeNull();
339 | expect(foundUser!.id).toEqual(firstUser.id);
340 |
341 | await expect(() =>
342 | testClient.user.findFirstOrThrow({
343 | where: { email: deletedUser.email },
344 | })
345 | ).rejects.toThrowError("No User found");
346 | });
347 | });
348 |
349 | describe("findUnique", () => {
350 | it("findUnique excludes soft deleted records", async () => {
351 | const foundUser = await testClient.user.findUnique({
352 | where: { id: firstUser.id },
353 | });
354 | expect(foundUser).not.toBeNull();
355 | expect(foundUser!.id).toEqual(firstUser.id);
356 |
357 | const notFoundUser = await testClient.user.findUnique({
358 | where: { id: deletedUser.id },
359 | });
360 | expect(notFoundUser).toBeNull();
361 | });
362 |
363 | it("throws a useful error when invalid where is passed", async () => {
364 | // throws useful error when no where is passed
365 | await expect(() =>
366 | // @ts-expect-error intentionally incorrect args
367 | testClient.user.findUnique()
368 | ).rejects.toThrowError(
369 | "Invalid `testClient.user.findUnique()` invocation"
370 | );
371 |
372 | // throws useful error when empty where is passed
373 | await expect(() =>
374 | // @ts-expect-error intentionally incorrect args
375 | testClient.user.findUnique({})
376 | ).rejects.toThrowError(
377 | "Invalid `testClient.user.findUnique()` invocation"
378 | );
379 |
380 | // throws useful error when where is passed undefined unique fields
381 | await expect(() =>
382 | testClient.user.findUnique({
383 | where: { id: undefined },
384 | })
385 | ).rejects.toThrowError(
386 | "Invalid `testClient.user.findUnique()` invocation"
387 | );
388 |
389 | // throws useful error when where has defined non-unique fields
390 | await expect(() =>
391 | testClient.user.findUnique({
392 | // @ts-expect-error intentionally incorrect args
393 | where: { name: firstUser.name },
394 | })
395 | ).rejects.toThrowError(
396 | "Invalid `testClient.user.findUnique()` invocation"
397 | );
398 |
399 | // throws useful error when where has undefined compound unique index field
400 | await expect(() =>
401 | testClient.user.findUnique({
402 | where: { name_email: undefined },
403 | })
404 | ).rejects.toThrowError(
405 | "Invalid `testClient.user.findUnique()` invocation"
406 | );
407 |
408 | // throws useful error when where has undefined unique field and defined non-unique field
409 | await expect(() =>
410 | testClient.user.findUnique({
411 | where: { id: undefined, name: firstUser.name },
412 | })
413 | ).rejects.toThrowError(
414 | "Invalid `testClient.user.findUnique()` invocation"
415 | );
416 | });
417 |
418 | // TODO:- enable this test when extendedWhereUnique is supported
419 | it.failing(
420 | "findUnique excludes soft-deleted records when using compound unique index fields",
421 | async () => {
422 | const notFoundUser = await testClient.user.findUnique({
423 | where: {
424 | name_email: {
425 | name: deletedUser.name,
426 | email: deletedUser.email,
427 | },
428 | },
429 | });
430 | expect(notFoundUser).toBeNull();
431 | }
432 | );
433 | });
434 |
435 | describe("findUniqueOrThrow", () => {
436 | it("findUniqueOrThrow throws for soft deleted records", async () => {
437 | const foundUser = await testClient.user.findUniqueOrThrow({
438 | where: { id: firstUser.id },
439 | });
440 | expect(foundUser).not.toBeNull();
441 | expect(foundUser!.id).toEqual(firstUser.id);
442 |
443 | await expect(() =>
444 | testClient.user.findUniqueOrThrow({
445 | where: { id: deletedUser.id },
446 | })
447 | ).rejects.toThrowError(
448 | "No User found"
449 | );
450 | });
451 |
452 | it("throws a useful error when invalid where is passed", async () => {
453 | // throws useful error when no where is passed
454 | await expect(() =>
455 | testClient.user.findUniqueOrThrow()
456 | ).rejects.toThrowError(
457 | "Invalid `testClient.user.findUniqueOrThrow()` invocation"
458 | );
459 |
460 | // throws useful error when empty where is passed
461 | await expect(() =>
462 | // @ts-expect-error intentionally incorrect args
463 | testClient.user.findUniqueOrThrow({})
464 | ).rejects.toThrowError(
465 | "Invalid `testClient.user.findUniqueOrThrow()` invocation"
466 | );
467 |
468 | // throws useful error when where is passed undefined unique fields
469 | await expect(() =>
470 | testClient.user.findUniqueOrThrow({
471 | where: { id: undefined },
472 | })
473 | ).rejects.toThrowError(
474 | "Invalid `testClient.user.findUniqueOrThrow()` invocation"
475 | );
476 |
477 | // throws useful error when where has defined non-unique fields
478 | await expect(() =>
479 | testClient.user.findUniqueOrThrow({
480 | // @ts-expect-error intentionally incorrect args
481 | where: { name: firstUser.name },
482 | })
483 | ).rejects.toThrowError(
484 | "Invalid `testClient.user.findUniqueOrThrow()` invocation"
485 | );
486 |
487 | // throws useful error when where has undefined compound unique index field
488 | await expect(() =>
489 | testClient.user.findUniqueOrThrow({
490 | where: { name_email: undefined },
491 | })
492 | ).rejects.toThrowError(
493 | "Invalid `testClient.user.findUniqueOrThrow()` invocation"
494 | );
495 |
496 | // throws useful error when where has undefined unique field and defined non-unique field
497 | await expect(() =>
498 | testClient.user.findUniqueOrThrow({
499 | where: { id: undefined, name: firstUser.name },
500 | })
501 | ).rejects.toThrowError(
502 | "Invalid `testClient.user.findUniqueOrThrow()` invocation"
503 | );
504 | });
505 |
506 | // TODO:- enable this test when extendedWhereUnique is supported
507 | it.failing(
508 | "findUniqueOrThrow excludes soft-deleted records when using compound unique index fields",
509 | async () => {
510 | const notFoundUser = await testClient.user.findUniqueOrThrow({
511 | where: {
512 | name_email: {
513 | name: deletedUser.name,
514 | email: deletedUser.email,
515 | },
516 | },
517 | });
518 | expect(notFoundUser).toBeNull();
519 | }
520 | );
521 | });
522 |
523 | describe("findMany", () => {
524 | it("findMany excludes soft deleted records", async () => {
525 | const foundUsers = await testClient.user.findMany({
526 | where: { name: { contains: "J" } },
527 | });
528 | expect(foundUsers).toHaveLength(2);
529 | expect(foundUsers.map(({ id }) => id).sort()).toEqual(
530 | [firstUser.id, secondUser.id].sort()
531 | );
532 | });
533 | });
534 |
535 | describe("count", () => {
536 | it("count excludes soft deleted records", async () => {
537 | const count = await testClient.user.count({
538 | where: { name: { contains: "J" } },
539 | });
540 | expect(count).toEqual(2);
541 | });
542 | });
543 |
544 | describe("aggregate", () => {
545 | it("aggregate excludes soft deleted records", async () => {
546 | const aggregate = await testClient.user.aggregate({
547 | where: { name: { contains: "J" } },
548 | _sum: {
549 | id: true,
550 | },
551 | });
552 | expect(aggregate._sum.id).toEqual(firstUser.id + secondUser.id);
553 | });
554 | });
555 |
556 | describe("create", () => {
557 | it("does not prevent creating a record", async () => {
558 | const result = await testClient.user.create({
559 | data: { email: faker.internet.email(), name: "New User" },
560 | });
561 | expect(result).not.toBeNull();
562 | });
563 |
564 | it("does not prevent creating a soft deleted record", async () => {
565 | const result = await testClient.user.create({
566 | data: {
567 | email: faker.internet.email(),
568 | name: "New User",
569 | deleted: true,
570 | },
571 | });
572 | expect(result).not.toBeNull();
573 | });
574 |
575 | it("does not prevent creating a nested record", async () => {
576 | const result = await testClient.profile.update({
577 | where: { id: profile.id },
578 | data: {
579 | users: {
580 | create: { email: faker.internet.email(), name: "New User" },
581 | },
582 | },
583 | include: {
584 | users: true,
585 | },
586 | });
587 | // should be first user and new user
588 | expect(result.users).toHaveLength(2);
589 |
590 | // first user should be there
591 | expect(result.users.find(({ id }) => id === firstUser.id)).not.toBeNull();
592 |
593 | // other users should not be returned
594 | expect(
595 | result.users.find(({ id }) => id === secondUser.id)
596 | ).not.toBeDefined();
597 | expect(
598 | result.users.find(({ id }) => id === deletedUser.id)
599 | ).not.toBeDefined();
600 | });
601 |
602 | it("does not prevent creating a nested soft deleted record", async () => {
603 | const result = await testClient.profile.update({
604 | where: { id: profile.id },
605 | data: {
606 | users: {
607 | create: {
608 | email: faker.internet.email(),
609 | name: "New User",
610 | deleted: true,
611 | },
612 | },
613 | },
614 | include: {
615 | users: true,
616 | },
617 | });
618 | // should be first user and new user
619 | expect(result.users).toHaveLength(1);
620 |
621 | // only first user should be there
622 | expect(result.users[0].id).toEqual(firstUser.id);
623 |
624 | // user was created
625 | const dbUser = await client.user.findFirst({
626 | where: { name: "New User" },
627 | });
628 | expect(dbUser).not.toBeNull();
629 | });
630 | });
631 |
632 | describe("createMany", () => {
633 | it("createMany can create records and soft deleted records", async () => {
634 | const result = await testClient.user.createMany({
635 | data: [
636 | { email: faker.internet.email(), name: faker.name.findName() },
637 | {
638 | email: faker.internet.email(),
639 | name: faker.name.findName(),
640 | deleted: true,
641 | },
642 | ],
643 | });
644 | expect(result).not.toBeNull();
645 | expect(result.count).toEqual(2);
646 | });
647 |
648 | it("nested createMany can create records and soft deleted records", async () => {
649 | const result = await testClient.user.create({
650 | data: {
651 | email: faker.internet.email(),
652 | name: faker.name.findName(),
653 | comments: {
654 | createMany: {
655 | data: [
656 | { content: faker.lorem.sentence() },
657 | {
658 | content: faker.lorem.sentence(),
659 | deleted: true,
660 | },
661 | ],
662 | },
663 | },
664 | },
665 | });
666 | expect(result).not.toBeNull();
667 |
668 | const dbUser = await client.user.findUniqueOrThrow({
669 | where: { id: result.id },
670 | include: { comments: true },
671 | });
672 | expect(dbUser.comments).toHaveLength(2);
673 | expect(dbUser.comments.filter(({ deleted }) => deleted)).toHaveLength(1);
674 | expect(dbUser.comments.filter(({ deleted }) => !deleted)).toHaveLength(1);
675 | });
676 | });
677 |
678 | describe("connect", () => {
679 | it("connect connects soft deleted records", async () => {
680 | await testClient.user.update({
681 | where: { id: firstUser.id },
682 | data: {
683 | comments: {
684 | connect: {
685 | id: comment.id,
686 | },
687 | },
688 | },
689 | });
690 |
691 | const dbComment = await client.comment.findUniqueOrThrow({
692 | where: { id: comment.id },
693 | });
694 | expect(dbComment.authorId).toEqual(firstUser.id);
695 | });
696 | });
697 |
698 | describe("connectOrCreate", () => {
699 | it("connectOrCreate connects soft deleted records", async () => {
700 | await testClient.user.update({
701 | where: { id: firstUser.id },
702 | data: {
703 | comments: {
704 | connectOrCreate: {
705 | where: { id: comment.id },
706 | create: { content: "Updated" },
707 | },
708 | },
709 | },
710 | });
711 |
712 | const dbComment = await client.comment.findUniqueOrThrow({
713 | where: { id: comment.id },
714 | });
715 | expect(dbComment.authorId).toEqual(firstUser.id);
716 | });
717 | });
718 |
719 | describe("disconnect", () => {
720 | it("toMany disconnect can disconnect soft deleted records", async () => {
721 | await testClient.user.update({
722 | where: { id: firstUser.id },
723 | data: {
724 | comments: {
725 | disconnect: {
726 | id: comment.id,
727 | },
728 | },
729 | },
730 | });
731 |
732 | const dbComment = await client.comment.findUniqueOrThrow({
733 | where: { id: comment.id },
734 | });
735 | expect(dbComment.authorId).toBeNull();
736 | });
737 |
738 | it("toOne disconnect can disconnect soft deleted records", async () => {
739 | await testClient.user.update({
740 | where: { id: firstUser.id },
741 | data: {
742 | profile: {
743 | disconnect: true,
744 | },
745 | },
746 | });
747 |
748 | const dbFirstUser = await client.user.findUniqueOrThrow({
749 | where: { id: firstUser.id },
750 | });
751 | expect(dbFirstUser.profileId).toBeNull();
752 | });
753 | });
754 | });
755 |
--------------------------------------------------------------------------------
/test/e2e/where.test.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient, Profile, User } from "@prisma/client";
2 | import faker from "faker";
3 |
4 | import { createSoftDeleteMiddleware } from "../../src";
5 | import client from "./client";
6 |
7 | describe("where", () => {
8 | let testClient: PrismaClient;
9 | let profile: Profile;
10 | let user: User;
11 |
12 | beforeAll(async () => {
13 | testClient = new PrismaClient();
14 | testClient.$use(
15 | createSoftDeleteMiddleware({ models: { Comment: true, Profile: true } })
16 | );
17 |
18 | profile = await client.profile.create({
19 | data: {
20 | bio: "foo",
21 | },
22 | });
23 | user = await client.user.create({
24 | data: {
25 | email: faker.internet.email(),
26 | name: faker.name.findName(),
27 | profileId: profile.id,
28 | comments: {
29 | create: [
30 | { content: "foo" },
31 | { content: "foo", deleted: true },
32 | { content: "bar", deleted: true },
33 | ],
34 | },
35 | },
36 | });
37 | });
38 | afterEach(async () => {
39 | // restore soft deleted profile
40 | await client.profile.update({
41 | where: { id: profile.id },
42 | data: {
43 | deleted: false,
44 | },
45 | });
46 | });
47 | afterAll(async () => {
48 | // disconnect test client
49 | await testClient.$disconnect();
50 |
51 | // delete user and related data
52 | await client.user.update({
53 | where: { id: user.id },
54 | data: {
55 | comments: { deleteMany: {} },
56 | profile: { delete: true },
57 | },
58 | });
59 | await client.user.delete({ where: { id: user.id } });
60 | });
61 |
62 | it("excludes deleted when filtering using 'is'", async () => {
63 | const foundUser = await testClient.user.findFirst({
64 | where: { profile: { is: { bio: "foo" } } },
65 | });
66 | expect(foundUser).not.toBeNull();
67 | expect(foundUser!.id).toEqual(user.id);
68 |
69 | // soft delete profile
70 | await testClient.profile.update({
71 | where: { id: profile?.id },
72 | data: { deleted: true },
73 | });
74 |
75 | const notFoundUser = await testClient.user.findFirst({
76 | where: { profile: { is: { bio: "foo" } } },
77 | });
78 | expect(notFoundUser).toBeNull();
79 | });
80 |
81 | it("excludes deleted when filtering using 'some'", async () => {
82 | const foundUser = await testClient.user.findFirst({
83 | where: { comments: { some: { content: "foo" } } },
84 | });
85 | expect(foundUser).not.toBeNull();
86 | expect(foundUser!.id).toEqual(user.id);
87 |
88 | const notFoundUser = await testClient.user.findFirst({
89 | where: { comments: { some: { content: "bar" } } },
90 | });
91 | expect(notFoundUser).toBeNull();
92 | });
93 |
94 | it("excludes deleted when filtering using 'every'", async () => {
95 | const foundUser = await testClient.user.findFirst({
96 | where: { comments: { every: { content: "foo" } } },
97 | });
98 | expect(foundUser).not.toBeNull();
99 | expect(foundUser!.id).toEqual(user.id);
100 |
101 | const notFoundUser = await testClient.user.findFirst({
102 | where: { comments: { every: { content: "bar" } } },
103 | });
104 | expect(notFoundUser).toBeNull();
105 | });
106 |
107 | it("excludes deleted when filtering using 'none'", async () => {
108 | const foundUser = await testClient.user.findFirst({
109 | where: { comments: { none: { content: "bar" } } },
110 | });
111 | expect(foundUser).not.toBeNull();
112 | expect(foundUser!.id).toEqual(user.id);
113 |
114 | const notFoundUser = await testClient.user.findFirst({
115 | where: { comments: { none: { content: "foo" } } },
116 | });
117 | expect(notFoundUser).toBeNull();
118 | });
119 |
120 | it("excludes deleted when using 'NOT' with 'some'", async () => {
121 | const foundUser = await testClient.user.findFirst({
122 | where: { NOT: { comments: { some: { content: "bar" } } } },
123 | });
124 | expect(foundUser).not.toBeNull();
125 | expect(foundUser!.id).toEqual(user.id);
126 |
127 | const notFoundUser = await testClient.user.findFirst({
128 | where: { NOT: { comments: { some: { content: "foo" } } } },
129 | });
130 | expect(notFoundUser).toBeNull();
131 | });
132 |
133 | it("excludes deleted when using 'NOT' with 'every'", async () => {
134 | const foundUser = await testClient.user.findFirst({
135 | where: { NOT: { comments: { every: { content: "bar" } } } },
136 | });
137 | expect(foundUser).not.toBeNull();
138 | expect(foundUser!.id).toEqual(user.id);
139 |
140 | const notFoundUser = await testClient.user.findFirst({
141 | where: { NOT: { comments: { every: { content: "foo" } } } },
142 | });
143 | expect(notFoundUser).toBeNull();
144 | });
145 |
146 | it("excludes deleted when using 'NOT' with 'none'", async () => {
147 | const foundUser = await testClient.user.findFirst({
148 | where: { NOT: { comments: { none: { content: "foo" } } } },
149 | });
150 | expect(foundUser).not.toBeNull();
151 | expect(foundUser!.id).toEqual(user.id);
152 |
153 | const notFoundUser = await testClient.user.findFirst({
154 | where: { NOT: { comments: { none: { content: "bar" } } } },
155 | });
156 | expect(notFoundUser).toBeNull();
157 | });
158 |
159 | it("excludes deleted when using 'NOT' with 'isNot'", async () => {
160 | const foundUser = await testClient.user.findFirst({
161 | where: { NOT: { profile: { isNot: { bio: "foo" } } } },
162 | });
163 | expect(foundUser).not.toBeNull();
164 | expect(foundUser!.id).toEqual(user.id);
165 |
166 | // soft delete profile
167 | await client.profile.update({
168 | where: { id: profile.id },
169 | data: {
170 | deleted: true,
171 | },
172 | });
173 |
174 | const notFoundUser = await testClient.user.findFirst({
175 | where: { NOT: { profile: { isNot: { bio: "foo" } } } },
176 | });
177 | expect(notFoundUser).toBeNull();
178 | });
179 |
180 | it("excludes deleted when using 'NOT' with 'is'", async () => {
181 | const notFoundUser = await testClient.user.findFirst({
182 | where: { NOT: { profile: { is: { bio: "foo" } } } },
183 | });
184 | expect(notFoundUser).toBeNull();
185 |
186 | // soft delete profile
187 | await client.profile.update({
188 | where: { id: profile.id },
189 | data: {
190 | deleted: true,
191 | },
192 | });
193 |
194 | const foundUser = await testClient.user.findFirst({
195 | where: { NOT: { profile: { is: { bio: "foo" } } } },
196 | });
197 | expect(foundUser).not.toBeNull();
198 | expect(foundUser!.id).toEqual(user.id);
199 | });
200 |
201 | it("excludes deleted when using 'NOT' nested in a 'NOT'", async () => {
202 | const foundUser = await testClient.user.findFirst({
203 | where: { NOT: { NOT: { profile: { bio: "foo" } } } },
204 | });
205 | expect(foundUser).not.toBeNull();
206 | expect(foundUser!.id).toEqual(user.id);
207 |
208 | const notFoundUser = await testClient.user.findFirst({
209 | where: { NOT: { NOT: { profile: { bio: "bar" } } } },
210 | });
211 | expect(notFoundUser).toBeNull();
212 | });
213 |
214 | it("excludes deleted when using 'NOT' nested in a 'NOT' with 'some'", async () => {
215 | const foundUser = await testClient.user.findFirst({
216 | where: { NOT: { NOT: { comments: { some: { content: "foo" } } } } },
217 | });
218 | expect(foundUser).not.toBeNull();
219 | expect(foundUser!.id).toEqual(user.id);
220 |
221 | const notFoundUser = await testClient.user.findFirst({
222 | where: { NOT: { NOT: { comments: { some: { content: "bar" } } } } },
223 | });
224 | expect(notFoundUser).toBeNull();
225 | });
226 |
227 | it("excludes deleted when using 'NOT' nested in a 'NOT' with 'every'", async () => {
228 | const foundUser = await testClient.user.findFirst({
229 | where: { NOT: { NOT: { comments: { every: { content: "foo" } } } } },
230 | });
231 | expect(foundUser).not.toBeNull();
232 | expect(foundUser!.id).toEqual(user.id);
233 |
234 | const notFoundUser = await testClient.user.findFirst({
235 | where: { NOT: { NOT: { comments: { every: { content: "bar" } } } } },
236 | });
237 | expect(notFoundUser).toBeNull();
238 | });
239 |
240 | it("excludes deleted when using 'NOT' nested in a 'NOT' with 'none'", async () => {
241 | const foundUser = await testClient.user.findFirst({
242 | where: { NOT: { NOT: { comments: { none: { content: "bar" } } } } },
243 | });
244 | expect(foundUser).not.toBeNull();
245 | expect(foundUser!.id).toEqual(user.id);
246 |
247 | const notFoundUser = await testClient.user.findFirst({
248 | where: { NOT: { NOT: { comments: { none: { content: "foo" } } } } },
249 | });
250 | expect(notFoundUser).toBeNull();
251 | });
252 |
253 | it("excludes deleted when using 'NOT' nested in a 'NOT' with 'isNot'", async () => {
254 | const foundUser = await testClient.user.findFirst({
255 | where: { NOT: { NOT: { profile: { isNot: { bio: "bar" } } } } },
256 | });
257 | expect(foundUser).not.toBeNull();
258 | expect(foundUser!.id).toEqual(user.id);
259 |
260 | const notFoundUser = await testClient.user.findFirst({
261 | where: { NOT: { NOT: { profile: { isNot: { bio: "foo" } } } } },
262 | });
263 | expect(notFoundUser).toBeNull();
264 | });
265 |
266 | it("excludes deleted when using 'NOT' nested in a 'NOT' with 'is'", async () => {
267 | const foundUser = await testClient.user.findFirst({
268 | where: { NOT: { NOT: { profile: { is: { bio: "foo" } } } } },
269 | });
270 | expect(foundUser).not.toBeNull();
271 | expect(foundUser!.id).toEqual(user.id);
272 |
273 | const notFoundUser = await testClient.user.findFirst({
274 | where: { NOT: { NOT: { profile: { is: { bio: "bar" } } } } },
275 | });
276 | expect(notFoundUser).toBeNull();
277 | });
278 |
279 | it("excludes deleted when using 'NOT' nested in a relation nested in a 'NOT'", async () => {
280 | const foundUser = await testClient.user.findFirst({
281 | where: {
282 | NOT: { profile: { NOT: { users: { some: { id: user.id } } } } },
283 | },
284 | });
285 | expect(foundUser).not.toBeNull();
286 | expect(foundUser!.id).toEqual(user.id);
287 |
288 | const notFoundUser = await testClient.user.findFirst({
289 | where: {
290 | NOT: { profile: { NOT: { users: { none: { id: user.id } } } } },
291 | },
292 | });
293 | expect(notFoundUser).toBeNull();
294 | });
295 | });
296 |
--------------------------------------------------------------------------------
/test/scripts/run-with-postgres.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | export DATABASE_URL=postgres://postgres:123@localhost:5432/test
4 |
5 | trap "docker compose down" EXIT
6 |
7 | docker compose up -d && sleep 1
8 | npx prisma db push
9 |
10 | $@
11 |
--------------------------------------------------------------------------------
/test/unit/aggregate.test.ts:
--------------------------------------------------------------------------------
1 | import { set } from "lodash";
2 | import { createSoftDeleteMiddleware } from "../../src";
3 | import { createParams } from "./utils/createParams";
4 |
5 | describe("aggregate", () => {
6 | it("does not change aggregate action if model is not in the list", async () => {
7 | const middleware = createSoftDeleteMiddleware({ models: {} });
8 |
9 | const params = createParams("User", "aggregate", {
10 | where: { email: { contains: "test" } },
11 | _sum: { id: true },
12 | });
13 | const next = jest.fn(() => Promise.resolve({}));
14 |
15 | await middleware(params, next);
16 |
17 | // params have not been modified
18 | expect(next).toHaveBeenCalledWith(params);
19 | });
20 |
21 | it("excludes deleted records from aggregate with no where", async () => {
22 | const middleware = createSoftDeleteMiddleware({
23 | models: { User: true },
24 | });
25 |
26 | const params = createParams("User", "aggregate", {});
27 | const next = jest.fn(() => Promise.resolve({}));
28 |
29 | await middleware(params, next);
30 |
31 | // params have been modified
32 | expect(next).toHaveBeenCalledWith(set(params, "args.where.deleted", false));
33 | });
34 |
35 | it("excludes deleted record from aggregate with where", async () => {
36 | const middleware = createSoftDeleteMiddleware({
37 | models: { User: true },
38 | });
39 |
40 | const params = createParams("User", "aggregate", {
41 | where: { email: { contains: "test" } },
42 | });
43 | const next = jest.fn(() => Promise.resolve({}));
44 |
45 | await middleware(params, next);
46 |
47 | // params have been modified
48 | expect(next).toHaveBeenCalledWith(set(params, "args.where.deleted", false));
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/test/unit/config.test.ts:
--------------------------------------------------------------------------------
1 | import faker from "faker";
2 | import { set } from "lodash";
3 |
4 | import { createSoftDeleteMiddleware } from "../../src";
5 | import { createParams } from "./utils/createParams";
6 |
7 | describe("config", () => {
8 | it('does not soft delete models where config is passed as "false"', async () => {
9 | const middleware = createSoftDeleteMiddleware({
10 | models: {
11 | User: false,
12 | },
13 | });
14 |
15 | const params = createParams("Post", "update", {
16 | where: { id: 1 },
17 | data: {
18 | author: { delete: true },
19 | comments: {
20 | updateMany: {
21 | where: { content: faker.lorem.sentence() },
22 | data: { content: faker.lorem.sentence() },
23 | },
24 | },
25 | },
26 | });
27 | const next = jest.fn(() => Promise.resolve({}));
28 |
29 | await middleware(params, next);
30 |
31 | expect(next).toHaveBeenCalledWith(params);
32 | });
33 |
34 | it("allows setting default config values", async () => {
35 | const deletedAt = new Date();
36 | const middleware = createSoftDeleteMiddleware({
37 | models: {
38 | Post: true,
39 | Comment: true,
40 | },
41 | defaultConfig: {
42 | field: "deletedAt",
43 | createValue: () => deletedAt,
44 | },
45 | });
46 |
47 | const params = createParams("User", "update", {
48 | where: { id: 1 },
49 | data: {
50 | posts: {
51 | delete: { id: 1 },
52 | },
53 | comments: {
54 | updateMany: {
55 | where: { content: faker.lorem.sentence() },
56 | data: { content: faker.lorem.sentence() },
57 | },
58 | },
59 | },
60 | });
61 | const next = jest.fn(() => Promise.resolve({}));
62 |
63 | await middleware(params, next);
64 |
65 | set(params, "args.data.posts", {
66 | update: { where: { id: 1 }, data: { deletedAt } },
67 | });
68 | set(params, "args.data.comments.updateMany.where.deletedAt", deletedAt);
69 |
70 | expect(next).toHaveBeenCalledWith(params);
71 | });
72 |
73 | it('throws when default config does not have a "field" property', () => {
74 | expect(() => {
75 | createSoftDeleteMiddleware({
76 | models: {
77 | Post: true,
78 | },
79 | // @ts-expect-error - we are testing the error case
80 | defaultConfig: {
81 | createValue: () => new Date(),
82 | },
83 | });
84 | }).toThrowError(
85 | "prisma-soft-delete-middleware: defaultConfig.field is required"
86 | );
87 | });
88 |
89 | it('throws when default config does not have a "createValue" property', () => {
90 | expect(() => {
91 | createSoftDeleteMiddleware({
92 | models: {
93 | Post: true,
94 | },
95 | // @ts-expect-error - we are testing the error case
96 | defaultConfig: {
97 | field: "deletedAt",
98 | },
99 | });
100 | }).toThrowError(
101 | "prisma-soft-delete-middleware: defaultConfig.createValue is required"
102 | );
103 | });
104 |
105 | it("allows setting model specific config values", async () => {
106 | const deletedAt = new Date();
107 | const middleware = createSoftDeleteMiddleware({
108 | models: {
109 | Post: {
110 | field: "deletedAt",
111 | createValue: () => deletedAt,
112 | },
113 | Comment: true,
114 | },
115 | });
116 |
117 | const params = createParams("User", "update", {
118 | where: { id: 1 },
119 | data: {
120 | posts: { delete: { id: 1 } },
121 | comments: {
122 | updateMany: {
123 | where: { content: faker.lorem.sentence() },
124 | data: { content: faker.lorem.sentence() },
125 | },
126 | },
127 | },
128 | });
129 |
130 | const next = jest.fn(() => Promise.resolve({}));
131 |
132 | await middleware(params, next);
133 |
134 | set(params, "args.data.posts", {
135 | update: { where: { id: 1 }, data: { deletedAt } },
136 | });
137 | set(params, "args.data.comments.updateMany.where.deleted", false);
138 |
139 | expect(next).toHaveBeenCalledWith(params);
140 | });
141 |
142 | it("allows overriding default config values", async () => {
143 | const deletedAt = new Date();
144 | const middleware = createSoftDeleteMiddleware({
145 | models: {
146 | Post: true,
147 | Comment: {
148 | field: "deleted",
149 | createValue: Boolean,
150 | },
151 | },
152 | defaultConfig: {
153 | field: "deletedAt",
154 | createValue: (deleted) => {
155 | if (deleted) return deletedAt;
156 | return null;
157 | },
158 | },
159 | });
160 |
161 | const params = createParams("User", "update", {
162 | where: { id: 1 },
163 | data: {
164 | posts: { delete: { id: 1 } },
165 | comments: {
166 | updateMany: {
167 | where: { content: faker.lorem.sentence() },
168 | data: { content: faker.lorem.sentence() },
169 | },
170 | },
171 | },
172 | });
173 |
174 | const next = jest.fn(() => Promise.resolve({}));
175 |
176 | await middleware(params, next);
177 |
178 | set(params, "args.data.posts", {
179 | update: { where: { id: 1 }, data: { deletedAt } },
180 | });
181 | set(params, "args.data.comments.updateMany.where.deleted", false);
182 |
183 | expect(next).toHaveBeenCalledWith(params);
184 | });
185 | });
186 |
--------------------------------------------------------------------------------
/test/unit/count.test.ts:
--------------------------------------------------------------------------------
1 | import { set } from "lodash";
2 | import { createSoftDeleteMiddleware } from "../../src";
3 | import { createParams } from "./utils/createParams";
4 |
5 | describe("count", () => {
6 | it("does not change count action if model is not in the list", async () => {
7 | const middleware = createSoftDeleteMiddleware({ models: {} });
8 |
9 | const params = createParams("User", "count", {});
10 | const next = jest.fn(() => Promise.resolve({}));
11 |
12 | await middleware(params, next);
13 |
14 | // params have not been modified
15 | expect(next).toHaveBeenCalledWith(params);
16 | });
17 |
18 | it("excludes deleted records from count", async () => {
19 | const middleware = createSoftDeleteMiddleware({
20 | models: { User: true },
21 | });
22 |
23 | const params = createParams("User", "count", undefined);
24 | const next = jest.fn(() => Promise.resolve({}));
25 |
26 | await middleware(params, next);
27 |
28 | // params have been modified
29 | expect(next).toHaveBeenCalledWith(set(params, "args.where.deleted", false));
30 | });
31 |
32 | it("excludes deleted records from count with empty args", async () => {
33 | const middleware = createSoftDeleteMiddleware({
34 | models: { User: true },
35 | });
36 |
37 | const params = createParams("User", "count", {});
38 | const next = jest.fn(() => Promise.resolve({}));
39 |
40 | await middleware(params, next);
41 |
42 | // params have been modified
43 | expect(next).toHaveBeenCalledWith(set(params, "args.where.deleted", false));
44 | });
45 |
46 | it("excludes deleted record from count with where", async () => {
47 | const middleware = createSoftDeleteMiddleware({
48 | models: { User: true },
49 | });
50 |
51 | const params = createParams("User", "count", {
52 | where: { email: { contains: "test" } },
53 | });
54 | const next = jest.fn(() => Promise.resolve({}));
55 |
56 | await middleware(params, next);
57 |
58 | // params have been modified
59 | expect(next).toHaveBeenCalledWith(set(params, "args.where.deleted", false));
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/test/unit/delete.test.ts:
--------------------------------------------------------------------------------
1 | import { set } from "lodash";
2 | import { createSoftDeleteMiddleware } from "../../src";
3 | import { createParams } from "./utils/createParams";
4 |
5 | describe("delete", () => {
6 | it("does not change delete action if model is not in the list", async () => {
7 | const middleware = createSoftDeleteMiddleware({ models: {} });
8 |
9 | const params = createParams("User", "delete", { where: { id: 1 } });
10 | const next = jest.fn(() => Promise.resolve({}));
11 |
12 | await middleware(params, next);
13 |
14 | // params have not been modified
15 | expect(next).toHaveBeenCalledWith(params);
16 | });
17 |
18 | it("does not change nested delete action if model is not in the list", async () => {
19 | const middleware = createSoftDeleteMiddleware({ models: {} });
20 |
21 | const params = createParams("User", "update", {
22 | where: { id: 1 },
23 | data: {
24 | posts: {
25 | delete: { id: 1 },
26 | },
27 | },
28 | });
29 | const next = jest.fn(() => Promise.resolve({}));
30 |
31 | await middleware(params, next);
32 |
33 | // params have not been modified
34 | expect(next).toHaveBeenCalledWith(params);
35 | });
36 |
37 | it("does not modify delete results", async () => {
38 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
39 |
40 | const params = createParams("User", "delete", { where: { id: 1 } });
41 | const next = jest.fn(() => Promise.resolve({ id: 1, deleted: true }));
42 |
43 | expect(await middleware(params, next)).toEqual({ id: 1, deleted: true });
44 | });
45 |
46 | it("does not modify delete with no args", async () => {
47 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
48 |
49 | // @ts-expect-error - args are required
50 | const params = createParams("User", "delete", undefined);
51 | const next = jest.fn(() => Promise.resolve({}));
52 |
53 | await middleware(params, next);
54 |
55 | // params have not been modified
56 | expect(next).toHaveBeenCalledWith(params);
57 | });
58 |
59 | it("does not modify delete with no where", async () => {
60 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
61 |
62 | // @ts-expect-error - where is required
63 | const params = createParams("User", "delete", {});
64 | const next = jest.fn(() => Promise.resolve({}));
65 |
66 | await middleware(params, next);
67 |
68 | // params have not been modified
69 | expect(next).toHaveBeenCalledWith(params);
70 | });
71 |
72 | it("changes delete action into an update to add deleted mark", async () => {
73 | const middleware = createSoftDeleteMiddleware({
74 | models: { User: true },
75 | });
76 |
77 | const params = createParams("User", "delete", { where: { id: 1 } });
78 | const next = jest.fn(() => Promise.resolve());
79 | await middleware(params, next);
80 |
81 | // params are modified correctly
82 | expect(next).toHaveBeenCalledWith({
83 | ...params,
84 | action: "update",
85 | args: {
86 | ...params.args,
87 | data: { deleted: true },
88 | },
89 | });
90 | });
91 |
92 | it("does not change nested delete false action", async () => {
93 | const middleware = createSoftDeleteMiddleware({
94 | models: { Profile: true },
95 | });
96 |
97 | const next = jest.fn(() => Promise.resolve({}));
98 | const params = createParams("User", "update", {
99 | where: { id: 1 },
100 | data: {
101 | profile: { delete: false },
102 | },
103 | });
104 |
105 | await middleware(params, next);
106 |
107 | // params have not been modified
108 | expect(next).toHaveBeenCalledWith(params);
109 | });
110 |
111 | it("changes nested delete true action into an update that adds deleted mark", async () => {
112 | const middleware = createSoftDeleteMiddleware({
113 | models: { Profile: true },
114 | });
115 |
116 | const next = jest.fn(() => Promise.resolve({}));
117 | const params = createParams("User", "update", {
118 | where: { id: 1 },
119 | data: {
120 | profile: {
121 | delete: true,
122 | },
123 | },
124 | });
125 |
126 | await middleware(params, next);
127 |
128 | // params are modified correctly
129 | expect(next).toHaveBeenCalledWith(
130 | set(params, "args.data.profile", {
131 | update: { deleted: true },
132 | })
133 | );
134 | });
135 |
136 | it("changes nested delete action on a toMany relation into an update that adds deleted mark", async () => {
137 | const middleware = createSoftDeleteMiddleware({
138 | models: { Post: true },
139 | });
140 |
141 | const next = jest.fn(() => Promise.resolve({}));
142 | const params = createParams("User", "update", {
143 | where: { id: 1 },
144 | data: {
145 | posts: {
146 | delete: { id: 1 },
147 | },
148 | },
149 | });
150 |
151 | await middleware(params, next);
152 |
153 | // params are modified correctly
154 | expect(next).toHaveBeenCalledWith({
155 | ...params,
156 | action: "update",
157 | args: {
158 | ...params.args,
159 | data: {
160 | posts: {
161 | update: {
162 | where: { id: 1 },
163 | data: { deleted: true },
164 | },
165 | },
166 | },
167 | },
168 | });
169 | });
170 |
171 | it("changes nested list of delete actions into a nested list of update actions", async () => {
172 | const middleware = createSoftDeleteMiddleware({
173 | models: { Post: true },
174 | });
175 |
176 | const next = jest.fn(() => Promise.resolve({}));
177 | const params = createParams("User", "update", {
178 | where: { id: 1 },
179 | data: {
180 | posts: {
181 | delete: [{ id: 1 }, { id: 2 }, { id: 3 }],
182 | },
183 | },
184 | });
185 |
186 | await middleware(params, next);
187 |
188 | // params are modified correctly
189 | expect(next).toHaveBeenCalledWith({
190 | ...params,
191 | action: "update",
192 | args: {
193 | ...params.args,
194 | data: {
195 | posts: {
196 | update: [
197 | { where: { id: 1 }, data: { deleted: true } },
198 | { where: { id: 2 }, data: { deleted: true } },
199 | { where: { id: 3 }, data: { deleted: true } },
200 | ],
201 | },
202 | },
203 | },
204 | });
205 | });
206 | });
207 |
--------------------------------------------------------------------------------
/test/unit/deleteMany.test.ts:
--------------------------------------------------------------------------------
1 | import { createSoftDeleteMiddleware } from "../../src";
2 | import { createParams } from "./utils/createParams";
3 |
4 | describe("deleteMany", () => {
5 | it("does not change deleteMany action if model is not in the list", async () => {
6 | const middleware = createSoftDeleteMiddleware({ models: {} });
7 |
8 | const params = createParams("User", "deleteMany", { where: { id: 1 } });
9 | const next = jest.fn(() => Promise.resolve({}));
10 |
11 | await middleware(params, next);
12 |
13 | // params have not been modified
14 | expect(next).toHaveBeenCalledWith(params);
15 | });
16 |
17 | it("does not change nested deleteMany action if model is not in the list", async () => {
18 | const middleware = createSoftDeleteMiddleware({ models: {} });
19 |
20 | const params = createParams("User", "update", {
21 | where: { id: 1 },
22 | data: {
23 | posts: {
24 | deleteMany: {
25 | id: 1,
26 | },
27 | },
28 | },
29 | });
30 | const next = jest.fn(() => Promise.resolve({}));
31 |
32 | await middleware(params, next);
33 |
34 | // params have not been modified
35 | expect(next).toHaveBeenCalledWith(params);
36 | });
37 |
38 | it("does not modify deleteMany results", async () => {
39 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
40 |
41 | const params = createParams("User", "deleteMany", { where: { id: 1 } });
42 | const next = jest.fn(() => Promise.resolve({ count: 1 }));
43 |
44 | expect(await middleware(params, next)).toEqual({ count: 1 });
45 | });
46 |
47 | it("changes deleteMany action into an updateMany that adds deleted mark", async () => {
48 | const middleware = createSoftDeleteMiddleware({
49 | models: { User: true },
50 | });
51 |
52 | const next = jest.fn(() => Promise.resolve({}));
53 | const params = createParams("User", "deleteMany", { where: { id: 1 } });
54 | await middleware(params, next);
55 |
56 | // params are modified correctly
57 | expect(next).toHaveBeenCalledWith({
58 | ...params,
59 | action: "updateMany",
60 | args: {
61 | where: { ...params.args.where, deleted: false },
62 | data: { deleted: true },
63 | },
64 | });
65 | });
66 |
67 | it("changes deleteMany action with no args into an updateMany that adds deleted mark", async () => {
68 | const middleware = createSoftDeleteMiddleware({
69 | models: { User: true },
70 | });
71 |
72 | const next = jest.fn(() => Promise.resolve({}));
73 | const params = createParams("User", "deleteMany", undefined);
74 | await middleware(params, next);
75 |
76 | // params are modified correctly
77 | expect(next).toHaveBeenCalledWith({
78 | ...params,
79 | action: "updateMany",
80 | args: {
81 | where: { deleted: false },
82 | data: { deleted: true },
83 | },
84 | });
85 | });
86 |
87 | it("changes deleteMany action with no where into an updateMany that adds deleted mark", async () => {
88 | const middleware = createSoftDeleteMiddleware({
89 | models: { User: true },
90 | });
91 |
92 | const next = jest.fn(() => Promise.resolve({}));
93 | const params = createParams("User", "deleteMany", {});
94 | await middleware(params, next);
95 |
96 | // params are modified correctly
97 | expect(next).toHaveBeenCalledWith({
98 | ...params,
99 | action: "updateMany",
100 | args: {
101 | where: { deleted: false },
102 | data: { deleted: true },
103 | },
104 | });
105 | });
106 |
107 | it("changes nested deleteMany action into an updateMany that adds deleted mark", async () => {
108 | const middleware = createSoftDeleteMiddleware({
109 | models: { Post: true },
110 | });
111 |
112 | const next = jest.fn(() => Promise.resolve({}));
113 | const params = createParams("User", "update", {
114 | where: { id: 1 },
115 | data: {
116 | posts: {
117 | deleteMany: {
118 | id: 1,
119 | },
120 | },
121 | },
122 | });
123 |
124 | await middleware(params, next);
125 |
126 | // params are modified correctly
127 | expect(next).toHaveBeenCalledWith({
128 | ...params,
129 | action: "update",
130 | args: {
131 | ...params.args,
132 | data: {
133 | posts: {
134 | updateMany: {
135 | where: { id: 1, deleted: false },
136 | data: { deleted: true },
137 | },
138 | },
139 | },
140 | },
141 | });
142 | });
143 | });
144 |
--------------------------------------------------------------------------------
/test/unit/findFirst.test.ts:
--------------------------------------------------------------------------------
1 | import { createSoftDeleteMiddleware } from "../../src";
2 | import { createParams } from "./utils/createParams";
3 |
4 | describe("findFirst", () => {
5 | it("does not change findFirst params if model is not in the list", async () => {
6 | const middleware = createSoftDeleteMiddleware({ models: {} });
7 |
8 | const params = createParams("User", "findFirst", { where: { id: 1 } });
9 | const next = jest.fn(() => Promise.resolve({}));
10 |
11 | await middleware(params, next);
12 |
13 | // params have not been modified
14 | expect(next).toHaveBeenCalledWith(params);
15 | });
16 |
17 | it("does not modify findFirst results", async () => {
18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
19 |
20 | const params = createParams("User", "findFirst", { where: { id: 1 } });
21 | const next = jest.fn(() => Promise.resolve({ id: 1, deleted: true }));
22 |
23 | expect(await middleware(params, next)).toEqual({ id: 1, deleted: true });
24 | });
25 |
26 | it("excludes deleted records from findFirst", async () => {
27 | const middleware = createSoftDeleteMiddleware({
28 | models: { User: true },
29 | });
30 |
31 | const params = createParams("User", "findFirst", { where: { id: 1 } });
32 | const next = jest.fn(() => Promise.resolve({}));
33 |
34 | await middleware(params, next);
35 |
36 | // params have been modified
37 | expect(next).toHaveBeenCalledWith({
38 | ...params,
39 | action: "findFirst",
40 | args: {
41 | where: {
42 | id: 1,
43 | deleted: false,
44 | },
45 | },
46 | });
47 | });
48 |
49 | it("excludes deleted records from findFirst with no args", async () => {
50 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
51 |
52 | const params = createParams("User", "findFirst", undefined);
53 | const next = jest.fn(() => Promise.resolve({}));
54 |
55 | await middleware(params, next);
56 |
57 | // params have been modified
58 | expect(next).toHaveBeenCalledWith({
59 | ...params,
60 | action: "findFirst",
61 | args: {
62 | where: {
63 | deleted: false,
64 | },
65 | },
66 | });
67 | });
68 |
69 | it("excludes deleted records from findFirst with empty args", async () => {
70 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
71 |
72 | const params = createParams("User", "findFirst", {});
73 | const next = jest.fn(() => Promise.resolve({}));
74 |
75 | await middleware(params, next);
76 |
77 | // params have been modified
78 | expect(next).toHaveBeenCalledWith({
79 | ...params,
80 | action: "findFirst",
81 | args: {
82 | where: {
83 | deleted: false,
84 | },
85 | },
86 | });
87 | });
88 |
89 | it("allows explicitly querying for deleted records using findFirst", async () => {
90 | const middleware = createSoftDeleteMiddleware({
91 | models: { User: true },
92 | });
93 |
94 | const params = createParams("User", "findFirst", {
95 | where: { id: 1, deleted: true },
96 | });
97 | const next = jest.fn(() => Promise.resolve({}));
98 |
99 | await middleware(params, next);
100 |
101 | // params have not been modified
102 | expect(next).toHaveBeenCalledWith(params);
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/test/unit/findFirstOrThrow.test.ts:
--------------------------------------------------------------------------------
1 | import { createSoftDeleteMiddleware } from "../../src";
2 | import { createParams } from "./utils/createParams";
3 |
4 | describe("findFirstOrThrow", () => {
5 | it("does not change findFirstOrThrow params if model is not in the list", async () => {
6 | const middleware = createSoftDeleteMiddleware({ models: {} });
7 |
8 | const params = createParams("User", "findFirstOrThrow", { where: { id: 1 } });
9 | const next = jest.fn(() => Promise.resolve({}));
10 |
11 | await middleware(params, next);
12 |
13 | // params have not been modified
14 | expect(next).toHaveBeenCalledWith(params);
15 | });
16 |
17 | it("does not modify findFirstOrThrow results", async () => {
18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
19 |
20 | const params = createParams("User", "findFirstOrThrow", { where: { id: 1 } });
21 | const next = jest.fn(() => Promise.resolve({ id: 1, deleted: true }));
22 |
23 | expect(await middleware(params, next)).toEqual({ id: 1, deleted: true });
24 | });
25 |
26 | it("excludes deleted records from findFirstOrThrow", async () => {
27 | const middleware = createSoftDeleteMiddleware({
28 | models: { User: true },
29 | });
30 |
31 | const params = createParams("User", "findFirstOrThrow", { where: { id: 1 } });
32 | const next = jest.fn(() => Promise.resolve({}));
33 |
34 | await middleware(params, next);
35 |
36 | // params have been modified
37 | expect(next).toHaveBeenCalledWith({
38 | ...params,
39 | action: "findFirstOrThrow",
40 | args: {
41 | where: {
42 | id: 1,
43 | deleted: false,
44 | },
45 | },
46 | });
47 | });
48 |
49 | it("excludes deleted records from findFirstOrThrow with no args", async () => {
50 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
51 |
52 | const params = createParams("User", "findFirstOrThrow", undefined);
53 | const next = jest.fn(() => Promise.resolve({}));
54 |
55 | await middleware(params, next);
56 |
57 | // params have been modified
58 | expect(next).toHaveBeenCalledWith({
59 | ...params,
60 | action: "findFirstOrThrow",
61 | args: {
62 | where: {
63 | deleted: false,
64 | },
65 | },
66 | });
67 | });
68 |
69 | it("excludes deleted records from findFirstOrThrow with empty args", async () => {
70 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
71 |
72 | const params = createParams("User", "findFirstOrThrow", {});
73 | const next = jest.fn(() => Promise.resolve({}));
74 |
75 | await middleware(params, next);
76 |
77 | // params have been modified
78 | expect(next).toHaveBeenCalledWith({
79 | ...params,
80 | action: "findFirstOrThrow",
81 | args: {
82 | where: {
83 | deleted: false,
84 | },
85 | },
86 | });
87 | });
88 |
89 | it("allows explicitly querying for deleted records using findFirstOrThrow", async () => {
90 | const middleware = createSoftDeleteMiddleware({
91 | models: { User: true },
92 | });
93 |
94 | const params = createParams("User", "findFirstOrThrow", {
95 | where: { id: 1, deleted: true },
96 | });
97 | const next = jest.fn(() => Promise.resolve({}));
98 |
99 | await middleware(params, next);
100 |
101 | // params have not been modified
102 | expect(next).toHaveBeenCalledWith(params);
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/test/unit/findMany.test.ts:
--------------------------------------------------------------------------------
1 | import { createSoftDeleteMiddleware } from "../../src";
2 | import { createParams } from "./utils/createParams";
3 |
4 | describe("findMany", () => {
5 | it("does not change findMany params if model is not in the list", async () => {
6 | const middleware = createSoftDeleteMiddleware({ models: {} });
7 |
8 | const params = createParams("User", "findMany", { where: { id: 1 } });
9 | const next = jest.fn(() => Promise.resolve({}));
10 |
11 | await middleware(params, next);
12 |
13 | // params have not been modified
14 | expect(next).toHaveBeenCalledWith(params);
15 | });
16 |
17 | it("does not modify findMany results", async () => {
18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
19 |
20 | const params = createParams("User", "findMany", { where: { id: 1 } });
21 | const next = jest.fn(() => Promise.resolve([{ id: 1, deleted: true }]));
22 |
23 | expect(await middleware(params, next)).toEqual([{ id: 1, deleted: true }]);
24 | });
25 |
26 | it("excludes deleted records from findMany", async () => {
27 | const middleware = createSoftDeleteMiddleware({
28 | models: { User: true },
29 | });
30 |
31 | const params = createParams("User", "findMany", { where: { id: 1 } });
32 | const next = jest.fn(() => Promise.resolve({}));
33 |
34 | await middleware(params, next);
35 |
36 | // params have been modified
37 | expect(next).toHaveBeenCalledWith({
38 | ...params,
39 | action: "findMany",
40 | args: {
41 | where: {
42 | id: 1,
43 | deleted: false,
44 | },
45 | },
46 | });
47 | });
48 |
49 | it("excludes deleted records from findMany with no args", async () => {
50 | const middleware = createSoftDeleteMiddleware({
51 | models: { User: true },
52 | });
53 |
54 | const params = createParams("User", "findMany", undefined);
55 | const next = jest.fn(() => Promise.resolve({}));
56 |
57 | await middleware(params, next);
58 |
59 | // params have been modified
60 | expect(next).toHaveBeenCalledWith({
61 | ...params,
62 | action: "findMany",
63 | args: {
64 | where: {
65 | deleted: false,
66 | },
67 | },
68 | });
69 | });
70 |
71 | it("excludes deleted records from findMany with empty args", async () => {
72 | const middleware = createSoftDeleteMiddleware({
73 | models: { User: true },
74 | });
75 |
76 | const params = createParams("User", "findMany", {});
77 | const next = jest.fn(() => Promise.resolve({}));
78 |
79 | await middleware(params, next);
80 |
81 | // params have been modified
82 | expect(next).toHaveBeenCalledWith({
83 | ...params,
84 | action: "findMany",
85 | args: {
86 | where: {
87 | deleted: false,
88 | },
89 | },
90 | });
91 | });
92 |
93 | it("allows explicitly querying for deleted records using findMany", async () => {
94 | const middleware = createSoftDeleteMiddleware({
95 | models: { User: true },
96 | });
97 |
98 | const params = createParams("User", "findMany", {
99 | where: { id: 1, deleted: true },
100 | });
101 | const next = jest.fn(() => Promise.resolve({}));
102 |
103 | await middleware(params, next);
104 |
105 | // params have not been modified
106 | expect(next).toHaveBeenCalledWith(params);
107 | });
108 | });
109 |
--------------------------------------------------------------------------------
/test/unit/findUnique.test.ts:
--------------------------------------------------------------------------------
1 | import { createSoftDeleteMiddleware } from "../../src";
2 | import { createParams } from "./utils/createParams";
3 |
4 | describe("findUnique", () => {
5 | it("does not change findUnique params if model is not in the list", async () => {
6 | const middleware = createSoftDeleteMiddleware({ models: {} });
7 |
8 | const params = createParams("User", "findUnique", { where: { id: 1 } });
9 | const next = jest.fn(() => Promise.resolve({}));
10 |
11 | await middleware(params, next);
12 |
13 | // params have not been modified
14 | expect(next).toHaveBeenCalledWith(params);
15 | });
16 |
17 | it("does not modify findUnique results", async () => {
18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
19 |
20 | const params = createParams("User", "findUnique", { where: { id: 1 } });
21 | const next = jest.fn(() => Promise.resolve({ id: 1, deleted: true }));
22 |
23 | expect(await middleware(params, next)).toEqual({ id: 1, deleted: true });
24 | });
25 |
26 | it("changes findUnique into findFirst and excludes deleted records", async () => {
27 | const middleware = createSoftDeleteMiddleware({
28 | models: { User: true },
29 | });
30 |
31 | const params = createParams("User", "findUnique", { where: { id: 1 } });
32 | const next = jest.fn(() => Promise.resolve({}));
33 |
34 | await middleware(params, next);
35 |
36 | // params have been modified
37 | expect(next).toHaveBeenCalledWith({
38 | ...params,
39 | action: "findFirst",
40 | args: {
41 | where: {
42 | id: 1,
43 | deleted: false,
44 | },
45 | },
46 | });
47 | });
48 |
49 | it("throws when trying to pass a findUnique where with a compound unique index field", async () => {
50 | const middleware = createSoftDeleteMiddleware({
51 | models: { User: true },
52 | });
53 |
54 | const params = createParams("User", "findUnique", {
55 | where: {
56 | name_email: {
57 | name: "test",
58 | email: "test@test.com",
59 | },
60 | },
61 | });
62 |
63 | const next = () => Promise.resolve({});
64 |
65 | await expect(middleware(params, next)).rejects.toThrowError(
66 | `prisma-soft-delete-middleware: query of model "User" through compound unique index field "name_email" found. Queries of soft deleted models through a unique index are not supported. Set "allowCompoundUniqueIndexWhere" to true to override this behaviour.`
67 | );
68 | });
69 |
70 | it('does not modify findUnique when compound unique index field used and "allowCompoundUniqueIndexWhere" is set to true', async () => {
71 | const middleware = createSoftDeleteMiddleware({
72 | models: {
73 | User: {
74 | field: "deleted",
75 | createValue: Boolean,
76 | allowCompoundUniqueIndexWhere: true,
77 | },
78 | },
79 | });
80 |
81 | const params = createParams("User", "findUnique", {
82 | where: {
83 | name_email: {
84 | name: "test",
85 | email: "test@test.com",
86 | },
87 | },
88 | });
89 | const next = jest.fn(() => Promise.resolve({}));
90 |
91 | await middleware(params, next);
92 |
93 | // params have not been modified
94 | expect(next).toHaveBeenCalledWith(params);
95 | });
96 |
97 | it("does not modify findUnique to be a findFirst when no args passed", async () => {
98 | const middleware = createSoftDeleteMiddleware({
99 | models: { User: true },
100 | });
101 |
102 | // @ts-expect-error testing if user doesn't pass args accidentally
103 | const params = createParams("User", "findUnique", undefined);
104 | const next = jest.fn(() => Promise.resolve({}));
105 |
106 | await middleware(params, next);
107 |
108 | // params have not been modified
109 | expect(next).toHaveBeenCalledWith(params);
110 | });
111 |
112 | it("does not modify findUnique to be a findFirst when invalid where passed", async () => {
113 | const middleware = createSoftDeleteMiddleware({
114 | models: { User: true },
115 | });
116 |
117 | // @ts-expect-error testing if user doesn't pass where accidentally
118 | let params = createParams("User", "findUnique", {});
119 | let next = jest.fn(() => Promise.resolve({}));
120 | await middleware(params, next);
121 | expect(next).toHaveBeenCalledWith(params);
122 |
123 | // expect empty where not to modify params
124 | // @ts-expect-error testing if user passes where without unique field
125 | params = createParams("User", "findUnique", { where: {} });
126 | next = jest.fn(() => Promise.resolve({}));
127 | await middleware(params, next);
128 | expect(next).toHaveBeenCalledWith(params);
129 |
130 | // expect where with undefined id field not to modify params
131 | params = createParams("User", "findUnique", { where: { id: undefined } });
132 | next = jest.fn(() => Promise.resolve({}));
133 | await middleware(params, next);
134 | expect(next).toHaveBeenCalledWith(params);
135 |
136 | // expect where with undefined unique field not to modify params
137 | params = createParams("User", "findUnique", {
138 | where: { email: undefined },
139 | });
140 | next = jest.fn(() => Promise.resolve({}));
141 | await middleware(params, next);
142 | expect(next).toHaveBeenCalledWith(params);
143 |
144 | // expect where with undefined unique index field not to modify params
145 | params = createParams("User", "findUnique", {
146 | where: { name_email: undefined },
147 | });
148 | next = jest.fn(() => Promise.resolve({}));
149 | await middleware(params, next);
150 | expect(next).toHaveBeenCalledWith(params);
151 |
152 | // expect where with defined non-unique field
153 | params = createParams("User", "findUnique", {
154 | // @ts-expect-error intentionally incorrect where
155 | where: { name: "test" },
156 | });
157 | next = jest.fn(() => Promise.resolve({}));
158 | await middleware(params, next);
159 | expect(next).toHaveBeenCalledWith(params);
160 |
161 | // expect where with defined non-unique field and undefined id field not to modify params
162 | params = createParams("User", "findUnique", {
163 | where: { id: undefined, name: "test" },
164 | });
165 | next = jest.fn(() => Promise.resolve({}));
166 | await middleware(params, next);
167 | expect(next).toHaveBeenCalledWith(params);
168 | });
169 | });
170 |
--------------------------------------------------------------------------------
/test/unit/findUniqueOrThrow.ts:
--------------------------------------------------------------------------------
1 | import { createSoftDeleteMiddleware } from "../../src";
2 | import { createParams } from "./utils/createParams";
3 |
4 | describe("findUniqueOrThrow", () => {
5 | it("does not change findUniqueOrThrow params if model is not in the list", async () => {
6 | const middleware = createSoftDeleteMiddleware({ models: {} });
7 |
8 | const params = createParams("User", "findUniqueOrThrow", { where: { id: 1 } });
9 | const next = jest.fn(() => Promise.resolve({}));
10 |
11 | await middleware(params, next);
12 |
13 | // params have not been modified
14 | expect(next).toHaveBeenCalledWith(params);
15 | });
16 |
17 | it("does not modify findUniqueOrThrow results", async () => {
18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
19 |
20 | const params = createParams("User", "findUniqueOrThrow", { where: { id: 1 } });
21 | const next = jest.fn(() => Promise.resolve({ id: 1, deleted: true }));
22 |
23 | expect(await middleware(params, next)).toEqual({ id: 1, deleted: true });
24 | });
25 |
26 | it("changes findUniqueOrThrow into findFirst and excludes deleted records", async () => {
27 | const middleware = createSoftDeleteMiddleware({
28 | models: { User: true },
29 | });
30 |
31 | const params = createParams("User", "findUniqueOrThrow", { where: { id: 1 } });
32 | const next = jest.fn(() => Promise.resolve({}));
33 |
34 | await middleware(params, next);
35 |
36 | // params have been modified
37 | expect(next).toHaveBeenCalledWith({
38 | ...params,
39 | action: "findFirst",
40 | args: {
41 | where: {
42 | id: 1,
43 | deleted: false,
44 | },
45 | },
46 | });
47 | });
48 |
49 | it("does not modify findUniqueOrThrow to be a findFirst when no args passed", async () => {
50 | const middleware = createSoftDeleteMiddleware({
51 | models: { User: true },
52 | });
53 |
54 | // @ts-expect-error testing if user doesn't pass args accidentally
55 | const params = createParams("User", "findUniqueOrThrow", undefined);
56 | const next = jest.fn(() => Promise.resolve({}));
57 |
58 | await middleware(params, next);
59 |
60 | // params have not been modified
61 | expect(next).toHaveBeenCalledWith(params);
62 | });
63 |
64 | it("does not modify findUniqueOrThrow to be a findFirst when invalid where passed", async () => {
65 | const middleware = createSoftDeleteMiddleware({
66 | models: { User: true },
67 | });
68 |
69 | // @ts-expect-error testing if user doesn't pass where accidentally
70 | let params = createParams("User", "findUniqueOrThrow", {});
71 | let next = jest.fn(() => Promise.resolve({}));
72 | await middleware(params, next);
73 | expect(next).toHaveBeenCalledWith(params);
74 |
75 | // expect empty where not to modify params
76 | // @ts-expect-error testing if user passes where without unique field
77 | params = createParams("User", "findUniqueOrThrow", { where: {} });
78 | next = jest.fn(() => Promise.resolve({}));
79 | await middleware(params, next);
80 | expect(next).toHaveBeenCalledWith(params);
81 |
82 | // expect where with undefined id field not to modify params
83 | params = createParams("User", "findUniqueOrThrow", { where: { id: undefined } });
84 | next = jest.fn(() => Promise.resolve({}));
85 | await middleware(params, next);
86 | expect(next).toHaveBeenCalledWith(params);
87 |
88 | // expect where with undefined unique field not to modify params
89 | params = createParams("User", "findUniqueOrThrow", {
90 | where: { email: undefined },
91 | });
92 | next = jest.fn(() => Promise.resolve({}));
93 | await middleware(params, next);
94 | expect(next).toHaveBeenCalledWith(params);
95 |
96 | // expect where with undefined unique index field not to modify params
97 | params = createParams("User", "findUniqueOrThrow", {
98 | where: { name_email: undefined },
99 | });
100 | next = jest.fn(() => Promise.resolve({}));
101 | await middleware(params, next);
102 | expect(next).toHaveBeenCalledWith(params);
103 |
104 | // expect where with defined non-unique field
105 | params = createParams("User", "findUniqueOrThrow", {
106 | // @ts-expect-error intentionally incorrect where
107 | where: { name: "test" },
108 | });
109 | next = jest.fn(() => Promise.resolve({}));
110 | await middleware(params, next);
111 | expect(next).toHaveBeenCalledWith(params);
112 |
113 | // expect where with defined non-unique field and undefined id field not to modify params
114 | params = createParams("User", "findUniqueOrThrow", {
115 | where: { id: undefined, name: "test" },
116 | });
117 | next = jest.fn(() => Promise.resolve({}));
118 | await middleware(params, next);
119 | expect(next).toHaveBeenCalledWith(params);
120 | });
121 | });
122 |
--------------------------------------------------------------------------------
/test/unit/groupBy.test.ts:
--------------------------------------------------------------------------------
1 | import { createSoftDeleteMiddleware } from "../../src";
2 | import { createParams } from "./utils/createParams";
3 |
4 | describe("groupBy", () => {
5 |
6 | //group by must always have by and order by, else we get an error,
7 | it("does not change groupBy action if model is not in the list", async () => {
8 | const middleware = createSoftDeleteMiddleware({ models: {} });
9 |
10 | const params = createParams("User", "groupBy", { where: { id: 1 }, by: ['id'], orderBy: {}, });
11 | const next = jest.fn(() => Promise.resolve({}));
12 | await middleware(params, next);
13 | // params have not been modified
14 | expect(next).toHaveBeenCalledWith(params);
15 | });
16 |
17 | it("does not modify groupBy results", async () => {
18 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
19 | const params = createParams("User", "groupBy", { where: { id: 1 }, by: ['id'], orderBy: {}, });
20 | const next = jest.fn(() => Promise.resolve([{ id: 1, deleted: true }]));
21 | expect(await middleware(params, next)).toEqual([{ id: 1, deleted: true }]);
22 | });
23 |
24 | it("excludes deleted records from groupBy", async () => {
25 | const middleware = createSoftDeleteMiddleware({
26 | models: { User: true },
27 | });
28 |
29 | const params = createParams("User", "groupBy", { where: { id: 1 },by: ['id'], orderBy: {} });
30 | const next = jest.fn(() => Promise.resolve({}));
31 |
32 | await middleware(params, next);
33 |
34 | // params have been modified
35 | expect(next).toHaveBeenCalledWith({
36 | ...params,
37 | action: "groupBy",
38 | args: {
39 | by: ['id'],
40 | orderBy: { },
41 | where: {
42 | id: 1,
43 | deleted: false,
44 | },
45 | },
46 | });
47 | });
48 |
49 |
50 | it("allows explicitly querying for deleted records using groupBy", async () => {
51 | const middleware = createSoftDeleteMiddleware({
52 | models: { User: true },
53 | });
54 |
55 | const params = createParams("User", "groupBy", {
56 | where: { id: 1, deleted: true }, by: ['id'], orderBy: {}
57 | });
58 | const next = jest.fn(() => Promise.resolve({}));
59 |
60 | await middleware(params, next);
61 |
62 | // params have not been modified
63 | expect(next).toHaveBeenCalledWith(params);
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/test/unit/include.test.ts:
--------------------------------------------------------------------------------
1 | import { set } from "lodash";
2 | import faker from "faker";
3 |
4 | import { createSoftDeleteMiddleware } from "../../src";
5 | import { createParams, ActionByModel } from "./utils/createParams";
6 |
7 | describe("include", () => {
8 | it("does not change include params if model is not in the list", async () => {
9 | const middleware = createSoftDeleteMiddleware({ models: {} });
10 |
11 | const params = createParams("User", "update", {
12 | where: { id: 1 },
13 | data: { email: "test@test.com" },
14 | include: { comments: true },
15 | });
16 |
17 | const next = jest.fn(() => Promise.resolve({}));
18 | await middleware(params, next);
19 |
20 | // params have not been modified
21 | expect(next).toHaveBeenCalledWith(params);
22 | });
23 |
24 | it.each([
25 | "delete",
26 | "update",
27 | "upsert",
28 | "findFirst",
29 | "findFirstOrThrow",
30 | "findUnique",
31 | "findUniqueOrThrow",
32 | "findMany",
33 | ] as Array>)(
34 | "can include records for configured models in %s",
35 | async (action) => {
36 | const middleware = createSoftDeleteMiddleware({
37 | models: { User: true },
38 | });
39 |
40 | const params = createParams("User", action, {
41 | where: { id: 1 },
42 | include: {
43 | comments: true,
44 | },
45 | });
46 |
47 | const next = jest.fn(() =>
48 | Promise.resolve({
49 | comments: [{ deleted: true }, { deleted: false }],
50 | })
51 | );
52 |
53 | await middleware(params, next);
54 |
55 | // @ts-expect-error - ts doesn't know there has been a call
56 | expect(next.mock.calls[0][0]?.args?.include).toEqual({
57 | comments: true,
58 | });
59 | }
60 | );
61 |
62 | it("uses params to exclude deleted records from toMany includes", async () => {
63 | const middleware = createSoftDeleteMiddleware({
64 | models: { Comment: true },
65 | });
66 |
67 | const params = createParams("User", "update", {
68 | where: { id: 1 },
69 | data: { email: "test@test.com" },
70 | include: {
71 | comments: true,
72 | },
73 | });
74 |
75 | const next = jest.fn(() => Promise.resolve({}));
76 | await middleware(params, next);
77 |
78 | // params have been modified
79 | expect(next).toHaveBeenCalledWith(
80 | set(params, "args.include.comments", {
81 | where: {
82 | deleted: false,
83 | },
84 | })
85 | );
86 | });
87 |
88 | it("uses params to exclude deleted records from toMany includes with where", async () => {
89 | const middleware = createSoftDeleteMiddleware({
90 | models: { Comment: true },
91 | });
92 |
93 | const params = createParams("User", "update", {
94 | where: { id: 1 },
95 | data: { email: "test@test.com" },
96 | include: {
97 | comments: {
98 | where: {
99 | content: faker.lorem.sentence(),
100 | },
101 | },
102 | },
103 | });
104 |
105 | const next = jest.fn(() => Promise.resolve({}));
106 | await middleware(params, next);
107 |
108 | // params have been modified
109 | expect(next).toHaveBeenCalledWith(
110 | set(params, "args.include.comments.where.deleted", false)
111 | );
112 | });
113 |
114 | it("manually excludes deleted records from boolean toOne include", async () => {
115 | const middleware = createSoftDeleteMiddleware({
116 | models: { User: true },
117 | });
118 |
119 | const params = createParams("Post", "update", {
120 | where: { id: 1 },
121 | data: { content: "foo" },
122 | include: {
123 | author: true,
124 | },
125 | });
126 |
127 | const next = jest.fn(() => Promise.resolve({ author: { deleted: true } }));
128 | const result = await middleware(params, next);
129 |
130 | expect(next).toHaveBeenCalledWith(params);
131 | expect(result).toEqual({ author: null });
132 | });
133 |
134 | it("does not manually exclude non-deleted records from boolean toOne include", async () => {
135 | const middleware = createSoftDeleteMiddleware({
136 | models: { User: true },
137 | });
138 |
139 | const params = createParams("Post", "update", {
140 | where: { id: 1 },
141 | data: { content: "foo" },
142 | include: {
143 | author: true,
144 | },
145 | });
146 |
147 | const next = jest.fn(() => Promise.resolve({ author: { deleted: false } }));
148 | const result = await middleware(params, next);
149 |
150 | expect(next).toHaveBeenCalledWith(params);
151 | expect(result).toEqual({ author: { deleted: false } });
152 | });
153 |
154 | it("manually excludes deleted records from toOne include with nested includes", async () => {
155 | const middleware = createSoftDeleteMiddleware({
156 | models: { User: true },
157 | });
158 |
159 | const params = createParams("Post", "update", {
160 | where: { id: 1 },
161 | data: { content: "foo" },
162 | include: {
163 | author: {
164 | include: {
165 | comments: true,
166 | },
167 | },
168 | },
169 | });
170 |
171 | const next = jest.fn(() =>
172 | Promise.resolve({ author: { deleted: true, comments: [] } })
173 | );
174 | const result = await middleware(params, next);
175 |
176 | expect(next).toHaveBeenCalledWith(params);
177 | expect(result).toEqual({ author: null });
178 | });
179 |
180 | it("does not manually exclude non-deleted records from toOne include with nested includes", async () => {
181 | const middleware = createSoftDeleteMiddleware({
182 | models: { User: true },
183 | });
184 |
185 | const params = createParams("Post", "update", {
186 | where: { id: 1 },
187 | data: { content: "foo" },
188 | include: {
189 | author: {
190 | include: {
191 | comments: true,
192 | },
193 | },
194 | },
195 | });
196 |
197 | const next = jest.fn(() =>
198 | Promise.resolve({
199 | author: {
200 | deleted: false,
201 | comments: [],
202 | },
203 | })
204 | );
205 |
206 | const result = await middleware(params, next);
207 |
208 | expect(result).toEqual({
209 | author: {
210 | deleted: false,
211 | comments: [],
212 | },
213 | });
214 | });
215 |
216 | it("manually excludes deleted records from toOne include nested in toMany include", async () => {
217 | const middleware = createSoftDeleteMiddleware({
218 | models: { User: true },
219 | });
220 |
221 | const params = createParams("User", "findFirst", {
222 | where: { id: 1, deleted: false },
223 | include: {
224 | posts: {
225 | include: {
226 | author: true,
227 | },
228 | },
229 | },
230 | });
231 |
232 | const next = jest.fn(() =>
233 | Promise.resolve({
234 | posts: [
235 | { author: null },
236 | { author: { deleted: true } },
237 | { author: { deleted: false } },
238 | ],
239 | })
240 | );
241 |
242 | const result = await middleware(params, next);
243 |
244 | expect(next).toHaveBeenCalledWith(params);
245 | expect(result).toEqual({
246 | posts: [
247 | { author: null },
248 | { author: null },
249 | { author: { deleted: false } },
250 | ],
251 | });
252 | });
253 |
254 | it("allows explicitly including deleted records using include", async () => {
255 | const middleware = createSoftDeleteMiddleware({
256 | models: { Comment: true },
257 | });
258 |
259 | const params = createParams("User", "update", {
260 | where: { id: 1 },
261 | data: { email: "test@test.com" },
262 | include: {
263 | comments: {
264 | where: {
265 | deleted: true,
266 | },
267 | },
268 | },
269 | });
270 | const next = jest.fn(() =>
271 | Promise.resolve({
272 | comments: [{ deleted: true }, { deleted: true }],
273 | })
274 | );
275 |
276 | const result = await middleware(params, next);
277 |
278 | // params have not been modified
279 | expect(next).toHaveBeenCalledWith(params);
280 | expect(result).toEqual({
281 | comments: [{ deleted: true }, { deleted: true }],
282 | });
283 | });
284 | });
285 |
--------------------------------------------------------------------------------
/test/unit/select.test.ts:
--------------------------------------------------------------------------------
1 | import { set } from "lodash";
2 | import faker from "faker";
3 |
4 | import { createSoftDeleteMiddleware } from "../../src";
5 | import { ActionByModel, createParams } from "./utils/createParams";
6 |
7 | describe("select", () => {
8 | it("does not change select params if model is not in the list", async () => {
9 | const middleware = createSoftDeleteMiddleware({ models: {} });
10 |
11 | const params = createParams("User", "update", {
12 | where: { id: 1 },
13 | data: { email: "test@test.com" },
14 | select: { comments: true },
15 | });
16 | const next = jest.fn(() => Promise.resolve({}));
17 |
18 | await middleware(params, next);
19 |
20 | // params have not been modified
21 | expect(next).toHaveBeenCalledWith(params);
22 | });
23 |
24 | it.each([
25 | "delete",
26 | "update",
27 | "upsert",
28 | "findFirst",
29 | "findFirstOrThrow",
30 | "findUnique",
31 | "findUniqueOrThrow",
32 | "findMany",
33 | ] as Array>)(
34 | "can select records for configured models in %s",
35 | async (action) => {
36 | const middleware = createSoftDeleteMiddleware({
37 | models: { User: true },
38 | });
39 |
40 | const params = createParams("User", action, {
41 | where: { id: 1 },
42 | select: {
43 | comments: true,
44 | },
45 | });
46 |
47 | const next = jest.fn(() =>
48 | Promise.resolve({
49 | comments: [{ deleted: true }, { deleted: false }],
50 | })
51 | );
52 |
53 | await middleware(params, next);
54 |
55 | // @ts-expect-error - ts doesn't know there has been a call
56 | expect(next.mock.calls[0][0]?.args?.select).toEqual({
57 | comments: true,
58 | });
59 | }
60 | );
61 |
62 | it("excludes deleted records from selects", async () => {
63 | const middleware = createSoftDeleteMiddleware({
64 | models: { Comment: true },
65 | });
66 |
67 | const params = createParams("User", "update", {
68 | where: { id: 1 },
69 | data: { email: "test@test.com" },
70 | select: {
71 | comments: true,
72 | },
73 | });
74 | const next = jest.fn(() => Promise.resolve({}));
75 |
76 | await middleware(params, next);
77 |
78 | // params have been modified
79 | expect(next).toHaveBeenCalledWith(
80 | set(params, "args.select.comments", {
81 | where: {
82 | deleted: false,
83 | },
84 | })
85 | );
86 | });
87 |
88 | it("excludes deleted records from selects using where", async () => {
89 | const middleware = createSoftDeleteMiddleware({
90 | models: { Comment: true },
91 | });
92 |
93 | const params = createParams("User", "update", {
94 | where: { id: 1 },
95 | data: { email: "test@test.com" },
96 | select: {
97 | comments: {
98 | where: {
99 | content: faker.lorem.sentence(),
100 | },
101 | },
102 | },
103 | });
104 | const next = jest.fn(() => Promise.resolve({}));
105 |
106 | await middleware(params, next);
107 |
108 | // params have been modified
109 | expect(next).toHaveBeenCalledWith(
110 | set(params, "args.select.comments.where.deleted", false)
111 | );
112 | });
113 |
114 | it("excludes deleted records from include with select", async () => {
115 | const middleware = createSoftDeleteMiddleware({
116 | models: { Comment: true },
117 | });
118 |
119 | const params = createParams("User", "update", {
120 | where: { id: 1 },
121 | data: { email: "test@test.com" },
122 | include: {
123 | comments: {
124 | select: {
125 | id: true,
126 | },
127 | },
128 | },
129 | });
130 | const next = jest.fn(() => Promise.resolve({}));
131 |
132 | await middleware(params, next);
133 |
134 | // params have not been modified
135 | expect(next).toHaveBeenCalledWith(
136 | set(params, "args.include.comments", {
137 | where: { deleted: false },
138 | select: { id: true },
139 | })
140 | );
141 | });
142 |
143 | it("allows explicitly selecting deleted records using select", async () => {
144 | const middleware = createSoftDeleteMiddleware({
145 | models: { Comment: true },
146 | });
147 |
148 | const params = createParams("User", "update", {
149 | where: { id: 1 },
150 | data: { email: "test@test.com" },
151 | select: {
152 | comments: {
153 | where: {
154 | deleted: true,
155 | },
156 | },
157 | },
158 | });
159 | const next = jest.fn(() => Promise.resolve({}));
160 |
161 | await middleware(params, next);
162 |
163 | // params have not been modified
164 | expect(next).toHaveBeenCalledWith(params);
165 | });
166 | });
167 |
--------------------------------------------------------------------------------
/test/unit/update.test.ts:
--------------------------------------------------------------------------------
1 | import { createSoftDeleteMiddleware } from "../../src";
2 | import { createParams } from "./utils/createParams";
3 |
4 | describe("update", () => {
5 | it("does not change update action if model is not in the list", async () => {
6 | const middleware = createSoftDeleteMiddleware({ models: {} });
7 |
8 | const params = createParams("User", "update", {
9 | where: { id: 1 },
10 | data: { email: "test@test.com" },
11 | });
12 | const next = jest.fn(() => Promise.resolve({}));
13 |
14 | await middleware(params, next);
15 |
16 | // params have not been modified
17 | expect(next).toHaveBeenCalledWith(params);
18 | });
19 |
20 | it("does not modify update results", async () => {
21 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
22 |
23 | const params = createParams("User", "update", {
24 | where: { id: 1 },
25 | data: { name: "John" },
26 | });
27 | const next = jest.fn(() => Promise.resolve({ id: 1, name: "John" }));
28 |
29 | expect(await middleware(params, next)).toEqual({ id: 1, name: "John" });
30 | });
31 |
32 | it("throws when trying to update a model configured for soft delete through a toOne relation", async () => {
33 | const middleware = createSoftDeleteMiddleware({
34 | models: { User: true },
35 | });
36 |
37 | const params = createParams("Post", "update", {
38 | where: { id: 1 },
39 | data: {
40 | author: {
41 | update: {
42 | email: "test@test.com",
43 | },
44 | },
45 | },
46 | });
47 |
48 | const next = () => Promise.resolve({});
49 |
50 | await expect(middleware(params, next)).rejects.toThrowError(
51 | 'prisma-soft-delete-middleware: update of model "User" through "Post.author" found. Updates of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.'
52 | );
53 | });
54 |
55 | it("does nothing to nested update actions for toOne relations when allowToOneUpdates is true", async () => {
56 | const middleware = createSoftDeleteMiddleware({
57 | models: { User: true },
58 | defaultConfig: {
59 | field: "deleted",
60 | createValue: Boolean,
61 | allowToOneUpdates: true,
62 | },
63 | });
64 |
65 | const params = createParams("Post", "update", {
66 | where: { id: 1 },
67 | data: {
68 | author: {
69 | update: {
70 | email: "blah",
71 | },
72 | },
73 | },
74 | });
75 | const next = jest.fn(() => Promise.resolve({}));
76 |
77 | await middleware(params, next);
78 |
79 | // params have been modified
80 | expect(next).toHaveBeenCalledWith(params);
81 | });
82 |
83 | it("does nothing to nested update actions for toMany relations", async () => {
84 | const middleware = createSoftDeleteMiddleware({
85 | models: { User: true },
86 | });
87 |
88 | const params = createParams("Post", "update", {
89 | where: { id: 1 },
90 | data: {
91 | comments: {
92 | update: {
93 | where: {
94 | id: 2,
95 | },
96 | data: {
97 | content: "content",
98 | },
99 | },
100 | },
101 | },
102 | });
103 | const next = jest.fn(() => Promise.resolve({}));
104 |
105 | await middleware(params, next);
106 |
107 | // params have not been modified
108 | expect(next).toHaveBeenCalledWith(params);
109 | });
110 |
111 | it("does not modify update when no args are passed", async () => {
112 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
113 |
114 | // @ts-expect-error - args are required
115 | const params = createParams("User", "update", undefined);
116 | const next = jest.fn(() => Promise.resolve({}));
117 |
118 | await middleware(params, next);
119 |
120 | // params have not been modified
121 | expect(next).toHaveBeenCalledWith(params);
122 | });
123 |
124 | it("does not modify update when no where is passed", async () => {
125 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
126 |
127 | // @ts-expect-error - where is required
128 | const params = createParams("User", "update", {});
129 | const next = jest.fn(() => Promise.resolve({}));
130 |
131 | await middleware(params, next);
132 |
133 | // params have not been modified
134 | expect(next).toHaveBeenCalledWith(params);
135 | });
136 | });
137 |
--------------------------------------------------------------------------------
/test/unit/updateMany.test.ts:
--------------------------------------------------------------------------------
1 | import { set } from "lodash";
2 | import { createSoftDeleteMiddleware } from "../../src";
3 | import { createParams } from "./utils/createParams";
4 |
5 | describe("updateMany", () => {
6 | it("does not change updateMany action if model is not in the list", async () => {
7 | const middleware = createSoftDeleteMiddleware({ models: {} });
8 |
9 | const params = createParams("User", "updateMany", {
10 | where: { id: { in: [1, 2] } },
11 | data: { email: "test@test.com" },
12 | });
13 | const next = jest.fn(() => Promise.resolve({}));
14 |
15 | await middleware(params, next);
16 |
17 | // params have not been modified
18 | expect(next).toHaveBeenCalledWith(params);
19 | });
20 |
21 | it("does not modify updateMany results", async () => {
22 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
23 |
24 | const params = createParams("User", "updateMany", {
25 | where: { id: 1 },
26 | data: { name: "John" },
27 | });
28 | const next = jest.fn(() => Promise.resolve({ count: 1 }));
29 |
30 | expect(await middleware(params, next)).toEqual({ count: 1 });
31 | });
32 |
33 | it("does not change updateMany action if args not passed", async () => {
34 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
35 |
36 | // @ts-expect-error - args are required
37 | const params = createParams("User", "updateMany", undefined);
38 | const next = jest.fn(() => Promise.resolve({}));
39 |
40 | await middleware(params, next);
41 |
42 | // params have not been modified
43 | expect(next).toHaveBeenCalledWith(params);
44 | });
45 |
46 | it("excludes deleted records from root updateMany action", async () => {
47 | const middleware = createSoftDeleteMiddleware({
48 | models: { User: true },
49 | });
50 |
51 | const params = createParams("User", "updateMany", {
52 | where: { id: 1 },
53 | data: { email: "test@test.com" },
54 | });
55 | const next = jest.fn(() => Promise.resolve({}));
56 |
57 | await middleware(params, next);
58 |
59 | // params have been modified
60 | expect(next).toHaveBeenCalledWith({
61 | ...params,
62 | args: {
63 | ...params.args,
64 | where: {
65 | ...params.args.where,
66 | deleted: false,
67 | },
68 | },
69 | });
70 | });
71 |
72 | it("excludes deleted records from root updateMany action when where not passed", async () => {
73 | const middleware = createSoftDeleteMiddleware({
74 | models: { User: true },
75 | });
76 |
77 | const params = createParams("User", "updateMany", {
78 | data: { name: "John" },
79 | });
80 | const next = jest.fn(() => Promise.resolve({}));
81 |
82 | await middleware(params, next);
83 |
84 | // params have been modified
85 | expect(next).toHaveBeenCalledWith({
86 | ...params,
87 | args: {
88 | ...params.args,
89 | where: {
90 | ...params.args.where,
91 | deleted: false,
92 | },
93 | },
94 | });
95 | });
96 |
97 | it("excludes deleted record from nested updateMany action", async () => {
98 | const middleware = createSoftDeleteMiddleware({
99 | models: { Comment: true },
100 | });
101 |
102 | const params = createParams("User", "update", {
103 | where: { id: 1 },
104 | data: {
105 | comments: {
106 | updateMany: {
107 | where: {
108 | content: "foo",
109 | OR: [{ authorId: 1 }, { authorId: 2 }],
110 | AND: [
111 | { createdAt: { gt: new Date() } },
112 | { createdAt: { lt: new Date() } },
113 | ],
114 | NOT: { content: "bar" },
115 | },
116 | data: { content: "bar" },
117 | },
118 | },
119 | },
120 | });
121 | const next = jest.fn(() => Promise.resolve({}));
122 |
123 | await middleware(params, next);
124 |
125 | // params have been modified
126 | expect(next).toHaveBeenCalledWith(
127 | set(params, "args.data.comments.updateMany.where.deleted", false)
128 | );
129 | });
130 |
131 | it("allows explicitly updating deleted records", async () => {
132 | const middleware = createSoftDeleteMiddleware({
133 | models: {
134 | User: true,
135 | },
136 | });
137 |
138 | const params = createParams("User", "updateMany", {
139 | where: { id: { in: [1, 2] }, deleted: true },
140 | data: { email: "test@test.com" },
141 | });
142 | const next = jest.fn(() => Promise.resolve({}));
143 |
144 | await middleware(params, next);
145 |
146 | // params have not been modified
147 | expect(next).toHaveBeenCalledWith(params);
148 | });
149 |
150 | it("allows explicitly updating deleted records when using custom deletedAt field", async () => {
151 | const middleware = createSoftDeleteMiddleware({
152 | models: {
153 | User: {
154 | field: "deletedAt",
155 | createValue: (deleted) => {
156 | if (deleted) return new Date();
157 | return null;
158 | },
159 | },
160 | },
161 | });
162 |
163 | const params = createParams("User", "updateMany", {
164 | where: { id: { in: [1, 2] }, deletedAt: { not: null } },
165 | data: { email: "test@test.com" },
166 | });
167 | const next = jest.fn(() => Promise.resolve({}));
168 |
169 | await middleware(params, next);
170 |
171 | // params have not been modified
172 | expect(next).toHaveBeenCalledWith(params);
173 | });
174 | });
175 |
--------------------------------------------------------------------------------
/test/unit/upsert.test.ts:
--------------------------------------------------------------------------------
1 | import { createSoftDeleteMiddleware } from "../../src";
2 | import { createParams } from "./utils/createParams";
3 |
4 | describe("upsert", () => {
5 | it("does not modify upsert results", async () => {
6 | const middleware = createSoftDeleteMiddleware({ models: { User: true } });
7 |
8 | const params = createParams("User", "upsert", {
9 | where: { id: 1 },
10 | create: { name: "John", email: "John@test.com" },
11 | update: { name: "John" },
12 | });
13 | const next = jest.fn(() =>
14 | Promise.resolve({ id: 1, name: "John", email: "John@test.com" })
15 | );
16 |
17 | expect(await middleware(params, next)).toEqual({
18 | id: 1,
19 | name: "John",
20 | email: "John@test.com",
21 | });
22 | });
23 |
24 | it("does nothing to root upsert action", async () => {
25 | const middleware = createSoftDeleteMiddleware({
26 | models: { User: true },
27 | });
28 |
29 | const params = createParams("User", "upsert", {
30 | where: { id: 1 },
31 | create: { name: "John", email: "john@test.com" },
32 | update: { name: "John" },
33 | });
34 | const next = jest.fn(() => Promise.resolve({}));
35 |
36 | await middleware(params, next);
37 |
38 | expect(next).toHaveBeenCalledWith(params);
39 | });
40 |
41 | it("does nothing to nested toMany upsert actions", async () => {
42 | const middleware = createSoftDeleteMiddleware({
43 | models: { User: true },
44 | });
45 |
46 | const params = createParams("Post", "update", {
47 | where: { id: 1 },
48 | data: {
49 | comments: {
50 | upsert: {
51 | where: { id: 1 },
52 | create: { content: "Hello", authorId: 1 },
53 | update: { content: "Hello" },
54 | },
55 | },
56 | },
57 | });
58 | const next = jest.fn(() => Promise.resolve({}));
59 |
60 | await middleware(params, next);
61 |
62 | expect(next).toHaveBeenCalledWith(params);
63 | });
64 |
65 | it("throws when trying to upsert a model configured for soft delete through a toOne relation", async () => {
66 | const middleware = createSoftDeleteMiddleware({
67 | models: { User: true },
68 | });
69 |
70 | const params = createParams("Post", "update", {
71 | where: { id: 1 },
72 | data: {
73 | author: {
74 | upsert: {
75 | create: {
76 | name: "test",
77 | email: "test@test.com",
78 | },
79 | update: {
80 | email: "test@test.com",
81 | },
82 | },
83 | },
84 | },
85 | });
86 |
87 | const next = () => Promise.resolve({});
88 |
89 | await expect(middleware(params, next)).rejects.toThrowError(
90 | 'prisma-soft-delete-middleware: upsert of model "User" through "Post.author" found. Upserts of soft deleted models through a toOne relation is not supported as it is possible to update a soft deleted record.'
91 | );
92 | });
93 | });
94 |
--------------------------------------------------------------------------------
/test/unit/utils/createParams.ts:
--------------------------------------------------------------------------------
1 | import { Prisma } from "@prisma/client";
2 |
3 | type DelegateByModel = Model extends "User"
4 | ? Prisma.UserDelegate
5 | : Model extends "Post"
6 | ? Prisma.PostDelegate
7 | : Model extends "Profile"
8 | ? Prisma.ProfileDelegate
9 | : Model extends "Comment"
10 | ? Prisma.CommentDelegate
11 | : never;
12 |
13 | type SelectByModel = Model extends "User"
14 | ? Prisma.UserSelect
15 | : Model extends "Post"
16 | ? Prisma.PostSelect
17 | : Model extends "Profile"
18 | ? Prisma.ProfileSelect
19 | : Model extends "Comment"
20 | ? Prisma.CommentSelect
21 | : never;
22 |
23 | type IncludeByModel = Model extends "User"
24 | ? Prisma.UserInclude
25 | : Model extends "Post"
26 | ? Prisma.PostInclude
27 | : Model extends "Profile"
28 | ? Prisma.ProfileInclude
29 | : Model extends "Comment"
30 | ? Prisma.CommentInclude
31 | : never;
32 |
33 | export type ActionByModel =
34 | | keyof DelegateByModel
35 | | "connectOrCreate"
36 | | "select"
37 | | "include";
38 |
39 | type ArgsByAction<
40 | Model extends Prisma.ModelName,
41 | Action extends ActionByModel
42 | > = Action extends "create"
43 | ? Parameters["create"]>[0]
44 | : Action extends "update"
45 | ? Parameters["update"]>[0]
46 | : Action extends "upsert"
47 | ? Parameters["upsert"]>[0]
48 | : Action extends "delete"
49 | ? Parameters["delete"]>[0]
50 | : Action extends "deleteMany"
51 | ? Parameters["deleteMany"]>[0]
52 | : Action extends "updateMany"
53 | ? Parameters["updateMany"]>[0]
54 | : Action extends "findUnique"
55 | ? Parameters["findUnique"]>[0]
56 | : Action extends "findUniqueOrThrow"
57 | ? Parameters["findUnique"]>[0]
58 | : Action extends "groupBy"
59 | ? Parameters["groupBy"]>[0]
60 | : Action extends "findFirst"
61 | ? Parameters["findFirst"]>[0]
62 | : Action extends "findFirstOrThrow"
63 | ? Parameters["findFirstOrThrow"]>[0]
64 | : Action extends "findMany"
65 | ? Parameters["findMany"]>[0]
66 | : Action extends "count"
67 | ? Parameters["count"]>[0]
68 | : Action extends "aggregate"
69 | ? Parameters["aggregate"]>[0]
70 | : Action extends "connectOrCreate"
71 | ? {
72 | where: Parameters["findUnique"]>[0];
73 | create: Parameters["create"]>[0];
74 | }
75 | : Action extends "select"
76 | ? SelectByModel
77 | : Action extends "include"
78 | ? IncludeByModel
79 | : never;
80 |
81 | /**
82 | * Creates params objects with strict typing of the `args` object to ensure it
83 | * is valid for the `model` and `action` passed.
84 | */
85 | export const createParams = <
86 | Model extends Prisma.ModelName,
87 | Action extends ActionByModel = ActionByModel
88 | >(
89 | model: Model,
90 | action: Action,
91 | args: ArgsByAction,
92 | dataPath: string[] = [],
93 | runInTransaction: boolean = false
94 | ): Prisma.MiddlewareParams => ({
95 | model,
96 | action: action as Prisma.PrismaAction,
97 | args,
98 | dataPath,
99 | runInTransaction,
100 | });
101 |
--------------------------------------------------------------------------------
/test/unit/where.test.ts:
--------------------------------------------------------------------------------
1 | import faker from "faker";
2 | import { set } from "lodash";
3 |
4 | import { createSoftDeleteMiddleware } from "../../src";
5 | import { createParams } from "./utils/createParams";
6 |
7 | describe("where", () => {
8 | it("does not change where action if model is not in the list", async () => {
9 | const middleware = createSoftDeleteMiddleware({ models: {} });
10 |
11 | const params = createParams("User", "deleteMany", {
12 | where: {
13 | email: faker.internet.email(),
14 | comments: {
15 | some: {
16 | content: faker.lorem.sentence(),
17 | },
18 | },
19 | },
20 | });
21 | const next = jest.fn(() => Promise.resolve({}));
22 |
23 | await middleware(params, next);
24 |
25 | // params have not been modified
26 | expect(next).toHaveBeenCalledWith(params);
27 | });
28 |
29 | it("changes root where correctly when model is nested", async () => {
30 | const middleware = createSoftDeleteMiddleware({
31 | models: { Comment: true },
32 | });
33 |
34 | const params = createParams("User", "deleteMany", {
35 | where: {
36 | email: faker.internet.email(),
37 | comments: {
38 | some: {
39 | AND: [
40 | { createdAt: { gt: faker.date.past() } },
41 | { createdAt: { lt: faker.date.future() } },
42 | ],
43 | OR: [
44 | { post: { content: faker.lorem.sentence() } },
45 | { post: { content: faker.lorem.sentence() } },
46 | ],
47 | NOT: { post: { is: { authorName: faker.name.findName() } } },
48 | content: faker.lorem.sentence(),
49 | post: {
50 | isNot: {
51 | content: faker.lorem.sentence(),
52 | },
53 | },
54 | },
55 | },
56 | },
57 | });
58 | const next = jest.fn(() => Promise.resolve({}));
59 |
60 | await middleware(params, next);
61 |
62 | set(params, "args.where.comments.some.deleted", false);
63 |
64 | // params have not been modified
65 | expect(next).toHaveBeenCalledWith(params);
66 | });
67 |
68 | it("handles where with modifiers correctly", async () => {
69 | const middleware = createSoftDeleteMiddleware({
70 | models: { Post: true, Comment: true, User: true },
71 | });
72 |
73 | const params = createParams("Comment", "findMany", {
74 | where: {
75 | content: faker.lorem.sentence(),
76 | post: {
77 | is: {
78 | content: "foo",
79 | },
80 | },
81 | author: {
82 | isNot: {
83 | name: "Jack",
84 | },
85 | },
86 | replies: {
87 | some: {
88 | content: "foo",
89 | },
90 | every: {
91 | content: "bar",
92 | },
93 | none: {
94 | content: "baz",
95 | },
96 | },
97 | },
98 | });
99 | const next = jest.fn(() => Promise.resolve({}));
100 |
101 | await middleware(params, next);
102 |
103 | set(params, "args.where.deleted", false);
104 | set(params, "args.where.post.is.deleted", false);
105 | set(params, "args.where.author.isNot.deleted", false);
106 | set(params, "args.where.replies.some.deleted", false);
107 | set(params, "args.where.replies.every", {
108 | OR: [{ deleted: { not: false } }, params.args.where.replies.every],
109 | });
110 | set(params, "args.where.replies.none.deleted", false);
111 |
112 | expect(next).toHaveBeenCalledWith(params);
113 | });
114 |
115 | it("changes root where correctly when model is deeply nested", async () => {
116 | const middleware = createSoftDeleteMiddleware({
117 | models: { Post: true },
118 | });
119 |
120 | const params = createParams("User", "deleteMany", {
121 | where: {
122 | email: faker.internet.email(),
123 | comments: {
124 | some: {
125 | AND: [
126 | { createdAt: { gt: faker.date.past() } },
127 | { post: { content: faker.lorem.sentence() } },
128 | ],
129 | OR: [
130 | { post: { content: faker.lorem.sentence() } },
131 | { createdAt: { lt: faker.date.future() } },
132 | ],
133 | NOT: {
134 | post: {
135 | is: {
136 | authorName: faker.name.findName(),
137 | },
138 | },
139 | },
140 | post: {
141 | isNot: {
142 | content: faker.lorem.sentence(),
143 | },
144 | },
145 | },
146 | },
147 | },
148 | });
149 | const next = jest.fn(() => Promise.resolve({}));
150 |
151 | await middleware(params, next);
152 |
153 | set(params, "args.where.comments.some.AND.1.post.deleted", false);
154 | set(params, "args.where.comments.some.OR.0.post.deleted", false);
155 | set(params, "args.where.comments.some.NOT.post.is.deleted", false);
156 | set(params, "args.where.comments.some.post.isNot.deleted", false);
157 |
158 | // params have not been modified
159 | expect(next).toHaveBeenCalledWith(params);
160 | });
161 |
162 | it("change root where correctly when multiple models passed", async () => {
163 | const middleware = createSoftDeleteMiddleware({
164 | models: {
165 | Comment: true,
166 | Post: true,
167 | },
168 | });
169 |
170 | const params = createParams("User", "deleteMany", {
171 | where: {
172 | email: faker.internet.email(),
173 | comments: {
174 | some: {
175 | AND: [
176 | { createdAt: { gt: faker.date.past() } },
177 | { createdAt: { lt: faker.date.future() } },
178 | ],
179 | OR: [
180 | { post: { content: faker.lorem.sentence() } },
181 | { post: { content: faker.lorem.sentence() } },
182 | ],
183 | NOT: { post: { is: { authorName: faker.name.findName() } } },
184 | content: faker.lorem.sentence(),
185 | post: {
186 | isNot: {
187 | content: faker.lorem.sentence(),
188 | },
189 | },
190 | },
191 | },
192 | },
193 | });
194 | const next = jest.fn(() => Promise.resolve({}));
195 |
196 | await middleware(params, next);
197 |
198 | set(params, "args.where.comments.some.deleted", false);
199 | set(params, "args.where.comments.some.NOT.post.is.deleted", false);
200 | set(params, "args.where.comments.some.OR.0.post.deleted", false);
201 | set(params, "args.where.comments.some.OR.1.post.deleted", false);
202 | set(params, "args.where.comments.some.post.isNot.deleted", false);
203 |
204 | // params have not been modified
205 | expect(next).toHaveBeenCalledWith(
206 | set(params, "args.where.comments.some.deleted", false)
207 | );
208 | });
209 |
210 | it("allows checking for deleted records explicitly", async () => {
211 | const middleware = createSoftDeleteMiddleware({
212 | models: {
213 | Comment: true,
214 | Post: true,
215 | },
216 | });
217 |
218 | const params = createParams("User", "deleteMany", {
219 | where: {
220 | email: faker.internet.email(),
221 | comments: {
222 | some: {
223 | deleted: true,
224 | AND: [
225 | { createdAt: { gt: faker.date.past() } },
226 | { createdAt: { lt: faker.date.future() } },
227 | ],
228 | OR: [
229 | { post: { deleted: true, content: faker.lorem.sentence() } },
230 | { post: { content: faker.lorem.sentence() } },
231 | ],
232 | NOT: {
233 | post: {
234 | is: { deleted: true, authorName: faker.name.findName() },
235 | },
236 | },
237 | content: faker.lorem.sentence(),
238 | post: {
239 | isNot: {
240 | content: faker.lorem.sentence(),
241 | },
242 | },
243 | replies: {
244 | some: {
245 | content: "foo",
246 | deleted: true,
247 | },
248 | every: {
249 | content: "bar",
250 | deleted: true,
251 | },
252 | none: {
253 | content: "baz",
254 | deleted: true,
255 | },
256 | },
257 | },
258 | },
259 | },
260 | });
261 | const next = jest.fn(() => Promise.resolve({}));
262 |
263 | await middleware(params, next);
264 |
265 | set(params, "args.where.comments.some.deleted", true);
266 | set(params, "args.where.comments.some.OR.0.post.deleted", true);
267 | set(params, "args.where.comments.some.OR.1.post.deleted", false);
268 | set(params, "args.where.comments.some.NOT.post.is.deleted", true);
269 | set(params, "args.where.comments.some.post.isNot.deleted", false);
270 | set(params, "args.where.comments.some.replies.some.deleted", true);
271 | set(params, "args.where.comments.some.replies.every.deleted", true);
272 | set(params, "args.where.comments.some.replies.none.deleted", true);
273 |
274 | expect(next).toHaveBeenCalledWith(params);
275 | });
276 |
277 | it("excludes deleted from include where with nested relations", async () => {
278 | const middleware = createSoftDeleteMiddleware({
279 | models: {
280 | Comment: true,
281 | },
282 | });
283 |
284 | const params = createParams("User", "findMany", {
285 | include: {
286 | posts: {
287 | where: {
288 | comments: {
289 | some: {
290 | content: faker.lorem.sentence(),
291 | },
292 | },
293 | },
294 | },
295 | },
296 | });
297 |
298 | const next = jest.fn(() => Promise.resolve({}));
299 |
300 | await middleware(params, next);
301 |
302 | set(params, "args.include.posts.where.comments.some.deleted", false);
303 |
304 | expect(next).toHaveBeenCalledWith(params);
305 | });
306 |
307 | it("excludes deleted from select where with nested relations", async () => {
308 | const middleware = createSoftDeleteMiddleware({
309 | models: {
310 | Comment: true,
311 | },
312 | });
313 |
314 | const params = createParams("User", "findMany", {
315 | select: {
316 | posts: {
317 | where: {
318 | comments: {
319 | some: {
320 | content: faker.lorem.sentence(),
321 | },
322 | },
323 | },
324 | },
325 | },
326 | });
327 |
328 | const next = jest.fn(() => Promise.resolve({}));
329 |
330 | await middleware(params, next);
331 |
332 | set(params, "args.select.posts.where.comments.some.deleted", false);
333 |
334 | expect(next).toHaveBeenCalledWith(params);
335 | });
336 |
337 | it("excludes deleted from include where with nested relations and multiple models", async () => {
338 | const middleware = createSoftDeleteMiddleware({
339 | models: {
340 | Comment: true,
341 | Post: true,
342 | },
343 | });
344 |
345 | const params = createParams("User", "findMany", {
346 | include: {
347 | comments: {
348 | where: {
349 | post: {
350 | content: faker.lorem.sentence(),
351 | comments: {
352 | some: {
353 | content: faker.lorem.sentence(),
354 | },
355 | },
356 | },
357 | },
358 | },
359 | },
360 | });
361 |
362 | const next = jest.fn(() => Promise.resolve({}));
363 |
364 | await middleware(params, next);
365 |
366 | set(params, "args.include.comments.where.deleted", false);
367 | set(params, "args.include.comments.where.post.deleted", false);
368 | set(
369 | params,
370 | "args.include.comments.where.post.comments.some.deleted",
371 | false
372 | );
373 |
374 | expect(next).toHaveBeenCalledWith(params);
375 | });
376 |
377 | it("excludes deleted from select where with nested relations and multiple models", async () => {
378 | const middleware = createSoftDeleteMiddleware({
379 | models: {
380 | Comment: true,
381 | Post: true,
382 | },
383 | });
384 |
385 | const params = createParams("User", "findMany", {
386 | select: {
387 | comments: {
388 | where: {
389 | post: {
390 | content: faker.lorem.sentence(),
391 | comments: {
392 | some: {
393 | content: faker.lorem.sentence(),
394 | },
395 | },
396 | },
397 | },
398 | },
399 | },
400 | });
401 |
402 | const next = jest.fn(() => Promise.resolve({}));
403 |
404 | await middleware(params, next);
405 |
406 | set(params, "args.select.comments.where.deleted", false);
407 | set(params, "args.select.comments.where.post.deleted", false);
408 | set(params, "args.select.comments.where.post.comments.some.deleted", false);
409 |
410 | expect(next).toHaveBeenCalledWith(params);
411 | });
412 | });
413 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "declaration": true,
5 | "noEmit": false
6 | },
7 | "exclude": ["test", "dist"]
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.esm.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "esnext",
5 | "outDir": "./dist/esm",
6 | "moduleResolution": "node",
7 | "declaration": true,
8 | "noEmit": false
9 | },
10 | "exclude": ["test", "dist"]
11 | }
12 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "es2019",
5 | "module": "commonjs",
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "declaration": true,
9 | "outDir": "./dist",
10 | "skipLibCheck": true,
11 | "noEmit": true
12 | },
13 | "exclude": ["test"]
14 | }
15 |
--------------------------------------------------------------------------------