├── .yo-rc.json ├── .npmrc ├── .prettierignore ├── src ├── __tests__ │ ├── unit │ │ ├── README.md │ │ ├── mixin │ │ │ ├── soft-delete-entity.mixin.unit.ts │ │ │ └── soft-crud.mixin.unit.ts │ │ ├── sequelize │ │ │ └── sequelize-soft-crud.unit.ts │ │ └── repository │ │ │ └── default-transaction-soft-crud.repository.base.ts │ ├── acceptance │ │ └── README.md │ └── integration │ │ └── README.md ├── models │ ├── index.ts │ └── soft-delete-entity.ts ├── error-keys.ts ├── repositories │ ├── sequelize │ │ ├── index.ts │ │ └── sequelize.soft-crud.repository.base.ts │ ├── index.ts │ ├── README.md │ ├── default-transaction-soft-crud.repository.base.ts │ └── soft-crud.repository.base.ts ├── mixins │ ├── index.ts │ ├── soft-crud.repository.mixin.ts │ ├── soft-delete-entity.mixin.ts │ └── README.md ├── index.ts ├── controllers │ └── README.md ├── component.ts ├── decorators │ ├── extend-prototype.ts │ └── README.md ├── release_notes │ ├── mymarkdown.ejs │ ├── release-notes.js │ └── post-processing.js ├── utils │ └── soft-filter-builder.ts ├── types.ts └── providers │ └── README.md ├── .husky ├── pre-commit ├── commit-msg └── prepare-commit-msg ├── .eslintignore ├── .mocharc.json ├── mkdocs.yml ├── tslint.json ├── tslint.build.json ├── .prettierrc ├── .nycrc ├── .eslintrc.js ├── trivy.yml ├── tsconfig.json ├── commitlint.config.js ├── .github ├── workflows │ ├── stale.yml │ ├── main.yaml │ ├── trivy.yaml │ ├── release.yaml │ └── sync-docs.yaml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── stale.yml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── .sonarcloud.properties ├── catalog-info.yaml ├── LICENSE ├── .gitignore ├── DEVELOPING.md ├── .cz-config.js ├── package.json ├── README.md └── docs └── README.md /.yo-rc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | *.json 3 | coverage 4 | -------------------------------------------------------------------------------- /src/__tests__/unit/README.md: -------------------------------------------------------------------------------- 1 | # Unit tests 2 | -------------------------------------------------------------------------------- /src/__tests__/acceptance/README.md: -------------------------------------------------------------------------------- 1 | # Acceptance tests 2 | -------------------------------------------------------------------------------- /src/__tests__/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration tests 2 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './soft-delete-entity'; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /src/error-keys.ts: -------------------------------------------------------------------------------- 1 | export const enum ErrorKeys { 2 | EntityNotFound = 'EntityNotFound', 3 | } 4 | -------------------------------------------------------------------------------- /src/repositories/sequelize/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sequelize.soft-crud.repository.base'; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit 5 | -------------------------------------------------------------------------------- /src/mixins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './soft-crud.repository.mixin'; 2 | export * from './soft-delete-entity.mixin'; 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | api-docs/ 5 | 6 | index.* 7 | .eslintrc.js 8 | commitlint.config.js 9 | -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | exec < /dev/tty && npx cz --hook || true 5 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "recursive": true, 3 | "reporter": ["dot"], 4 | "require": "source-map-support/register" 5 | } 6 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: loopback4-soft-delete 2 | site_description: loopback4-soft-delete 3 | 4 | plugins: 5 | - techdocs-core 6 | -------------------------------------------------------------------------------- /src/repositories/index.ts: -------------------------------------------------------------------------------- 1 | export * from './soft-crud.repository.base'; 2 | export * from './default-transaction-soft-crud.repository.base'; 3 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tslint", 3 | "extends": ["@loopback/tslint-config/tslint.common.json"] 4 | } 5 | -------------------------------------------------------------------------------- /tslint.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tslint", 3 | "extends": ["@loopback/tslint-config/tslint.build.json"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "singleQuote": true, 4 | "printWidth": 80, 5 | "trailingComma": "all", 6 | "arrowParens": "avoid" 7 | } 8 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["dist"], 3 | "exclude": ["dist/__tests__/"], 4 | "extension": [".js", ".ts"], 5 | "reporter": ["text", "html"], 6 | "exclude-after-remap": false 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '@loopback/eslint-config', 3 | rules: { 4 | 'no-extra-boolean-cast': 'off', 5 | '@typescript-eslint/interface-name-prefix': 'off', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './component'; 2 | export * from './error-keys'; 3 | export * from './mixins'; 4 | export * from './models'; 5 | export * from './repositories'; 6 | export * from './types'; 7 | -------------------------------------------------------------------------------- /src/repositories/README.md: -------------------------------------------------------------------------------- 1 | # Repositories 2 | 3 | This directory contains code for repositories provided by this extension. 4 | 5 | For more information, see . 6 | -------------------------------------------------------------------------------- /src/controllers/README.md: -------------------------------------------------------------------------------- 1 | # Controllers 2 | 3 | This directory contains source files for the controllers exported by this 4 | extension. 5 | 6 | For more information, see . 7 | -------------------------------------------------------------------------------- /trivy.yml: -------------------------------------------------------------------------------- 1 | format: table 2 | exit-code: 1 3 | severity: 4 | - HIGH 5 | - CRITICAL 6 | skip-files: 7 | - db.env 8 | security-checks: 9 | - vuln 10 | - secret 11 | - license 12 | vulnerability: 13 | type: 14 | - os 15 | - library 16 | ignore-unfixed: true 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "@loopback/build/config/tsconfig.common.json", 4 | "compilerOptions": { 5 | "experimentalDecorators": true, 6 | "rootDir": "src", 7 | "outDir": "dist" 8 | }, 9 | "include": ["src"] 10 | } 11 | -------------------------------------------------------------------------------- /src/component.ts: -------------------------------------------------------------------------------- 1 | import {Component, ProviderMap} from '@loopback/core'; 2 | export class SoftDeleteComponent implements Component { 3 | providers?: ProviderMap = {}; 4 | 5 | constructor() { 6 | // Initialize the providers property in the constructor 7 | this.providers = {}; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'header-max-length': [2, 'always', 100], 5 | 'body-leading-blank': [2, 'always'], 6 | 'footer-leading-blank': [0, 'always'], 7 | 'references-empty': [2, 'never'], 8 | 'body-empty': [2, 'never'], 9 | }, 10 | parserPreset: { 11 | parserOpts: { 12 | issuePrefixes: ['GH-'], 13 | }, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: "30 1 * * *" 6 | 7 | jobs: 8 | stale: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/stale@v1 14 | with: 15 | repo-token: ${{ secrets.GITHUB_TOKEN }} 16 | stale-issue-message: 'Stale issue message' 17 | stale-pr-message: 'Stale pull request message' 18 | stale-issue-label: 'no-issue-activity' 19 | stale-pr-label: 'no-pr-activity' 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "node", 5 | "request": "launch", 6 | "name": "Run Mocha tests", 7 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 8 | "runtimeArgs": [ 9 | "-r", 10 | "${workspaceRoot}/node_modules/source-map-support/register" 11 | ], 12 | "cwd": "${workspaceRoot}", 13 | "autoAttachChildProcesses": true, 14 | "args": [ 15 | "--config", 16 | "${workspaceRoot}/.mocharc.json", 17 | "${workspaceRoot}/dist/__tests__/**/*.js", 18 | "-t", 19 | "0" 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.rulers": [80], 3 | "editor.tabCompletion": "on", 4 | "editor.tabSize": 2, 5 | "editor.trimAutoWhitespace": true, 6 | "editor.formatOnSave": true, 7 | 8 | "sonarlint.connectedMode.project": { 9 | "serverId": "sf_sonar", 10 | "projectKey": "sourcefuse_loopback4-soft-delete" 11 | }, 12 | 13 | "files.exclude": { 14 | "**/.DS_Store": true, 15 | "**/.git": true, 16 | "**/.hg": true, 17 | "**/.svn": true, 18 | "**/CVS": true, 19 | "dist": true, 20 | }, 21 | "files.insertFinalNewline": true, 22 | "files.trimTrailingWhitespace": true, 23 | 24 | "typescript.tsdk": "./node_modules/typescript/lib" 25 | } 26 | -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | # Path to sources 2 | sonar.sources=src 3 | 4 | # Ignoring transaction and sequelize soft crud repository as the duplication of the code is a compulsion in it. 5 | # These will be removed in a next major release in favor of universal mixin usage. 6 | sonar.exclusions=src/__tests__/**,src/repositories/default-transaction-soft-crud.repository.base.ts,src/repositories/sequelize/sequelize.soft-crud.repository.base.ts 7 | #sonar.inclusions= 8 | 9 | # Path to tests 10 | sonar.tests=src/__tests__ 11 | #sonar.test.exclusions= 12 | #sonar.test.inclusions= 13 | 14 | # Source encoding 15 | sonar.sourceEncoding=UTF-8 16 | 17 | # Exclusions for copy-paste detection 18 | #sonar.cpd.exclusions= 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: backstage.io/v1alpha1 2 | kind: Component 3 | metadata: 4 | name: loopback4-soft-delete 5 | annotations: 6 | github.com/project-slug: sourcefuse/loopback4-soft-delete 7 | backstage.io/techdocs-ref: dir:. 8 | namespace: arc 9 | description: A LoopBack 4 Extension for soft delete feature. 10 | tags: 11 | - soft-delete 12 | - loopback 13 | - extension 14 | links: 15 | - url: https://npmjs.com/package/loopback4-soft-delete 16 | title: NPM Package 17 | - url: https://loopback.io/doc/en/lb4/Extending-LoopBack-4.html#overview 18 | title: Extending LoopBack 19 | spec: 20 | type: component 21 | lifecycle: production 22 | owner: sourcefuse 23 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | node_matrix_tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [20, 22, 24] 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: Install Dependencies 21 | run: npm ci 22 | - name: Run Test Cases 23 | run: npm run test 24 | 25 | npm_test: 26 | runs-on: ubuntu-latest 27 | needs: node_matrix_tests 28 | if: success() 29 | steps: 30 | - name: Final status 31 | run: echo "✅ All tests passed for Node.js 20, 22, and 24" 32 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "Watch and Compile Project", 8 | "type": "shell", 9 | "command": "npm", 10 | "args": ["--silent", "run", "build:watch"], 11 | "group": { 12 | "kind": "build", 13 | "isDefault": true 14 | }, 15 | "problemMatcher": "$tsc-watch" 16 | }, 17 | { 18 | "label": "Build, Test and Lint", 19 | "type": "shell", 20 | "command": "npm", 21 | "args": ["--silent", "run", "test:dev"], 22 | "group": { 23 | "kind": "test", 24 | "isDefault": true 25 | }, 26 | "problemMatcher": ["$tsc", "$tslint5"] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /src/decorators/extend-prototype.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A Class decorator that takes in one or more classes and extends the prototype of a target class with the properties and methods from the provided classes. 3 | * @param classes One or more classes whose prototype will be extended onto the target class. 4 | */ 5 | export default function extendPrototype( 6 | ...classes: unknown & {prototype: unknown}[] 7 | ) { 8 | return function (target: unknown & {prototype: unknown}) { 9 | classes.forEach(mixin => { 10 | Object.getOwnPropertyNames(mixin.prototype).forEach(name => { 11 | Object.defineProperty( 12 | target.prototype, 13 | name, 14 | Object.getOwnPropertyDescriptor(mixin.prototype, name) ?? 15 | Object.create(null), 16 | ); 17 | }); 18 | }); 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/release_notes/mymarkdown.ejs: -------------------------------------------------------------------------------- 1 | ## Release [<%= range.split('..')[1] %>](https://github.com/sourcefuse/loopback4-soft-delete/compare/<%= range %>) <%= new Date().toLocaleDateString('en-us', {year:"numeric", month:"long", day:"numeric"}) 2 | ;%> 3 | Welcome to the <%= new Date().toLocaleDateString('en-us', {year:"numeric", month:"long", day:"numeric"});%> release of loopback4-soft-delete. There are many updates in this version that we hope you will like, the key highlights include: 4 | <% commits.forEach(function (commit) { %> 5 | - [<%= commit.issueTitle %>](https://github.com/sourcefuse/loopback4-soft-delete/issues/<%= commit.issueno %>) :- [<%= commit.title %>](https://github.com/sourcefuse/loopback4-soft-delete/commit/<%= commit.sha1%>) was commited on <%= commit.committerDate %> by [<%= commit.authorName %>](mailto:<%= commit.authorEmail %>) 6 | <% commit.messageLines.forEach(function (message) { %> 7 | - <%= message %> 8 | <% }) %> 9 | <% }) %> 10 | Clink on the above links to understand the changes in detail. 11 | ___ 12 | 13 | -------------------------------------------------------------------------------- /src/mixins/soft-crud.repository.mixin.ts: -------------------------------------------------------------------------------- 1 | import {DefaultCrudRepository, Entity} from '@loopback/repository'; 2 | 3 | import { 4 | Constructor, 5 | IBaseEntity, 6 | ISoftCrudRepositoryMixin, 7 | MixinBaseClass, 8 | } from '../types'; 9 | import {SoftCrudRepository} from '../repositories'; 10 | import extendPrototype from '../decorators/extend-prototype'; 11 | 12 | export function SoftCrudRepositoryMixin< 13 | E extends Entity & IBaseEntity, 14 | ID, 15 | T extends MixinBaseClass>, 16 | R extends object = {}, 17 | >(base: T): T & Constructor> { 18 | // Using extendPrototype decorator here as Typescript doesn't support multilevel inheritance. 19 | // This will result in a class extending `base` class overridden with `SoftCrudRepository`'s methods and properties. 20 | @extendPrototype(SoftCrudRepository) 21 | class SoftCrudRepositoryExtended extends base {} 22 | return SoftCrudRepositoryExtended as T & 23 | Constructor>; 24 | } 25 | -------------------------------------------------------------------------------- /src/decorators/README.md: -------------------------------------------------------------------------------- 1 | # Decorators 2 | 3 | ## Overview 4 | 5 | Decorators provide annotations for class methods and arguments. Decorators use 6 | the form `@decorator` where `decorator` is the name of the function that will be 7 | called at runtime. 8 | 9 | ## Basic Usage 10 | 11 | ### txIdFromHeader 12 | 13 | This simple decorator allows you to annotate a `Controller` method argument. The 14 | decorator will annotate the method argument with the value of the header 15 | `X-Transaction-Id` from the request. 16 | 17 | **Example** 18 | 19 | ```ts 20 | class MyController { 21 | @get('/') 22 | getHandler(@txIdFromHeader() txId: string) { 23 | return `Your transaction id is: ${txId}`; 24 | } 25 | } 26 | ``` 27 | 28 | ## Related Resources 29 | 30 | You can check out the following resource to learn more about decorators and how 31 | they are used in LoopBack Next. 32 | 33 | - [TypeScript Handbook: Decorators](https://www.typescriptlang.org/docs/handbook/decorators.html) 34 | - [Decorators in LoopBack](http://loopback.io/doc/en/lb4/Decorators.html) 35 | -------------------------------------------------------------------------------- /src/models/soft-delete-entity.ts: -------------------------------------------------------------------------------- 1 | import {Entity, property} from '@loopback/repository'; 2 | 3 | /** 4 | * Abstract base class for all soft-delete enabled models 5 | * 6 | * @description 7 | * Base class for all soft-delete enabled models created. 8 | * It adds three attributes to the model class for handling soft-delete, 9 | * namely, 'deleted', deletedOn, deletedBy 10 | * Its an abstract class so no repository class should be based on this. 11 | */ 12 | export abstract class SoftDeleteEntity extends Entity { 13 | @property({ 14 | type: 'boolean', 15 | default: false, 16 | }) 17 | deleted?: boolean; 18 | 19 | @property({ 20 | type: 'date', 21 | name: 'deleted_on', 22 | jsonSchema: { 23 | nullable: true, 24 | }, 25 | }) 26 | deletedOn?: Date; 27 | 28 | @property({ 29 | type: 'string', 30 | name: 'deleted_by', 31 | jsonSchema: { 32 | nullable: true, 33 | }, 34 | }) 35 | deletedBy?: string; 36 | 37 | constructor(data?: Partial) { 38 | super(data); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/mixins/soft-delete-entity.mixin.ts: -------------------------------------------------------------------------------- 1 | import {Constructor} from '@loopback/context'; 2 | import {Entity, property} from '@loopback/repository'; 3 | import {AbstractConstructor, IBaseEntity, IBaseEntityConfig} from '../types'; 4 | 5 | export function SoftDeleteEntityMixin< 6 | T extends Entity, 7 | S extends Constructor | AbstractConstructor, 8 | >(base: S, config?: IBaseEntityConfig): typeof base & Constructor { 9 | class SoftDeleteEntity extends base { 10 | @property({ 11 | type: 'boolean', 12 | default: false, 13 | ...(config?.deleted ?? {}), 14 | }) 15 | deleted?: boolean; 16 | 17 | @property({ 18 | type: 'date', 19 | name: 'deleted_on', 20 | jsonSchema: { 21 | nullable: true, 22 | }, 23 | ...(config?.deletedOn ?? {}), 24 | }) 25 | deletedOn?: Date; 26 | 27 | @property({ 28 | type: 'string', 29 | name: 'deleted_by', 30 | jsonSchema: { 31 | nullable: true, 32 | }, 33 | ...(config?.deletedBy ?? {}), 34 | }) 35 | deletedBy?: string; 36 | } 37 | 38 | return SoftDeleteEntity; 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [2023] [SourceFuse] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/trivy.yaml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Trivy Scan 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | pull_request: 9 | branches: [master] 10 | types: [opened, synchronize, reopened] 11 | 12 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 13 | jobs: 14 | # This workflow contains a single job called "trivy" 15 | trivy: 16 | # The type of runner that the job will run on 17 | runs-on: [self-hosted, linux, codebuild] 18 | 19 | # Steps represent a sequence of tasks that will be executed as part of the job 20 | steps: 21 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 22 | - uses: actions/checkout@v3 23 | 24 | - name: Run Trivy vulnerability scanner in repo mode 25 | uses: aquasecurity/trivy-action@0.28.0 26 | with: 27 | scan-type: "fs" 28 | scan-ref: "${{ github.workspace }}" 29 | trivy-config: "${{ github.workspace }}/trivy.yml" 30 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | Fixes # (issue) 6 | 7 | ## Type of change 8 | 9 | Please delete options that are not relevant. 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue) 12 | - [ ] New feature (non-breaking change which adds functionality) 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 14 | - [ ] Intermediate change (work in progress) 15 | 16 | ## How Has This Been Tested ? 17 | 18 | Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration 19 | 20 | - [ ] Test A 21 | - [ ] Test B 22 | 23 | ## Checklist: 24 | 25 | - [ ] Performed a self-review of my own code 26 | - [ ] npm test passes on your machine 27 | - [ ] New tests added or existing tests modified to cover all changes 28 | - [ ] Code conforms with the style guide 29 | - [ ] API Documentation in code was updated 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # Generated apidocs 61 | api-docs/ 62 | 63 | # Transpiled JavaScript files from Typescript 64 | /dist 65 | 66 | *.tsbuildinfo 67 | -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | # Developer's Guide 2 | 3 | We use Visual Studio Code for developing LoopBack and recommend the same to our 4 | users. 5 | 6 | ## VSCode setup 7 | 8 | Install the following extensions: 9 | 10 | - [tslint](https://marketplace.visualstudio.com/items?itemName=eg2.tslint) 11 | - [prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) 12 | 13 | ## Development workflow 14 | 15 | ### Visual Studio Code 16 | 17 | 1. Start the build task (Cmd+Shift+B) to run TypeScript compiler in the 18 | background, watching and recompiling files as you change them. Compilation 19 | errors will be shown in the VSCode's "PROBLEMS" window. 20 | 21 | 2. Execute "Run Rest Task" from the Command Palette (Cmd+Shift+P) to re-run the 22 | test suite and lint the code for both programming and style errors. Linting 23 | errors will be shown in VSCode's "PROBLEMS" window. Failed tests are printed 24 | to terminal output only. 25 | 26 | ### Other editors/IDEs 27 | 28 | 1. Open a new terminal window/tab and start the continous build process via 29 | `npm run build:watch`. It will run TypeScript compiler in watch mode, 30 | recompiling files as you change them. Any compilation errors will be printed 31 | to the terminal. 32 | 33 | 2. In your main terminal window/tab, run `npm run test:dev` to re-run the test 34 | suite and lint the code for both programming and style errors. You should run 35 | this command manually whenever you have new changes to test. Test failures 36 | and linter errors will be printed to the terminal. 37 | -------------------------------------------------------------------------------- /src/utils/soft-filter-builder.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AndClause, 3 | Condition, 4 | Filter, 5 | FilterBuilder, 6 | OrClause, 7 | } from '@loopback/repository'; 8 | import {SoftDeleteEntity} from '../models'; 9 | 10 | /** 11 | * A builder for soft crud filter. 12 | * Example 13 | * 14 | * ```ts 15 | * const filterBuilder = new SoftFilterBuilder(originalFilter) 16 | * .imposeCondition({ deleted: false }) 17 | * .build(); 18 | * ``` 19 | */ 20 | export class SoftFilterBuilder { 21 | filter: Filter; 22 | 23 | constructor(originalFilter?: Filter) { 24 | this.filter = originalFilter ?? {}; 25 | } 26 | 27 | limit(limit: number) { 28 | this.filter.limit = new FilterBuilder(this.filter) 29 | .limit(limit) 30 | .build().limit; 31 | return this; 32 | } 33 | 34 | imposeCondition(conditionToEnsure: Condition) { 35 | this.filter.where = this.filter.where ?? {}; 36 | conditionToEnsure = conditionToEnsure ?? ({deleted: false} as Condition); 37 | 38 | const hasAndClause = (this.filter.where as AndClause).and?.length > 0; 39 | const hasOrClause = (this.filter.where as OrClause).or?.length > 0; 40 | 41 | if (hasAndClause) { 42 | (this.filter.where as AndClause).and.push(conditionToEnsure); 43 | } 44 | if (hasOrClause) { 45 | this.filter.where = { 46 | and: [ 47 | conditionToEnsure, 48 | { 49 | or: (this.filter.where as OrClause).or, 50 | }, 51 | ], 52 | }; 53 | } 54 | if (!(hasAndClause && hasOrClause)) { 55 | Object.assign(this.filter.where, conditionToEnsure); 56 | } 57 | return this; 58 | } 59 | 60 | build() { 61 | return this.filter; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Label to use when marking an issue or a PR as stale 2 | staleLabel: stale 3 | 4 | # Configuration for issues 5 | issues: 6 | # Number of days of inactivity before an issue becomes stale 7 | daysUntilStale: 90 8 | # Comment to post when marking an issue as stale. Set to `false` to disable 9 | markComment: > 10 | This issue has been marked stale because it has not seen any activity within 11 | three months. If you believe this to be an error, please contact one of the code owners. 12 | This issue will be closed within 15 days of being stale. 13 | # Number of days of inactivity before a stale issue is closed 14 | daysUntilClose: 15 15 | # Comment to post when closing a stale issue. Set to `false` to disable 16 | closeComment: > 17 | This issue has been closed due to continued inactivity. Thank you for your understanding. 18 | If you believe this to be in error, please contact one of the code owners. 19 | # Configuration for pull requests 20 | pulls: 21 | # Number of days of inactivity before a PR becomes stale 22 | daysUntilStale: 60 23 | # Comment to post when marking a PR as stale. Set to `false` to disable 24 | markComment: > 25 | This pull request has been marked stale because it has not seen any activity 26 | within two months. It will be closed within 15 days of being stale 27 | unless there is new activity. 28 | # Number of days of inactivity before a stale PR is closed 29 | daysUntilClose: 15 30 | # Comment to post when closing a stale issue. Set to `false` to disable 31 | closeComment: > 32 | This pull request has been closed due to continued inactivity. If you are 33 | interested in finishing the proposed changes, then feel free to re-open 34 | this pull request or open a new one. 35 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Count, 3 | Filter, 4 | Getter, 5 | Options, 6 | PropertyDefinition, 7 | Where, 8 | } from '@loopback/repository'; 9 | 10 | export interface Constructor { 11 | prototype: T; 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | new (...args: any[]): T; // NOSONAR 14 | } 15 | 16 | export interface AbstractConstructor { 17 | prototype: T; 18 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 19 | new (...args: any[]): T; // NOSONAR 20 | } 21 | 22 | export type MixinBaseClass = Constructor & AbstractConstructor; 23 | 24 | export interface IUser { 25 | id?: number | string; 26 | getIdentifier?(): number | string | undefined; 27 | } 28 | 29 | export interface IBaseEntityConfig { 30 | deleted?: Partial; 31 | deletedOn?: Partial; 32 | deletedBy?: Partial; 33 | } 34 | 35 | export interface IBaseEntity { 36 | deleted?: boolean; 37 | deletedOn?: Date; 38 | deletedBy?: string; 39 | } 40 | 41 | export interface ISoftCrudRepositoryMixin { 42 | getCurrentUser: Getter; 43 | findAll(filter?: Filter, options?: Options): Promise<(E & R)[]>; 44 | deleteHard(entity: E, options?: Options): Promise; 45 | deleteByIdHard(id: ID, options?: Options): Promise; 46 | findByIdIncludeSoftDelete( 47 | id: ID, 48 | filter?: Filter, 49 | options?: Options, 50 | ): Promise; 51 | deleteAllHard(where?: Where, options?: Options): Promise; 52 | findOneIncludeSoftDelete( 53 | filter?: Filter, 54 | options?: Options, 55 | ): Promise<(E & R) | null>; 56 | findById(id: ID, filter?: Filter, options?: Options): Promise; 57 | countAll(where?: Where, options?: Options): Promise; 58 | } 59 | -------------------------------------------------------------------------------- /src/release_notes/release-notes.js: -------------------------------------------------------------------------------- 1 | const releaseNotes = require('git-release-notes'); 2 | const simpleGit = require('simple-git/promise'); 3 | const path = require('path'); 4 | const {readFile, writeFile, ensureFile} = require('fs-extra'); 5 | 6 | async function generateReleaseNotes() { 7 | try { 8 | const OPTIONS = { 9 | branch: 'master', 10 | s: './post-processing.js', 11 | }; 12 | const RANGE = await getRange(); 13 | const TEMPLATE = './mymarkdown.ejs'; 14 | 15 | const changelog = await releaseNotes(OPTIONS, RANGE, TEMPLATE); 16 | 17 | const changelogPath = path.resolve(__dirname, '../..', 'CHANGELOG.md'); 18 | await ensureFile(changelogPath); 19 | const currentFile = (await readFile(changelogPath)).toString().trim(); 20 | if (currentFile) { 21 | console.log('Update %s', changelogPath); 22 | } else { 23 | console.log('Create %s', changelogPath); 24 | } 25 | 26 | await writeFile(changelogPath, changelog); 27 | await writeFile(changelogPath, currentFile, {flag: 'a+'}); 28 | await addAndCommit().then(() => { 29 | console.log('Changelog has been updated'); 30 | }); 31 | } catch (ex) { 32 | console.error(ex); 33 | process.exit(1); 34 | } 35 | } 36 | 37 | async function getRange() { 38 | const git = simpleGit(); 39 | const tags = (await git.tag({'--sort': 'committerdate'})).split('\n'); 40 | tags.pop(); 41 | 42 | const startTag = tags.slice(-2)[0]; 43 | const endTag = tags.slice(-1)[0]; 44 | return `${startTag}..${endTag}`; 45 | } 46 | 47 | async function addAndCommit() { 48 | const git = simpleGit(); 49 | await git.add(['../../CHANGELOG.md']); 50 | await git.commit('chore(release): changelog file', { 51 | '--no-verify': null, 52 | }); 53 | await git.push('origin', 'master'); 54 | } 55 | 56 | generateReleaseNotes().catch(ex => { 57 | console.error(ex); 58 | process.exit(1); 59 | }); 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This Manually Executable Workflow is for NPM Releases 2 | name: Release [Manual] 3 | on: workflow_dispatch 4 | 5 | permissions: 6 | contents: write 7 | id-token: write # REQUIRED for trusted publishing 8 | 9 | jobs: 10 | Release: 11 | runs-on: ubuntu-latest 12 | # Specify environment if you configured one in npm 13 | # environment: production # Uncomment if you set an environment name in npm trusted publisher settings 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | # fetch-depth is necessary to get all tags 19 | # otherwise lerna can't detect the changes and will end up bumping the versions for all packages 20 | fetch-depth: 0 21 | token: ${{ secrets.RELEASE_COMMIT_GH_PAT }} 22 | 23 | - name: Setup Node 24 | uses: actions/setup-node@v4 # UPDATED to v4 25 | with: 26 | node-version: '22' 27 | registry-url: 'https://registry.npmjs.org' 28 | always-auth: false # important for trusted publishing 29 | 30 | - name: Configure CI Git User 31 | run: | 32 | git config --global user.name $CONFIG_USERNAME 33 | git config --global user.email $CONFIG_EMAIL 34 | git remote set-url origin https://$GITHUB_ACTOR:$GITHUB_PAT@github.com/sourcefuse/loopback4-soft-delete 35 | env: 36 | GITHUB_PAT: ${{ secrets.RELEASE_COMMIT_GH_PAT }} 37 | CONFIG_USERNAME: ${{ vars.RELEASE_COMMIT_USERNAME }} 38 | CONFIG_EMAIL: ${{ vars.RELEASE_COMMIT_EMAIL }} 39 | 40 | - name: Install 📌 41 | run: npm install 42 | 43 | - name: Test 🔧 44 | run: npm run test 45 | 46 | # ✅ CHANGED THIS SECTION 47 | - name: Semantic Publish to NPM 🚀 48 | run: | 49 | npm config set provenance true 50 | HUSKY=0 npx semantic-release 51 | env: 52 | GH_TOKEN: ${{ secrets.RELEASE_COMMIT_GH_PAT }} 53 | # REMOVED: NPM_TOKEN is not needed with trusted publishing 54 | # The id-token: write permission above handles authentication 55 | 56 | - name: Changelog 📝 57 | run: cd src/release_notes && HUSKY=0 node release-notes.js 58 | -------------------------------------------------------------------------------- /src/release_notes/post-processing.js: -------------------------------------------------------------------------------- 1 | const https = require('node:https'); 2 | const jsdom = require('jsdom'); 3 | module.exports = async function (data, callback) { 4 | const rewritten = []; 5 | for (const commit of data.commits) { 6 | if (commit.title.indexOf('chore(release)') !== -1) { 7 | continue; 8 | } 9 | 10 | const commitTitle = commit.title; 11 | commit.title = commitTitle.substring(0, commitTitle.indexOf('#') - 1); 12 | 13 | commit.messageLines = commit.messageLines.filter(message => { 14 | if (message.indexOf('efs/remotes/origin') === -1) return message; 15 | }); 16 | 17 | commit.messageLines.forEach(message => { 18 | commit.issueno = message.includes('GH-') 19 | ? message.replace('GH-', '').trim() 20 | : null; 21 | }); 22 | 23 | const issueDesc = await getIssueDesc(commit.issueno).then(res => { 24 | return res; 25 | }); 26 | commit.issueTitle = issueDesc; 27 | 28 | commit.committerDate = new Date(commit.committerDate).toLocaleDateString( 29 | 'en-us', 30 | { 31 | year: 'numeric', 32 | month: 'long', 33 | day: 'numeric', 34 | }, 35 | ); 36 | rewritten.push(commit); 37 | } 38 | callback({ 39 | commits: rewritten.filter(Boolean), 40 | range: data.range, 41 | }); 42 | }; 43 | 44 | function getIssueDesc(issueNo) { 45 | return new Promise((resolve, reject) => { 46 | let result = ''; 47 | const req = https.get( 48 | `https://github.com/sourcefuse/loopback4-soft-delete/issues/${encodeURIComponent( 49 | issueNo, 50 | )}`, 51 | res => { 52 | res.setEncoding('utf8'); 53 | res.on('data', chunk => { 54 | result = result + chunk; 55 | }); 56 | res.on('end', () => { 57 | const {JSDOM} = jsdom; 58 | const dom = new JSDOM(result); 59 | const title = dom.window.document.getElementsByClassName( 60 | 'js-issue-title markdown-title', 61 | ); 62 | let issueTitle = ''; 63 | for (const ele of title) { 64 | if (ele.nodeName === 'BDI') { 65 | issueTitle = ele.innerHTML; 66 | } 67 | } 68 | resolve(issueTitle); 69 | }); 70 | }, 71 | ); 72 | req.on('error', e => { 73 | reject(e); 74 | }); 75 | req.end(); 76 | }); 77 | } 78 | -------------------------------------------------------------------------------- /.cz-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | types: [ 3 | {value: 'feat', name: 'feat: A new feature'}, 4 | {value: 'fix', name: 'fix: A bug fix'}, 5 | {value: 'docs', name: 'docs: Documentation only changes'}, 6 | { 7 | value: 'style', 8 | name: 'style: Changes that do not affect the meaning of the code\n (white-space, formatting, missing semi-colons, etc)', 9 | }, 10 | { 11 | value: 'refactor', 12 | name: 'refactor: A code change that neither fixes a bug nor adds a feature', 13 | }, 14 | { 15 | value: 'perf', 16 | name: 'perf: A code change that improves performance', 17 | }, 18 | {value: 'test', name: 'test: Adding missing tests'}, 19 | { 20 | value: 'chore', 21 | name: 'chore: Changes to the build process or auxiliary tools\n and libraries such as documentation generation', 22 | }, 23 | {value: 'revert', name: 'revert: Reverting a commit'}, 24 | {value: 'WIP', name: 'WIP: Work in progress'}, 25 | ], 26 | 27 | scopes: [ 28 | {name: 'chore'}, 29 | {name: 'deps'}, 30 | {name: 'ci-cd'}, 31 | {name: 'repository'}, 32 | {name: 'entity'}, 33 | {name: 'sequelize'}, 34 | {name: 'maintenance'}, 35 | ], 36 | 37 | appendBranchNameToCommitMessage: false, 38 | allowTicketNumber: false, 39 | isTicketNumberRequired: false, 40 | ticketNumberPrefix: 'Fixes - ', 41 | 42 | // override the messages, defaults are as follows 43 | messages: { 44 | type: "Select the type of change that you're committing:", 45 | scope: 'Denote the SCOPE of this change:', 46 | // used if allowCustomScopes is true 47 | customScope: 'Mention the SCOPE of this change:', 48 | subject: 'Write a SHORT, IMPERATIVE tense description of the change:\n', 49 | body: 'Provide a LONGER description of the change (mandatory). Use "\\n" to break new line:\n', 50 | breaking: 'List any BREAKING CHANGES (optional):\n', 51 | footer: 52 | 'List any ISSUES CLOSED by this change (optional). E.g.: GH-31, GH-34:\n', 53 | confirmCommit: 'Are you sure you want to proceed with the commit above?', 54 | }, 55 | 56 | allowCustomScopes: true, 57 | allowBreakingChanges: ['feat', 'fix'], 58 | 59 | // limit subject length 60 | subjectLimit: 100, 61 | breaklineChar: '|', // It is supported for fields body and footer. 62 | footerPrefix: '', 63 | askForBreakingChangeFirst: true, // default is false 64 | }; 65 | -------------------------------------------------------------------------------- /src/__tests__/unit/mixin/soft-delete-entity.mixin.unit.ts: -------------------------------------------------------------------------------- 1 | import {expect} from '@loopback/testlab'; 2 | 3 | import { 4 | Constructor, 5 | Getter, 6 | MetadataInspector, 7 | MetadataMap, 8 | } from '@loopback/context'; 9 | import { 10 | DefaultTransactionalRepository, 11 | Entity, 12 | juggler, 13 | model, 14 | MODEL_PROPERTIES_KEY, 15 | property, 16 | } from '@loopback/repository'; 17 | 18 | import {PropertyDefinition} from 'loopback-datasource-juggler'; 19 | import {SoftCrudRepositoryMixin, SoftDeleteEntityMixin} from '../../..'; 20 | import {IUser} from '../../../types'; 21 | /** 22 | * A mock up model class 23 | */ 24 | class Customer extends Entity { 25 | @property({ 26 | id: true, 27 | type: 'number', 28 | required: true, 29 | }) 30 | id: number; 31 | @property({ 32 | type: 'string', 33 | required: true, 34 | }) 35 | email: string; 36 | constructor(data?: Partial) { 37 | super(data); 38 | } 39 | } 40 | 41 | const configProperties = { 42 | deleted: { 43 | name: 'delete', 44 | }, 45 | deletedBy: { 46 | name: 'deletedByUser', 47 | }, 48 | deletedOn: { 49 | name: 'deletedOnDate', 50 | additionalProperty: 'value', 51 | }, 52 | }; 53 | 54 | @model() 55 | class CustomerSoftDelete extends SoftDeleteEntityMixin( 56 | Customer, 57 | configProperties, 58 | ) {} 59 | 60 | const config = {name: 'test_memory', connector: 'memory'}; 61 | export class TestDataSource extends juggler.DataSource { 62 | static dataSourceName = 'test'; 63 | static readonly defaultConfig = config; 64 | constructor(dsConfig: object = config) { 65 | super(dsConfig); 66 | } 67 | } 68 | 69 | class CustomerCrudRepo extends SoftCrudRepositoryMixin< 70 | CustomerSoftDelete, 71 | typeof CustomerSoftDelete.prototype.id, 72 | Constructor< 73 | DefaultTransactionalRepository< 74 | CustomerSoftDelete, 75 | typeof CustomerSoftDelete.prototype.id, 76 | {} 77 | > 78 | >, 79 | {} 80 | >(DefaultTransactionalRepository) { 81 | constructor( 82 | dataSource: juggler.DataSource, 83 | readonly getCurrentUser: Getter, 84 | ) { 85 | super(CustomerSoftDelete, dataSource); 86 | } 87 | } 88 | 89 | describe('SoftCrudRepositoryMixin', () => { 90 | let repo: CustomerCrudRepo; 91 | let ds: juggler.DataSource; 92 | before(() => { 93 | ds = new TestDataSource(); 94 | repo = new CustomerCrudRepo(ds, () => 95 | Promise.resolve({ 96 | id: '1', 97 | username: 'test', 98 | }), 99 | ); 100 | }); 101 | 102 | it('should contain soft delete properties', async () => { 103 | const res = await repo.create({ 104 | id: 1, 105 | email: 'alice@example.com', 106 | }); 107 | 108 | expect(res).to.have.property('deleted'); 109 | expect(res).to.have.property('deletedOn'); 110 | expect(res).to.have.property('deletedBy'); 111 | await repo.deleteAllHard(); 112 | }); 113 | 114 | it('should be able to provide/override Metadata for soft delete properties', async () => { 115 | const res = MetadataInspector.getAllPropertyMetadata( 116 | MODEL_PROPERTIES_KEY, 117 | CustomerSoftDelete.prototype, 118 | ) as MetadataMap; 119 | 120 | expect(res.deletedBy.name).to.equal(configProperties.deletedBy.name); 121 | expect(res.deleted.name).to.equal(configProperties.deleted.name); 122 | expect(res.deletedOn.name).to.equal(configProperties.deletedOn.name); 123 | expect(res.deletedOn.additionalProperty).to.equal( 124 | configProperties.deletedOn.additionalProperty, 125 | ); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /.github/workflows/sync-docs.yaml: -------------------------------------------------------------------------------- 1 | name: Sync Docs to arc-docs repo 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | env: 9 | DOCS_REPO: sourcefuse/arc-docs 10 | BRANCH_PREFIX: automated-docs-sync/ 11 | GITHUB_TOKEN: ${{secrets.ARC_DOCS_API_TOKEN_GITHUB}} 12 | CONFIG_USERNAME: ${{ vars.GIT_COMMIT_USERNAME }} 13 | CONFIG_EMAIL: ${{ vars.GIT_COMMIT_EMAIL }} 14 | 15 | jobs: 16 | sync-docs: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout Extension Code 21 | uses: actions/checkout@v3 22 | with: 23 | token: ${{env.GITHUB_TOKEN}} 24 | path: './extension/' 25 | 26 | - name: Checkout Docs Repository 27 | uses: actions/checkout@v3 28 | with: 29 | token: ${{env.GITHUB_TOKEN}} 30 | repository: ${{env.DOCS_REPO}} 31 | path: './arc-docs/' 32 | 33 | - name: Configure GIT 34 | id: configure_git 35 | working-directory: arc-docs 36 | run: | 37 | git config --global user.email $CONFIG_EMAIL 38 | git config --global user.name $CONFIG_USERNAME 39 | 40 | extension_branch="${{env.BRANCH_PREFIX}}$(basename $GITHUB_REPOSITORY)" 41 | echo "extension_branch=$extension_branch" >> $GITHUB_OUTPUT 42 | 43 | - name: Update Files 44 | id: update_files 45 | working-directory: arc-docs 46 | run: | 47 | extension_branch="${{ steps.configure_git.outputs.extension_branch }}" 48 | 49 | # Create a new branch if it doesn't exist, or switch to it if it does 50 | git checkout -B $extension_branch || git checkout $extension_branch 51 | 52 | # Copy README from the extension repo 53 | cp ../extension/docs/README.md docs/arc-api-docs/extensions/$(basename $GITHUB_REPOSITORY)/ 54 | git add . 55 | 56 | if git diff --quiet --cached; then 57 | have_changes="false"; 58 | else 59 | have_changes="true"; 60 | fi 61 | 62 | echo "Have Changes to be commited: $have_changes" 63 | echo "have_changes=$have_changes" >> $GITHUB_OUTPUT 64 | 65 | - name: Commit Changes 66 | id: commit 67 | working-directory: arc-docs 68 | if: steps.update_files.outputs.have_changes == 'true' 69 | run: | 70 | git commit -m "sync $(basename $GITHUB_REPOSITORY) docs" 71 | - name: Push Changes 72 | id: push_branch 73 | if: steps.update_files.outputs.have_changes == 'true' 74 | working-directory: arc-docs 75 | run: | 76 | extension_branch="${{ steps.configure_git.outputs.extension_branch }}" 77 | git push https://oauth2:${GITHUB_TOKEN}@github.com/${{env.DOCS_REPO}}.git HEAD:$extension_branch --force 78 | 79 | - name: Check PR Status 80 | id: pr_status 81 | if: steps.update_files.outputs.have_changes == 'true' 82 | working-directory: arc-docs 83 | run: | 84 | extension_branch="${{ steps.configure_git.outputs.extension_branch }}" 85 | gh pr status --json headRefName >> "${{github.workspace}}/pr-status.json" 86 | pr_exists="$(jq --arg extension_branch "$extension_branch" '.createdBy[].headRefName == $extension_branch' "${{github.workspace}}/pr-status.json")" 87 | echo "PR Exists: $pr_exists" 88 | echo "pr_exists=$pr_exists" >> $GITHUB_OUTPUT 89 | 90 | - name: Create Pull Request 91 | id: create_pull_request 92 | if: steps.pr_status.outputs.pr_exists != 'true' && steps.update_files.outputs.have_changes == 'true' 93 | working-directory: arc-docs 94 | run: | 95 | extension_branch="${{ steps.configure_git.outputs.extension_branch }}" 96 | 97 | gh pr create --head $(git branch --show-current) --title "Sync ${{ github.event.repository.name }} Docs" --body "This Pull Request has been created by the 'sync-docs' action within the '${{ github.event.repository.name }}' repository, with the purpose of updating markdown files." 98 | -------------------------------------------------------------------------------- /src/providers/README.md: -------------------------------------------------------------------------------- 1 | # Providers 2 | 3 | This directory contains providers contributing additional bindings, for example 4 | custom sequence actions. 5 | 6 | ## Overview 7 | 8 | A [provider](http://loopback.io/doc/en/lb4/Creating-components.html#providers) 9 | is a class that provides a `value()` function. This function is called `Context` 10 | when another entity requests a value to be injected. 11 | 12 | Here we create a provider for a logging function that can be used as a new 13 | action in a custom [sequence](http://loopback.io/doc/en/lb4/Sequence.html). 14 | 15 | The logger will log the URL, the parsed request parameters, and the result. The 16 | logger is also capable of timing the sequence if you start a timer at the start 17 | of the sequence using `this.logger.startTimer()`. 18 | 19 | ## Basic Usage 20 | 21 | ### TimerProvider 22 | 23 | TimerProvider is automatically bound to your Application's 24 | [Context](http://loopback.io/doc/en/lb4/Context.html) using the LogComponent 25 | which exports this provider with a binding key of `extension-starter.timer`. You 26 | can learn more about components in the 27 | [related resources section](#related-resources). 28 | 29 | This provider makes availble to your application a timer function which given a 30 | start time _(given as an array [seconds, nanoseconds])_ can give you a total 31 | time elapsed since the start in milliseconds. The timer can also start timing if 32 | no start time is given. This is used by LogComponent to allow a user to time a 33 | Sequence. 34 | 35 | _NOTE:_ _You can get the start time in the required format by using 36 | `this.logger.startTimer()`._ 37 | 38 | You can provide your own implementation of the elapsed time function by binding 39 | it to the binding key (accessible via `ExtensionStarterBindings`) as follows: 40 | 41 | ```ts 42 | app.bind(ExtensionStarterBindings.TIMER).to(timerFn); 43 | ``` 44 | 45 | ### LogProvider 46 | 47 | LogProvider can automatically be bound to your Application's Context using the 48 | LogComponent which exports the provider with a binding key of 49 | `extension-starter.actions.log`. 50 | 51 | The key can be accessed by importing `ExtensionStarterBindings` as follows: 52 | 53 | **Example: Binding Keys** 54 | 55 | ```ts 56 | import {ExtensionStarterBindings} from 'HelloExtensions'; 57 | // Key can be accessed as follows now 58 | const key = ExtensionStarterBindings.LOG_ACTION; 59 | ``` 60 | 61 | LogProvider gives us a seuqence action and a `startTimer` function. In order to 62 | use the sequence action, you must define your own sequence as shown below. 63 | 64 | **Example: Sequence** 65 | 66 | ```ts 67 | class LogSequence implements SequenceHandler { 68 | constructor( 69 | @inject(coreSequenceActions.FIND_ROUTE) protected findRoute: FindRoute, 70 | @inject(coreSequenceActions.PARSE_PARAMS) 71 | protected parseParams: ParseParams, 72 | @inject(coreSequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, 73 | @inject(coreSequenceActions.SEND) protected send: Send, 74 | @inject(coreSequenceActions.REJECT) protected reject: Reject, 75 | // We get the logger injected by the LogProvider here 76 | @inject(ExtensionStarterBindings.LOG_ACTION) protected logger: LogFn, 77 | ) {} 78 | 79 | async handle(context: RequestContext) { 80 | const {request, response} = context; 81 | 82 | // We define these variable outside so they can be accessed by logger. 83 | let args: any = []; 84 | let result: any; 85 | 86 | // Optionally start timing the sequence using the timer 87 | // function available via LogFn 88 | const start = this.logger.startTimer(); 89 | 90 | try { 91 | const route = this.findRoute(request); 92 | args = await this.parseParams(request, route); 93 | result = await this.invoke(route, args); 94 | this.send(response, result); 95 | } catch (error) { 96 | result = error; // so we can log the error message in the logger 97 | this.reject(context, error); 98 | } 99 | 100 | // We call the logger function given to us by LogProvider 101 | this.logger(request, args, result, start); 102 | } 103 | } 104 | ``` 105 | 106 | Once a sequence has been written, we can just use that in our Application as 107 | follows: 108 | 109 | **Example: Application** 110 | 111 | ```ts 112 | const app = new Application({ 113 | sequence: LogSequence, 114 | }); 115 | app.component(LogComponent); 116 | 117 | // Now all requests handled by our sequence will be logged. 118 | ``` 119 | 120 | ## Related Resources 121 | 122 | You can check out the following resource to learn more about providers, 123 | components, sequences, and binding keys. 124 | 125 | - [Providers](http://loopback.io/doc/en/lb4/Creating-components.html#providers) 126 | - [Creating Components](http://loopback.io/doc/en/lb4/Creating-components.html) 127 | - [Using Components](http://loopback.io/doc/en/lb4/Using-components.html) 128 | - [Sequence](http://loopback.io/doc/en/lb4/Sequence.html) 129 | - [Binding Keys](http://loopback.io/doc/en/lb4/Decorators.html) 130 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loopback4-soft-delete", 3 | "version": "11.0.0", 4 | "author": "Sourcefuse", 5 | "description": "A loopback-next extension for soft delete feature.", 6 | "keywords": [ 7 | "loopback-extension", 8 | "loopback" 9 | ], 10 | "main": "dist/index.js", 11 | "types": "dist/index.d.ts", 12 | "exports": { 13 | ".": "./dist/index.js", 14 | "./sequelize": { 15 | "types": "./dist/repositories/sequelize/index.d.ts", 16 | "default": "./dist/repositories/sequelize/index.js" 17 | } 18 | }, 19 | "typesVersions": { 20 | "*": { 21 | "sequelize": [ 22 | "./dist/repositories/sequelize/index.d.ts" 23 | ] 24 | } 25 | }, 26 | "engines": { 27 | "node": ">=20" 28 | }, 29 | "scripts": { 30 | "build": "npm run clean && lb-tsc", 31 | "build:watch": "lb-tsc --watch", 32 | "clean": "lb-clean dist *.tsbuildinfo", 33 | "lint": "npm run prettier:check && npm run eslint", 34 | "lint:fix": "npm run eslint:fix && npm run prettier:fix", 35 | "prettier:cli": "lb-prettier \"**/*.ts\" \"**/*.js\"", 36 | "prettier:check": "npm run prettier:cli -- -l", 37 | "prettier:fix": "npm run prettier:cli -- --write", 38 | "eslint": "lb-eslint --report-unused-disable-directives .", 39 | "eslint:fix": "npm run eslint -- --fix", 40 | "pretest": "npm run clean && npm run build", 41 | "test": "lb-mocha --allow-console-logs \"dist/__tests__\"", 42 | "coverage": "lb-nyc npm run test", 43 | "coverage:ci": "lb-nyc report --reporter=text-lcov | coveralls", 44 | "posttest": "npm run lint", 45 | "test:dev": "lb-mocha --allow-console-logs dist/__tests__/**/*.js && npm run posttest", 46 | "prepublishOnly": "npm run test", 47 | "prepare": "husky install" 48 | }, 49 | "repository": { 50 | "type": "git", 51 | "url": "https://github.com/sourcefuse/loopback4-soft-delete" 52 | }, 53 | "license": "MIT", 54 | "files": [ 55 | "README.md", 56 | "index.js", 57 | "index.d.ts", 58 | "dist", 59 | "src", 60 | "!*/__tests__" 61 | ], 62 | "peerDependencies": { 63 | "@loopback/boot": "^8.0.4", 64 | "@loopback/context": "^8.0.3", 65 | "@loopback/repository": "^8.0.3", 66 | "@loopback/sequelize": "^0.8.0", 67 | "loopback-datasource-juggler": "^5.0.9" 68 | }, 69 | "peerDependenciesMeta": { 70 | "@loopback/sequelize": { 71 | "optional": true 72 | } 73 | }, 74 | "devDependencies": { 75 | "@commitlint/cli": "^17.7.1", 76 | "@commitlint/config-conventional": "^17.7.0", 77 | "@loopback/boot": "^8.0.4", 78 | "@loopback/build": "^12.0.3", 79 | "@loopback/context": "^8.0.3", 80 | "@loopback/repository": "^8.0.3", 81 | "@loopback/sequelize": "^0.8.0", 82 | "@loopback/testlab": "^8.0.3", 83 | "@loopback/tslint-config": "^2.1.0", 84 | "@semantic-release/changelog": "^6.0.1", 85 | "@semantic-release/commit-analyzer": "^9.0.2", 86 | "@semantic-release/git": "^10.0.1", 87 | "@semantic-release/github": "^12.0.0", 88 | "@semantic-release/npm": "^13.1.1", 89 | "@semantic-release/release-notes-generator": "^10.0.3", 90 | "@types/lodash": "^4.14.191", 91 | "@types/node": "^16.18.119", 92 | "@typescript-eslint/eslint-plugin": "^7.16.0", 93 | "@typescript-eslint/parser": "^7.16.0", 94 | "commitizen": "^4.2.4", 95 | "cz-conventional-changelog": "^3.3.0", 96 | "cz-customizable": "^6.3.0", 97 | "eslint": "^8.57.0", 98 | "eslint-config-prettier": "^9.1.0", 99 | "eslint-plugin-eslint-plugin": "^5.5.1", 100 | "eslint-plugin-mocha": "^10.4.3", 101 | "git-release-notes": "^5.0.0", 102 | "husky": "^7.0.4", 103 | "jsdom": "^21.0.0", 104 | "loopback-datasource-juggler": "^5.1.3", 105 | "minimist": ">=1.2.6", 106 | "semantic-release": "^25.0.1", 107 | "simple-git": "^3.15.1", 108 | "source-map-support": "^0.5.21", 109 | "sqlite3": "^5.1.4", 110 | "typescript": "~5.2.2" 111 | }, 112 | "publishConfig": { 113 | "registry": "https://registry.npmjs.org/" 114 | }, 115 | "overrides": { 116 | "body-parser": { 117 | "debug": "^4.3.4" 118 | }, 119 | "express": { 120 | "debug": "^4.3.4", 121 | "finalhandler": "^1.2.0", 122 | "send": "^0.18.0", 123 | "serve-static": "^1.15.0" 124 | }, 125 | "git-release-notes": { 126 | "ejs": "^3.1.8", 127 | "yargs": "^17.6.2" 128 | } 129 | }, 130 | "dependencies": { 131 | "@loopback/core": "^7.0.3", 132 | "@loopback/rest": "^15.0.4", 133 | "lodash": "^4.17.21" 134 | }, 135 | "config": { 136 | "commitizen": { 137 | "path": "./node_modules/cz-customizable" 138 | } 139 | }, 140 | "release": { 141 | "branches": [ 142 | "master" 143 | ], 144 | "plugins": [ 145 | [ 146 | "@semantic-release/commit-analyzer", 147 | { 148 | "preset": "angular", 149 | "releaseRules": [ 150 | { 151 | "type": "chore", 152 | "scope": "deps", 153 | "release": "patch" 154 | } 155 | ] 156 | } 157 | ], 158 | "@semantic-release/release-notes-generator", 159 | [ 160 | "@semantic-release/npm", 161 | { 162 | "npmPublish": true, 163 | "pkgRoot": ".", 164 | "tarballDir": "dist" 165 | } 166 | ], 167 | 168 | [ 169 | "@semantic-release/git", 170 | { 171 | "assets": [ 172 | "package.json", 173 | "CHANGELOG.md" 174 | ], 175 | "message": "chore(release): ${nextRelease.version} semantic" 176 | } 177 | ], 178 | "@semantic-release/github" 179 | ], 180 | "repositoryUrl": "https://github.com/sourcefuse/loopback4-soft-delete.git" 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/repositories/sequelize/sequelize.soft-crud.repository.base.ts: -------------------------------------------------------------------------------- 1 | import {Getter} from '@loopback/core'; 2 | import { 3 | DataObject, 4 | Entity, 5 | Filter, 6 | Where, 7 | Condition, 8 | } from '@loopback/repository'; 9 | import {Count} from '@loopback/repository/src/common-types'; 10 | import {HttpErrors} from '@loopback/rest'; 11 | import {cloneDeep} from 'lodash'; 12 | import {Options} from 'loopback-datasource-juggler'; 13 | import { 14 | SequelizeCrudRepository, 15 | SequelizeDataSource, 16 | } from '@loopback/sequelize'; 17 | 18 | import {ErrorKeys} from '../../error-keys'; 19 | import {SoftDeleteEntity} from '../../models'; 20 | import {IUser} from '../../types'; 21 | import {SoftFilterBuilder} from '../../utils/soft-filter-builder'; 22 | 23 | export abstract class SequelizeSoftCrudRepository< 24 | E extends SoftDeleteEntity, 25 | ID, 26 | R extends object = {}, 27 | > extends SequelizeCrudRepository { 28 | constructor( 29 | entityClass: typeof Entity & { 30 | prototype: E; 31 | }, 32 | dataSource: SequelizeDataSource, 33 | protected readonly getCurrentUser?: Getter, 34 | ) { 35 | super(entityClass, dataSource); 36 | } 37 | 38 | find(filter?: Filter, options?: Options): Promise<(E & R)[]> { 39 | const modifiedFilter = new SoftFilterBuilder(filter) 40 | .imposeCondition({ 41 | deleted: false, 42 | } as Condition) 43 | .build(); 44 | 45 | return super.find(modifiedFilter, options); 46 | } 47 | 48 | findAll(filter?: Filter, options?: Options): Promise<(E & R)[]> { 49 | return super.find(filter, options); 50 | } 51 | 52 | findOne(filter?: Filter, options?: Options): Promise<(E & R) | null> { 53 | const modifiedFilter = new SoftFilterBuilder(filter) 54 | .imposeCondition({ 55 | deleted: false, 56 | } as Condition) 57 | .build(); 58 | 59 | return super.findOne(modifiedFilter, options); 60 | } 61 | 62 | // findOne() including soft deleted entry 63 | findOneIncludeSoftDelete( 64 | filter?: Filter, 65 | options?: Options, 66 | ): Promise<(E & R) | null> { 67 | return super.findOne(filter, options); 68 | } 69 | 70 | async findById( 71 | id: ID, 72 | filter?: Filter, 73 | options?: Options, 74 | ): Promise { 75 | const originalFilter = filter ?? {}; 76 | const idProp = this.entityClass.getIdProperties()[0]; 77 | 78 | const modifiedFilter = new SoftFilterBuilder(cloneDeep(originalFilter)) 79 | .imposeCondition({ 80 | deleted: false, 81 | [idProp]: id, 82 | } as Condition) 83 | .limit(1) 84 | .build(); 85 | 86 | const entity = await super.find(modifiedFilter, options); 87 | 88 | if (entity && entity.length > 0) { 89 | return entity[0]; 90 | } else { 91 | throw new HttpErrors.NotFound(ErrorKeys.EntityNotFound); 92 | } 93 | } 94 | 95 | // findById (including soft deleted record) 96 | async findByIdIncludeSoftDelete( 97 | id: ID, 98 | filter?: Filter, 99 | options?: Options, 100 | ): Promise { 101 | //As parent method findById have filter: FilterExcludingWhere 102 | //so we need add check here. 103 | const entity = await super.findOne(filter, options); 104 | 105 | if (entity) { 106 | // Now call super 107 | return super.findById(id, filter, options); 108 | } else { 109 | throw new HttpErrors.NotFound(ErrorKeys.EntityNotFound); 110 | } 111 | } 112 | 113 | updateAll( 114 | data: DataObject, 115 | where?: Where, 116 | options?: Options, 117 | ): Promise { 118 | const filter = new SoftFilterBuilder({where}) 119 | .imposeCondition({ 120 | deleted: false, 121 | } as Condition) 122 | .build(); 123 | 124 | return super.updateAll(data, filter.where, options); 125 | } 126 | 127 | count(where?: Where, options?: Options): Promise { 128 | const filter = new SoftFilterBuilder({where}) 129 | .imposeCondition({ 130 | deleted: false, 131 | } as Condition) 132 | .build(); 133 | return super.count(filter.where, options); 134 | } 135 | 136 | countAll(where?: Where, options?: Options): Promise { 137 | return super.count(where, options); 138 | } 139 | 140 | // soft delete 141 | async delete(entity: E, options?: Options): Promise { 142 | return this.deleteById(entity.getId(), options); 143 | } 144 | 145 | async deleteAll(where?: Where, options?: Options): Promise { 146 | const deletedBy = await this.getUserId(this.getCurrentUser); 147 | const dataToUpdate: DataObject = { 148 | deleted: true, 149 | deletedOn: new Date(), 150 | deletedBy, 151 | }; 152 | return super.updateAll(dataToUpdate, where, options); 153 | } 154 | 155 | // soft delete by id 156 | async deleteById(id: ID, options?: Options): Promise { 157 | const deletedBy = await this.getUserId(this.getCurrentUser); 158 | return super.updateById( 159 | id, 160 | { 161 | deleted: true, 162 | deletedOn: new Date(), 163 | deletedBy, 164 | }, 165 | options, 166 | ); 167 | } 168 | 169 | /** 170 | * Method to perform hard delete of entries. Take caution. 171 | * @param entity 172 | * @param options 173 | */ 174 | deleteHard(entity: E, options?: Options): Promise { 175 | // Do hard delete 176 | return super.deleteById(entity.getId(), options); 177 | } 178 | 179 | /** 180 | * Method to perform hard delete of entries. Take caution. 181 | * @param entity 182 | * @param options 183 | */ 184 | deleteAllHard(where?: Where, options?: Options): Promise { 185 | // Do hard delete 186 | return super.deleteAll(where, options); 187 | } 188 | 189 | /** 190 | * Method to perform hard delete of entries. Take caution. 191 | * @param entity 192 | * @param options 193 | */ 194 | deleteByIdHard(id: ID, options?: Options): Promise { 195 | // Do hard delete 196 | return super.deleteById(id, options); 197 | } 198 | 199 | private async getUserId(options?: Options): Promise { 200 | if (!this.getCurrentUser) { 201 | return undefined; 202 | } 203 | let currentUser = await this.getCurrentUser(); 204 | currentUser = currentUser ?? options?.currentUser; 205 | if (!currentUser) { 206 | return undefined; 207 | } 208 | const userId = currentUser.getIdentifier?.() ?? currentUser.id; 209 | return userId?.toString(); 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/mixins/README.md: -------------------------------------------------------------------------------- 1 | # Mixins 2 | 3 | This directory contains source files for the mixins exported by this extension. 4 | 5 | ## Overview 6 | 7 | Sometimes it's helpful to write partial classes and then combining them together 8 | to build more powerful classes. This pattern is called Mixins (mixing in partial 9 | classes) and is supported by LoopBack 4. 10 | 11 | LoopBack 4 supports mixins at an `Application` level. Your partial class can 12 | then be mixed into the `Application` class. A mixin class can modify or override 13 | existing methods of the class or add new ones! It is also possible to mixin 14 | multiple classes together as needed. 15 | 16 | ### High level example 17 | 18 | ```ts 19 | class MyApplication extends MyMixinClass(Application) { 20 | // Your code 21 | } 22 | 23 | // Multiple Classes mixed together 24 | class MyApp extends MyMixinClass(MyMixinClass2(Application)) { 25 | // Your code 26 | } 27 | ``` 28 | 29 | ## Getting Started 30 | 31 | For hello-extensions we write a simple Mixin that allows the `Application` class 32 | to bind a `Logger` class from ApplicationOptions, Components, or `.logger()` 33 | method that is mixed in. `Logger` instances are bound to the key 34 | `loggers.${Logger.name}`. Once a Logger has been bound, the user can retrieve it 35 | by using 36 | [Dependency Injection](http://loopback.io/doc/en/lb4/Dependency-injection.html) 37 | and the key for the `Logger`. 38 | 39 | ### What is a Logger? 40 | 41 | > A Logger class is provides a mechanism for logging messages of varying 42 | > priority by providing an implementation for `Logger.info()` & 43 | > `Logger.error()`. An example of a Logger is `console` which has 44 | > `console.log()` and `console.error()`. 45 | 46 | #### An example Logger 47 | 48 | ```ts 49 | class ColorLogger implements Logger { 50 | log(...args: LogArgs) { 51 | console.log('log :', ...args); 52 | } 53 | 54 | error(...args: LogArgs) { 55 | // log in red color 56 | console.log('\x1b[31m error: ', ...args, '\x1b[0m'); 57 | } 58 | } 59 | ``` 60 | 61 | ## LoggerMixin 62 | 63 | A complete & functional implementation can be found in `logger.mixin.ts`. _Here 64 | are some key things to keep in mind when writing your own Mixin_. 65 | 66 | ### constructor() 67 | 68 | A Mixin constructor must take an array of any type as it's argument. This would 69 | represent `ApplicationOptions` for our base class `Application` as well as any 70 | properties we would like for our Mixin. 71 | 72 | It is also important for the constructor to call `super(args)` so `Application` 73 | continues to work as expected. 74 | 75 | ```ts 76 | constructor(...args: any[]) { 77 | super(args); 78 | } 79 | ``` 80 | 81 | ### Binding via `ApplicationOptions` 82 | 83 | As mentioned earlier, since our `args` represents `ApplicationOptions`, we can 84 | make it possible for users to pass in their `Logger` implementations in a 85 | `loggers` array on `ApplicationOptions`. We can then read the array and 86 | automatically bind these for the user. 87 | 88 | #### Example user experience 89 | 90 | ```ts 91 | class MyApp extends LoggerMixin(Application) { 92 | constructor(...args: any[]) { 93 | super(...args); 94 | } 95 | } 96 | 97 | const app = new MyApp({ 98 | loggers: [ColorLogger], 99 | }); 100 | ``` 101 | 102 | #### Example Implementation 103 | 104 | To implement this, we would check `this.options` to see if it has a `loggers` 105 | array and if so, bind it by calling the `.logger()` method. (More on that 106 | below). 107 | 108 | ```ts 109 | if (this.options.loggers) { 110 | for (const logger of this.options.loggers) { 111 | this.logger(logger); 112 | } 113 | } 114 | ``` 115 | 116 | ### Binding via `.logger()` 117 | 118 | As mentioned earlier, we can add a new function to our `Application` class 119 | called `.logger()` into which a user would pass in their `Logger` implementation 120 | so we can bind it to the `loggers.*` key for them. We just add this new method 121 | on our partial Mixin class. 122 | 123 | ```ts 124 | logger(logClass: Logger) { 125 | const loggerKey = `loggers.${logClass.name}`; 126 | this.bind(loggerKey).toClass(logClass); 127 | } 128 | ``` 129 | 130 | ### Binding a `Logger` from a `Component` 131 | 132 | Our base class of `Application` already has a method that binds components. We 133 | can modify this method to continue binding a `Component` as usual but also 134 | binding any `Logger` instances provided by that `Component`. When modifying 135 | behavior of an existing method, we can ensure existing behavior by calling the 136 | `super.method()`. In our case the method is `.component()`. 137 | 138 | ```ts 139 | component(component: Constructor) { 140 | super.component(component); // ensures existing behavior from Application 141 | this.mountComponentLoggers(component); 142 | } 143 | ``` 144 | 145 | We have now modified `.component()` to do it's thing and then call our method 146 | `mountComponentLoggers()`. In this method is where we check for `Logger` 147 | implementations declared by the component in a `loggers` array by retrieving the 148 | instance of the `Component`. Then if `loggers` array exists, we bind the 149 | `Logger` instances as normal (by leveraging our `.logger()` method). 150 | 151 | ```ts 152 | mountComponentLoggers(component: Constructor) { 153 | const componentKey = `components.${component.name}`; 154 | const compInstance = this.getSync(componentKey); 155 | 156 | if (compInstance.loggers) { 157 | for (const logger of compInstance.loggers) { 158 | this.logger(logger); 159 | } 160 | } 161 | } 162 | ``` 163 | 164 | ## Retrieving the Logger instance 165 | 166 | Now that we have bound a Logger to our Application via one of the many ways made 167 | possible by `LoggerMixin`, we need to be able to retrieve it so we can use it. 168 | Let's say we want to use it in a controller. Here's an example to retrieving it 169 | so we can use it. 170 | 171 | ```ts 172 | class MyController { 173 | constructor(@inject('loggers.ColorLogger') protected log: Logger) {} 174 | 175 | helloWorld() { 176 | this.log.log('hello log'); 177 | this.log.error('hello error'); 178 | } 179 | } 180 | ``` 181 | 182 | ## Examples for using LoggerMixin 183 | 184 | ### Using the app's `.logger()` method 185 | 186 | ```ts 187 | class LoggingApplication extends LoggerMixin(Application) { 188 | constructor(...args: any[]) { 189 | super(...args); 190 | this.logger(ColorLogger); 191 | } 192 | } 193 | ``` 194 | 195 | ### Using the app's constructor 196 | 197 | ```ts 198 | class LoggerApplication extends LoggerMixin(Application) { 199 | constructor() { 200 | super({ 201 | loggers: [ColorLogger], 202 | }); 203 | } 204 | } 205 | ``` 206 | 207 | ### Binding a Logger provided by a component 208 | 209 | ```ts 210 | class LoggingComponent implements Component { 211 | loggers: [ColorLogger]; 212 | } 213 | 214 | const app = new LoggingApplication(); 215 | app.component(LoggingComponent); // Logger from MyComponent will be bound to loggers.ColorLogger 216 | ``` 217 | -------------------------------------------------------------------------------- /src/repositories/default-transaction-soft-crud.repository.base.ts: -------------------------------------------------------------------------------- 1 | // DEVELOPMENT NOTE: 2 | // Please ensure that any modifications made to this file are also applied to the following locations: 3 | // 1) src/repositories/soft-crud.repository.base.ts 4 | 5 | import { 6 | Condition, 7 | DataObject, 8 | DefaultTransactionalRepository, 9 | Entity, 10 | Filter, 11 | Getter, 12 | juggler, 13 | Where, 14 | } from '@loopback/repository'; 15 | import {Count} from '@loopback/repository/src/common-types'; 16 | import {HttpErrors} from '@loopback/rest'; 17 | import {cloneDeep} from 'lodash'; 18 | import {Options} from 'loopback-datasource-juggler'; 19 | import {ErrorKeys} from '../error-keys'; 20 | import {SoftDeleteEntity} from '../models'; 21 | import {IUser} from '../types'; 22 | import {SoftFilterBuilder} from '../utils/soft-filter-builder'; 23 | 24 | /** 25 | * Soft Delete Repository for loopback's `DefaultTransactionalRepository` 26 | * @deprecated Use the {@link SoftCrudRepositoryMixin} instead. 27 | * eg. 28 | * ```ts 29 | * class CustomerRepository extends SoftCrudRepositoryMixin< 30 | * Customer, 31 | * typeof Customer.prototype.id, 32 | * Constructor< 33 | * DefaultTransactionalRepository 34 | * >, 35 | * {} 36 | * >(DefaultTransactionalRepository) { 37 | * // ... 38 | * } 39 | * ``` 40 | */ 41 | export abstract class DefaultTransactionSoftCrudRepository< 42 | E extends SoftDeleteEntity, 43 | ID, 44 | R extends object = {}, 45 | > extends DefaultTransactionalRepository { 46 | constructor( 47 | entityClass: typeof Entity & { 48 | prototype: E; 49 | }, 50 | dataSource: juggler.DataSource, 51 | protected readonly getCurrentUser?: Getter, 52 | ) { 53 | super(entityClass, dataSource); 54 | } 55 | 56 | find(filter?: Filter, options?: Options): Promise<(E & R)[]> { 57 | const modifiedFilter = new SoftFilterBuilder(filter) 58 | .imposeCondition({ 59 | deleted: false, 60 | } as Condition) 61 | .build(); 62 | 63 | return super.find(modifiedFilter, options); 64 | } 65 | 66 | findAll(filter?: Filter, options?: Options): Promise<(E & R)[]> { 67 | return super.find(filter, options); 68 | } 69 | 70 | findOne(filter?: Filter, options?: Options): Promise<(E & R) | null> { 71 | const modifiedFilter = new SoftFilterBuilder(filter) 72 | .imposeCondition({ 73 | deleted: false, 74 | } as Condition) 75 | .build(); 76 | 77 | return super.findOne(modifiedFilter, options); 78 | } 79 | 80 | // findOne() including soft deleted entry 81 | findOneIncludeSoftDelete( 82 | filter?: Filter, 83 | options?: Options, 84 | ): Promise<(E & R) | null> { 85 | return super.findOne(filter, options); 86 | } 87 | 88 | async findById( 89 | id: ID, 90 | filter?: Filter, 91 | options?: Options, 92 | ): Promise { 93 | const originalFilter = filter ?? {}; 94 | const idProp = this.entityClass.getIdProperties()[0]; 95 | 96 | const modifiedFilter = new SoftFilterBuilder(cloneDeep(originalFilter)) 97 | .imposeCondition({ 98 | deleted: false, 99 | [idProp]: id, 100 | } as Condition) 101 | .limit(1) 102 | .build(); 103 | 104 | const entity = await super.find(modifiedFilter, options); 105 | 106 | if (entity && entity.length > 0) { 107 | return entity[0]; 108 | } else { 109 | throw new HttpErrors.NotFound(ErrorKeys.EntityNotFound); 110 | } 111 | } 112 | 113 | // findById (including soft deleted record) 114 | async findByIdIncludeSoftDelete( 115 | id: ID, 116 | filter?: Filter, 117 | options?: Options, 118 | ): Promise { 119 | //As parent method findById have filter: FilterExcludingWhere 120 | //so we need add check here. 121 | const entity = await super.findOne(filter, options); 122 | 123 | if (entity) { 124 | // Now call super 125 | return super.findById(id, filter, options); 126 | } else { 127 | throw new HttpErrors.NotFound(ErrorKeys.EntityNotFound); 128 | } 129 | } 130 | 131 | updateAll( 132 | data: DataObject, 133 | where?: Where, 134 | options?: Options, 135 | ): Promise { 136 | const filter = new SoftFilterBuilder({where}) 137 | .imposeCondition({ 138 | deleted: false, 139 | } as Condition) 140 | .build(); 141 | 142 | return super.updateAll(data, filter.where, options); 143 | } 144 | 145 | count(where?: Where, options?: Options): Promise { 146 | const filter = new SoftFilterBuilder({where}) 147 | .imposeCondition({ 148 | deleted: false, 149 | } as Condition) 150 | .build(); 151 | return super.count(filter.where, options); 152 | } 153 | 154 | countAll(where?: Where, options?: Options): Promise { 155 | return super.count(where, options); 156 | } 157 | 158 | // soft delete 159 | async delete(entity: E, options?: Options): Promise { 160 | return this.deleteById(entity.getId(), options); 161 | } 162 | 163 | async deleteAll(where?: Where, options?: Options): Promise { 164 | const deletedBy = await this.getUserId(this.getCurrentUser); 165 | const dataToUpdate: DataObject = { 166 | deleted: true, 167 | deletedOn: new Date(), 168 | deletedBy, 169 | }; 170 | return super.updateAll(dataToUpdate, where, options); 171 | } 172 | 173 | // soft delete by id 174 | async deleteById(id: ID, options?: Options): Promise { 175 | const deletedBy = await this.getUserId(this.getCurrentUser); 176 | return super.updateById( 177 | id, 178 | { 179 | deleted: true, 180 | deletedOn: new Date(), 181 | deletedBy, 182 | }, 183 | options, 184 | ); 185 | } 186 | 187 | /** 188 | * Method to perform hard delete of entries. Take caution. 189 | * @param entity 190 | * @param options 191 | */ 192 | deleteHard(entity: E, options?: Options): Promise { 193 | // Do hard delete 194 | return super.deleteById(entity.getId(), options); 195 | } 196 | 197 | /** 198 | * Method to perform hard delete of entries. Take caution. 199 | * @param entity 200 | * @param options 201 | */ 202 | deleteAllHard(where?: Where, options?: Options): Promise { 203 | // Do hard delete 204 | return super.deleteAll(where, options); 205 | } 206 | 207 | /** 208 | * Method to perform hard delete of entries. Take caution. 209 | * @param entity 210 | * @param options 211 | */ 212 | deleteByIdHard(id: ID, options?: Options): Promise { 213 | // Do hard delete 214 | return super.deleteById(id, options); 215 | } 216 | 217 | private async getUserId(options?: Options): Promise { 218 | if (!this.getCurrentUser) { 219 | return undefined; 220 | } 221 | let currentUser = await this.getCurrentUser(); 222 | currentUser = currentUser ?? options?.currentUser; 223 | if (!currentUser) { 224 | return undefined; 225 | } 226 | const userId = currentUser.getIdentifier?.() ?? currentUser.id; 227 | return userId?.toString(); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/repositories/soft-crud.repository.base.ts: -------------------------------------------------------------------------------- 1 | // DEVELOPMENT NOTE: 2 | // Please ensure that any modifications made to this file are also applied to the following locations: 3 | // 1) src/repositories/default-transaction-soft-crud.repository.base.ts 4 | 5 | import {Getter} from '@loopback/core'; 6 | import { 7 | Condition, 8 | DataObject, 9 | DefaultCrudRepository, 10 | Entity, 11 | Filter, 12 | Where, 13 | juggler, 14 | } from '@loopback/repository'; 15 | import {Count} from '@loopback/repository/src/common-types'; 16 | import {HttpErrors} from '@loopback/rest'; 17 | import {cloneDeep} from 'lodash'; 18 | import {Options} from 'loopback-datasource-juggler'; 19 | 20 | import {ErrorKeys} from '../error-keys'; 21 | import {SoftDeleteEntity} from '../models'; 22 | import {IUser} from '../types'; 23 | import {SoftFilterBuilder} from './../utils/soft-filter-builder'; 24 | 25 | export abstract class SoftCrudRepository< 26 | E extends SoftDeleteEntity, 27 | ID, 28 | R extends object = {}, 29 | > extends DefaultCrudRepository { 30 | constructor( 31 | entityClass: typeof Entity & { 32 | prototype: E; 33 | }, 34 | dataSource: juggler.DataSource, 35 | protected readonly getCurrentUser?: Getter, 36 | ) { 37 | super(entityClass, dataSource); 38 | } 39 | 40 | find(filter?: Filter, options?: Options): Promise<(E & R)[]> { 41 | const modifiedFilter = new SoftFilterBuilder(filter) 42 | .imposeCondition({ 43 | deleted: false, 44 | } as Condition) 45 | .build(); 46 | 47 | return super.find(modifiedFilter, options); 48 | } 49 | 50 | findAll(filter?: Filter, options?: Options): Promise<(E & R)[]> { 51 | return super.find(filter, options); 52 | } 53 | 54 | findOne(filter?: Filter, options?: Options): Promise<(E & R) | null> { 55 | const modifiedFilter = new SoftFilterBuilder(filter) 56 | .imposeCondition({ 57 | deleted: false, 58 | } as Condition) 59 | .build(); 60 | 61 | return super.findOne(modifiedFilter, options); 62 | } 63 | 64 | // findOne() including soft deleted entry 65 | findOneIncludeSoftDelete( 66 | filter?: Filter, 67 | options?: Options, 68 | ): Promise<(E & R) | null> { 69 | return super.findOne(filter, options); 70 | } 71 | 72 | async findById( 73 | id: ID, 74 | filter?: Filter, 75 | options?: Options, 76 | ): Promise { 77 | const originalFilter = filter ?? {}; 78 | const idProp = this.entityClass.getIdProperties()[0]; 79 | 80 | const modifiedFilter = new SoftFilterBuilder(cloneDeep(originalFilter)) 81 | .imposeCondition({ 82 | deleted: false, 83 | [idProp]: id, 84 | } as Condition) 85 | .limit(1) 86 | .build(); 87 | 88 | const entity = await super.find(modifiedFilter, options); 89 | 90 | if (entity && entity.length > 0) { 91 | return entity[0]; 92 | } else { 93 | throw new HttpErrors.NotFound(ErrorKeys.EntityNotFound); 94 | } 95 | } 96 | 97 | // findById (including soft deleted record) 98 | async findByIdIncludeSoftDelete( 99 | id: ID, 100 | filter?: Filter, 101 | options?: Options, 102 | ): Promise { 103 | //As parent method findById have filter: FilterExcludingWhere 104 | //so we need add check here. 105 | const entity = await super.findOne(filter, options); 106 | 107 | if (entity) { 108 | // Now call super 109 | return super.findById(id, filter, options); 110 | } else { 111 | throw new HttpErrors.NotFound(ErrorKeys.EntityNotFound); 112 | } 113 | } 114 | 115 | updateAll( 116 | data: DataObject, 117 | where?: Where, 118 | options?: Options, 119 | ): Promise { 120 | const filter = new SoftFilterBuilder({where}) 121 | .imposeCondition({ 122 | deleted: false, 123 | } as Condition) 124 | .build(); 125 | 126 | return super.updateAll(data, filter.where, options); 127 | } 128 | 129 | count(where?: Where, options?: Options): Promise { 130 | const filter = new SoftFilterBuilder({where}) 131 | .imposeCondition({ 132 | deleted: false, 133 | } as Condition) 134 | .build(); 135 | return super.count(filter.where, options); 136 | } 137 | 138 | countAll(where?: Where, options?: Options): Promise { 139 | return super.count(where, options); 140 | } 141 | 142 | // soft delete 143 | async delete(entity: E, options?: Options): Promise { 144 | return this.deleteById(entity.getId(), options); 145 | } 146 | 147 | async deleteAll(where?: Where, options?: Options): Promise { 148 | const deletedBy = await this.getUserId(this.getCurrentUser); 149 | const dataToUpdate: DataObject = { 150 | deleted: true, 151 | deletedOn: new Date(), 152 | deletedBy, 153 | }; 154 | return super.updateAll(dataToUpdate, where, options); 155 | } 156 | 157 | // soft delete by id 158 | async deleteById(id: ID, options?: Options): Promise { 159 | const deletedBy = await this.getUserId(this.getCurrentUser); 160 | return super.updateById( 161 | id, 162 | { 163 | deleted: true, 164 | deletedOn: new Date(), 165 | deletedBy, 166 | }, 167 | options, 168 | ); 169 | } 170 | 171 | /** 172 | * Method to perform undo the soft delete by Id. 173 | * @param id 174 | * @param options 175 | */ 176 | async undoSoftDeleteById(id: ID, options?: Options): Promise { 177 | await this.undoSoftDeleteAll({id} as Where, options); 178 | } 179 | 180 | /** 181 | * Method to perform undo all the soft deletes 182 | * @param where 183 | * @param options 184 | */ 185 | async undoSoftDeleteAll(where?: Where, options?: Options): Promise { 186 | const filter = new SoftFilterBuilder({where}) 187 | .imposeCondition({ 188 | deleted: true, 189 | } as Condition) 190 | .build(); 191 | return super.updateAll( 192 | {deleted: false, deletedOn: undefined}, 193 | filter.where, 194 | options, 195 | ); 196 | } 197 | 198 | /** 199 | * Method to perform hard delete of entries. Take caution. 200 | * @param entity 201 | * @param options 202 | */ 203 | deleteHard(entity: E, options?: Options): Promise { 204 | // Do hard delete 205 | return super.deleteById(entity.getId(), options); 206 | } 207 | 208 | /** 209 | * Method to perform hard delete of entries. Take caution. 210 | * @param entity 211 | * @param options 212 | */ 213 | deleteAllHard(where?: Where, options?: Options): Promise { 214 | // Do hard delete 215 | return super.deleteAll(where, options); 216 | } 217 | 218 | /** 219 | * Method to perform hard delete of entries. Take caution. 220 | * @param entity 221 | * @param options 222 | */ 223 | deleteByIdHard(id: ID, options?: Options): Promise { 224 | // Do hard delete 225 | return super.deleteById(id, options); 226 | } 227 | 228 | private async getUserId(options?: Options): Promise { 229 | if (!this.getCurrentUser) { 230 | return undefined; 231 | } 232 | let currentUser = await this.getCurrentUser(); 233 | currentUser = currentUser ?? options?.currentUser; 234 | if (!currentUser) { 235 | return undefined; 236 | } 237 | const userId = currentUser.getIdentifier?.() ?? currentUser.id; 238 | return userId?.toString(); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ARC By SourceFuse logo 2 | 3 | # [loopback4-soft-delete](https://github.com/sourcefuse/loopback4-soft-delete) 4 | 5 |

6 | 7 | npm version 8 | 9 | 10 | Sonar Quality Gate 11 | 12 | 13 | GitHub contributors 14 | 15 | 16 | downloads 17 | 18 | 19 | License 20 | 21 | 22 | Powered By LoopBack 4 23 | 24 |

25 | 26 | ## Install 27 | 28 | ```sh 29 | npm install loopback4-soft-delete 30 | ``` 31 | 32 | ## Quick Starter 33 | 34 | For a quick starter guide, you can refer to our [loopback 4 starter](https://github.com/sourcefuse/loopback4-starter) application which utilizes this package for soft-deletes in a multi-tenant application. 35 | 36 | ## Usage 37 | 38 | The package exports following classes and mixins: 39 | 40 | 1. [SoftDeleteEntity](#softdeleteentity) - To add required soft delete props in the model. 41 | 2. [SoftCrudRepository](#softcrudrepository) - Class providing soft crud capabilitiies (to be used in place of `DefaultCrudRepository`). 42 | 3. [SoftCrudRepositoryMixin](#softcrudrepositorymixin) - Mixin accepting any respository that extends the DefaultCrudRepository to add soft delete functionality to. Can be used as a wrapper for `DefaultCrudRepository`, `DefaultTransactionalRepository` etc. 43 | 4. [SoftDeleteEntityMixin](#softdeleteentitymixin) 44 | 5. [DefaultTransactionSoftCrudRepository](#defaulttransactionsoftcrudrepository-deprecated) (Deprecated) - Class providing soft crud capabilitiies. To be used in place of `DefaultTransactionalRepository`. 45 | 6. [SequelizeSoftCrudRepository](#sequelizesoftcrudrepository) - Class providing soft crud capabilitiies for [@loopback/sequelize](https://www.npmjs.com/package/@loopback/sequelize) package. To be used in place of `SequelizeCrudRepository`. 46 | 47 | Following are more details on the usage of above artifcats: 48 | 49 | ### SoftDeleteEntity 50 | 51 | An abstract base class for all models which require soft delete feature. 52 | This class is a wrapper over Entity class from [@loopback/repository](https://github.com/strongloop/loopback-next/tree/master/packages/repository) adding three attributes to the model class for handling soft-delete, namely, deleted, deletedOn, deletedBy. 53 | The column names needed to be there in DB within that table are - 'deleted', 'deleted_on', 'deleted_by'. 54 | If you are using auto-migration of loopback 4, then, you may not need to do anything specific to add this column. 55 | If not, then please add these columns to the DB table. 56 | 57 | ### SequelizeSoftCrudRepository 58 | 59 | An abstract base class providing soft delete capabilities for projects using [@loopback/sequelize](https://www.npmjs.com/package/@loopback/sequelize) package. 60 | All the other workings are similar to [SoftCrudRepository](#softcrudrepository), except it's imported using directory import syntax from `loopback4-soft-delete/sequelize`. 61 | 62 | ### SoftCrudRepository 63 | 64 | An abstract base class for all repositories which require soft delete feature. 65 | This class is going to be the one which handles soft delete operations and ensures soft deleted entries are not returned in responses, However if there is a need to query soft deleted entries as well, there is an options to achieve that and you can use findAll() in place of find() , findOneIncludeSoftDelete() in place of findOne() and findByIdIncludeSoftDelete() in place of findById(), these will give you the responses including soft deleted entries. 66 | This class is a wrapper over DefaultCrudRepository class from [@loopback/repository](https://github.com/strongloop/loopback-next/tree/master/packages/repository). 67 | 68 | ### DefaultTransactionSoftCrudRepository (Deprecated) 69 | 70 | > Note: `DefaultTransactionSoftCrudRepository` is deprecated in favour of [SoftCrudRepositoryMixin](#softcrudrepositorymixin) and will be removed in future releases. 71 | 72 | An abstract base class similar to [SoftCrudRepository](#softcrudrepository) but with transaction support. 73 | 74 | This class is a wrapper over `DefaultTransactionalRepository` class from [@loopback/repository](https://github.com/strongloop/loopback-next/tree/master/packages/repository). 75 | 76 | In order to use this extension in your application, please follow below steps. 77 | 78 | 1. Extend models with SoftDeleteEntity class replacing Entity. Like below: 79 | 80 | ```ts 81 | import {model, property} from '@loopback/repository'; 82 | import {SoftDeleteEntity} from 'loopback4-soft-delete'; 83 | 84 | @model({ 85 | name: 'users', 86 | }) 87 | export class User extends SoftDeleteEntity { 88 | @property({ 89 | type: 'number', 90 | id: true, 91 | }) 92 | id?: number; 93 | 94 | // .... More properties 95 | } 96 | ``` 97 | 98 | 2. Extend repositories with SoftCrudRepository class replacing DefaultCrudRepository. Like below: 99 | 100 | ```ts 101 | import {Getter, inject} from '@loopback/core'; 102 | import {SoftCrudRepository} from 'loopback4-soft-delete'; 103 | import {AuthenticationBindings, IAuthUser} from 'loopback4-authentication'; 104 | 105 | import {PgdbDataSource} from '../datasources'; 106 | import {User, UserRelations} from '../models'; 107 | 108 | export class UserRepository extends SoftCrudRepository< 109 | User, 110 | typeof User.prototype.id, 111 | UserRelations 112 | > { 113 | constructor( 114 | @inject('datasources.pgdb') dataSource: PgdbDataSource, 115 | @inject.getter(AuthenticationBindings.CURRENT_USER, {optional: true}) 116 | protected readonly getCurrentUser: Getter, 117 | ) { 118 | super(User, dataSource, getCurrentUser); 119 | } 120 | } 121 | ``` 122 | 123 | 3. For transaction support, use the `SoftCrudRepositoryMixin` and wrap it around `DefaultTransactionalRepository`. Like below: 124 | 125 | ```ts 126 | import {Getter, inject} from '@loopback/core'; 127 | import {SoftCrudRepository} from 'loopback4-soft-delete'; 128 | import {AuthenticationBindings, IAuthUser} from 'loopback4-authentication'; 129 | 130 | import {PgdbDataSource} from '../datasources'; 131 | import {User, UserRelations} from '../models'; 132 | 133 | export class UserRepository extends SoftCrudRepositoryMixin< 134 | User, 135 | typeof User.prototype.id, 136 | UserRelations 137 | >(DefaultTransactionalRepository) { 138 | constructor( 139 | @inject('datasources.pgdb') dataSource: PgdbDataSource, 140 | @inject.getter(AuthenticationBindings.CURRENT_USER, {optional: true}) 141 | protected readonly getCurrentUser: Getter, 142 | ) { 143 | super(User, dataSource, getCurrentUser); 144 | } 145 | } 146 | ``` 147 | 148 | ## Mixins Usage 149 | 150 | The package also provides the following mixins which can be used for soft delete functionality: 151 | 152 | ### SoftDeleteEntityMixin 153 | 154 | This mixin adds the soft delete properties to your model. The properties added are represented by the [IBaseEntity](#ibaseentity) interface: 155 | 156 | There is also an option to provide config for the `@property` decorator for all these properties. 157 | 158 | Usage of `SoftDeleteEntityMixin` is as follows: 159 | 160 | ```ts 161 | class Item extends Entity { 162 | @property({ 163 | type: 'number', 164 | id: true, 165 | generated: true, 166 | }) 167 | id?: number; 168 | 169 | @property({ 170 | type: 'string', 171 | required: true, 172 | }) 173 | name: string; 174 | 175 | constructor(data?: Partial) { 176 | super(data); 177 | } 178 | } 179 | 180 | @model() 181 | export class ItemSoftDelete extends SoftDeleteEntityMixin(Item, { 182 | deletedBy: { 183 | name: 'deleted_by_userid', 184 | }, 185 | }) {} 186 | ``` 187 | 188 | #### IBaseEntity 189 | 190 | The soft deleted properties added by [SoftDeleteEntityMixin](#softdeleteentitymixin) are represented by `IBaseEntity` interface. 191 | 192 | ```ts 193 | interface IBaseEntity { 194 | deleted?: boolean; 195 | deletedOn?: Date; 196 | deletedBy?: string; 197 | } 198 | ``` 199 | 200 | ### SoftCrudRepositoryMixin 201 | 202 | You can make use of this mixin to get the soft delete functionality for `DefaultCrudRepository` or any respository that extends the `DefaultCrudRepository`. You need to extend your repository with this mixin and provide DefaultCrudRepository (or any repository that extends DefaultCrudRepository) as input. This means that this same mixin can also be used to provide soft delete functionality for DefaultTransactionSoftCrudRepository (as DefaultTransactionSoftCrudRepository extends DefaultCrudRepository). You will have to inject the getter for IAuthUser in the contructor of your repository. 203 | 204 | #### Example: 205 | 206 | ```ts 207 | import {Constructor, Getter, inject} from '@loopback/core'; 208 | import {DefaultCrudRepository} from '@loopback/repository'; 209 | import {AuthenticationBindings, IAuthUser} from 'loopback4-authentication'; 210 | import {SoftCrudRepositoryMixin} from 'loopback4-soft-delete'; 211 | import {TestDataSource} from '../datasources'; 212 | import {ItemSoftDelete, ItemSoftDeleteRelations} from '../models'; 213 | 214 | export class ItemRepository extends SoftCrudRepositoryMixin< 215 | ItemSoftDelete, 216 | typeof ItemSoftDelete.prototype.id, 217 | Constructor< 218 | DefaultCrudRepository< 219 | ItemSoftDelete, 220 | typeof ItemSoftDelete.prototype.id, 221 | ItemSoftDeleteRelations 222 | > 223 | >, 224 | ItemSoftDeleteRelations 225 | >(DefaultCrudRepository) { 226 | constructor( 227 | @inject('datasources.test') dataSource: TestDataSource, 228 | @inject.getter(AuthenticationBindings.CURRENT_USER) 229 | public getCurrentUser: Getter, 230 | ) { 231 | super(ItemSoftDelete, dataSource); 232 | } 233 | } 234 | ``` 235 | 236 | ## Additional Repository Methods 237 | 238 | Following are some additional methods that you can use when working with repositories in your application, either by extending the base repositories provided or by using the [SoftCrudRepositoryMixin](#softcrudrepositorymixin): 239 | 240 | 1. `findAll` - This method is similar to `find`, but it returns entries including soft deleted ones. 241 | 2. `deleteHard` - This method is used to perform a hard delete on a specified entity. 242 | 3. `deleteByIdHard` - This method is used to perform a hard delete of an entity based on the provided ID. 243 | 4. `findByIdIncludeSoftDelete` - This method is similar to `findById`, but it returns the entity even if it is soft deleted. 244 | 5. `deleteAllHard` - This method is used to perform a hard delete of multiple entities based on a specified condition. 245 | 6. `findOneIncludeSoftDelete` - This method is similar to `findOne`, but it returns a single entity even if it is soft deleted. 246 | 7. `countAll` - This method is similar to `count`, but it returns the total count of all entities including soft deleted ones. 247 | 248 | ### deletedBy 249 | 250 | Whenever any entry is deleted using deleteById, delete and deleteAll repository methods, it also sets deletedBy column with a value with user id whoever is logged in currently. Hence it uses a Getter function of IUser type. However, if you want to use some other attribute of user model other than id, you can do it by overriding deletedByIdKey. Here is an example. 251 | 252 | ```ts 253 | import {Getter, inject} from '@loopback/core'; 254 | import {SoftCrudRepository, IUser} from 'loopback4-soft-delete'; 255 | import {AuthenticationBindings} from 'loopback4-authentication'; 256 | 257 | import {PgdbDataSource} from '../datasources'; 258 | import {User, UserRelations} from '../models'; 259 | 260 | export class UserRepository extends SoftCrudRepository< 261 | User, 262 | typeof User.prototype.id, 263 | UserRelations 264 | > { 265 | constructor( 266 | @inject('datasources.pgdb') dataSource: PgdbDataSource, 267 | @inject.getter(AuthenticationBindings.CURRENT_USER, {optional: true}) 268 | protected readonly getCurrentUser: Getter, 269 | ) { 270 | super(User, dataSource, getCurrentUser); 271 | } 272 | } 273 | ``` 274 | 275 | Model class for custom identifier case. Notice the `getIdentifier() method` and `IUser` interface implemented. 276 | 277 | ```ts 278 | @model() 279 | class User extends SoftDeleteEntity implements IUser { 280 | @property({ 281 | id: true, 282 | }) 283 | id: string; 284 | 285 | @property() 286 | username: string; 287 | 288 | getIdentifier() { 289 | return this.username; 290 | } 291 | 292 | constructor(data?: Partial) { 293 | super(data); 294 | } 295 | } 296 | ``` 297 | 298 | ## License 299 | 300 | [MIT](https://github.com/sourcefuse/loopback4-soft-delete/blob/master/LICENSE) 301 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ARC By SourceFuse logo 2 | 3 | # [loopback4-soft-delete](https://github.com/sourcefuse/loopback4-soft-delete) 4 | 5 |

6 | 7 | npm version 8 | 9 | 10 | Sonar Quality Gate 11 | 12 | 13 | Synk Status 14 | 15 | 16 | GitHub contributors 17 | 18 | 19 | downloads 20 | 21 | 22 | License 23 | 24 | 25 | Powered By LoopBack 4 26 | 27 |

28 | 29 | ## Install 30 | 31 | ```sh 32 | npm install loopback4-soft-delete 33 | ``` 34 | 35 | ## Quick Starter 36 | 37 | For a quick starter guide, you can refer to our [loopback 4 starter](https://github.com/sourcefuse/loopback4-starter) application which utilizes this package for soft-deletes in a multi-tenant application. 38 | 39 | ## Usage 40 | 41 | The package exports following classes and mixins: 42 | 43 | 1. [SoftDeleteEntity](#softdeleteentity) - To add required soft delete props in the model. 44 | 2. [SoftCrudRepository](#softcrudrepository) - Class providing soft crud capabilitiies (to be used in place of `DefaultCrudRepository`). 45 | 3. [SoftCrudRepositoryMixin](#softcrudrepositorymixin) - Mixin accepting any respository that extends the DefaultCrudRepository to add soft delete functionality to. Can be used as a wrapper for `DefaultCrudRepository`, `DefaultTransactionalRepository` etc. 46 | 4. [SoftDeleteEntityMixin](#softdeleteentitymixin) 47 | 5. [DefaultTransactionSoftCrudRepository](#defaulttransactionsoftcrudrepository-deprecated) (Deprecated) - Class providing soft crud capabilitiies. To be used in place of `DefaultTransactionalRepository`. 48 | 6. [SequelizeSoftCrudRepository](#sequelizesoftcrudrepository) - Class providing soft crud capabilitiies for [@loopback/sequelize](https://www.npmjs.com/package/@loopback/sequelize) package. To be used in place of `SequelizeCrudRepository`. 49 | 50 | Following are more details on the usage of above artifcats: 51 | 52 | ### SoftDeleteEntity 53 | 54 | An abstract base class for all models which require soft delete feature. 55 | This class is a wrapper over Entity class from [@loopback/repository](https://github.com/strongloop/loopback-next/tree/master/packages/repository) adding three attributes to the model class for handling soft-delete, namely, deleted, deletedOn, deletedBy. 56 | The column names needed to be there in DB within that table are - 'deleted', 'deleted_on', 'deleted_by'. 57 | If you are using auto-migration of loopback 4, then, you may not need to do anything specific to add this column. 58 | If not, then please add these columns to the DB table. 59 | 60 | ### SequelizeSoftCrudRepository 61 | 62 | An abstract base class providing soft delete capabilities for projects using [@loopback/sequelize](https://www.npmjs.com/package/@loopback/sequelize) package. 63 | All the other workings are similar to [SoftCrudRepository](#softcrudrepository), except it's imported using directory import syntax from `loopback4-soft-delete/sequelize`. 64 | 65 | ### SoftCrudRepository 66 | 67 | An abstract base class for all repositories which require soft delete feature. 68 | This class is going to be the one which handles soft delete operations and ensures soft deleted entries are not returned in responses, However if there is a need to query soft deleted entries as well, there is an options to achieve that and you can use findAll() in place of find() , findOneIncludeSoftDelete() in place of findOne() and findByIdIncludeSoftDelete() in place of findById(), these will give you the responses including soft deleted entries. 69 | This class is a wrapper over DefaultCrudRepository class from [@loopback/repository](https://github.com/strongloop/loopback-next/tree/master/packages/repository). 70 | 71 | ### DefaultTransactionSoftCrudRepository (Deprecated) 72 | 73 | > Note: `DefaultTransactionSoftCrudRepository` is deprecated in favour of [SoftCrudRepositoryMixin](#softcrudrepositorymixin) and will be removed in future releases. 74 | 75 | An abstract base class similar to [SoftCrudRepository](#softcrudrepository) but with transaction support. 76 | 77 | This class is a wrapper over `DefaultTransactionalRepository` class from [@loopback/repository](https://github.com/strongloop/loopback-next/tree/master/packages/repository). 78 | 79 | In order to use this extension in your application, please follow below steps. 80 | 81 | 1. Extend models with SoftDeleteEntity class replacing Entity. Like below: 82 | 83 | ```ts 84 | import {model, property} from '@loopback/repository'; 85 | import {SoftDeleteEntity} from 'loopback4-soft-delete'; 86 | 87 | @model({ 88 | name: 'users', 89 | }) 90 | export class User extends SoftDeleteEntity { 91 | @property({ 92 | type: 'number', 93 | id: true, 94 | }) 95 | id?: number; 96 | 97 | // .... More properties 98 | } 99 | ``` 100 | 101 | 2. Extend repositories with SoftCrudRepository class replacing DefaultCrudRepository. Like below: 102 | 103 | ```ts 104 | import {Getter, inject} from '@loopback/core'; 105 | import {SoftCrudRepository} from 'loopback4-soft-delete'; 106 | import {AuthenticationBindings, IAuthUser} from 'loopback4-authentication'; 107 | 108 | import {PgdbDataSource} from '../datasources'; 109 | import {User, UserRelations} from '../models'; 110 | 111 | export class UserRepository extends SoftCrudRepository< 112 | User, 113 | typeof User.prototype.id, 114 | UserRelations 115 | > { 116 | constructor( 117 | @inject('datasources.pgdb') dataSource: PgdbDataSource, 118 | @inject.getter(AuthenticationBindings.CURRENT_USER, {optional: true}) 119 | protected readonly getCurrentUser: Getter, 120 | ) { 121 | super(User, dataSource, getCurrentUser); 122 | } 123 | } 124 | ``` 125 | 126 | 3. For transaction support, use the `SoftCrudRepositoryMixin` and wrap it around `DefaultTransactionalRepository`. Like below: 127 | 128 | ```ts 129 | import {Getter, inject} from '@loopback/core'; 130 | import {SoftCrudRepository} from 'loopback4-soft-delete'; 131 | import {AuthenticationBindings, IAuthUser} from 'loopback4-authentication'; 132 | 133 | import {PgdbDataSource} from '../datasources'; 134 | import {User, UserRelations} from '../models'; 135 | 136 | export class UserRepository extends SoftCrudRepositoryMixin< 137 | User, 138 | typeof User.prototype.id, 139 | UserRelations 140 | >(DefaultTransactionalRepository) { 141 | constructor( 142 | @inject('datasources.pgdb') dataSource: PgdbDataSource, 143 | @inject.getter(AuthenticationBindings.CURRENT_USER, {optional: true}) 144 | protected readonly getCurrentUser: Getter, 145 | ) { 146 | super(User, dataSource, getCurrentUser); 147 | } 148 | } 149 | ``` 150 | 151 | ## Mixins Usage 152 | 153 | The package also provides the following mixins which can be used for soft delete functionality: 154 | 155 | ### SoftDeleteEntityMixin 156 | 157 | This mixin adds the soft delete properties to your model. The properties added are represented by the [IBaseEntity](#ibaseentity) interface: 158 | 159 | There is also an option to provide config for the `@property` decorator for all these properties. 160 | 161 | Usage of `SoftDeleteEntityMixin` is as follows: 162 | 163 | ```ts 164 | class Item extends Entity { 165 | @property({ 166 | type: 'number', 167 | id: true, 168 | generated: true, 169 | }) 170 | id?: number; 171 | 172 | @property({ 173 | type: 'string', 174 | required: true, 175 | }) 176 | name: string; 177 | 178 | constructor(data?: Partial) { 179 | super(data); 180 | } 181 | } 182 | 183 | @model() 184 | export class ItemSoftDelete extends SoftDeleteEntityMixin(Item, { 185 | deletedBy: { 186 | name: 'deleted_by_userid', 187 | }, 188 | }) {} 189 | ``` 190 | 191 | #### IBaseEntity 192 | 193 | The soft deleted properties added by [SoftDeleteEntityMixin](#softdeleteentitymixin) are represented by `IBaseEntity` interface. 194 | 195 | ```ts 196 | interface IBaseEntity { 197 | deleted?: boolean; 198 | deletedOn?: Date; 199 | deletedBy?: string; 200 | } 201 | ``` 202 | 203 | ### SoftCrudRepositoryMixin 204 | 205 | You can make use of this mixin to get the soft delete functionality for `DefaultCrudRepository` or any respository that extends the `DefaultCrudRepository`. You need to extend your repository with this mixin and provide DefaultCrudRepository (or any repository that extends DefaultCrudRepository) as input. This means that this same mixin can also be used to provide soft delete functionality for DefaultTransactionSoftCrudRepository (as DefaultTransactionSoftCrudRepository extends DefaultCrudRepository). You will have to inject the getter for IAuthUser in the contructor of your repository. 206 | 207 | #### Example: 208 | 209 | ```ts 210 | import {Constructor, Getter, inject} from '@loopback/core'; 211 | import {DefaultCrudRepository} from '@loopback/repository'; 212 | import {AuthenticationBindings, IAuthUser} from 'loopback4-authentication'; 213 | import {SoftCrudRepositoryMixin} from 'loopback4-soft-delete'; 214 | import {TestDataSource} from '../datasources'; 215 | import {ItemSoftDelete, ItemSoftDeleteRelations} from '../models'; 216 | 217 | export class ItemRepository extends SoftCrudRepositoryMixin< 218 | ItemSoftDelete, 219 | typeof ItemSoftDelete.prototype.id, 220 | Constructor< 221 | DefaultCrudRepository< 222 | ItemSoftDelete, 223 | typeof ItemSoftDelete.prototype.id, 224 | ItemSoftDeleteRelations 225 | > 226 | >, 227 | ItemSoftDeleteRelations 228 | >(DefaultCrudRepository) { 229 | constructor( 230 | @inject('datasources.test') dataSource: TestDataSource, 231 | @inject.getter(AuthenticationBindings.CURRENT_USER) 232 | public getCurrentUser: Getter, 233 | ) { 234 | super(ItemSoftDelete, dataSource); 235 | } 236 | } 237 | ``` 238 | 239 | ## Additional Repository Methods 240 | 241 | Following are some additional methods that you can use when working with repositories in your application, either by extending the base repositories provided or by using the [SoftCrudRepositoryMixin](#softcrudrepositorymixin): 242 | 243 | 1. `findAll` - This method is similar to `find`, but it returns entries including soft deleted ones. 244 | 2. `deleteHard` - This method is used to perform a hard delete on a specified entity. 245 | 3. `deleteByIdHard` - This method is used to perform a hard delete of an entity based on the provided ID. 246 | 4. `findByIdIncludeSoftDelete` - This method is similar to `findById`, but it returns the entity even if it is soft deleted. 247 | 5. `deleteAllHard` - This method is used to perform a hard delete of multiple entities based on a specified condition. 248 | 6. `findOneIncludeSoftDelete` - This method is similar to `findOne`, but it returns a single entity even if it is soft deleted. 249 | 7. `countAll` - This method is similar to `count`, but it returns the total count of all entities including soft deleted ones. 250 | 251 | ### deletedBy 252 | 253 | Whenever any entry is deleted using deleteById, delete and deleteAll repository methods, it also sets deletedBy column with a value with user id whoever is logged in currently. Hence it uses a Getter function of IUser type. However, if you want to use some other attribute of user model other than id, you can do it by overriding deletedByIdKey. Here is an example. 254 | 255 | ```ts 256 | import {Getter, inject} from '@loopback/core'; 257 | import {SoftCrudRepository, IUser} from 'loopback4-soft-delete'; 258 | import {AuthenticationBindings} from 'loopback4-authentication'; 259 | 260 | import {PgdbDataSource} from '../datasources'; 261 | import {User, UserRelations} from '../models'; 262 | 263 | export class UserRepository extends SoftCrudRepository< 264 | User, 265 | typeof User.prototype.id, 266 | UserRelations 267 | > { 268 | constructor( 269 | @inject('datasources.pgdb') dataSource: PgdbDataSource, 270 | @inject.getter(AuthenticationBindings.CURRENT_USER, {optional: true}) 271 | protected readonly getCurrentUser: Getter, 272 | ) { 273 | super(User, dataSource, getCurrentUser); 274 | } 275 | } 276 | ``` 277 | 278 | Model class for custom identifier case. Notice the `getIdentifier() method` and `IUser` interface implemented. 279 | 280 | ```ts 281 | @model() 282 | class User extends SoftDeleteEntity implements IUser { 283 | @property({ 284 | id: true, 285 | }) 286 | id: string; 287 | 288 | @property() 289 | username: string; 290 | 291 | getIdentifier() { 292 | return this.username; 293 | } 294 | 295 | constructor(data?: Partial) { 296 | super(data); 297 | } 298 | } 299 | ``` 300 | 301 | ## License 302 | 303 | [MIT](https://github.com/sourcefuse/loopback4-soft-delete/blob/master/LICENSE) 304 | -------------------------------------------------------------------------------- /src/__tests__/unit/sequelize/sequelize-soft-crud.unit.ts: -------------------------------------------------------------------------------- 1 | import {expect} from '@loopback/testlab'; 2 | 3 | import {Getter} from '@loopback/context'; 4 | import { 5 | Entity, 6 | EntityNotFoundError, 7 | Model, 8 | model, 9 | property, 10 | } from '@loopback/repository'; 11 | import {fail} from 'assert'; 12 | import {SoftDeleteEntity} from '../../../models'; 13 | import {IUser} from '../../../types'; 14 | import {SequelizeDataSource} from '@loopback/sequelize'; 15 | import {SequelizeSoftCrudRepository} from '../../../repositories/sequelize/sequelize.soft-crud.repository.base'; 16 | 17 | /** 18 | * A mock up model class 19 | */ 20 | @model() 21 | class Customer extends SoftDeleteEntity { 22 | @property({ 23 | id: true, 24 | }) 25 | id: number; 26 | 27 | @property() 28 | email: string; 29 | } 30 | 31 | @model() 32 | class Customer2 extends SoftDeleteEntity { 33 | @property({ 34 | id: true, 35 | }) 36 | id: number; 37 | 38 | @property() 39 | email: string; 40 | } 41 | 42 | @model() 43 | class User extends Model implements IUser { 44 | @property({ 45 | id: true, 46 | }) 47 | id: string; 48 | 49 | @property() 50 | username: string; 51 | 52 | constructor(data?: Partial) { 53 | super(data); 54 | } 55 | } 56 | 57 | @model() 58 | class UserWithCustomId extends Model implements IUser { 59 | @property({ 60 | id: true, 61 | }) 62 | id: string; 63 | 64 | @property() 65 | username: string; 66 | 67 | getIdentifier() { 68 | return this.username; 69 | } 70 | 71 | constructor(data?: Partial) { 72 | super(data); 73 | } 74 | } 75 | 76 | class CustomerCrudRepo extends SequelizeSoftCrudRepository< 77 | Customer, 78 | typeof Customer.prototype.id, 79 | {} 80 | > { 81 | constructor( 82 | entityClass: typeof Entity & { 83 | prototype: Customer; 84 | }, 85 | dataSource: SequelizeDataSource, 86 | public readonly getCurrentUser: Getter, 87 | ) { 88 | super(entityClass, dataSource, getCurrentUser); 89 | } 90 | } 91 | 92 | class Customer2CrudRepo extends SequelizeSoftCrudRepository< 93 | Customer2, 94 | typeof Customer2.prototype.id, 95 | {} 96 | > { 97 | constructor( 98 | entityClass: typeof Entity & { 99 | prototype: Customer; 100 | }, 101 | dataSource: SequelizeDataSource, 102 | public readonly getCurrentUser: Getter, 103 | ) { 104 | super(entityClass, dataSource, getCurrentUser); 105 | } 106 | } 107 | 108 | describe('SoftCrudRepositoryMixin with Sequelize Repository', () => { 109 | let repo: CustomerCrudRepo; 110 | let repoWithCustomDeletedByKey: Customer2CrudRepo; 111 | const userData: User = new User({ 112 | id: '1', 113 | username: 'test', 114 | }); 115 | 116 | const userData2: UserWithCustomId = new UserWithCustomId({ 117 | id: '2', 118 | username: 'test2', 119 | }); 120 | 121 | before(async () => { 122 | const ds: SequelizeDataSource = new SequelizeDataSource({ 123 | name: 'db', 124 | host: '0.0.0.0', 125 | connector: 'sqlite3', 126 | database: 'database', 127 | file: ':memory:', 128 | }); 129 | await ds.init(); 130 | 131 | repo = new CustomerCrudRepo(Customer, ds, () => Promise.resolve(userData)); 132 | await repo.syncLoadedSequelizeModels({force: true}); 133 | 134 | repoWithCustomDeletedByKey = new Customer2CrudRepo(Customer2, ds, () => 135 | Promise.resolve(userData2), 136 | ); 137 | await repoWithCustomDeletedByKey.syncLoadedSequelizeModels({force: true}); 138 | }); 139 | 140 | afterEach(clearTestData); 141 | 142 | describe('find', () => { 143 | beforeEach(setupTestData); 144 | afterEach(clearTestData); 145 | it('should find non soft deleted entries', async () => { 146 | const customers = await repo.find(); 147 | expect(customers).to.have.length(3); 148 | }); 149 | it('should find non soft deleted entries with and operator', async () => { 150 | const customers = await repo.find({ 151 | where: { 152 | and: [ 153 | { 154 | email: 'john@example.com', 155 | }, 156 | { 157 | id: 1, 158 | }, 159 | ], 160 | }, 161 | }); 162 | expect(customers).to.have.length(1); 163 | }); 164 | it('should not find soft deleted entries with and operator', async () => { 165 | const deletedCustomers = await repo.find({ 166 | where: { 167 | and: [ 168 | { 169 | email: 'alice@example.com', 170 | }, 171 | { 172 | id: 2, 173 | }, 174 | ], 175 | }, 176 | }); 177 | expect(deletedCustomers).to.have.length(0); 178 | }); 179 | it('should find non soft deleted entries with or operator', async () => { 180 | const customers = await repo.find({ 181 | where: { 182 | or: [ 183 | { 184 | email: 'john@example.com', 185 | }, 186 | { 187 | email: 'alice@example.com', 188 | }, 189 | ], 190 | }, 191 | }); 192 | expect(customers).to.have.length(1); 193 | }); 194 | }); 195 | 196 | describe('findAll', () => { 197 | it('should find all entries, soft deleted and otherwise', async () => { 198 | await setupTestData(); 199 | const customers = await repo.findAll(); 200 | expect(customers).to.have.length(4); 201 | }); 202 | }); 203 | 204 | describe('findOne', () => { 205 | beforeEach(setupTestData); 206 | afterEach(clearTestData); 207 | it('should find one non soft deleted entry', async () => { 208 | const customer = await repo.findOne({ 209 | where: { 210 | email: 'john@example.com', 211 | }, 212 | }); 213 | expect(customer).to.have.property('email').equal('john@example.com'); 214 | }); 215 | 216 | it('should find none soft deleted entries', async () => { 217 | const customer = await repo.findOne({ 218 | where: { 219 | email: 'alice@example', 220 | }, 221 | }); 222 | expect(customer).to.be.null(); 223 | }); 224 | 225 | it('should find one non soft deleted entry using and operator', async () => { 226 | const customer = await repo.findOne({ 227 | where: { 228 | and: [ 229 | { 230 | email: 'john@example.com', 231 | }, 232 | { 233 | id: 1, 234 | }, 235 | ], 236 | }, 237 | }); 238 | 239 | expect(customer).to.have.property('email').equal('john@example.com'); 240 | }); 241 | 242 | it('should find none soft deleted entry using and operator', async () => { 243 | const customer = await repo.findOne({ 244 | where: { 245 | and: [ 246 | { 247 | email: 'alice@example.com', 248 | }, 249 | { 250 | id: 3, 251 | }, 252 | ], 253 | }, 254 | }); 255 | 256 | expect(customer).to.be.null(); 257 | }); 258 | 259 | it('should find one non soft deleted entry using or operator', async () => { 260 | const customer = await repo.findOne({ 261 | where: { 262 | or: [ 263 | { 264 | email: 'john@example.com', 265 | }, 266 | { 267 | id: 1, 268 | }, 269 | ], 270 | }, 271 | }); 272 | 273 | expect(customer).to.have.property('email').equal('john@example.com'); 274 | }); 275 | 276 | it('shound find none soft deleted entry using or operator', async () => { 277 | const customer = await repo.findOne({ 278 | where: { 279 | or: [ 280 | { 281 | email: 'alice@example.com', 282 | }, 283 | { 284 | id: 3, 285 | }, 286 | ], 287 | }, 288 | }); 289 | 290 | expect(customer).to.be.null(); 291 | }); 292 | }); 293 | 294 | describe('findOneIncludeSoftDelete', () => { 295 | it('should find one soft deleted entry', async () => { 296 | await repo.create({id: 3, email: 'alice@example.com'}); 297 | await repo.deleteById(3); 298 | const customer = await repo.findOneIncludeSoftDelete({ 299 | where: { 300 | email: 'alice@example.com', 301 | }, 302 | }); 303 | 304 | expect(customer).to.have.property('email').equal('alice@example.com'); 305 | }); 306 | }); 307 | 308 | describe('findById', () => { 309 | beforeEach(setupTestData); 310 | afterEach(clearTestData); 311 | 312 | it('should find one non soft deleted entry by id', async () => { 313 | const customer = await repo.findById(1); 314 | expect(customer).to.have.property('email').equal('john@example.com'); 315 | }); 316 | 317 | it('should reject on finding soft deleted entry by id', async () => { 318 | try { 319 | await repo.findById(3); 320 | fail(); 321 | } catch (e) { 322 | expect(e.message).to.be.equal('EntityNotFound'); 323 | } 324 | }); 325 | 326 | it('should find one non soft deleted entry by id, using and operator', async () => { 327 | const customer = await repo.findById(1, { 328 | where: { 329 | and: [{email: 'john@example.com'}, {id: 1}], 330 | }, 331 | }); 332 | expect(customer).to.have.property('email').equal('john@example.com'); 333 | }); 334 | 335 | it('should find no soft deleted entry by id, using and operator', async () => { 336 | try { 337 | await repo.findById(3, { 338 | where: { 339 | and: [{email: 'alice@example.com'}, {id: 3}], 340 | }, 341 | }); 342 | fail(); 343 | } catch (e) { 344 | expect(e.message).to.be.equal('EntityNotFound'); 345 | } 346 | }); 347 | 348 | it('should find one non soft deleted entry by id, using or operator', async () => { 349 | const customer = await repo.findById(1, { 350 | where: { 351 | or: [{email: 'john@example.com'}, {id: 1}], 352 | }, 353 | }); 354 | expect(customer).to.have.property('email').equal('john@example.com'); 355 | }); 356 | 357 | it('should find no soft entry by id, using or operator', async () => { 358 | try { 359 | await repo.findById(3, { 360 | where: { 361 | or: [{email: 'alice@example.com'}, {id: 3}], 362 | }, 363 | }); 364 | fail(); 365 | } catch (e) { 366 | expect(e.message).to.be.equal('EntityNotFound'); 367 | } 368 | }); 369 | it('should not return soft deleted entry by id, without using deleted in fields filter', async () => { 370 | try { 371 | await repo.findById(3, { 372 | fields: { 373 | id: true, 374 | email: true, 375 | }, 376 | }); 377 | fail(); 378 | } catch (e) { 379 | expect(e.message).to.be.equal('EntityNotFound'); 380 | } 381 | }); 382 | it('should not return soft deleted entry by id, without using deleted in fields filter(fields fileter is passed as array)', async () => { 383 | try { 384 | await repo.findById(3, { 385 | fields: ['id', 'email'], 386 | }); 387 | fail(); 388 | } catch (e) { 389 | expect(e.message).to.be.equal('EntityNotFound'); 390 | } 391 | }); 392 | it('should return requested fields only when not using deleted in fields filter', async () => { 393 | const customer = await repo.findById(4, { 394 | fields: { 395 | id: true, 396 | email: true, 397 | }, 398 | }); 399 | expect(customer).to.not.have.property('deleted'); 400 | }); 401 | it('should return requested fields matched with fields filter', async () => { 402 | const customer = await repo.findById(4, { 403 | fields: { 404 | id: true, 405 | email: true, 406 | deleted: true, 407 | }, 408 | }); 409 | expect(customer).to.have.property('deleted'); 410 | }); 411 | it('should return requested fields only when not using deleted in fields filter array', async () => { 412 | const customer = await repo.findById(4, { 413 | fields: ['id', 'email'], 414 | }); 415 | expect(customer).to.not.have.property('deleted'); 416 | }); 417 | it('should return requested fields matched with fields filter array', async () => { 418 | const customer = await repo.findById(4, { 419 | fields: ['id', 'email', 'deleted'], 420 | }); 421 | expect(customer).to.have.property('deleted'); 422 | }); 423 | }); 424 | 425 | describe('findByIdIncludeSoftDelete', () => { 426 | beforeEach(setupTestData); 427 | afterEach(clearTestData); 428 | it('should find one by id', async () => { 429 | const customer = await repo.findByIdIncludeSoftDelete(1); 430 | expect(customer).to.have.property('email').equal('john@example.com'); 431 | }); 432 | it('should find one by id even if soft deleted', async () => { 433 | const customer = await repo.findByIdIncludeSoftDelete(3); 434 | expect(customer).to.have.property('email').equal('alice@example.com'); 435 | }); 436 | it('should find one by id with and operator', async () => { 437 | const customer = await repo.findByIdIncludeSoftDelete(1, { 438 | where: { 439 | and: [ 440 | { 441 | email: 'john@example.com', 442 | }, 443 | { 444 | id: 1, 445 | }, 446 | ], 447 | }, 448 | }); 449 | expect(customer).to.have.property('email').equal('john@example.com'); 450 | }); 451 | 452 | it('should find one soft deleted entry by id with and operator', async () => { 453 | const customer = await repo.findByIdIncludeSoftDelete(3, { 454 | where: { 455 | and: [ 456 | { 457 | email: 'alice@example.com', 458 | }, 459 | { 460 | id: 3, 461 | }, 462 | ], 463 | }, 464 | }); 465 | expect(customer).to.have.property('email').equal('alice@example.com'); 466 | }); 467 | 468 | it('should find one by id with or operator', async () => { 469 | const customer = await repo.findByIdIncludeSoftDelete(1, { 470 | where: { 471 | or: [ 472 | { 473 | email: 'john@example.com', 474 | }, 475 | { 476 | id: 1, 477 | }, 478 | ], 479 | }, 480 | }); 481 | expect(customer).to.have.property('email').equal('john@example.com'); 482 | }); 483 | 484 | it('should find one soft deleted entry by id with or operator', async () => { 485 | const customer = await repo.findByIdIncludeSoftDelete(3, { 486 | where: { 487 | or: [ 488 | { 489 | email: 'alice@example.com', 490 | }, 491 | { 492 | id: 3, 493 | }, 494 | ], 495 | }, 496 | }); 497 | expect(customer).to.have.property('email').equal('alice@example.com'); 498 | }); 499 | }); 500 | 501 | describe('update', () => { 502 | beforeEach(async () => { 503 | await repo.create({id: 1, email: 'john@example.com'}); 504 | await repo.create({id: 2, email: 'mary@example.com'}); 505 | await repo.create({id: 3, email: 'alice@example.com'}); 506 | await repo.create({id: 4, email: 'bob@example.com'}); 507 | await repo.deleteById(3); 508 | }); 509 | afterEach(async () => { 510 | await repo.deleteAllHard(); 511 | }); 512 | it('should update non soft deleted entries', async () => { 513 | const customers = await repo.updateAll( 514 | { 515 | email: 'johnupdated@example', 516 | }, 517 | { 518 | id: 1, 519 | }, 520 | ); 521 | expect(customers.count).to.eql(1); 522 | }); 523 | it('should update non soft deleted entries with and operator', async () => { 524 | const customers = await repo.updateAll( 525 | { 526 | email: 'johnupdated@example', 527 | }, 528 | { 529 | and: [ 530 | { 531 | email: 'john@example.com', 532 | }, 533 | { 534 | id: 1, 535 | }, 536 | ], 537 | }, 538 | ); 539 | expect(customers.count).to.eql(1); 540 | const deletedCustomers = await repo.updateAll( 541 | { 542 | email: 'aliceupdated@example', 543 | }, 544 | { 545 | and: [ 546 | { 547 | email: 'alice@example.com', 548 | }, 549 | { 550 | id: 2, 551 | }, 552 | ], 553 | }, 554 | ); 555 | expect(deletedCustomers.count).to.eql(0); 556 | }); 557 | it('should update non soft deleted entries with or operator', async () => { 558 | const customers = await repo.updateAll( 559 | { 560 | email: 'updated@example.com', 561 | }, 562 | { 563 | or: [ 564 | { 565 | email: 'john@example.com', 566 | }, 567 | { 568 | email: 'alice@example.com', 569 | }, 570 | ], 571 | }, 572 | ); 573 | expect(customers.count).to.eql(1); 574 | }); 575 | }); 576 | 577 | describe('count', () => { 578 | beforeEach(setupTestData); 579 | afterEach(clearTestData); 580 | it('should return count for non soft deleted entries', async () => { 581 | const count = await repo.count({ 582 | email: 'john@example.com', 583 | }); 584 | expect(count.count).to.be.equal(1); 585 | }); 586 | it('should return zero count for soft deleted entries', async () => { 587 | const count = await repo.count({ 588 | email: 'alice@example.com', 589 | }); 590 | expect(count.count).to.be.equal(0); 591 | }); 592 | it('should return count for non soft deleted entries with and operator', async () => { 593 | const count = await repo.count({ 594 | and: [ 595 | { 596 | email: 'john@example.com', 597 | }, 598 | { 599 | id: 1, 600 | }, 601 | ], 602 | }); 603 | expect(count.count).to.be.equal(1); 604 | }); 605 | it('should return zero count for soft deleted entries with and operator', async () => { 606 | const count = await repo.count({ 607 | and: [ 608 | { 609 | email: 'alice@example.com', 610 | }, 611 | { 612 | id: 3, 613 | }, 614 | ], 615 | }); 616 | expect(count.count).to.be.equal(0); 617 | }); 618 | it('should return count for non soft deleted entries with or operator', async () => { 619 | const count = await repo.count({ 620 | or: [{email: 'john@example.com'}, {id: 1}], 621 | }); 622 | expect(count.count).to.be.equal(1); 623 | }); 624 | it('should return zero for soft deleted entries with or operator', async () => { 625 | const count = await repo.count({ 626 | or: [{email: 'alice@example.com'}, {id: 3}], 627 | }); 628 | expect(count.count).to.be.equal(0); 629 | }); 630 | }); 631 | 632 | describe('countAll', () => { 633 | beforeEach(setupTestData); 634 | afterEach(clearTestData); 635 | it('should return total count when no conditions are passed', async () => { 636 | const count = await repo.countAll(); 637 | expect(count.count).to.be.equal(4); 638 | }); 639 | it('should return count for soft deleted entries when conditions specified', async () => { 640 | const count = await repo.countAll({ 641 | email: 'alice@example.com', 642 | }); 643 | expect(count.count).to.be.equal(1); 644 | }); 645 | }); 646 | 647 | describe('deleteById', () => { 648 | beforeEach(setupTestData); 649 | afterEach(clearTestData); 650 | 651 | it('should soft delete entries', async () => { 652 | await repo.deleteById(1); 653 | try { 654 | await repo.findById(1); 655 | fail(); 656 | } catch (e) { 657 | expect(e.message).to.be.equal('EntityNotFound'); 658 | } 659 | const afterDeleteIncludeSoftDeleted = 660 | await repo.findByIdIncludeSoftDelete(1); 661 | expect(afterDeleteIncludeSoftDeleted) 662 | .to.have.property('email') 663 | .equal('john@example.com'); 664 | }); 665 | 666 | it('should soft delete entries with deletedBy set to id', async () => { 667 | await repo.deleteById(1); 668 | try { 669 | await repo.findById(1); 670 | fail(); 671 | } catch (e) { 672 | expect(e.message).to.be.equal('EntityNotFound'); 673 | } 674 | const afterDeleteIncludeSoftDeleted = 675 | await repo.findByIdIncludeSoftDelete(1); 676 | expect(afterDeleteIncludeSoftDeleted) 677 | .to.have.property('deletedBy') 678 | .equal(userData.id); 679 | }); 680 | 681 | it('should soft delete entries with deletedBy set to custom key provided', async () => { 682 | await repoWithCustomDeletedByKey.deleteById(1); 683 | try { 684 | await repoWithCustomDeletedByKey.findById(1); 685 | fail(); 686 | } catch (e) { 687 | expect(e.message).to.be.equal('EntityNotFound'); 688 | } 689 | const afterDeleteIncludeSoftDeleted = 690 | await repoWithCustomDeletedByKey.findByIdIncludeSoftDelete(1); 691 | expect(afterDeleteIncludeSoftDeleted) 692 | .to.have.property('deletedBy') 693 | .equal(userData2.username); 694 | }); 695 | }); 696 | 697 | describe('delete', () => { 698 | beforeEach(setupTestData); 699 | afterEach(clearTestData); 700 | 701 | it('should soft delete entries', async () => { 702 | const entity = await repo.findById(1); 703 | await repo.delete(entity); 704 | try { 705 | await repo.findById(1); 706 | fail(); 707 | } catch (e) { 708 | expect(e.message).to.be.equal('EntityNotFound'); 709 | } 710 | const afterDeleteIncludeSoftDeleted = 711 | await repo.findByIdIncludeSoftDelete(1); 712 | expect(afterDeleteIncludeSoftDeleted) 713 | .to.have.property('email') 714 | .equal('john@example.com'); 715 | }); 716 | 717 | it('should soft delete entries with deletedBy set to id', async () => { 718 | const entity = await repo.findById(1); 719 | await repo.delete(entity); 720 | try { 721 | await repo.findById(1); 722 | fail(); 723 | } catch (e) { 724 | expect(e.message).to.be.equal('EntityNotFound'); 725 | } 726 | const afterDeleteIncludeSoftDeleted = 727 | await repo.findByIdIncludeSoftDelete(1); 728 | expect(afterDeleteIncludeSoftDeleted) 729 | .to.have.property('deletedBy') 730 | .equal(userData.id); 731 | }); 732 | 733 | it('should soft delete entries with deletedBy set to custom key provided', async () => { 734 | const entity = await repoWithCustomDeletedByKey.findById(1); 735 | await repoWithCustomDeletedByKey.delete(entity); 736 | try { 737 | await repoWithCustomDeletedByKey.findById(1); 738 | fail(); 739 | } catch (e) { 740 | expect(e.message).to.be.equal('EntityNotFound'); 741 | } 742 | const afterDeleteIncludeSoftDeleted = 743 | await repoWithCustomDeletedByKey.findByIdIncludeSoftDelete(1); 744 | expect(afterDeleteIncludeSoftDeleted) 745 | .to.have.property('deletedBy') 746 | .equal(userData2.username); 747 | }); 748 | }); 749 | 750 | describe('deleteAll', () => { 751 | beforeEach(setupTestData); 752 | afterEach(clearTestData); 753 | 754 | it('should soft delete all entries', async () => { 755 | await repo.deleteAll(); 756 | const customers = await repo.find(); 757 | expect(customers).to.have.length(0); 758 | const afterDeleteAll = await repo.findAll(); 759 | expect(afterDeleteAll).to.have.length(4); 760 | }); 761 | 762 | it('should soft delete entries with deletedBy set to id', async () => { 763 | await repo.deleteAll(); 764 | const customers = await repo.find(); 765 | expect(customers).to.have.length(0); 766 | const afterDeleteAll = await repo.findAll(); 767 | expect(afterDeleteAll).to.have.length(4); 768 | afterDeleteAll.forEach(rec => { 769 | expect(rec).to.have.property('deletedBy').equal(userData.id); 770 | }); 771 | }); 772 | 773 | it('should soft delete entries with deletedBy set to custom key provided', async () => { 774 | await repoWithCustomDeletedByKey.deleteAll(); 775 | const customers = await repoWithCustomDeletedByKey.find(); 776 | expect(customers).to.have.length(0); 777 | const afterDeleteAll = await repoWithCustomDeletedByKey.findAll(); 778 | expect(afterDeleteAll).to.have.length(4); 779 | afterDeleteAll.forEach(rec => { 780 | expect(rec).to.have.property('deletedBy').equal(userData2.username); 781 | }); 782 | }); 783 | }); 784 | 785 | describe('deleteHard', () => { 786 | beforeEach(setupTestData); 787 | afterEach(clearTestData); 788 | it('should hard delete an entry', async () => { 789 | const customer = await repo.findById(1); 790 | await repo.deleteHard(customer); 791 | try { 792 | await repo.findByIdIncludeSoftDelete(1); 793 | fail(); 794 | } catch (e) { 795 | expect(e).to.be.instanceOf(EntityNotFoundError); 796 | } 797 | }); 798 | }); 799 | 800 | describe('deleteByIdHard', () => { 801 | beforeEach(setupTestData); 802 | afterEach(clearTestData); 803 | it('should hard delete an entry by id', async () => { 804 | await repo.deleteByIdHard(1); 805 | try { 806 | await repo.findByIdIncludeSoftDelete(1); 807 | fail(); 808 | } catch (e) { 809 | expect(e).to.be.instanceOf(EntityNotFoundError); 810 | } 811 | }); 812 | }); 813 | 814 | async function setupTestData() { 815 | await repo.create({id: 1, email: 'john@example.com'}); 816 | await repo.create({id: 2, email: 'mary@example.com'}); 817 | await repo.create({id: 3, email: 'alice@example.com'}); 818 | await repo.create({id: 4, email: 'bob@example.com'}); 819 | await repo.deleteById(3); 820 | 821 | await repoWithCustomDeletedByKey.create({id: 1, email: 'john@example.com'}); 822 | await repoWithCustomDeletedByKey.create({id: 2, email: 'mary@example.com'}); 823 | await repoWithCustomDeletedByKey.create({ 824 | id: 3, 825 | email: 'alice@example.com', 826 | }); 827 | await repoWithCustomDeletedByKey.create({id: 4, email: 'bob@example.com'}); 828 | await repoWithCustomDeletedByKey.deleteById(3); 829 | } 830 | 831 | async function clearTestData() { 832 | await repo.deleteAllHard(); 833 | await repoWithCustomDeletedByKey.deleteAllHard(); 834 | } 835 | }); 836 | -------------------------------------------------------------------------------- /src/__tests__/unit/repository/default-transaction-soft-crud.repository.base.ts: -------------------------------------------------------------------------------- 1 | // DEVELOPMENT NOTE: 2 | // Please ensure that any modifications made to this file are also applied to the following locations: 3 | // 1) src/__tests__/unit/mixin/soft-crud.mixin.unit.ts 4 | // 2) src/__tests__/unit/repository/soft-crud.repository.unit.ts 5 | 6 | import {expect} from '@loopback/testlab'; 7 | 8 | import {Getter} from '@loopback/context'; 9 | import { 10 | Entity, 11 | EntityNotFoundError, 12 | juggler, 13 | Model, 14 | model, 15 | property, 16 | } from '@loopback/repository'; 17 | import {fail} from 'assert'; 18 | import {SoftDeleteEntity} from '../../../models'; 19 | import {DefaultTransactionSoftCrudRepository} from '../../../repositories'; 20 | import {IUser} from '../../../types'; 21 | 22 | /** 23 | * A mock up model class 24 | */ 25 | @model() 26 | class Customer extends SoftDeleteEntity { 27 | @property({ 28 | id: true, 29 | }) 30 | id: number; 31 | @property() 32 | email: string; 33 | } 34 | 35 | @model() 36 | class Customer2 extends SoftDeleteEntity { 37 | @property({ 38 | id: true, 39 | }) 40 | id: number; 41 | @property() 42 | email: string; 43 | } 44 | 45 | @model() 46 | class User extends Model implements IUser { 47 | @property({ 48 | id: true, 49 | }) 50 | id: string; 51 | 52 | @property() 53 | username: string; 54 | 55 | constructor(data?: Partial) { 56 | super(data); 57 | } 58 | } 59 | 60 | @model() 61 | class UserWithCustomId extends Model implements IUser { 62 | @property({ 63 | id: true, 64 | }) 65 | id: string; 66 | 67 | @property() 68 | username: string; 69 | 70 | getIdentifier() { 71 | return this.username; 72 | } 73 | 74 | constructor(data?: Partial) { 75 | super(data); 76 | } 77 | } 78 | 79 | class CustomerCrudRepo extends DefaultTransactionSoftCrudRepository< 80 | Customer, 81 | number 82 | > { 83 | constructor( 84 | entityClass: typeof Entity & { 85 | prototype: Customer; 86 | }, 87 | dataSource: juggler.DataSource, 88 | protected readonly getCurrentUser?: Getter, 89 | ) { 90 | super(entityClass, dataSource, getCurrentUser); 91 | } 92 | } 93 | 94 | class Customer2CrudRepo extends DefaultTransactionSoftCrudRepository< 95 | Customer2, 96 | number 97 | > { 98 | constructor( 99 | entityClass: typeof Entity & { 100 | prototype: Customer2; 101 | }, 102 | dataSource: juggler.DataSource, 103 | protected readonly getCurrentUser?: Getter, 104 | ) { 105 | super(entityClass, dataSource, getCurrentUser); 106 | } 107 | } 108 | 109 | describe('DefaultTransactionSoftCrudRepository', () => { 110 | let repo: CustomerCrudRepo; 111 | let repoWithCustomDeletedByKey: Customer2CrudRepo; 112 | const userData: User = new User({ 113 | id: '1', 114 | username: 'test', 115 | }); 116 | 117 | const userData2: UserWithCustomId = new UserWithCustomId({ 118 | id: '2', 119 | username: 'test2', 120 | }); 121 | 122 | before(() => { 123 | const ds: juggler.DataSource = new juggler.DataSource({ 124 | name: 'db', 125 | connector: 'memory', 126 | }); 127 | repo = new CustomerCrudRepo(Customer, ds, () => Promise.resolve(userData)); 128 | repoWithCustomDeletedByKey = new Customer2CrudRepo(Customer2, ds, () => 129 | Promise.resolve(userData2), 130 | ); 131 | }); 132 | 133 | afterEach(clearTestData); 134 | 135 | describe('find', () => { 136 | beforeEach(setupTestData); 137 | afterEach(clearTestData); 138 | it('should find non soft deleted entries', async () => { 139 | const customers = await repo.find(); 140 | expect(customers).to.have.length(3); 141 | }); 142 | it('should find non soft deleted entries with and operator', async () => { 143 | const customers = await repo.find({ 144 | where: { 145 | and: [ 146 | { 147 | email: 'john@example.com', 148 | }, 149 | { 150 | id: 1, 151 | }, 152 | ], 153 | }, 154 | }); 155 | expect(customers).to.have.length(1); 156 | }); 157 | it('should not find soft deleted entries with and operator', async () => { 158 | const deletedCustomers = await repo.find({ 159 | where: { 160 | and: [ 161 | { 162 | email: 'alice@example.com', 163 | }, 164 | { 165 | id: 2, 166 | }, 167 | ], 168 | }, 169 | }); 170 | expect(deletedCustomers).to.have.length(0); 171 | }); 172 | 173 | it('should find non soft deleted entries when deleted fields are not included', async () => { 174 | const customers = await repo.find({ 175 | fields: {deleted: false, deletedBy: false, deletedOn: false}, 176 | }); 177 | expect(customers).to.have.length(3); 178 | }); 179 | 180 | it('should find non soft deleted entries when deleted fields are included', async () => { 181 | const customers = await repo.find({ 182 | fields: {deleted: true}, 183 | }); 184 | expect(customers).to.have.length(3); 185 | }); 186 | 187 | it('should find non soft deleted entries with or operator', async () => { 188 | const customers = await repo.find({ 189 | where: { 190 | or: [ 191 | { 192 | email: 'john@example.com', 193 | }, 194 | { 195 | email: 'alice@example.com', 196 | }, 197 | ], 198 | }, 199 | }); 200 | expect(customers).to.have.length(1); 201 | }); 202 | }); 203 | 204 | describe('findAll', () => { 205 | it('should find all entries, soft deleted and otherwise', async () => { 206 | await setupTestData(); 207 | const customers = await repo.findAll(); 208 | expect(customers).to.have.length(4); 209 | }); 210 | 211 | it('should find all entries when deleted fields are not included', async () => { 212 | await setupTestData(); 213 | const customers = await repo.findAll({ 214 | fields: {deleted: false, deletedBy: false, deletedOn: false}, 215 | }); 216 | expect(customers).to.have.length(4); 217 | }); 218 | }); 219 | 220 | describe('findOne', () => { 221 | beforeEach(setupTestData); 222 | afterEach(clearTestData); 223 | it('should find one non soft deleted entry', async () => { 224 | const customer = await repo.findOne({ 225 | where: { 226 | email: 'john@example.com', 227 | }, 228 | }); 229 | expect(customer).to.have.property('email').equal('john@example.com'); 230 | }); 231 | 232 | it('should find none soft deleted entries', async () => { 233 | const customer = await repo.findOne({ 234 | where: { 235 | email: 'alice@example', 236 | }, 237 | }); 238 | expect(customer).to.be.null(); 239 | }); 240 | 241 | it('should find one non soft deleted entry using and operator', async () => { 242 | const customer = await repo.findOne({ 243 | where: { 244 | and: [ 245 | { 246 | email: 'john@example.com', 247 | }, 248 | { 249 | id: 1, 250 | }, 251 | ], 252 | }, 253 | }); 254 | 255 | expect(customer).to.have.property('email').equal('john@example.com'); 256 | }); 257 | 258 | it('should find none soft deleted entry using and operator', async () => { 259 | const customer = await repo.findOne({ 260 | where: { 261 | and: [ 262 | { 263 | email: 'alice@example.com', 264 | }, 265 | { 266 | id: 3, 267 | }, 268 | ], 269 | }, 270 | }); 271 | 272 | expect(customer).to.be.null(); 273 | }); 274 | 275 | it('should find one non soft deleted entry using or operator', async () => { 276 | const customer = await repo.findOne({ 277 | where: { 278 | or: [ 279 | { 280 | email: 'john@example.com', 281 | }, 282 | { 283 | id: 1, 284 | }, 285 | ], 286 | }, 287 | }); 288 | 289 | expect(customer).to.have.property('email').equal('john@example.com'); 290 | }); 291 | 292 | it('should find one soft deleted entry even when `deleted` field is not included', async () => { 293 | const customer = await repo.findOne({ 294 | where: { 295 | id: 4, 296 | }, 297 | fields: {deleted: false, deletedBy: false, deletedOn: false}, 298 | }); 299 | 300 | expect(customer?.id).to.have.be.eql(4); 301 | }); 302 | 303 | it('shound find none soft deleted entry using or operator', async () => { 304 | const customer = await repo.findOne({ 305 | where: { 306 | or: [ 307 | { 308 | email: 'alice@example.com', 309 | }, 310 | { 311 | id: 3, 312 | }, 313 | ], 314 | }, 315 | }); 316 | 317 | expect(customer).to.be.null(); 318 | }); 319 | }); 320 | 321 | describe('findOneIncludeSoftDelete', () => { 322 | it('should find one soft deleted entry', async () => { 323 | await repo.create({id: 3, email: 'alice@example.com'}); 324 | await repo.deleteById(3); 325 | const customer = await repo.findOneIncludeSoftDelete({ 326 | where: { 327 | email: 'alice@example.com', 328 | }, 329 | }); 330 | 331 | expect(customer).to.have.property('email').equal('alice@example.com'); 332 | }); 333 | }); 334 | 335 | describe('findById', () => { 336 | beforeEach(setupTestData); 337 | afterEach(clearTestData); 338 | 339 | it('should find one non soft deleted entry by id', async () => { 340 | const customer = await repo.findById(1); 341 | expect(customer).to.have.property('email').equal('john@example.com'); 342 | }); 343 | 344 | it('should reject on finding soft deleted entry by id', async () => { 345 | try { 346 | await repo.findById(3); 347 | fail(); 348 | } catch (e) { 349 | expect(e.message).to.be.equal('EntityNotFound'); 350 | } 351 | }); 352 | 353 | it('should find one non soft deleted entry by id, using and operator', async () => { 354 | const customer = await repo.findById(1, { 355 | where: { 356 | and: [{email: 'john@example.com'}, {id: 1}], 357 | }, 358 | }); 359 | expect(customer).to.have.property('email').equal('john@example.com'); 360 | }); 361 | 362 | it('should find no soft deleted entry by id, using and operator', async () => { 363 | try { 364 | await repo.findById(3, { 365 | where: { 366 | and: [{email: 'alice@example.com'}, {id: 3}], 367 | }, 368 | }); 369 | fail(); 370 | } catch (e) { 371 | expect(e.message).to.be.equal('EntityNotFound'); 372 | } 373 | }); 374 | 375 | it('should find one non soft deleted entry by id, using or operator', async () => { 376 | const customer = await repo.findById(1, { 377 | where: { 378 | or: [{email: 'john@example.com'}, {id: 1}], 379 | }, 380 | }); 381 | expect(customer).to.have.property('email').equal('john@example.com'); 382 | }); 383 | 384 | it('should find no soft entry by id, using or operator', async () => { 385 | try { 386 | await repo.findById(3, { 387 | where: { 388 | or: [{email: 'alice@example.com'}, {id: 3}], 389 | }, 390 | }); 391 | fail(); 392 | } catch (e) { 393 | expect(e.message).to.be.equal('EntityNotFound'); 394 | } 395 | }); 396 | it('should not return soft deleted entry by id when using fields filter without including deleted column', async () => { 397 | try { 398 | await repo.findById(3, { 399 | fields: { 400 | id: true, 401 | email: true, 402 | }, 403 | }); 404 | fail(); 405 | } catch (e) { 406 | expect(e.message).to.be.equal('EntityNotFound'); 407 | } 408 | }); 409 | it('should not return soft deleted entry by id, without using deleted in fields filter(fields fileter is passed as array)', async () => { 410 | try { 411 | await repo.findById(3, { 412 | fields: ['id', 'email'], 413 | }); 414 | fail(); 415 | } catch (e) { 416 | expect(e.message).to.be.equal('EntityNotFound'); 417 | } 418 | }); 419 | it('should return requested fields only when not using deleted in fields filter', async () => { 420 | const customer = await repo.findById(4, { 421 | fields: { 422 | id: true, 423 | email: true, 424 | }, 425 | }); 426 | const customer2 = await repo.findById(4, { 427 | fields: ['id', 'email'], 428 | }); 429 | expect(customer.deleted).to.be.undefined(); 430 | expect(customer2.deleted).to.be.undefined(); 431 | }); 432 | it('should return requested fields matched with fields filter', async () => { 433 | const customer = await repo.findById(4, { 434 | fields: { 435 | id: true, 436 | email: true, 437 | deleted: true, 438 | }, 439 | }); 440 | expect(customer).to.have.property('deleted'); 441 | }); 442 | 443 | it('should return non soft deleted entry even if deleted column is not included in fields', async () => { 444 | const customer = await repo.findById(4, { 445 | fields: { 446 | deleted: false, 447 | deletedBy: false, 448 | deletedOn: false, 449 | }, 450 | }); 451 | 452 | expect(customer.id).to.be.eql(4); 453 | expect(customer.email).to.be.eql('bob@example.com'); 454 | expect(customer.deleted).to.be.undefined(); 455 | }); 456 | 457 | it('should return requested fields only when not using deleted in fields filter array', async () => { 458 | const customer = await repo.findById(4, { 459 | fields: ['id', 'email'], 460 | }); 461 | 462 | expect(customer.id).to.be.eql(4); 463 | expect(customer.deleted).to.be.undefined(); 464 | }); 465 | 466 | it('should return requested fields matched with fields filter array', async () => { 467 | const customer = await repo.findById(4, { 468 | fields: ['id', 'email', 'deleted'], 469 | }); 470 | expect(customer.id).to.be.eql(4); 471 | expect(customer.email).to.be.eql('bob@example.com'); 472 | expect(customer.deleted).to.be.false(); 473 | expect(customer.deletedBy).to.be.undefined(); 474 | }); 475 | }); 476 | 477 | describe('findByIdIncludeSoftDelete', () => { 478 | beforeEach(setupTestData); 479 | afterEach(clearTestData); 480 | it('should find one by id', async () => { 481 | const customer = await repo.findByIdIncludeSoftDelete(1); 482 | expect(customer).to.have.property('email').equal('john@example.com'); 483 | }); 484 | it('should find one by id even if soft deleted', async () => { 485 | const customer = await repo.findByIdIncludeSoftDelete(3); 486 | expect(customer).to.have.property('email').equal('alice@example.com'); 487 | }); 488 | it('should find one by id with and operator', async () => { 489 | const customer = await repo.findByIdIncludeSoftDelete(1, { 490 | where: { 491 | and: [ 492 | { 493 | email: 'john@example.com', 494 | }, 495 | { 496 | id: 1, 497 | }, 498 | ], 499 | }, 500 | }); 501 | expect(customer).to.have.property('email').equal('john@example.com'); 502 | }); 503 | 504 | it('should find one soft deleted entry by id with and operator', async () => { 505 | const customer = await repo.findByIdIncludeSoftDelete(3, { 506 | where: { 507 | and: [ 508 | { 509 | email: 'alice@example.com', 510 | }, 511 | { 512 | id: 3, 513 | }, 514 | ], 515 | }, 516 | }); 517 | expect(customer).to.have.property('email').equal('alice@example.com'); 518 | }); 519 | 520 | it('should find one by id with or operator', async () => { 521 | const customer = await repo.findByIdIncludeSoftDelete(1, { 522 | where: { 523 | or: [ 524 | { 525 | email: 'john@example.com', 526 | }, 527 | { 528 | id: 1, 529 | }, 530 | ], 531 | }, 532 | }); 533 | expect(customer).to.have.property('email').equal('john@example.com'); 534 | }); 535 | 536 | it('should find one soft deleted entry by id with or operator', async () => { 537 | const customer = await repo.findByIdIncludeSoftDelete(3, { 538 | where: { 539 | or: [ 540 | { 541 | email: 'alice@example.com', 542 | }, 543 | { 544 | id: 3, 545 | }, 546 | ], 547 | }, 548 | }); 549 | expect(customer).to.have.property('email').equal('alice@example.com'); 550 | }); 551 | }); 552 | 553 | describe('update', () => { 554 | beforeEach(async () => { 555 | await repo.create({id: 1, email: 'john@example.com'}); 556 | await repo.create({id: 2, email: 'mary@example.com'}); 557 | await repo.create({id: 3, email: 'alice@example.com'}); 558 | await repo.create({id: 4, email: 'bob@example.com'}); 559 | await repo.deleteById(3); 560 | }); 561 | afterEach(async () => { 562 | await repo.deleteAllHard(); 563 | }); 564 | it('should update non soft deleted entries', async () => { 565 | const customers = await repo.updateAll( 566 | { 567 | email: 'johnupdated@example', 568 | }, 569 | { 570 | id: 1, 571 | }, 572 | ); 573 | expect(customers.count).to.eql(1); 574 | }); 575 | it('should update non soft deleted entries with and operator', async () => { 576 | const customers = await repo.updateAll( 577 | { 578 | email: 'johnupdated@example', 579 | }, 580 | { 581 | and: [ 582 | { 583 | email: 'john@example.com', 584 | }, 585 | { 586 | id: 1, 587 | }, 588 | ], 589 | }, 590 | ); 591 | expect(customers.count).to.eql(1); 592 | const deletedCustomers = await repo.updateAll( 593 | { 594 | email: 'aliceupdated@example', 595 | }, 596 | { 597 | and: [ 598 | { 599 | email: 'alice@example.com', 600 | }, 601 | { 602 | id: 2, 603 | }, 604 | ], 605 | }, 606 | ); 607 | expect(deletedCustomers.count).to.eql(0); 608 | }); 609 | it('should update non soft deleted entries with or operator', async () => { 610 | const customers = await repo.updateAll( 611 | { 612 | email: 'updated@example.com', 613 | }, 614 | { 615 | or: [ 616 | { 617 | email: 'john@example.com', 618 | }, 619 | { 620 | email: 'alice@example.com', 621 | }, 622 | ], 623 | }, 624 | ); 625 | expect(customers.count).to.eql(1); 626 | }); 627 | }); 628 | 629 | describe('count', () => { 630 | beforeEach(setupTestData); 631 | afterEach(clearTestData); 632 | it('should return count for non soft deleted entries', async () => { 633 | const count = await repo.count({ 634 | email: 'john@example.com', 635 | }); 636 | expect(count.count).to.be.equal(1); 637 | }); 638 | it('should return zero count for soft deleted entries', async () => { 639 | const count = await repo.count({ 640 | email: 'alice@example.com', 641 | }); 642 | expect(count.count).to.be.equal(0); 643 | }); 644 | it('should return count for non soft deleted entries with and operator', async () => { 645 | const count = await repo.count({ 646 | and: [ 647 | { 648 | email: 'john@example.com', 649 | }, 650 | { 651 | id: 1, 652 | }, 653 | ], 654 | }); 655 | expect(count.count).to.be.equal(1); 656 | }); 657 | it('should return zero count for soft deleted entries with and operator', async () => { 658 | const count = await repo.count({ 659 | and: [ 660 | { 661 | email: 'alice@example.com', 662 | }, 663 | { 664 | id: 3, 665 | }, 666 | ], 667 | }); 668 | expect(count.count).to.be.equal(0); 669 | }); 670 | it('should return count for non soft deleted entries with or operator', async () => { 671 | const count = await repo.count({ 672 | or: [{email: 'john@example.com'}, {id: 1}], 673 | }); 674 | expect(count.count).to.be.equal(1); 675 | }); 676 | it('should return zero for soft deleted entries with or operator', async () => { 677 | const count = await repo.count({ 678 | or: [{email: 'alice@example.com'}, {id: 3}], 679 | }); 680 | expect(count.count).to.be.equal(0); 681 | }); 682 | }); 683 | 684 | describe('countAll', () => { 685 | beforeEach(setupTestData); 686 | afterEach(clearTestData); 687 | it('should return total count when no conditions are passed', async () => { 688 | const count = await repo.countAll(); 689 | expect(count.count).to.be.equal(4); 690 | }); 691 | it('should return count for soft deleted entries when conditions specified', async () => { 692 | const count = await repo.countAll({ 693 | email: 'alice@example.com', 694 | }); 695 | expect(count.count).to.be.equal(1); 696 | }); 697 | }); 698 | 699 | describe('deleteById', () => { 700 | beforeEach(setupTestData); 701 | afterEach(clearTestData); 702 | 703 | it('should soft delete entries', async () => { 704 | await repo.deleteById(1); 705 | try { 706 | await repo.findById(1); 707 | fail(); 708 | } catch (e) { 709 | expect(e.message).to.be.equal('EntityNotFound'); 710 | } 711 | const afterDeleteIncludeSoftDeleted = 712 | await repo.findByIdIncludeSoftDelete(1); 713 | expect(afterDeleteIncludeSoftDeleted) 714 | .to.have.property('email') 715 | .equal('john@example.com'); 716 | }); 717 | 718 | it('should soft delete entries with deletedBy set to id', async () => { 719 | await repo.deleteById(1); 720 | try { 721 | await repo.findById(1); 722 | fail(); 723 | } catch (e) { 724 | expect(e.message).to.be.equal('EntityNotFound'); 725 | } 726 | const afterDeleteIncludeSoftDeleted = 727 | await repo.findByIdIncludeSoftDelete(1); 728 | expect(afterDeleteIncludeSoftDeleted) 729 | .to.have.property('deletedBy') 730 | .equal(userData.id); 731 | }); 732 | 733 | it('should soft delete entries with deletedBy set to custom key provided', async () => { 734 | await repoWithCustomDeletedByKey.deleteById(1); 735 | try { 736 | await repoWithCustomDeletedByKey.findById(1); 737 | fail(); 738 | } catch (e) { 739 | expect(e.message).to.be.equal('EntityNotFound'); 740 | } 741 | const afterDeleteIncludeSoftDeleted = 742 | await repoWithCustomDeletedByKey.findByIdIncludeSoftDelete(1); 743 | expect(afterDeleteIncludeSoftDeleted) 744 | .to.have.property('deletedBy') 745 | .equal(userData2.username); 746 | }); 747 | }); 748 | 749 | describe('delete', () => { 750 | beforeEach(setupTestData); 751 | afterEach(clearTestData); 752 | 753 | it('should soft delete entries', async () => { 754 | const entity = await repo.findById(1); 755 | await repo.delete(entity); 756 | try { 757 | await repo.findById(1); 758 | fail(); 759 | } catch (e) { 760 | expect(e.message).to.be.equal('EntityNotFound'); 761 | } 762 | const afterDeleteIncludeSoftDeleted = 763 | await repo.findByIdIncludeSoftDelete(1); 764 | expect(afterDeleteIncludeSoftDeleted) 765 | .to.have.property('email') 766 | .equal('john@example.com'); 767 | }); 768 | 769 | it('should soft delete entries with deletedBy set to id', async () => { 770 | const entity = await repo.findById(1); 771 | await repo.delete(entity); 772 | try { 773 | await repo.findById(1); 774 | fail(); 775 | } catch (e) { 776 | expect(e.message).to.be.equal('EntityNotFound'); 777 | } 778 | const afterDeleteIncludeSoftDeleted = 779 | await repo.findByIdIncludeSoftDelete(1); 780 | expect(afterDeleteIncludeSoftDeleted) 781 | .to.have.property('deletedBy') 782 | .equal(userData.id); 783 | }); 784 | 785 | it('should soft delete entries with deletedBy set to custom key provided', async () => { 786 | const entity = await repoWithCustomDeletedByKey.findById(1); 787 | await repoWithCustomDeletedByKey.delete(entity); 788 | try { 789 | await repoWithCustomDeletedByKey.findById(1); 790 | fail(); 791 | } catch (e) { 792 | expect(e.message).to.be.equal('EntityNotFound'); 793 | } 794 | const afterDeleteIncludeSoftDeleted = 795 | await repoWithCustomDeletedByKey.findByIdIncludeSoftDelete(1); 796 | expect(afterDeleteIncludeSoftDeleted) 797 | .to.have.property('deletedBy') 798 | .equal(userData2.username); 799 | }); 800 | }); 801 | 802 | describe('deleteAll', () => { 803 | beforeEach(setupTestData); 804 | afterEach(clearTestData); 805 | 806 | it('should soft delete all entries', async () => { 807 | await repo.deleteAll(); 808 | const customers = await repo.find(); 809 | expect(customers).to.have.length(0); 810 | const afterDeleteAll = await repo.findAll(); 811 | expect(afterDeleteAll).to.have.length(4); 812 | }); 813 | 814 | it('should soft delete entries with deletedBy set to id', async () => { 815 | await repo.deleteAll(); 816 | const customers = await repo.find(); 817 | expect(customers).to.have.length(0); 818 | const afterDeleteAll = await repo.findAll(); 819 | expect(afterDeleteAll).to.have.length(4); 820 | afterDeleteAll.forEach(rec => { 821 | expect(rec).to.have.property('deletedBy').equal(userData.id); 822 | }); 823 | }); 824 | 825 | it('should soft delete entries with deletedBy set to custom key provided', async () => { 826 | await repoWithCustomDeletedByKey.deleteAll(); 827 | const customers = await repoWithCustomDeletedByKey.find(); 828 | expect(customers).to.have.length(0); 829 | const afterDeleteAll = await repoWithCustomDeletedByKey.findAll(); 830 | expect(afterDeleteAll).to.have.length(4); 831 | afterDeleteAll.forEach(rec => { 832 | expect(rec).to.have.property('deletedBy').equal(userData2.username); 833 | }); 834 | }); 835 | }); 836 | 837 | describe('deleteHard', () => { 838 | beforeEach(setupTestData); 839 | afterEach(clearTestData); 840 | it('should hard delete an entry', async () => { 841 | const customer = await repo.findById(1); 842 | await repo.deleteHard(customer); 843 | try { 844 | await repo.findByIdIncludeSoftDelete(1); 845 | fail(); 846 | } catch (e) { 847 | expect(e).to.be.instanceOf(EntityNotFoundError); 848 | } 849 | }); 850 | }); 851 | 852 | describe('deleteByIdHard', () => { 853 | beforeEach(setupTestData); 854 | afterEach(clearTestData); 855 | it('should hard delete an entry by id', async () => { 856 | await repo.deleteByIdHard(1); 857 | try { 858 | await repo.findByIdIncludeSoftDelete(1); 859 | fail(); 860 | } catch (e) { 861 | expect(e).to.be.instanceOf(EntityNotFoundError); 862 | } 863 | }); 864 | }); 865 | 866 | async function setupTestData() { 867 | await repo.create({id: 1, email: 'john@example.com'}); 868 | await repo.create({id: 2, email: 'mary@example.com'}); 869 | await repo.create({id: 3, email: 'alice@example.com'}); 870 | await repo.create({id: 4, email: 'bob@example.com'}); 871 | await repo.deleteById(3); 872 | 873 | await repoWithCustomDeletedByKey.create({id: 1, email: 'john@example.com'}); 874 | await repoWithCustomDeletedByKey.create({id: 2, email: 'mary@example.com'}); 875 | await repoWithCustomDeletedByKey.create({ 876 | id: 3, 877 | email: 'alice@example.com', 878 | }); 879 | await repoWithCustomDeletedByKey.create({id: 4, email: 'bob@example.com'}); 880 | await repoWithCustomDeletedByKey.deleteById(3); 881 | } 882 | 883 | async function clearTestData() { 884 | await repo.deleteAllHard(); 885 | await repoWithCustomDeletedByKey.deleteAllHard(); 886 | } 887 | }); 888 | -------------------------------------------------------------------------------- /src/__tests__/unit/mixin/soft-crud.mixin.unit.ts: -------------------------------------------------------------------------------- 1 | // DEVELOPMENT NOTE: 2 | // Please ensure that any modifications made to this file are also applied to the following locations: 3 | // 1) src/__tests_/unit/repository/default-transaction-soft-crud.repository.base.ts 4 | // 2) src/__tests_/unit/repository/soft-crud.repository.unit.ts 5 | 6 | import {expect} from '@loopback/testlab'; 7 | 8 | import {Constructor, Getter} from '@loopback/context'; 9 | import { 10 | DefaultCrudRepository, 11 | DefaultTransactionalRepository, 12 | Entity, 13 | EntityNotFoundError, 14 | juggler, 15 | Model, 16 | model, 17 | property, 18 | } from '@loopback/repository'; 19 | import {fail} from 'assert'; 20 | import {SoftCrudRepositoryMixin} from '../../..'; 21 | import {SoftDeleteEntity} from '../../../models'; 22 | import {IUser} from '../../../types'; 23 | 24 | /** 25 | * A mock up model class 26 | */ 27 | @model() 28 | class Customer extends SoftDeleteEntity { 29 | @property({ 30 | id: true, 31 | }) 32 | id: number; 33 | @property() 34 | email: string; 35 | } 36 | 37 | @model() 38 | class Customer2 extends SoftDeleteEntity { 39 | @property({ 40 | id: true, 41 | }) 42 | id: number; 43 | @property() 44 | email: string; 45 | } 46 | 47 | @model() 48 | class User extends Model implements IUser { 49 | @property({ 50 | id: true, 51 | }) 52 | id: string; 53 | 54 | @property() 55 | username: string; 56 | 57 | constructor(data?: Partial) { 58 | super(data); 59 | } 60 | } 61 | 62 | @model() 63 | class UserWithCustomId extends Model implements IUser { 64 | @property({ 65 | id: true, 66 | }) 67 | id: string; 68 | 69 | @property() 70 | username: string; 71 | 72 | getIdentifier() { 73 | return this.username; 74 | } 75 | 76 | constructor(data?: Partial) { 77 | super(data); 78 | } 79 | } 80 | 81 | class CustomerCrudRepo extends SoftCrudRepositoryMixin< 82 | Customer, 83 | typeof Customer.prototype.id, 84 | Constructor< 85 | DefaultTransactionalRepository 86 | >, 87 | {} 88 | >(DefaultTransactionalRepository) { 89 | constructor( 90 | entityClass: typeof Entity & { 91 | prototype: Customer; 92 | }, 93 | dataSource: juggler.DataSource, 94 | readonly getCurrentUser: Getter, 95 | ) { 96 | super(entityClass, dataSource, getCurrentUser); 97 | } 98 | } 99 | 100 | class Customer2CrudRepo extends SoftCrudRepositoryMixin< 101 | Customer, 102 | typeof Customer.prototype.id, 103 | Constructor< 104 | DefaultCrudRepository 105 | >, 106 | {} 107 | >(DefaultCrudRepository) { 108 | constructor( 109 | entityClass: typeof Entity & { 110 | prototype: Customer; 111 | }, 112 | dataSource: juggler.DataSource, 113 | readonly getCurrentUser: Getter, 114 | ) { 115 | super(entityClass, dataSource, getCurrentUser); 116 | } 117 | } 118 | 119 | describe('SoftCrudRepositoryMixin', () => { 120 | let repo: CustomerCrudRepo; 121 | let repoWithCustomDeletedByKey: Customer2CrudRepo; 122 | const userData: User = new User({ 123 | id: '1', 124 | username: 'test', 125 | }); 126 | 127 | const userData2: UserWithCustomId = new UserWithCustomId({ 128 | id: '2', 129 | username: 'test2', 130 | }); 131 | 132 | before(() => { 133 | const ds: juggler.DataSource = new juggler.DataSource({ 134 | name: 'db', 135 | connector: 'memory', 136 | }); 137 | repo = new CustomerCrudRepo(Customer, ds, () => Promise.resolve(userData)); 138 | repoWithCustomDeletedByKey = new Customer2CrudRepo(Customer2, ds, () => 139 | Promise.resolve(userData2), 140 | ); 141 | }); 142 | 143 | afterEach(clearTestData); 144 | 145 | describe('find', () => { 146 | beforeEach(setupTestData); 147 | afterEach(clearTestData); 148 | it('should find non soft deleted entries', async () => { 149 | const customers = await repo.find(); 150 | expect(customers).to.have.length(3); 151 | }); 152 | it('should find non soft deleted entries with and operator', async () => { 153 | const customers = await repo.find({ 154 | where: { 155 | and: [ 156 | { 157 | email: 'john@example.com', 158 | }, 159 | { 160 | id: 1, 161 | }, 162 | ], 163 | }, 164 | }); 165 | expect(customers).to.have.length(1); 166 | }); 167 | it('should not find soft deleted entries with and operator', async () => { 168 | const deletedCustomers = await repo.find({ 169 | where: { 170 | and: [ 171 | { 172 | email: 'alice@example.com', 173 | }, 174 | { 175 | id: 2, 176 | }, 177 | ], 178 | }, 179 | }); 180 | expect(deletedCustomers).to.have.length(0); 181 | }); 182 | 183 | it('should find non soft deleted entries when deleted fields are not included', async () => { 184 | const customers = await repo.find({ 185 | fields: {deleted: false, deletedBy: false, deletedOn: false}, 186 | }); 187 | expect(customers).to.have.length(3); 188 | }); 189 | 190 | it('should find non soft deleted entries when deleted fields are included', async () => { 191 | const customers = await repo.find({ 192 | fields: {deleted: true}, 193 | }); 194 | expect(customers).to.have.length(3); 195 | }); 196 | 197 | it('should find non soft deleted entries with or operator', async () => { 198 | const customers = await repo.find({ 199 | where: { 200 | or: [ 201 | { 202 | email: 'john@example.com', 203 | }, 204 | { 205 | email: 'alice@example.com', 206 | }, 207 | ], 208 | }, 209 | }); 210 | expect(customers).to.have.length(1); 211 | }); 212 | }); 213 | 214 | describe('findAll', () => { 215 | it('should find all entries, soft deleted and otherwise', async () => { 216 | await setupTestData(); 217 | const customers = await repo.findAll(); 218 | expect(customers).to.have.length(4); 219 | }); 220 | 221 | it('should find all entries when deleted fields are not included', async () => { 222 | await setupTestData(); 223 | const customers = await repo.findAll({ 224 | fields: {deleted: false, deletedBy: false, deletedOn: false}, 225 | }); 226 | expect(customers).to.have.length(4); 227 | }); 228 | }); 229 | 230 | describe('findOne', () => { 231 | beforeEach(setupTestData); 232 | afterEach(clearTestData); 233 | it('should find one non soft deleted entry', async () => { 234 | const customer = await repo.findOne({ 235 | where: { 236 | email: 'john@example.com', 237 | }, 238 | }); 239 | expect(customer).to.have.property('email').equal('john@example.com'); 240 | }); 241 | 242 | it('should find none soft deleted entries', async () => { 243 | const customer = await repo.findOne({ 244 | where: { 245 | email: 'alice@example', 246 | }, 247 | }); 248 | expect(customer).to.be.null(); 249 | }); 250 | 251 | it('should find one non soft deleted entry using and operator', async () => { 252 | const customer = await repo.findOne({ 253 | where: { 254 | and: [ 255 | { 256 | email: 'john@example.com', 257 | }, 258 | { 259 | id: 1, 260 | }, 261 | ], 262 | }, 263 | }); 264 | 265 | expect(customer).to.have.property('email').equal('john@example.com'); 266 | }); 267 | 268 | it('should find none soft deleted entry using and operator', async () => { 269 | const customer = await repo.findOne({ 270 | where: { 271 | and: [ 272 | { 273 | email: 'alice@example.com', 274 | }, 275 | { 276 | id: 3, 277 | }, 278 | ], 279 | }, 280 | }); 281 | 282 | expect(customer).to.be.null(); 283 | }); 284 | 285 | it('should find one non soft deleted entry using or operator', async () => { 286 | const customer = await repo.findOne({ 287 | where: { 288 | or: [ 289 | { 290 | email: 'john@example.com', 291 | }, 292 | { 293 | id: 1, 294 | }, 295 | ], 296 | }, 297 | }); 298 | 299 | expect(customer).to.have.property('email').equal('john@example.com'); 300 | }); 301 | 302 | it('should find one soft deleted entry even when `deleted` field is not included', async () => { 303 | const customer = await repo.findOne({ 304 | where: { 305 | id: 4, 306 | }, 307 | fields: {deleted: false, deletedBy: false, deletedOn: false}, 308 | }); 309 | 310 | expect(customer?.id).to.have.be.eql(4); 311 | }); 312 | 313 | it('shound find none soft deleted entry using or operator', async () => { 314 | const customer = await repo.findOne({ 315 | where: { 316 | or: [ 317 | { 318 | email: 'alice@example.com', 319 | }, 320 | { 321 | id: 3, 322 | }, 323 | ], 324 | }, 325 | }); 326 | 327 | expect(customer).to.be.null(); 328 | }); 329 | }); 330 | 331 | describe('findOneIncludeSoftDelete', () => { 332 | it('should find one soft deleted entry', async () => { 333 | await repo.create({id: 3, email: 'alice@example.com'}); 334 | await repo.deleteById(3); 335 | const customer = await repo.findOneIncludeSoftDelete({ 336 | where: { 337 | email: 'alice@example.com', 338 | }, 339 | }); 340 | 341 | expect(customer).to.have.property('email').equal('alice@example.com'); 342 | }); 343 | }); 344 | 345 | describe('findById', () => { 346 | beforeEach(setupTestData); 347 | afterEach(clearTestData); 348 | 349 | it('should find one non soft deleted entry by id', async () => { 350 | const customer = await repo.findById(1); 351 | expect(customer).to.have.property('email').equal('john@example.com'); 352 | }); 353 | 354 | it('should reject on finding soft deleted entry by id', async () => { 355 | try { 356 | await repo.findById(3); 357 | fail(); 358 | } catch (e) { 359 | expect(e.message).to.be.equal('EntityNotFound'); 360 | } 361 | }); 362 | 363 | it('should find one non soft deleted entry by id, using and operator', async () => { 364 | const customer = await repo.findById(1, { 365 | where: { 366 | and: [{email: 'john@example.com'}, {id: 1}], 367 | }, 368 | }); 369 | expect(customer).to.have.property('email').equal('john@example.com'); 370 | }); 371 | 372 | it('should find no soft deleted entry by id, using and operator', async () => { 373 | try { 374 | await repo.findById(3, { 375 | where: { 376 | and: [{email: 'alice@example.com'}, {id: 3}], 377 | }, 378 | }); 379 | fail(); 380 | } catch (e) { 381 | expect(e.message).to.be.equal('EntityNotFound'); 382 | } 383 | }); 384 | 385 | it('should find one non soft deleted entry by id, using or operator', async () => { 386 | const customer = await repo.findById(1, { 387 | where: { 388 | or: [{email: 'john@example.com'}, {id: 1}], 389 | }, 390 | }); 391 | expect(customer).to.have.property('email').equal('john@example.com'); 392 | }); 393 | 394 | it('should find no soft entry by id, using or operator', async () => { 395 | try { 396 | await repo.findById(3, { 397 | where: { 398 | or: [{email: 'alice@example.com'}, {id: 3}], 399 | }, 400 | }); 401 | fail(); 402 | } catch (e) { 403 | expect(e.message).to.be.equal('EntityNotFound'); 404 | } 405 | }); 406 | it('should not return soft deleted entry by id when using fields filter without including deleted column', async () => { 407 | try { 408 | await repo.findById(3, { 409 | fields: { 410 | id: true, 411 | email: true, 412 | }, 413 | }); 414 | fail(); 415 | } catch (e) { 416 | expect(e.message).to.be.equal('EntityNotFound'); 417 | } 418 | }); 419 | it('should not return soft deleted entry by id, without using deleted in fields filter(fields fileter is passed as array)', async () => { 420 | try { 421 | await repo.findById(3, { 422 | fields: ['id', 'email'], 423 | }); 424 | fail(); 425 | } catch (e) { 426 | expect(e.message).to.be.equal('EntityNotFound'); 427 | } 428 | }); 429 | it('should return requested fields only when not using deleted in fields filter', async () => { 430 | const customer = await repo.findById(4, { 431 | fields: { 432 | id: true, 433 | email: true, 434 | }, 435 | }); 436 | const customer2 = await repo.findById(4, { 437 | fields: ['id', 'email'], 438 | }); 439 | expect(customer.deleted).to.be.undefined(); 440 | expect(customer2.deleted).to.be.undefined(); 441 | }); 442 | it('should return requested fields matched with fields filter', async () => { 443 | const customer = await repo.findById(4, { 444 | fields: { 445 | id: true, 446 | email: true, 447 | deleted: true, 448 | }, 449 | }); 450 | expect(customer).to.have.property('deleted'); 451 | }); 452 | 453 | it('should return non soft deleted entry even if deleted column is not included in fields', async () => { 454 | const customer = await repo.findById(4, { 455 | fields: { 456 | deleted: false, 457 | deletedBy: false, 458 | deletedOn: false, 459 | }, 460 | }); 461 | 462 | expect(customer.id).to.be.eql(4); 463 | expect(customer.email).to.be.eql('bob@example.com'); 464 | expect(customer.deleted).to.be.undefined(); 465 | }); 466 | 467 | it('should return requested fields only when not using deleted in fields filter array', async () => { 468 | const customer = await repo.findById(4, { 469 | fields: ['id', 'email'], 470 | }); 471 | 472 | expect(customer.id).to.be.eql(4); 473 | expect(customer.deleted).to.be.undefined(); 474 | }); 475 | 476 | it('should return requested fields matched with fields filter array', async () => { 477 | const customer = await repo.findById(4, { 478 | fields: ['id', 'email', 'deleted'], 479 | }); 480 | expect(customer.id).to.be.eql(4); 481 | expect(customer.email).to.be.eql('bob@example.com'); 482 | expect(customer.deleted).to.be.false(); 483 | expect(customer.deletedBy).to.be.undefined(); 484 | }); 485 | }); 486 | 487 | describe('findByIdIncludeSoftDelete', () => { 488 | beforeEach(setupTestData); 489 | afterEach(clearTestData); 490 | it('should find one by id', async () => { 491 | const customer = await repo.findByIdIncludeSoftDelete(1); 492 | expect(customer).to.have.property('email').equal('john@example.com'); 493 | }); 494 | it('should find one by id even if soft deleted', async () => { 495 | const customer = await repo.findByIdIncludeSoftDelete(3); 496 | expect(customer).to.have.property('email').equal('alice@example.com'); 497 | }); 498 | it('should find one by id with and operator', async () => { 499 | const customer = await repo.findByIdIncludeSoftDelete(1, { 500 | where: { 501 | and: [ 502 | { 503 | email: 'john@example.com', 504 | }, 505 | { 506 | id: 1, 507 | }, 508 | ], 509 | }, 510 | }); 511 | expect(customer).to.have.property('email').equal('john@example.com'); 512 | }); 513 | 514 | it('should find one soft deleted entry by id with and operator', async () => { 515 | const customer = await repo.findByIdIncludeSoftDelete(3, { 516 | where: { 517 | and: [ 518 | { 519 | email: 'alice@example.com', 520 | }, 521 | { 522 | id: 3, 523 | }, 524 | ], 525 | }, 526 | }); 527 | expect(customer).to.have.property('email').equal('alice@example.com'); 528 | }); 529 | 530 | it('should find one by id with or operator', async () => { 531 | const customer = await repo.findByIdIncludeSoftDelete(1, { 532 | where: { 533 | or: [ 534 | { 535 | email: 'john@example.com', 536 | }, 537 | { 538 | id: 1, 539 | }, 540 | ], 541 | }, 542 | }); 543 | expect(customer).to.have.property('email').equal('john@example.com'); 544 | }); 545 | 546 | it('should find one soft deleted entry by id with or operator', async () => { 547 | const customer = await repo.findByIdIncludeSoftDelete(3, { 548 | where: { 549 | or: [ 550 | { 551 | email: 'alice@example.com', 552 | }, 553 | { 554 | id: 3, 555 | }, 556 | ], 557 | }, 558 | }); 559 | expect(customer).to.have.property('email').equal('alice@example.com'); 560 | }); 561 | }); 562 | 563 | describe('update', () => { 564 | beforeEach(async () => { 565 | await repo.create({id: 1, email: 'john@example.com'}); 566 | await repo.create({id: 2, email: 'mary@example.com'}); 567 | await repo.create({id: 3, email: 'alice@example.com'}); 568 | await repo.create({id: 4, email: 'bob@example.com'}); 569 | await repo.deleteById(3); 570 | }); 571 | afterEach(async () => { 572 | await repo.deleteAllHard(); 573 | }); 574 | it('should update non soft deleted entries', async () => { 575 | const customers = await repo.updateAll( 576 | { 577 | email: 'johnupdated@example', 578 | }, 579 | { 580 | id: 1, 581 | }, 582 | ); 583 | expect(customers.count).to.eql(1); 584 | }); 585 | it('should update non soft deleted entries with and operator', async () => { 586 | const customers = await repo.updateAll( 587 | { 588 | email: 'johnupdated@example', 589 | }, 590 | { 591 | and: [ 592 | { 593 | email: 'john@example.com', 594 | }, 595 | { 596 | id: 1, 597 | }, 598 | ], 599 | }, 600 | ); 601 | expect(customers.count).to.eql(1); 602 | const deletedCustomers = await repo.updateAll( 603 | { 604 | email: 'aliceupdated@example', 605 | }, 606 | { 607 | and: [ 608 | { 609 | email: 'alice@example.com', 610 | }, 611 | { 612 | id: 2, 613 | }, 614 | ], 615 | }, 616 | ); 617 | expect(deletedCustomers.count).to.eql(0); 618 | }); 619 | it('should update non soft deleted entries with or operator', async () => { 620 | const customers = await repo.updateAll( 621 | { 622 | email: 'updated@example.com', 623 | }, 624 | { 625 | or: [ 626 | { 627 | email: 'john@example.com', 628 | }, 629 | { 630 | email: 'alice@example.com', 631 | }, 632 | ], 633 | }, 634 | ); 635 | expect(customers.count).to.eql(1); 636 | }); 637 | }); 638 | 639 | describe('count', () => { 640 | beforeEach(setupTestData); 641 | afterEach(clearTestData); 642 | it('should return count for non soft deleted entries', async () => { 643 | const count = await repo.count({ 644 | email: 'john@example.com', 645 | }); 646 | expect(count.count).to.be.equal(1); 647 | }); 648 | it('should return zero count for soft deleted entries', async () => { 649 | const count = await repo.count({ 650 | email: 'alice@example.com', 651 | }); 652 | expect(count.count).to.be.equal(0); 653 | }); 654 | it('should return count for non soft deleted entries with and operator', async () => { 655 | const count = await repo.count({ 656 | and: [ 657 | { 658 | email: 'john@example.com', 659 | }, 660 | { 661 | id: 1, 662 | }, 663 | ], 664 | }); 665 | expect(count.count).to.be.equal(1); 666 | }); 667 | it('should return zero count for soft deleted entries with and operator', async () => { 668 | const count = await repo.count({ 669 | and: [ 670 | { 671 | email: 'alice@example.com', 672 | }, 673 | { 674 | id: 3, 675 | }, 676 | ], 677 | }); 678 | expect(count.count).to.be.equal(0); 679 | }); 680 | it('should return count for non soft deleted entries with or operator', async () => { 681 | const count = await repo.count({ 682 | or: [{email: 'john@example.com'}, {id: 1}], 683 | }); 684 | expect(count.count).to.be.equal(1); 685 | }); 686 | it('should return zero for soft deleted entries with or operator', async () => { 687 | const count = await repo.count({ 688 | or: [{email: 'alice@example.com'}, {id: 3}], 689 | }); 690 | expect(count.count).to.be.equal(0); 691 | }); 692 | }); 693 | 694 | describe('countAll', () => { 695 | beforeEach(setupTestData); 696 | afterEach(clearTestData); 697 | it('should return total count when no conditions are passed', async () => { 698 | const count = await repo.countAll(); 699 | expect(count.count).to.be.equal(4); 700 | }); 701 | it('should return count for soft deleted entries when conditions specified', async () => { 702 | const count = await repo.countAll({ 703 | email: 'alice@example.com', 704 | }); 705 | expect(count.count).to.be.equal(1); 706 | }); 707 | }); 708 | 709 | describe('deleteById', () => { 710 | beforeEach(setupTestData); 711 | afterEach(clearTestData); 712 | 713 | it('should soft delete entries', async () => { 714 | await repo.deleteById(1); 715 | try { 716 | await repo.findById(1); 717 | fail(); 718 | } catch (e) { 719 | expect(e.message).to.be.equal('EntityNotFound'); 720 | } 721 | const afterDeleteIncludeSoftDeleted = 722 | await repo.findByIdIncludeSoftDelete(1); 723 | expect(afterDeleteIncludeSoftDeleted) 724 | .to.have.property('email') 725 | .equal('john@example.com'); 726 | }); 727 | 728 | it('should soft delete entries with deletedBy set to id', async () => { 729 | await repo.deleteById(1); 730 | try { 731 | await repo.findById(1); 732 | fail(); 733 | } catch (e) { 734 | expect(e.message).to.be.equal('EntityNotFound'); 735 | } 736 | const afterDeleteIncludeSoftDeleted = 737 | await repo.findByIdIncludeSoftDelete(1); 738 | expect(afterDeleteIncludeSoftDeleted) 739 | .to.have.property('deletedBy') 740 | .equal(userData.id); 741 | }); 742 | 743 | it('should soft delete entries with deletedBy set to custom key provided', async () => { 744 | await repoWithCustomDeletedByKey.deleteById(1); 745 | try { 746 | await repoWithCustomDeletedByKey.findById(1); 747 | fail(); 748 | } catch (e) { 749 | expect(e.message).to.be.equal('EntityNotFound'); 750 | } 751 | const afterDeleteIncludeSoftDeleted = 752 | await repoWithCustomDeletedByKey.findByIdIncludeSoftDelete(1); 753 | expect(afterDeleteIncludeSoftDeleted) 754 | .to.have.property('deletedBy') 755 | .equal(userData2.username); 756 | }); 757 | }); 758 | 759 | describe('delete', () => { 760 | beforeEach(setupTestData); 761 | afterEach(clearTestData); 762 | 763 | it('should soft delete entries', async () => { 764 | const entity = await repo.findById(1); 765 | await repo.delete(entity); 766 | try { 767 | await repo.findById(1); 768 | fail(); 769 | } catch (e) { 770 | expect(e.message).to.be.equal('EntityNotFound'); 771 | } 772 | const afterDeleteIncludeSoftDeleted = 773 | await repo.findByIdIncludeSoftDelete(1); 774 | expect(afterDeleteIncludeSoftDeleted) 775 | .to.have.property('email') 776 | .equal('john@example.com'); 777 | }); 778 | 779 | it('should soft delete entries with deletedBy set to id', async () => { 780 | const entity = await repo.findById(1); 781 | await repo.delete(entity); 782 | try { 783 | await repo.findById(1); 784 | fail(); 785 | } catch (e) { 786 | expect(e.message).to.be.equal('EntityNotFound'); 787 | } 788 | const afterDeleteIncludeSoftDeleted = 789 | await repo.findByIdIncludeSoftDelete(1); 790 | expect(afterDeleteIncludeSoftDeleted) 791 | .to.have.property('deletedBy') 792 | .equal(userData.id); 793 | }); 794 | 795 | it('should soft delete entries with deletedBy set to custom key provided', async () => { 796 | const entity = await repoWithCustomDeletedByKey.findById(1); 797 | await repoWithCustomDeletedByKey.delete(entity); 798 | try { 799 | await repoWithCustomDeletedByKey.findById(1); 800 | fail(); 801 | } catch (e) { 802 | expect(e.message).to.be.equal('EntityNotFound'); 803 | } 804 | const afterDeleteIncludeSoftDeleted = 805 | await repoWithCustomDeletedByKey.findByIdIncludeSoftDelete(1); 806 | expect(afterDeleteIncludeSoftDeleted) 807 | .to.have.property('deletedBy') 808 | .equal(userData2.username); 809 | }); 810 | }); 811 | 812 | describe('deleteAll', () => { 813 | beforeEach(setupTestData); 814 | afterEach(clearTestData); 815 | 816 | it('should soft delete all entries', async () => { 817 | await repo.deleteAll(); 818 | const customers = await repo.find(); 819 | expect(customers).to.have.length(0); 820 | const afterDeleteAll = await repo.findAll(); 821 | expect(afterDeleteAll).to.have.length(4); 822 | }); 823 | 824 | it('should soft delete entries with deletedBy set to id', async () => { 825 | await repo.deleteAll(); 826 | const customers = await repo.find(); 827 | expect(customers).to.have.length(0); 828 | const afterDeleteAll = await repo.findAll(); 829 | expect(afterDeleteAll).to.have.length(4); 830 | afterDeleteAll.forEach(rec => { 831 | expect(rec).to.have.property('deletedBy').equal(userData.id); 832 | }); 833 | }); 834 | 835 | it('should soft delete entries with deletedBy set to custom key provided', async () => { 836 | await repoWithCustomDeletedByKey.deleteAll(); 837 | const customers = await repoWithCustomDeletedByKey.find(); 838 | expect(customers).to.have.length(0); 839 | const afterDeleteAll = await repoWithCustomDeletedByKey.findAll(); 840 | expect(afterDeleteAll).to.have.length(4); 841 | afterDeleteAll.forEach(rec => { 842 | expect(rec).to.have.property('deletedBy').equal(userData2.username); 843 | }); 844 | }); 845 | }); 846 | 847 | describe('deleteHard', () => { 848 | beforeEach(setupTestData); 849 | afterEach(clearTestData); 850 | it('should hard delete an entry', async () => { 851 | const customer = await repo.findById(1); 852 | await repo.deleteHard(customer); 853 | try { 854 | await repo.findByIdIncludeSoftDelete(1); 855 | fail(); 856 | } catch (e) { 857 | expect(e).to.be.instanceOf(EntityNotFoundError); 858 | } 859 | }); 860 | }); 861 | 862 | describe('deleteByIdHard', () => { 863 | beforeEach(setupTestData); 864 | afterEach(clearTestData); 865 | it('should hard delete an entry by id', async () => { 866 | await repo.deleteByIdHard(1); 867 | try { 868 | await repo.findByIdIncludeSoftDelete(1); 869 | fail(); 870 | } catch (e) { 871 | expect(e).to.be.instanceOf(EntityNotFoundError); 872 | } 873 | }); 874 | }); 875 | 876 | async function setupTestData() { 877 | await repo.create({id: 1, email: 'john@example.com'}); 878 | await repo.create({id: 2, email: 'mary@example.com'}); 879 | await repo.create({id: 3, email: 'alice@example.com'}); 880 | await repo.create({id: 4, email: 'bob@example.com'}); 881 | await repo.deleteById(3); 882 | 883 | await repoWithCustomDeletedByKey.create({id: 1, email: 'john@example.com'}); 884 | await repoWithCustomDeletedByKey.create({id: 2, email: 'mary@example.com'}); 885 | await repoWithCustomDeletedByKey.create({ 886 | id: 3, 887 | email: 'alice@example.com', 888 | }); 889 | await repoWithCustomDeletedByKey.create({id: 4, email: 'bob@example.com'}); 890 | await repoWithCustomDeletedByKey.deleteById(3); 891 | } 892 | 893 | async function clearTestData() { 894 | await repo.deleteAllHard(); 895 | await repoWithCustomDeletedByKey.deleteAllHard(); 896 | } 897 | }); 898 | --------------------------------------------------------------------------------