├── .adr-dir ├── .env.test ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── ci.yml │ └── codeql-analysis.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .prettierrc ├── .release-it.json ├── .vscode └── settings.json ├── LICENSE.md ├── README.ko.md ├── README.md ├── commitlint.config.cjs ├── doc ├── adr │ └── 0001-record-architecture-decisions.md └── readme.md ├── docker-compose.test.yml ├── eslint.config.mjs ├── jest.config.ts ├── jest.setup.ts ├── lint-staged.config.cjs ├── package.json ├── spec ├── auth-guard │ ├── auth-guard.controller.ts │ ├── auth-guard.module.ts │ ├── auth-guard.spec.ts │ └── auth.guard.ts ├── author │ ├── author-object.interceptor.ts │ ├── author-object.module.ts │ ├── author-object.spec.ts │ ├── author-value.module.ts │ ├── author-value.spec.ts │ ├── author.interceptor.ts │ ├── author.module.ts │ └── author.spec.ts ├── base │ ├── base.controller.create.spec.ts │ ├── base.controller.delete.spec.ts │ ├── base.controller.read-many.spec.ts │ ├── base.controller.read-one.spec.ts │ ├── base.controller.recover.spec.ts │ ├── base.controller.search.spec.ts │ ├── base.controller.spec.ts │ ├── base.controller.swagger.spec.ts │ ├── base.controller.ts │ ├── base.controller.update.spec.ts │ ├── base.controller.upsert.spec.ts │ ├── base.entity.ts │ ├── base.module.ts │ └── base.service.ts ├── changed-error-message │ ├── duplicated.spec.ts │ └── test.module.ts ├── crud.abstract.entity.ts ├── custom-entity │ ├── custom-entity.controller.create.spec.ts │ ├── custom-entity.controller.delete.spec.ts │ ├── custom-entity.controller.read-many.spec.ts │ ├── custom-entity.controller.read-one.spec.ts │ ├── custom-entity.controller.recover.spec.ts │ ├── custom-entity.controller.search.spec.ts │ ├── custom-entity.controller.ts │ ├── custom-entity.controller.update.spec.ts │ ├── custom-entity.controller.upsert.spec.ts │ ├── custom-entity.entity.ts │ ├── custom-entity.module.ts │ └── custom-entity.service.ts ├── custom-swagger-decorator │ ├── apply-api-extra-model.spec.ts │ ├── extra-model.ts │ └── without-custom-decorator.spec.ts ├── dynamic-crud.module.ts ├── embedded-entities │ ├── embedded-entites.spec.ts │ ├── embedded-entities.module.ts │ ├── employee.controller.ts │ ├── employee.entity.ts │ ├── employee.service.ts │ ├── name.ts │ ├── user.controller.ts │ ├── user.entity.ts │ └── user.service.ts ├── entity-inheritance │ ├── child-entity-noname.spec.ts │ └── child-entity.spec.ts ├── exclude-swagger │ ├── additional-base-info.ts │ ├── custom-response.dto.ts │ ├── exclude-swagger-resonse-name.spec.ts │ ├── exclude-swagger.controller.ts │ ├── exclude-swagger.module.ts │ └── exclude-swagger.spec.ts ├── exclude │ └── exclude.spec.ts ├── general-entity │ ├── general.spec.ts │ ├── readme.md │ └── without-name.spec.ts ├── listeners │ ├── allow-listeners.spec.ts │ └── disallow-listeners.spec.ts ├── logging │ └── logging.spec.ts ├── mongodb │ └── mongodb.spec.ts ├── multiple-primary-key │ ├── multiple-primary-key.controller.create.spec.ts │ ├── multiple-primary-key.controller.delete.spec.ts │ ├── multiple-primary-key.controller.read-one.spec.ts │ ├── multiple-primary-key.controller.recover.spec.ts │ ├── multiple-primary-key.controller.ts │ ├── multiple-primary-key.controller.upsert.spec.ts │ ├── multiple-primary-key.entity.ts │ ├── multiple-primary-key.module.ts │ └── multiple-primary-key.service.ts ├── naming-strategy │ ├── embedded-entites.spec.ts │ ├── naming-stategy.spec.ts │ └── snake-naming.strategy.ts ├── pagination │ ├── pagination-swagger.spec.ts │ ├── pagination.interceptor.spec.ts │ ├── pagination.module.ts │ ├── pagination.spec.ts │ ├── read-many.request.interceptor.ts │ ├── view-entity-pagination.module.ts │ └── with-pagination-keys.spec.ts ├── param-option │ ├── param-option.entity-key.spec.ts │ ├── param-option.non-entity-key.spec.ts │ ├── param-option.with-interceptor.spec.ts │ └── params.interceptor.ts ├── pgsql │ └── pgsql.spec.ts ├── read-many │ ├── number-of-take.controller.ts │ ├── read-many.controller.spec.ts │ ├── read-many.module.ts │ ├── sort-asc.controller.ts │ └── sort-desc.controller.ts ├── relation-entities │ ├── README.ko.md │ ├── README.md │ ├── category.entity.ts │ ├── category.service.ts │ ├── comment-relation.interceptor.ts │ ├── comment.entity.ts │ ├── comment.service.ts │ ├── question.entity.ts │ ├── question.service.ts │ ├── relation-entities-disable-relations.spec.ts │ ├── relation-entities-interceptor.spec.ts │ ├── relation-entities-read.spec.ts │ ├── relation-entities-search.spec.ts │ ├── relation-entities-seleted-relations.spec.ts │ ├── relation-entities.module.ts │ ├── relation-entities.spec.ts │ ├── writer.entity.ts │ └── writer.service.ts ├── replication │ └── replication.spec.ts ├── request-interceptor │ ├── delete.request.interceptor.ts │ ├── read-many.request.interceptor.ts │ ├── read-one.request.interceptor.ts │ ├── request-interceptor.controller.ts │ ├── request-interceptor.module.ts │ └── request-interceptor.spec.ts ├── reserved-words │ ├── reserved-name.controller.spec.ts │ └── reserved-name.controller.ts ├── response-interceptor │ ├── response-custom.interceptor.ts │ ├── response-interceptor.controller.ts │ ├── response-interceptor.module.ts │ └── response-interceptor.spec.ts ├── search-json-column │ ├── fixture.ts │ ├── interface.ts │ ├── json.module.ts │ ├── jsonb.module.ts │ ├── search-json.mysql.spec.ts │ └── search-jsonb-postgresql.spec.ts ├── search │ ├── module.ts │ ├── search-complex-condition.spec.ts │ ├── search-cursor-pagination.spec.ts │ ├── search-query-operator.spec.ts │ └── search-with-params.spec.ts ├── soft-delete-and-recover │ ├── delete-and-get-soft-deleted.controller.ts │ ├── delete-and-ignore-soft-deleted.controller.ts │ ├── soft-delete-and-get-soft-deleted.controller.ts │ ├── soft-delete-and-ignore-soft-deleted.controller.ts │ ├── soft-delete-and-recover.controller.spec.ts │ └── soft-delete-and-recover.module.ts ├── sub-path │ ├── depth-one.entity.ts │ ├── depth-one.service.ts │ ├── depth-two.entity.ts │ ├── depth-two.service.ts │ ├── sub-path-more-than-one-parent.spec.ts │ ├── sub-path-one-parent.spec.ts │ ├── sub-path.module.ts │ └── sub-path.spec.ts ├── swagger-decorator │ ├── params.request.interceptor.ts │ ├── swagger-decorator.controller.ts │ ├── swagger-decorator.module.ts │ ├── swagger-decorator.spec.ts │ └── update-request.dto.ts ├── test.helper.ts └── unique-key │ ├── unique.controller.create.mysql.spec.ts │ ├── unique.controller.create.pgsql.spec.ts │ ├── unique.controller.create.spec.ts │ ├── unique.controller.ts │ ├── unique.entity.ts │ ├── unique.module.ts │ └── unique.service.ts ├── src ├── index.ts └── lib │ ├── abstract │ ├── abstract.pagination.spec.ts │ ├── abstract.pagination.ts │ ├── abstract.request.interceptor.spec.ts │ ├── abstract.request.interceptor.ts │ └── index.ts │ ├── capitalize-first-letter.ts │ ├── constants.ts │ ├── crud.decorator.spec.ts │ ├── crud.decorator.ts │ ├── crud.policy.ts │ ├── crud.route.factory.spec.ts │ ├── crud.route.factory.ts │ ├── crud.service.spec.ts │ ├── crud.service.ts │ ├── dto │ ├── index.ts │ ├── pagination-cursor.dto.ts │ ├── pagination-offset.dto.spec.ts │ ├── pagination-offset.dto.ts │ ├── params.dto.ts │ ├── request-fields.dto.spec.ts │ ├── request-fields.dto.ts │ ├── request-search-first-cursor.dto.ts │ ├── request-search-first-offset.dto.ts │ ├── request-search-next-cursor.dto.ts │ ├── request-search-next-offset.dto.ts │ ├── request-search.dto.ts │ └── request.dto.ts │ ├── interceptor │ ├── create-request.interceptor.spec.ts │ ├── create-request.interceptor.ts │ ├── custom-request.interceptor.spec.ts │ ├── custom-request.interceptor.ts │ ├── delete-request.interceptor.spec.ts │ ├── delete-request.interceptor.ts │ ├── index.ts │ ├── read-many-request.interceptor.spec.ts │ ├── read-many-request.interceptor.ts │ ├── read-one-request.interceptor.spec.ts │ ├── read-one-request.interceptor.ts │ ├── recover-request.interceptor.spec.ts │ ├── recover-request.interceptor.ts │ ├── search-request.interceptor.spec.ts │ ├── search-request.interceptor.ts │ ├── update-request.interceptor.spec.ts │ ├── update-request.interceptor.ts │ ├── upsert-request.interceptor.spec.ts │ └── upsert-request.interceptor.ts │ ├── interface │ ├── author.interface.ts │ ├── controller.interface.ts │ ├── decorator-option.interface.ts │ ├── entity.ts │ ├── factory-option.interface.ts │ ├── index.ts │ ├── method.ts │ ├── pagination.interface.ts │ ├── query-operation.interface.ts │ ├── request.interface.ts │ └── sort.ts │ ├── provider │ ├── crud-logger.ts │ ├── execution-context-host.mock.ts │ ├── index.ts │ ├── pagination.helper.spec.ts │ ├── pagination.helper.ts │ └── typeorm-query-builder.helper.ts │ └── request │ ├── index.ts │ ├── read-many.request.spec.ts │ └── read-many.request.ts ├── tsconfig.cjs.json ├── tsconfig.json ├── tsconfig.mjs.json ├── tsconfig.spec.json └── yarn.lock /.adr-dir: -------------------------------------------------------------------------------- 1 | doc/adr 2 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | MYSQL_DATABASE_NAME=mysql_test 2 | MYSQL_DATABASE_USERNAME=root 3 | MYSQL_DATABASE_PASSWORD=local 4 | 5 | POSTGRESQL_DATABASE_NAME=pg_test 6 | POSTGRESQL_DATABASE_USERNAME=postgres 7 | POSTGRESQL_DATABASE_PASSWORD=local 8 | 9 | POSTGRESQL_SLAVE_PORT=5433 -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | 3 | Please check if your PR fulfills the following requirements: 4 | 5 | - [ ] Tests for the changes have been added (for bug fixes / features) 6 | - [ ] Docs have been added / updated (for bug fixes / features) 7 | 8 | ## PR Type 9 | 10 | What kind of change does this PR introduce? 11 | 12 | 13 | 14 | - [ ] Bugfix 15 | - [ ] Feature 16 | - [ ] Code style update (formatting, local variables) 17 | - [ ] Refactoring (no functional changes, no api changes) 18 | - [ ] Build related changes 19 | - [ ] CI related changes 20 | - [ ] Documentation content changes 21 | - [ ] Other... Please describe: 22 | 23 | ## What is the current behavior? 24 | 25 | 26 | 27 | Issue Number: N/A 28 | 29 | ## What is the new behavior? 30 | 31 | ## Does this PR introduce a breaking change? 32 | 33 | - [ ] Yes 34 | - [ ] No 35 | 36 | 37 | 38 | ## Other information 39 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Basic dependabot.yml file with 2 | # minimum configuration for two package managers 3 | 4 | version: 2 5 | updates: 6 | # Enable version updates for npm 7 | - package-ecosystem: 'npm' 8 | # Look for `package.json` and `lock` files in the `root` directory 9 | directory: '/' 10 | # Check the npm registry for updates every day (weekdays) 11 | schedule: 12 | interval: 'weekly' 13 | 14 | # Enable version updates for Docker 15 | - package-ecosystem: 'docker' 16 | # Look for a `Dockerfile` in the `root` directory 17 | directory: '/' 18 | # Check for updates once a week 19 | schedule: 20 | interval: 'weekly' 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 2 | 3 | name: Node.js CI 4 | 5 | on: 6 | push: 7 | branches: ['main'] 8 | pull_request: 9 | branches: ['main'] 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [20.x, 22.x] 18 | steps: 19 | - uses: actions/checkout@v3 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | # https://stackoverflow.com/questions/61010294/how-to-cache-yarn-packages-in-github-actions 25 | cache: 'yarn' 26 | - name: Install docker-compose 27 | run: | 28 | curl -L "https://github.com/docker/compose/releases/download/v2.29.2/docker-compose-$(uname -s | tr '[A-Z]' '[a-z]')-$(uname -m)" -o /usr/local/bin/docker-compose 29 | chmod +x /usr/local/bin/docker-compose 30 | # https://github.com/kiranojhanp/fullstack-typescript-turborepo-starter/blob/main/.github/workflows/api.yaml 31 | - name: Get yarn cache directory path 32 | id: yarn-cache-dir-path 33 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 34 | 35 | - name: Cache node modules 36 | uses: actions/cache@v4 37 | env: 38 | cache-name: cache-node-modules 39 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 40 | with: 41 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 42 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 43 | restore-keys: | 44 | ${{ runner.os }}-yarn- 45 | - name: Install Dependencies 46 | run: yarn install --frozen-lockfile --prefer-offline 47 | - name: Format Check 48 | run: yarn format-check 49 | 50 | - name: Lint 51 | run: yarn lint 52 | 53 | - name: Type Check 54 | run: yarn type-check 55 | 56 | - name: Test 57 | run: yarn test:ci 58 | 59 | - name: Coveralls 60 | uses: coverallsapp/github-action@v2 61 | # https://github.com/actions/typescript-action/blob/main/.github/workflows/codeql-analysis.yml 62 | 63 | # https://github.com/actions/typescript-action/blob/main/.github/workflows/test.yml 64 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'CodeQL' 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [main] 9 | schedule: 10 | - cron: '0 17 * * 4' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | analyse: 17 | permissions: 18 | security-events: write 19 | name: Analyse 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v3 25 | with: 26 | # We must fetch at least the immediate parents so that if this is 27 | # a pull request then we can checkout the head. 28 | fetch-depth: 2 29 | 30 | # If this run was triggered by a pull request event, then checkout 31 | # the head of the pull request instead of the merge commit. 32 | - run: git checkout HEAD^2 33 | if: ${{ github.event_name == 'pull_request' }} 34 | 35 | # Initializes the CodeQL tools for scanning. 36 | - name: Initialize CodeQL 37 | uses: github/codeql-action/init@v2 38 | with: 39 | queries: +security-extended 40 | # Override language selection by uncommenting this and choosing your languages 41 | # with: 42 | # languages: go, javascript, csharp, python, cpp, java 43 | 44 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 45 | # If this step fails, then you should remove it and run the build manually (see below) 46 | - name: Autobuild 47 | uses: github/codeql-action/autobuild@v2 48 | 49 | # ℹ️ Command-line programs to run using the OS shell. 50 | # 📚 https://git.io/JvXDl 51 | 52 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 53 | # and modify them (or add more) to build your code if your project 54 | # uses a compiled language 55 | 56 | #- run: | 57 | # make bootstrap 58 | # make release 59 | 60 | - name: Perform CodeQL Analysis 61 | uses: github/codeql-action/analyze@v2 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /tmp 4 | /out-tsc 5 | 6 | # dependencies 7 | node_modules 8 | 9 | # IDEs and editors 10 | /.idea 11 | .project 12 | .classpath 13 | .c9/ 14 | *.launch 15 | .settings/ 16 | *.sublime-workspace 17 | 18 | # IDE - VSCode 19 | .vscode/* 20 | !.vscode/settings.json 21 | !.vscode/tasks.json 22 | !.vscode/launch.json 23 | !.vscode/extensions.json 24 | 25 | # System Files 26 | .DS_Store 27 | Thumbs.db 28 | 29 | # ORM Logs 30 | ormlogs.log 31 | *.iml 32 | 33 | /coverage 34 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "printWidth": 140, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "push": true, 4 | "commitMessage": "chore: release v${version}" 5 | }, 6 | "github": { 7 | "release": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["Denormalized", "ILIKE", "metatype", "nestjs", "Pgsql", "typeorm"], 3 | "typescript.tsdk": "node_modules/typescript/lib" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Woowabros 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 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /doc/adr/0001-record-architecture-decisions.md: -------------------------------------------------------------------------------- 1 | # 1. Record architecture decisions 2 | 3 | Date: 2023-03-21 4 | 5 | ## Status 6 | 7 | Accepted 8 | 9 | ## Context 10 | 11 | We need to record the architectural decisions made on this project. 12 | 13 | ## Decision 14 | 15 | We will use Architecture Decision Records, as [described by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions). 16 | 17 | ## Consequences 18 | 19 | See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's [adr-tools](https://github.com/npryce/adr-tools). 20 | -------------------------------------------------------------------------------- /doc/readme.md: -------------------------------------------------------------------------------- 1 | # Architecture 2 | 3 | ## Architecture Decision Records 4 | 5 | We will record design decisions for the architecture to ensure we preserve the context of our 6 | choices. These will be written in the format proposed in a 7 | [blog post by Michael Nygard](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions) 8 | 9 | Please see the [decisions directory](decisions/) for a list of all ADRs. 10 | 11 | ### Tooling 12 | 13 | We will use [adr-tools](https://github.com/npryce/adr-tools) to help manage the decisions. 14 | 15 | `brew install adr-tools` 16 | 17 | `adr new 'Decision to record'` 18 | 19 | Please ensure that this tool is used at the **root** of the repository only. 20 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | mysql: 5 | image: mysql:latest 6 | restart: always 7 | environment: 8 | MYSQL_DATABASE: $MYSQL_DATABASE_NAME 9 | MYSQL_ROOT_PASSWORD: $MYSQL_DATABASE_PASSWORD 10 | command: 11 | - --character-set-server=utf8mb4 12 | - --collation-server=utf8mb4_unicode_ci 13 | ports: 14 | - '3306:3306' 15 | healthcheck: 16 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_DATABASE_PASSWORD"] 17 | interval: 5s 18 | timeout: 5s 19 | retries: 5 20 | start_period: 10s 21 | 22 | postgresql: 23 | image: postgres:latest 24 | restart: always 25 | environment: 26 | POSTGRES_DB: $POSTGRESQL_DATABASE_NAME 27 | POSTGRES_USER: $POSTGRESQL_DATABASE_USERNAME 28 | POSTGRES_PASSWORD: $POSTGRESQL_DATABASE_PASSWORD 29 | ports: 30 | - '5432:5432' 31 | healthcheck: 32 | test: ["CMD-SHELL", "pg_isready -U $POSTGRESQL_DATABASE_USERNAME -d $POSTGRESQL_DATABASE_NAME"] 33 | interval: 5s 34 | timeout: 5s 35 | retries: 5 36 | start_period: 10s 37 | 38 | postgresql-slave: 39 | image: postgres:latest 40 | restart: always 41 | environment: 42 | POSTGRES_DB: $POSTGRESQL_DATABASE_NAME 43 | POSTGRES_USER: $POSTGRESQL_DATABASE_USERNAME 44 | POSTGRES_PASSWORD: $POSTGRESQL_DATABASE_PASSWORD 45 | ports: 46 | - '$POSTGRESQL_SLAVE_PORT:5432' 47 | healthcheck: 48 | test: ["CMD-SHELL", "pg_isready -U $POSTGRESQL_DATABASE_USERNAME -d $POSTGRESQL_DATABASE_NAME"] 49 | interval: 5s 50 | timeout: 5s 51 | retries: 5 52 | start_period: 10s 53 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention */ 2 | import type { Config } from 'jest'; 3 | 4 | const config: Config = { 5 | moduleFileExtensions: ['js', 'json', 'ts'], 6 | transform: { 7 | '^.+\\.spec\\.(t|j)s$': [ 8 | 'ts-jest', 9 | { 10 | tsconfig: '/tsconfig.spec.json', 11 | }, 12 | ], 13 | '^.+\\.(t|j)s$': [ 14 | 'ts-jest', 15 | { 16 | tsconfig: 'tsconfig.json', 17 | }, 18 | ], 19 | }, 20 | testTimeout: 120_000, 21 | testEnvironment: 'node', 22 | verbose: true, 23 | detectLeaks: false, 24 | detectOpenHandles: true, 25 | collectCoverage: true, 26 | collectCoverageFrom: ['**/*.ts', '!**/*.d.ts'], 27 | coverageThreshold: { 28 | global: { 29 | statements: 60, 30 | branches: 60, 31 | functions: 60, 32 | lines: 60, 33 | }, 34 | }, 35 | coveragePathIgnorePatterns: ['/jest.config.ts', '.mock.ts', 'spec/'], 36 | setupFilesAfterEnv: ['/jest.setup.ts'], 37 | }; 38 | 39 | export default config; 40 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | config({ path: __dirname + '/.env.test' }); 3 | -------------------------------------------------------------------------------- /lint-staged.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.+(ts|js|json)': ['eslint -c eslint.config.mjs', 'prettier --write'], 3 | '*.+(ts)': [() => 'tsc -p tsconfig.json --noEmit', () => 'tsc -p tsconfig.spec.json --noEmit'], 4 | }; 5 | -------------------------------------------------------------------------------- /spec/auth-guard/auth-guard.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, UseGuards } from '@nestjs/common'; 2 | 3 | import { AuthGuard } from './auth.guard'; 4 | import { Crud } from '../../src/lib/crud.decorator'; 5 | import { CrudController } from '../../src/lib/interface'; 6 | import { BaseEntity } from '../base/base.entity'; 7 | import { BaseService } from '../base/base.service'; 8 | 9 | @Crud({ 10 | entity: BaseEntity, 11 | routes: { 12 | readOne: { 13 | decorators: [UseGuards(AuthGuard)], 14 | }, 15 | readMany: { 16 | decorators: [UseGuards(AuthGuard)], 17 | }, 18 | create: { 19 | decorators: [UseGuards(AuthGuard)], 20 | }, 21 | update: { 22 | decorators: [UseGuards(AuthGuard)], 23 | }, 24 | delete: { 25 | decorators: [UseGuards(AuthGuard)], 26 | }, 27 | upsert: { 28 | decorators: [UseGuards(AuthGuard)], 29 | }, 30 | recover: { 31 | decorators: [UseGuards(AuthGuard)], 32 | }, 33 | search: { 34 | decorators: [UseGuards(AuthGuard)], 35 | }, 36 | }, 37 | }) 38 | @Controller('auth-guard') 39 | export class AuthGuardController implements CrudController { 40 | constructor(public readonly crudService: BaseService) {} 41 | } 42 | -------------------------------------------------------------------------------- /spec/auth-guard/auth-guard.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { AuthGuardController } from './auth-guard.controller'; 5 | import { AuthGuard } from './auth.guard'; 6 | import { BaseEntity } from '../base/base.entity'; 7 | import { BaseService } from '../base/base.service'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([BaseEntity])], 11 | controllers: [AuthGuardController], 12 | providers: [BaseService, AuthGuard], 13 | }) 14 | export class AuthGuardModule {} 15 | -------------------------------------------------------------------------------- /spec/auth-guard/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | 4 | @Injectable() 5 | export class AuthGuard implements CanActivate { 6 | canActivate(context: ExecutionContext): boolean | Promise | Observable { 7 | const request = context.switchToHttp().getRequest(); 8 | return request.headers['x-api-key'] === 'secret'; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /spec/author/author-object.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | import { Author } from './author-object.module'; 5 | 6 | import type { Observable } from 'rxjs'; 7 | 8 | @Injectable() 9 | export class AuthorInterceptor implements NestInterceptor { 10 | intercept(context: ExecutionContext, next: CallHandler): Observable { 11 | const req = context.switchToHttp().getRequest(); 12 | const author: Author = { 13 | id: 'ID-1234', 14 | name: 'name', 15 | department: 'Request Department', 16 | modifiedAt: new Date(), 17 | }; 18 | (req as unknown as Record)['user'] = author; 19 | return next.handle(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /spec/author/author-object.module.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Injectable, Module } from '@nestjs/common'; 2 | import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; 3 | import { IsInt, IsOptional, IsString } from 'class-validator'; 4 | import { Entity, BaseEntity, Repository, PrimaryColumn, Column, DeleteDateColumn } from 'typeorm'; 5 | 6 | import { AuthorInterceptor } from './author-object.interceptor'; 7 | import { Crud } from '../../src/lib/crud.decorator'; 8 | import { CrudService } from '../../src/lib/crud.service'; 9 | import { CrudController, Method } from '../../src/lib/interface'; 10 | 11 | export interface Author { 12 | department: string; 13 | id: string; 14 | name: string; 15 | modifiedAt: Date; 16 | } 17 | @Entity('author_object') 18 | export class TestEntity extends BaseEntity { 19 | @PrimaryColumn() 20 | @IsString({ always: true }) 21 | @IsOptional({ groups: [Method.UPSERT, Method.UPDATE] }) 22 | col1: string; 23 | 24 | @Column() 25 | @IsInt({ always: true }) 26 | @IsOptional({ groups: [Method.UPDATE] }) 27 | col2: number; 28 | 29 | @Column({ nullable: true }) 30 | @IsInt({ always: true }) 31 | @IsOptional({ groups: [Method.UPDATE] }) 32 | col3: number; 33 | 34 | @Column({ type: 'jsonb', nullable: true }) 35 | createdBy: Author; 36 | 37 | @Column({ type: 'jsonb', nullable: true }) 38 | updatedBy: Author; 39 | 40 | @Column({ type: 'jsonb', nullable: true }) 41 | deletedBy: Author; 42 | 43 | @DeleteDateColumn() 44 | deletedAt?: Date; 45 | } 46 | 47 | @Injectable() 48 | export class TestService extends CrudService { 49 | constructor(@InjectRepository(TestEntity) repository: Repository) { 50 | super(repository); 51 | } 52 | } 53 | 54 | @Crud({ 55 | entity: TestEntity, 56 | routes: { 57 | create: { 58 | interceptors: [AuthorInterceptor], 59 | author: { 60 | filter: 'user', 61 | property: 'createdBy', 62 | }, 63 | }, 64 | update: { 65 | interceptors: [AuthorInterceptor], 66 | author: { 67 | filter: 'user', 68 | property: 'updatedBy', 69 | }, 70 | }, 71 | upsert: { 72 | interceptors: [AuthorInterceptor], 73 | author: { 74 | filter: 'user', 75 | property: 'updatedBy', 76 | }, 77 | }, 78 | delete: { 79 | interceptors: [AuthorInterceptor], 80 | author: { 81 | filter: 'user', 82 | property: 'deletedBy', 83 | }, 84 | }, 85 | }, 86 | }) 87 | @Controller('base') 88 | export class TestController implements CrudController { 89 | constructor(public readonly crudService: TestService) {} 90 | } 91 | 92 | @Module({ 93 | imports: [TypeOrmModule.forFeature([TestEntity])], 94 | controllers: [TestController], 95 | providers: [TestService], 96 | }) 97 | export class TestModule {} 98 | -------------------------------------------------------------------------------- /spec/author/author-value.module.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Injectable, Module } from '@nestjs/common'; 2 | import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'; 3 | import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; 4 | import { IsInt, IsOptional, IsString } from 'class-validator'; 5 | import { Entity, BaseEntity, Repository, PrimaryColumn, Column, DeleteDateColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; 6 | 7 | import { AuthorInterceptor } from './author.interceptor'; 8 | import { Crud } from '../../src/lib/crud.decorator'; 9 | import { CrudService } from '../../src/lib/crud.service'; 10 | import { CrudController, Method } from '../../src/lib/interface'; 11 | 12 | @Entity('author_value') 13 | export class TestEntity extends BaseEntity { 14 | @PrimaryColumn() 15 | @IsString({ always: true }) 16 | @IsOptional({ groups: [Method.UPSERT, Method.UPDATE] }) 17 | col1: string; 18 | 19 | @Column() 20 | @IsInt({ always: true }) 21 | @IsOptional({ groups: [Method.UPDATE] }) 22 | col2: number; 23 | 24 | @Column({ nullable: true }) 25 | @IsInt({ always: true }) 26 | @IsOptional({ groups: [Method.UPDATE] }) 27 | col3: number; 28 | 29 | @Column({ nullable: true }) 30 | createdBy: string; 31 | 32 | @Column({ nullable: true }) 33 | updatedBy: string; 34 | 35 | @Column({ nullable: true }) 36 | deletedBy: string; 37 | 38 | @DeleteDateColumn() 39 | @ApiHideProperty() 40 | deletedAt?: Date; 41 | 42 | @CreateDateColumn() 43 | @ApiProperty({ description: 'Created At' }) 44 | createdAt?: Date; 45 | 46 | @UpdateDateColumn() 47 | @ApiProperty({ description: 'Last Modified At' }) 48 | lastModifiedAt?: Date; 49 | } 50 | 51 | @Injectable() 52 | export class TestService extends CrudService { 53 | constructor(@InjectRepository(TestEntity) repository: Repository) { 54 | super(repository); 55 | } 56 | } 57 | 58 | @Crud({ 59 | entity: TestEntity, 60 | routes: { 61 | create: { 62 | interceptors: [AuthorInterceptor], 63 | author: { 64 | property: 'createdBy', 65 | value: 'fixed value', 66 | }, 67 | }, 68 | update: { 69 | interceptors: [], 70 | author: { 71 | filter: 'user', 72 | property: 'updatedBy', 73 | value: 'default value', 74 | }, 75 | }, 76 | upsert: { 77 | interceptors: [AuthorInterceptor], 78 | author: { 79 | filter: 'user', 80 | property: 'updatedBy', 81 | }, 82 | }, 83 | }, 84 | }) 85 | @Controller('base') 86 | export class TestController implements CrudController { 87 | constructor(public readonly crudService: TestService) {} 88 | } 89 | 90 | @Module({ 91 | imports: [TypeOrmModule.forFeature([TestEntity])], 92 | controllers: [TestController], 93 | providers: [TestService], 94 | }) 95 | export class TestModule {} 96 | -------------------------------------------------------------------------------- /spec/author/author-value.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { TestEntity, TestModule } from './author-value.module'; 6 | import { TestHelper } from '../test.helper'; 7 | 8 | import type { INestApplication } from '@nestjs/common'; 9 | import type { TestingModule } from '@nestjs/testing'; 10 | 11 | describe('Author', () => { 12 | let app: INestApplication; 13 | 14 | beforeAll(async () => { 15 | const moduleFixture: TestingModule = await Test.createTestingModule({ 16 | imports: [TestModule, TestHelper.getTypeOrmMysqlModule([TestEntity])], 17 | }).compile(); 18 | app = moduleFixture.createNestApplication(); 19 | await app.init(); 20 | }); 21 | 22 | afterAll(async () => { 23 | await TestHelper.dropTypeOrmEntityTables(); 24 | await app?.close(); 25 | }); 26 | 27 | it('should be set to value, if filter is empty', async () => { 28 | const { body: createdBody } = await request(app.getHttpServer()) 29 | .post('/base') 30 | .send({ col1: '1', col2: 1, col3: 10 }) 31 | .expect(HttpStatus.CREATED); 32 | expect(createdBody.col2).toEqual(1); 33 | expect(createdBody.col3).toEqual(10); 34 | expect(createdBody.createdBy).toEqual('fixed value'); 35 | expect(createdBody.createdAt).toBeDefined(); 36 | expect(createdBody.updatedBy).toBeNull(); 37 | expect(createdBody.lastModifiedAt).toBeDefined(); 38 | expect(createdBody.deletedBy).toBeNull(); 39 | expect(createdBody.deletedAt).toBeNull(); 40 | }); 41 | 42 | it('should be set to value, if the value obtained by filter is empty', async () => { 43 | const { body: upsertBody } = await request(app.getHttpServer()).put('/base/2').send({ col2: 2, col3: 20 }).expect(HttpStatus.OK); 44 | expect(upsertBody.col2).toEqual(2); 45 | expect(upsertBody.createdBy).toBeNull(); 46 | expect(upsertBody.createdAt).toBeDefined(); 47 | expect(upsertBody.updatedBy).toEqual('Request User'); 48 | expect(upsertBody.lastModifiedAt).toBeDefined(); 49 | expect(upsertBody.deletedBy).toBeNull(); 50 | expect(upsertBody.deletedAt).toBeNull(); 51 | 52 | const { body: updatedBody } = await request(app.getHttpServer()).patch('/base/2').send({ col3: 15 }).expect(HttpStatus.OK); 53 | expect(updatedBody.col2).toEqual(2); 54 | expect(updatedBody.col3).toEqual(15); 55 | expect(updatedBody.createdBy).toEqual(upsertBody.createdBy); 56 | expect(updatedBody.createdAt).toEqual(upsertBody.createdAt); 57 | expect(updatedBody.updatedBy).toEqual('default value'); 58 | expect(updatedBody.lastModifiedAt).not.toEqual(upsertBody.lastModifiedAt); 59 | expect(updatedBody.deletedBy).toBeNull(); 60 | expect(updatedBody.deletedAt).toBeNull(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /spec/author/author.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | import type { Observable } from 'rxjs'; 5 | 6 | @Injectable() 7 | export class AuthorInterceptor implements NestInterceptor { 8 | intercept(context: ExecutionContext, next: CallHandler): Observable { 9 | const req = context.switchToHttp().getRequest(); 10 | (req as unknown as Record)['user'] = 'Request User'; 11 | return next.handle(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/base/base.controller.create.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { BaseEntity } from './base.entity'; 6 | import { BaseModule } from './base.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('BaseController', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [BaseModule, TestHelper.getTypeOrmMysqlModule([BaseEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | beforeEach(async () => { 25 | await BaseEntity.delete({}); 26 | }); 27 | 28 | afterAll(async () => { 29 | await TestHelper.dropTypeOrmEntityTables(); 30 | await app?.close(); 31 | }); 32 | 33 | describe('CREATE_ONE', () => { 34 | it('should be provided /', async () => { 35 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 36 | expect(routerPathList.post).toEqual(expect.arrayContaining(['/base'])); 37 | }); 38 | 39 | it('creates one entity and returns it', async () => { 40 | const name = 'name1'; 41 | const response = await request(app.getHttpServer()).post('/base').send({ name }); 42 | 43 | expect(response.statusCode).toEqual(HttpStatus.CREATED); 44 | expect(response.body.name).toEqual(name); 45 | 46 | await request(app.getHttpServer()).get(`/base/${response.body.id}`).expect(HttpStatus.OK); 47 | }); 48 | 49 | it('create value of unknown key', async () => { 50 | const name = 'name1'; 51 | await request(app.getHttpServer()).post('/base').send({ name, nonamed: 1 }).expect(HttpStatus.UNPROCESSABLE_ENTITY); 52 | }); 53 | }); 54 | 55 | describe('CREATE_MANY', () => { 56 | it('creates many entities and returns all', async () => { 57 | const toCreate = [{ name: 'name1' }, { name: 'name2' }]; 58 | 59 | const response = await request(app.getHttpServer()).post('/base').send(toCreate); 60 | 61 | expect(response.statusCode).toEqual(HttpStatus.CREATED); 62 | expect(response.body).toHaveLength(toCreate.length); 63 | 64 | await request(app.getHttpServer()).get(`/base/${response.body[0].id}`).expect(HttpStatus.OK); 65 | }); 66 | 67 | it('create value of unknown key', async () => { 68 | const toCreate = [{ name: 'name1' }, { name: 'name2', nonamed: 2 }]; 69 | await request(app.getHttpServer()).post('/base').send(toCreate).expect(HttpStatus.UNPROCESSABLE_ENTITY); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /spec/base/base.controller.delete.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { BaseEntity } from './base.entity'; 6 | import { BaseModule } from './base.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('BaseController', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [BaseModule, TestHelper.getTypeOrmMysqlModule([BaseEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | beforeEach(async () => { 25 | await BaseEntity.delete({}); 26 | }); 27 | 28 | afterAll(async () => { 29 | await TestHelper.dropTypeOrmEntityTables(); 30 | await app?.close(); 31 | }); 32 | 33 | describe('DELETE', () => { 34 | it('should be provided /:id', async () => { 35 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 36 | expect(routerPathList.delete).toEqual(expect.arrayContaining(['/base/:id'])); 37 | }); 38 | 39 | it('removes one entity', async () => { 40 | const name = 'name1'; 41 | const created = await request(app.getHttpServer()).post('/base').send({ name }).expect(HttpStatus.CREATED); 42 | const id = created.body.id; 43 | 44 | await request(app.getHttpServer()).delete(`/base/${id}`).expect(HttpStatus.OK); 45 | 46 | await request(app.getHttpServer()).get(`/base/${id}`).expect(HttpStatus.NOT_FOUND); 47 | 48 | await request(app.getHttpServer()).delete(`/base/${id}`).expect(HttpStatus.NOT_FOUND); 49 | }); 50 | 51 | it('should be checked params type', async () => { 52 | await request(app.getHttpServer()) 53 | .delete(`/base/${Number('a')}`) 54 | .expect(HttpStatus.NOT_FOUND); 55 | }); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /spec/base/base.controller.read-many.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { BaseEntity } from './base.entity'; 4 | import { BaseModule } from './base.module'; 5 | import { TestHelper } from '../test.helper'; 6 | 7 | import type { INestApplication } from '@nestjs/common'; 8 | import type { TestingModule } from '@nestjs/testing'; 9 | 10 | describe('BaseController', () => { 11 | let app: INestApplication; 12 | 13 | beforeAll(async () => { 14 | const moduleFixture: TestingModule = await Test.createTestingModule({ 15 | imports: [BaseModule, TestHelper.getTypeOrmMysqlModule([BaseEntity])], 16 | }).compile(); 17 | app = moduleFixture.createNestApplication(); 18 | 19 | await app.init(); 20 | }); 21 | 22 | afterAll(async () => { 23 | await TestHelper.dropTypeOrmEntityTables(); 24 | await app?.close(); 25 | }); 26 | 27 | describe('READ_MANY', () => { 28 | it('should be provided /', async () => { 29 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 30 | expect(routerPathList.get).toEqual(expect.arrayContaining(['/base'])); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /spec/base/base.controller.recover.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { BaseEntity } from './base.entity'; 6 | import { BaseModule } from './base.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('BaseController', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [BaseModule, TestHelper.getTypeOrmMysqlModule([BaseEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('RECOVER', () => { 30 | it('should be provided /:id/recover', async () => { 31 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 32 | expect(routerPathList.post).toEqual(expect.arrayContaining(['/base/:id/recover'])); 33 | }); 34 | 35 | it('recover the entity after delete', async () => { 36 | const name = 'name1'; 37 | const created = await request(app.getHttpServer()).post('/base').send({ name }).expect(HttpStatus.CREATED); 38 | const id = created.body.id; 39 | 40 | await request(app.getHttpServer()).get(`/base/${id}`).expect(HttpStatus.OK); 41 | 42 | await request(app.getHttpServer()).delete(`/base/${id}`).expect(HttpStatus.OK); 43 | 44 | await request(app.getHttpServer()).get(`/base/${id}`).expect(HttpStatus.NOT_FOUND); 45 | 46 | const { body } = await request(app.getHttpServer()).get('/base').expect(HttpStatus.OK); 47 | expect(body.data.some((entity: any) => entity.id === id)).toBeFalsy(); 48 | 49 | const recoverResponse = await request(app.getHttpServer()).post(`/base/${id}/recover`).expect(HttpStatus.CREATED); 50 | expect(recoverResponse.body.deletedAt).toBeNull(); 51 | 52 | const getResponse = await request(app.getHttpServer()).get(`/base/${id}`).expect(HttpStatus.OK); 53 | expect(getResponse.body.deletedAt).toBeNull(); 54 | }); 55 | 56 | it('should be checked params type', async () => { 57 | await request(app.getHttpServer()) 58 | .post(`/base/${Number('a')}/recover`) 59 | .expect(HttpStatus.NOT_FOUND); 60 | }); 61 | 62 | it('should be throw not found exception', async () => { 63 | await request(app.getHttpServer()).post('/base/0/recover').expect(HttpStatus.NOT_FOUND); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /spec/base/base.controller.search.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { BaseEntity } from './base.entity'; 4 | import { BaseModule } from './base.module'; 5 | import { TestHelper } from '../test.helper'; 6 | 7 | import type { INestApplication } from '@nestjs/common'; 8 | import type { TestingModule } from '@nestjs/testing'; 9 | 10 | describe('BaseController', () => { 11 | let app: INestApplication; 12 | 13 | beforeAll(async () => { 14 | const moduleFixture: TestingModule = await Test.createTestingModule({ 15 | imports: [BaseModule, TestHelper.getTypeOrmMysqlModule([BaseEntity])], 16 | }).compile(); 17 | app = moduleFixture.createNestApplication(); 18 | 19 | await app.init(); 20 | }); 21 | 22 | afterAll(async () => { 23 | await TestHelper.dropTypeOrmEntityTables(); 24 | await app?.close(); 25 | }); 26 | 27 | describe('SEARCH', () => { 28 | it('should be provided /search', async () => { 29 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 30 | expect(routerPathList.post).toEqual(expect.arrayContaining(['/base/search'])); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /spec/base/base.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { BaseController } from './base.controller'; 4 | import { BaseEntity } from './base.entity'; 5 | import { BaseModule } from './base.module'; 6 | import { BaseService } from './base.service'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('BaseController', () => { 13 | let app: INestApplication; 14 | let controller: BaseController; 15 | let service: BaseService; 16 | 17 | beforeAll(async () => { 18 | const moduleFixture: TestingModule = await Test.createTestingModule({ 19 | imports: [BaseModule, TestHelper.getTypeOrmMysqlModule([BaseEntity])], 20 | }).compile(); 21 | app = moduleFixture.createNestApplication(); 22 | 23 | controller = moduleFixture.get(BaseController); 24 | service = moduleFixture.get(BaseService); 25 | await service.repository.save(['name1', 'name2'].map((name: string) => service.repository.create({ name }))); 26 | 27 | await app.init(); 28 | }); 29 | 30 | afterAll(async () => { 31 | await TestHelper.dropTypeOrmEntityTables(); 32 | await app?.close(); 33 | }); 34 | 35 | it('dynamic method on controller', async () => { 36 | const controllerPrototype = Object.getPrototypeOf(Object.getPrototypeOf(controller)); 37 | const propertyNames = Object.getOwnPropertyNames(controllerPrototype).filter((name) => name !== 'constructor'); 38 | 39 | const expectedMethods = [ 40 | 'reservedReadOne', 41 | 'reservedReadMany', 42 | 'reservedCreate', 43 | 'reservedUpdate', 44 | 'reservedUpsert', 45 | 'reservedDelete', 46 | 'reservedRecover', 47 | 'reservedSearch', 48 | ]; 49 | 50 | expect(propertyNames).toHaveLength(expectedMethods.length); 51 | expect(propertyNames).toEqual(expect.arrayContaining(expectedMethods)); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /spec/base/base.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { BaseEntity } from './base.entity'; 4 | import { BaseService } from './base.service'; 5 | import { Crud } from '../../src/lib/crud.decorator'; 6 | import { CrudController } from '../../src/lib/interface'; 7 | 8 | @Crud({ 9 | entity: BaseEntity, 10 | }) 11 | @Controller('base') 12 | export class BaseController implements CrudController { 13 | constructor(public readonly crudService: BaseService) {} 14 | } 15 | -------------------------------------------------------------------------------- /spec/base/base.entity.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsString, IsOptional, IsInt } from 'class-validator'; 3 | import { Column, Entity } from 'typeorm'; 4 | 5 | import { GROUP } from '../../src/lib/interface'; 6 | import { CrudAbstractEntity } from '../crud.abstract.entity'; 7 | 8 | @Entity('base') 9 | export class BaseEntity extends CrudAbstractEntity { 10 | @Column({ nullable: true }) 11 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.PARAMS, GROUP.SEARCH] }) 12 | @IsOptional({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.SEARCH] }) 13 | name: string; 14 | 15 | @Column({ nullable: true }) 16 | @Type(() => Number) 17 | @IsInt({ always: true }) 18 | @IsOptional({ always: true }) 19 | type: number; 20 | 21 | @Column({ nullable: true }) 22 | @IsString({ always: true }) 23 | @IsOptional({ always: true }) 24 | description: string; 25 | } 26 | -------------------------------------------------------------------------------- /spec/base/base.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { BaseController } from './base.controller'; 5 | import { BaseEntity } from './base.entity'; 6 | import { BaseService } from './base.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([BaseEntity])], 10 | controllers: [BaseController], 11 | providers: [BaseService], 12 | }) 13 | export class BaseModule {} 14 | -------------------------------------------------------------------------------- /spec/base/base.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { BaseEntity } from './base.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class BaseService extends CrudService { 10 | constructor(@InjectRepository(BaseEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/changed-error-message/duplicated.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { TestEntity, TestModule } from './test.module'; 6 | import { TestHelper } from '../test.helper'; 7 | 8 | import type { INestApplication } from '@nestjs/common'; 9 | import type { TestingModule } from '@nestjs/testing'; 10 | 11 | describe('Changed Error message', () => { 12 | let app: INestApplication; 13 | 14 | beforeAll(async () => { 15 | const moduleFixture: TestingModule = await Test.createTestingModule({ 16 | imports: [TestModule, TestHelper.getTypeOrmPgsqlModule([TestEntity])], 17 | }).compile(); 18 | app = moduleFixture.createNestApplication(); 19 | await app.init(); 20 | }); 21 | 22 | afterAll(async () => { 23 | await TestHelper.dropTypeOrmEntityTables(); 24 | await app?.close(); 25 | }); 26 | 27 | it('should be returned defined error message when duplicated', async () => { 28 | await request(app.getHttpServer()).post('/base').send({ uuid: 'uuid', name: 'name' }).expect(HttpStatus.CREATED); 29 | const { body } = await request(app.getHttpServer()).post('/base').send({ uuid: 'uuid2', name: 'name' }).expect(HttpStatus.CONFLICT); 30 | expect(body).toEqual({ statusCode: 409, message: 'custom error message' }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /spec/changed-error-message/test.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ConflictException, 5 | Controller, 6 | ExceptionFilter, 7 | HttpException, 8 | Injectable, 9 | Module, 10 | UseFilters, 11 | } from '@nestjs/common'; 12 | import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; 13 | import { IsOptional, IsString } from 'class-validator'; 14 | import { Response } from 'express'; 15 | import { Entity, BaseEntity, Repository, PrimaryColumn, Column, Index } from 'typeorm'; 16 | 17 | import { Crud } from '../../src/lib/crud.decorator'; 18 | import { CrudService } from '../../src/lib/crud.service'; 19 | import { CrudController, GROUP, Method } from '../../src/lib/interface'; 20 | 21 | @Catch(ConflictException) 22 | export class HttpExceptionFilter implements ExceptionFilter { 23 | constructor(private readonly message: string) {} 24 | catch(exception: HttpException, host: ArgumentsHost): void { 25 | const ctx = host.switchToHttp(); 26 | const response = ctx.getResponse(); 27 | const status = exception.getStatus(); 28 | 29 | response.status(status).json({ 30 | statusCode: status, 31 | message: this.message, 32 | }); 33 | } 34 | } 35 | 36 | @Entity('test') 37 | export class TestEntity extends BaseEntity { 38 | @PrimaryColumn() 39 | @IsString({ always: true }) 40 | @IsOptional({ groups: [GROUP.READ_MANY] }) 41 | uuid: string; 42 | 43 | @Column() 44 | @Index({ unique: true }) 45 | @IsString({ always: true }) 46 | @IsOptional({ groups: [GROUP.READ_MANY] }) 47 | name: string; 48 | } 49 | 50 | @Injectable() 51 | export class TestService extends CrudService { 52 | constructor(@InjectRepository(TestEntity) repository: Repository) { 53 | super(repository); 54 | } 55 | } 56 | 57 | @Crud({ 58 | entity: TestEntity, 59 | only: ['create', 'readMany'], 60 | routes: { 61 | [Method.CREATE]: { 62 | decorators: [UseFilters(new HttpExceptionFilter('custom error message'))], 63 | }, 64 | }, 65 | }) 66 | @Controller('base') 67 | export class TestController implements CrudController { 68 | constructor(public readonly crudService: TestService) {} 69 | } 70 | 71 | @Module({ 72 | imports: [TypeOrmModule.forFeature([TestEntity])], 73 | controllers: [TestController], 74 | providers: [TestService], 75 | }) 76 | export class TestModule {} 77 | -------------------------------------------------------------------------------- /spec/crud.abstract.entity.ts: -------------------------------------------------------------------------------- 1 | import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'; 2 | import { Type } from 'class-transformer'; 3 | import { IsNumber } from 'class-validator'; 4 | import { BaseEntity, CreateDateColumn, DeleteDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; 5 | 6 | import { GROUP } from '../src/lib/interface'; 7 | 8 | export class CrudAbstractEntity extends BaseEntity { 9 | @PrimaryGeneratedColumn({ type: 'bigint' }) 10 | @IsNumber({}, { groups: [GROUP.PARAMS] }) 11 | @Type(() => Number) 12 | id: number; 13 | 14 | /** 15 | * 삭제시간 16 | */ 17 | @DeleteDateColumn() 18 | @ApiHideProperty() 19 | deletedAt?: Date; 20 | 21 | /** 22 | * 생성시간 23 | */ 24 | @CreateDateColumn() 25 | @ApiProperty({ description: 'Created At' }) 26 | createdAt?: Date; 27 | 28 | /** 29 | * 수정시간 30 | */ 31 | @UpdateDateColumn() 32 | @ApiProperty({ description: 'Last Modified At' }) 33 | lastModifiedAt?: Date; 34 | } 35 | -------------------------------------------------------------------------------- /spec/custom-entity/custom-entity.controller.create.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { CustomEntity } from './custom-entity.entity'; 6 | import { CustomEntityModule } from './custom-entity.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('CustomEntity - Create', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [CustomEntityModule, TestHelper.getTypeOrmMysqlModule([CustomEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('CREATE_ONE', () => { 30 | it('should be provided /', async () => { 31 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 32 | expect(routerPathList.post).toEqual(expect.arrayContaining(['/base'])); 33 | }); 34 | 35 | it('creates one entity and returns it', async () => { 36 | const name = 'name1'; 37 | const response = await request(app.getHttpServer()).post('/base').send({ name }); 38 | 39 | expect(response.statusCode).toEqual(HttpStatus.CREATED); 40 | expect(response.body.name).toEqual(name); 41 | 42 | await request(app.getHttpServer()).get(`/base/${response.body.uuid}`).expect(HttpStatus.OK); 43 | }); 44 | }); 45 | 46 | describe('CREATE_MANY', () => { 47 | it('creates many entities and returns all', async () => { 48 | const toCreate = [{ name: 'name1' }, { name: 'name2' }]; 49 | 50 | const response = await request(app.getHttpServer()).post('/base').send(toCreate); 51 | 52 | expect(response.statusCode).toEqual(HttpStatus.CREATED); 53 | expect(response.body).toHaveLength(toCreate.length); 54 | 55 | await request(app.getHttpServer()).get(`/base/${response.body[0].uuid}`).expect(HttpStatus.OK); 56 | }); 57 | 58 | it('create value of unknown key', async () => { 59 | const toCreate = [{ name: 'name1' }, { name: 'name2', nonamed: 2 }]; 60 | await request(app.getHttpServer()).post('/base').send(toCreate).expect(HttpStatus.UNPROCESSABLE_ENTITY); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /spec/custom-entity/custom-entity.controller.delete.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { CustomEntity } from './custom-entity.entity'; 6 | import { CustomEntityModule } from './custom-entity.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('CustomEntity - Delete', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [CustomEntityModule, TestHelper.getTypeOrmMysqlModule([CustomEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('DELETE', () => { 30 | it('should be provided /:uuid', async () => { 31 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 32 | expect(routerPathList.delete).toEqual(expect.arrayContaining(['/base/:uuid'])); 33 | }); 34 | 35 | it('removes one entity', async () => { 36 | const name = 'name1'; 37 | const { 38 | body: { uuid }, 39 | } = await request(app.getHttpServer()).post('/base').send({ name }).expect(HttpStatus.CREATED); 40 | 41 | await request(app.getHttpServer()).delete(`/base/${uuid}`).expect(HttpStatus.OK); 42 | 43 | await request(app.getHttpServer()).get(`/base/${uuid}`).expect(HttpStatus.NOT_FOUND); 44 | 45 | await request(app.getHttpServer()).delete(`/base/${uuid}`).expect(HttpStatus.NOT_FOUND); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /spec/custom-entity/custom-entity.controller.read-one.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { CustomEntity } from './custom-entity.entity'; 6 | import { CustomEntityModule } from './custom-entity.module'; 7 | import { CustomEntityService } from './custom-entity.service'; 8 | import { TestHelper } from '../test.helper'; 9 | 10 | import type { INestApplication } from '@nestjs/common'; 11 | import type { TestingModule } from '@nestjs/testing'; 12 | 13 | describe('CustomEntity - ReadOne', () => { 14 | let app: INestApplication; 15 | let service: CustomEntityService; 16 | 17 | beforeAll(async () => { 18 | const moduleFixture: TestingModule = await Test.createTestingModule({ 19 | imports: [CustomEntityModule, TestHelper.getTypeOrmMysqlModule([CustomEntity])], 20 | }).compile(); 21 | app = moduleFixture.createNestApplication(); 22 | 23 | service = moduleFixture.get(CustomEntityService); 24 | await service.repository.save(['name1', 'name2'].map((name: string) => service.repository.create({ name }))); 25 | 26 | await app.init(); 27 | }); 28 | 29 | afterAll(async () => { 30 | await TestHelper.dropTypeOrmEntityTables(); 31 | await app?.close(); 32 | }); 33 | 34 | describe('READ_ONE', () => { 35 | let id: string; 36 | beforeAll(async () => { 37 | id = (await service.getAll())?.[0]?.uuid; 38 | }); 39 | 40 | it('should be provided /:uuid', async () => { 41 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 42 | expect(routerPathList.get).toEqual(expect.arrayContaining(['/base/:uuid'])); 43 | }); 44 | 45 | it('should be returned only one entity', async () => { 46 | const response = await request(app.getHttpServer()) 47 | .get(`/base/${id}`) 48 | .query({ fields: ['uuid', 'name'] }); 49 | 50 | expect(response.statusCode).toEqual(HttpStatus.OK); 51 | expect(response.body.uuid).toEqual(id); 52 | expect(response.body.name).toEqual(expect.any(String)); 53 | expect(response.body.lastModifiedAt).toBeUndefined(); 54 | }); 55 | 56 | it('should be use only column names in field options', async () => { 57 | await request(app.getHttpServer()) 58 | .get(`/base/${id}`) 59 | .query({ fields: ['uuid', 'name'] }) 60 | .expect(HttpStatus.OK); 61 | 62 | await request(app.getHttpServer()) 63 | .get(`/base/${id}`) 64 | .query({ fields: ['id', 'name', 'createdAt', true] }) 65 | .expect(HttpStatus.UNPROCESSABLE_ENTITY); 66 | 67 | await request(app.getHttpServer()) 68 | .get(`/base/${id}`) 69 | .query({ fields: ['uuid', 'name', 'test'] }) 70 | .expect(HttpStatus.UNPROCESSABLE_ENTITY); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /spec/custom-entity/custom-entity.controller.recover.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { CustomEntity } from './custom-entity.entity'; 6 | import { CustomEntityModule } from './custom-entity.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('CustomEntity - Delete', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [CustomEntityModule, TestHelper.getTypeOrmMysqlModule([CustomEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('RECOVER', () => { 30 | it('should be provided /:uuid/recover', async () => { 31 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 32 | expect(routerPathList.post).toEqual(expect.arrayContaining(['/base/:uuid/recover'])); 33 | }); 34 | 35 | it('recover the entity after delete', async () => { 36 | const name = 'name1'; 37 | const { 38 | body: { uuid }, 39 | } = await request(app.getHttpServer()).post('/base').send({ name }).expect(HttpStatus.CREATED); 40 | 41 | await request(app.getHttpServer()).get(`/base/${uuid}`).expect(HttpStatus.OK); 42 | 43 | await request(app.getHttpServer()).delete(`/base/${uuid}`).expect(HttpStatus.OK); 44 | 45 | await request(app.getHttpServer()).get(`/base/${uuid}`).expect(HttpStatus.NOT_FOUND); 46 | 47 | const { body } = await request(app.getHttpServer()).get('/base').expect(HttpStatus.OK); 48 | expect(body.data.some((entity: any) => entity.uuid === uuid)).toBeFalsy(); 49 | 50 | await request(app.getHttpServer()).post(`/base/${uuid}/recover`).expect(HttpStatus.CREATED); 51 | 52 | await request(app.getHttpServer()).get(`/base/${uuid}`).expect(HttpStatus.OK); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /spec/custom-entity/custom-entity.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { CustomEntity } from './custom-entity.entity'; 4 | import { CustomEntityService } from './custom-entity.service'; 5 | import { Crud } from '../../src/lib/crud.decorator'; 6 | import { CrudController } from '../../src/lib/interface'; 7 | 8 | @Crud({ 9 | entity: CustomEntity, 10 | }) 11 | @Controller('base') 12 | export class CustomEntityController implements CrudController { 13 | constructor(public readonly crudService: CustomEntityService) {} 14 | } 15 | -------------------------------------------------------------------------------- /spec/custom-entity/custom-entity.controller.update.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { CustomEntity } from './custom-entity.entity'; 6 | import { CustomEntityModule } from './custom-entity.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('CustomEntity - Update', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [CustomEntityModule, TestHelper.getTypeOrmMysqlModule([CustomEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('UPDATE_ONE', () => { 30 | it('should be provided /:uuid', async () => { 31 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 32 | expect(routerPathList.patch).toEqual(expect.arrayContaining(['/base/:uuid'])); 33 | }); 34 | 35 | it('updates one entity', async () => { 36 | const oldName = 'name1'; 37 | const created = await request(app.getHttpServer()).post('/base').send({ name: oldName }).expect(HttpStatus.CREATED); 38 | expect(created.body.name).toEqual(oldName); 39 | 40 | const newName = 'name2'; 41 | await request(app.getHttpServer()) 42 | .patch(`/base/${created.body.uuid}`) 43 | .send({ name: newName }) 44 | .expect(HttpStatus.UNPROCESSABLE_ENTITY); 45 | 46 | await request(app.getHttpServer()).patch(`/base/${created.body.uuid}`).send({}).expect(HttpStatus.OK); 47 | 48 | const descriptions = 'descriptions'; 49 | await request(app.getHttpServer()).patch(`/base/${created.body.uuid}`).send({ descriptions }).expect(HttpStatus.OK); 50 | 51 | const response = await request(app.getHttpServer()).get(`/base/${created.body.uuid}`).expect(HttpStatus.OK); 52 | expect(response.body.name).not.toEqual(newName); 53 | expect(response.body.descriptions).toEqual(descriptions); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /spec/custom-entity/custom-entity.entity.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; 4 | import { BaseEntity, BeforeInsert, Column, DeleteDateColumn, Entity, PrimaryColumn } from 'typeorm'; 5 | 6 | import { GROUP } from '../../src/lib/interface'; 7 | 8 | @Entity('base') 9 | export class CustomEntity extends BaseEntity { 10 | @PrimaryColumn() 11 | @IsString({ groups: [GROUP.PARAMS, GROUP.SEARCH] }) 12 | @IsOptional({ groups: [GROUP.SEARCH] }) 13 | uuid: string; 14 | 15 | @Column({ nullable: true }) 16 | @IsString({ groups: [GROUP.CREATE, GROUP.UPSERT, GROUP.SEARCH] }) 17 | @IsNotEmpty({ groups: [GROUP.CREATE] }) 18 | @IsOptional({ groups: [GROUP.UPSERT, GROUP.SEARCH] }) 19 | name: string; 20 | 21 | @Column('varchar', { nullable: true }) 22 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.UPSERT, GROUP.SEARCH] }) 23 | @IsOptional({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.UPSERT, GROUP.SEARCH] }) 24 | descriptions?: string; 25 | 26 | @DeleteDateColumn() 27 | deletedAt?: Date; 28 | 29 | @BeforeInsert() 30 | setPrimaryKey(): void { 31 | this.uuid = this.uuid ?? `${Date.now()}${crypto.getRandomValues(new Uint16Array(1))[0]}`; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /spec/custom-entity/custom-entity.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { CustomEntityController } from './custom-entity.controller'; 5 | import { CustomEntity } from './custom-entity.entity'; 6 | import { CustomEntityService } from './custom-entity.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([CustomEntity])], 10 | controllers: [CustomEntityController], 11 | providers: [CustomEntityService], 12 | }) 13 | export class CustomEntityModule {} 14 | -------------------------------------------------------------------------------- /spec/custom-entity/custom-entity.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { CustomEntity } from './custom-entity.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class CustomEntityService extends CrudService { 10 | constructor(@InjectRepository(CustomEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | 14 | async getAll(): Promise { 15 | const entities = await this.repository.find(); 16 | return entities; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spec/custom-swagger-decorator/apply-api-extra-model.spec.ts: -------------------------------------------------------------------------------- 1 | import { ApiExtraModels } from '@nestjs/swagger'; 2 | import { SwaggerScanner } from '@nestjs/swagger/dist/swagger-scanner'; 3 | import { Test } from '@nestjs/testing'; 4 | 5 | import { ExtraModel } from './extra-model'; 6 | import { DynamicCrudModule } from '../dynamic-crud.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('Apply ApiExtraModels Decorator', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [DynamicCrudModule({ readMany: { decorators: [ApiExtraModels(ExtraModel)] } })], 18 | }).compile(); 19 | 20 | app = moduleFixture.createNestApplication(); 21 | 22 | await app.init(); 23 | }); 24 | 25 | afterAll(async () => { 26 | await TestHelper.dropTypeOrmEntityTables(); 27 | await app?.close(); 28 | }); 29 | 30 | it('should include extra model', () => { 31 | const swaggerScanner = new SwaggerScanner(); 32 | const document = swaggerScanner.scanApplication(app, {}); 33 | 34 | expect(document.components?.schemas).toHaveProperty(ExtraModel.name); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /spec/custom-swagger-decorator/extra-model.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ExtraModel { 4 | @ApiProperty({ description: 'id' }) 5 | id: string; 6 | } 7 | -------------------------------------------------------------------------------- /spec/custom-swagger-decorator/without-custom-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { SwaggerScanner } from '@nestjs/swagger/dist/swagger-scanner'; 2 | import { Test } from '@nestjs/testing'; 3 | 4 | import { ExtraModel } from './extra-model'; 5 | import { DynamicCrudModule } from '../dynamic-crud.module'; 6 | import { TestHelper } from '../test.helper'; 7 | 8 | import type { INestApplication } from '@nestjs/common'; 9 | import type { TestingModule } from '@nestjs/testing'; 10 | 11 | describe('No custom Swagger Decorator', () => { 12 | let app: INestApplication; 13 | 14 | beforeAll(async () => { 15 | const moduleFixture: TestingModule = await Test.createTestingModule({ 16 | imports: [DynamicCrudModule({ readMany: { decorators: [] } })], 17 | }).compile(); 18 | 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | it('should not include extra model', () => { 30 | const swaggerScanner = new SwaggerScanner(); 31 | const document = swaggerScanner.scanApplication(app, {}); 32 | 33 | expect(document.components?.schemas).not.toHaveProperty(ExtraModel.name); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /spec/dynamic-crud.module.ts: -------------------------------------------------------------------------------- 1 | import { Controller, forwardRef, Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { BaseEntity } from './base/base.entity'; 5 | import { BaseService } from './base/base.service'; 6 | import { TestHelper } from './test.helper'; 7 | import { Crud, CrudController, CrudOptions } from '../src'; 8 | 9 | export function DynamicCrudModule(routeOption: CrudOptions['routes'], prefix = 'base', entity = BaseEntity) { 10 | function getController() { 11 | @Crud({ entity, routes: routeOption }) 12 | @Controller(prefix) 13 | class BaseController implements CrudController { 14 | constructor(public readonly crudService: BaseService) {} 15 | } 16 | return BaseController; 17 | } 18 | 19 | function getModule() { 20 | @Module({ 21 | imports: [forwardRef(() => TestHelper.getTypeOrmMysqlModule([BaseEntity])), TypeOrmModule.forFeature([BaseEntity])], 22 | controllers: [getController()], 23 | providers: [BaseService], 24 | }) 25 | class BaseModule {} 26 | return BaseModule; 27 | } 28 | 29 | return getModule(); 30 | } 31 | -------------------------------------------------------------------------------- /spec/embedded-entities/embedded-entites.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { EmbeddedEntitiesModule } from './embedded-entities.module'; 6 | import { EmployeeEntity } from './employee.entity'; 7 | import { EmployeeService } from './employee.service'; 8 | import { UserEntity } from './user.entity'; 9 | import { TestHelper } from '../test.helper'; 10 | 11 | import type { INestApplication } from '@nestjs/common'; 12 | import type { TestingModule } from '@nestjs/testing'; 13 | 14 | describe('Embedded-entities', () => { 15 | let app: INestApplication; 16 | 17 | beforeAll(async () => { 18 | const moduleFixture: TestingModule = await Test.createTestingModule({ 19 | imports: [EmbeddedEntitiesModule, TestHelper.getTypeOrmPgsqlModule([UserEntity, EmployeeEntity])], 20 | }).compile(); 21 | app = moduleFixture.createNestApplication(); 22 | await app.init(); 23 | }); 24 | 25 | afterAll(async () => { 26 | const repository = app.get(EmployeeService).repository; 27 | await repository.query('DROP TABLE IF EXISTS user_entity'); 28 | await repository.query('DROP TABLE IF EXISTS employee_entity'); 29 | await app?.close(); 30 | }); 31 | 32 | it('should be provided url', async () => { 33 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 34 | expect(routerPathList.get).toEqual(expect.arrayContaining(['/user/:id', '/user', '/employee/:id', '/employee'])); 35 | }); 36 | 37 | it('should be used embedded entity', async () => { 38 | const { body: created } = await request(app.getHttpServer()) 39 | .post('/user') 40 | .send({ isActive: true, name: { first: 'firstUserName', last: 'lastUserName' } }) 41 | .expect(HttpStatus.CREATED); 42 | 43 | const { body: readOne } = await request(app.getHttpServer()).get(`/user/${created.id}`).expect(HttpStatus.OK); 44 | expect(readOne).toEqual({ 45 | id: created.id, 46 | isActive: true, 47 | name: { first: 'firstUserName', last: 'lastUserName' }, 48 | }); 49 | 50 | const { body: readMany } = await request(app.getHttpServer()).get('/user').expect(HttpStatus.OK); 51 | expect(readMany.data[0].name).toBeDefined(); 52 | 53 | const { body: updated } = await request(app.getHttpServer()) 54 | .patch(`/user/${created.id}`) 55 | .send({ isActive: false, name: { first: 'updatedFirstUserName', last: 'updatedLastUserName' } }) 56 | .expect(HttpStatus.OK); 57 | expect(updated).toEqual({ 58 | id: created.id, 59 | isActive: false, 60 | name: { first: 'updatedFirstUserName', last: 'updatedLastUserName' }, 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /spec/embedded-entities/embedded-entities.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { EmployeeController } from './employee.controller'; 5 | import { EmployeeEntity } from './employee.entity'; 6 | import { EmployeeService } from './employee.service'; 7 | import { UserController } from './user.controller'; 8 | import { UserEntity } from './user.entity'; 9 | import { UserService } from './user.service'; 10 | 11 | @Module({ 12 | imports: [TypeOrmModule.forFeature([UserEntity, EmployeeEntity])], 13 | controllers: [UserController, EmployeeController], 14 | providers: [UserService, EmployeeService], 15 | }) 16 | export class EmbeddedEntitiesModule {} 17 | -------------------------------------------------------------------------------- /spec/embedded-entities/employee.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { EmployeeEntity } from './employee.entity'; 4 | import { EmployeeService } from './employee.service'; 5 | import { Crud } from '../../src/lib/crud.decorator'; 6 | import { CrudController } from '../../src/lib/interface'; 7 | 8 | @Crud({ 9 | entity: EmployeeEntity, 10 | }) 11 | @Controller('employee') 12 | export class EmployeeController implements CrudController { 13 | constructor(public readonly crudService: EmployeeService) {} 14 | } 15 | -------------------------------------------------------------------------------- /spec/embedded-entities/employee.entity.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNumber, IsOptional } from 'class-validator'; 3 | import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; 4 | 5 | import { Name } from './name'; 6 | import { GROUP } from '../../src/lib/interface'; 7 | 8 | @Entity() 9 | export class EmployeeEntity { 10 | @PrimaryGeneratedColumn() 11 | @Type(() => Number) 12 | @IsNumber({ allowNaN: false, allowInfinity: false }, { groups: [GROUP.PARAMS] }) 13 | id: string; 14 | 15 | @Column(() => Name) 16 | @Type(() => Name) 17 | name: Name; 18 | 19 | @Column() 20 | @IsNumber({ allowNaN: false, allowInfinity: false }, { always: true }) 21 | @IsOptional({ always: true }) 22 | salary: number; 23 | } 24 | -------------------------------------------------------------------------------- /spec/embedded-entities/employee.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { EmployeeEntity } from './employee.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class EmployeeService extends CrudService { 10 | constructor(@InjectRepository(EmployeeEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/embedded-entities/name.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsString } from 'class-validator'; 2 | import { Column } from 'typeorm'; 3 | 4 | export class Name { 5 | @Column({ nullable: true, default: 'first' }) 6 | @IsString({ always: true }) 7 | @IsOptional({ always: true }) 8 | first: string; 9 | 10 | @Column({ nullable: true, default: 'last' }) 11 | @IsString({ always: true }) 12 | @IsOptional({ always: true }) 13 | last: string; 14 | } 15 | -------------------------------------------------------------------------------- /spec/embedded-entities/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { UserEntity } from './user.entity'; 4 | import { UserService } from './user.service'; 5 | import { Crud } from '../../src/lib/crud.decorator'; 6 | import { CrudController } from '../../src/lib/interface'; 7 | 8 | @Crud({ 9 | entity: UserEntity, 10 | }) 11 | @Controller('user') 12 | export class UserController implements CrudController { 13 | constructor(public readonly crudService: UserService) {} 14 | } 15 | -------------------------------------------------------------------------------- /spec/embedded-entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsBoolean, IsNumber, IsObject, IsOptional, ValidateNested } from 'class-validator'; 3 | import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; 4 | 5 | import { Name } from './name'; 6 | import { GROUP } from '../../src/lib/interface'; 7 | 8 | @Entity() 9 | export class UserEntity { 10 | @PrimaryGeneratedColumn() 11 | @Type(() => Number) 12 | @IsNumber({ allowNaN: false, allowInfinity: false }, { groups: [GROUP.PARAMS] }) 13 | id: string; 14 | 15 | @Column(() => Name) 16 | @Type(() => Name) 17 | @IsOptional({ always: true }) 18 | @IsObject({ always: true }) 19 | @ValidateNested({ always: true }) 20 | name: Name; 21 | 22 | @Column() 23 | @Type(() => Boolean) 24 | @IsBoolean({ always: true }) 25 | @IsOptional({ always: true }) 26 | isActive: boolean; 27 | } 28 | -------------------------------------------------------------------------------- /spec/embedded-entities/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { UserEntity } from './user.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class UserService extends CrudService { 10 | constructor(@InjectRepository(UserEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/exclude-swagger/additional-base-info.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class AdditionalBaseInfo { 4 | @ApiProperty() 5 | color: string; 6 | } 7 | -------------------------------------------------------------------------------- /spec/exclude-swagger/custom-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class CustomResponseDto { 4 | @ApiProperty() 5 | name: string; 6 | } 7 | -------------------------------------------------------------------------------- /spec/exclude-swagger/exclude-swagger.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | import { IntersectionType, PickType } from '@nestjs/swagger'; 3 | 4 | import { AdditionalBaseInfo } from './additional-base-info'; 5 | import { CustomResponseDto } from './custom-response.dto'; 6 | import { Crud } from '../../src/lib/crud.decorator'; 7 | import { CrudController } from '../../src/lib/interface'; 8 | import { BaseEntity } from '../base/base.entity'; 9 | import { BaseService } from '../base/base.service'; 10 | 11 | @Crud({ 12 | entity: BaseEntity, 13 | routes: { 14 | recover: { swagger: { hide: true } }, 15 | readOne: { swagger: { response: PickType(BaseEntity, ['name']) } }, 16 | readMany: { swagger: { response: IntersectionType(BaseEntity, AdditionalBaseInfo) } }, 17 | create: { swagger: { body: PickType(BaseEntity, ['name']) } }, 18 | update: { swagger: { response: CustomResponseDto } }, 19 | }, 20 | }) 21 | @Controller('exclude-swagger') 22 | export class ExcludeSwaggerController implements CrudController { 23 | constructor(public readonly crudService: BaseService) {} 24 | } 25 | -------------------------------------------------------------------------------- /spec/exclude-swagger/exclude-swagger.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { ExcludeSwaggerController } from './exclude-swagger.controller'; 5 | import { BaseEntity } from '../base/base.entity'; 6 | import { BaseService } from '../base/base.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([BaseEntity])], 10 | controllers: [ExcludeSwaggerController], 11 | providers: [BaseService], 12 | }) 13 | export class ExcludeSwaggerModule {} 14 | -------------------------------------------------------------------------------- /spec/general-entity/readme.md: -------------------------------------------------------------------------------- 1 | # ENG 2 | 3 | This is a test for escaping the constraints of BaseEntity from the basic type of Entity. 4 | 5 | # KOR 6 | 7 | Entity의 기본 타입을 BaseEntity의 제약에서 벗어나는 것에 대한 테스트 입니다. 8 | -------------------------------------------------------------------------------- /spec/multiple-primary-key/multiple-primary-key.controller.create.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { MultiplePrimaryKeyEntity } from './multiple-primary-key.entity'; 6 | import { MultiplePrimaryKeyModule } from './multiple-primary-key.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('MultiplePrimaryKey - Create', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [MultiplePrimaryKeyModule, TestHelper.getTypeOrmMysqlModule([MultiplePrimaryKeyEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('CREATE_ONE', () => { 30 | it('should be provided /', async () => { 31 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 32 | expect(routerPathList.post).toEqual(expect.arrayContaining(['/base'])); 33 | }); 34 | 35 | it('creates one entity and returns it', async () => { 36 | const name = 'name1'; 37 | const response = await request(app.getHttpServer()).post('/base').send({ name }); 38 | 39 | expect(response.statusCode).toEqual(HttpStatus.CREATED); 40 | expect(response.body.name).toEqual(name); 41 | 42 | await request(app.getHttpServer()).get(`/base/${response.body.uuid1}/${response.body.uuid2}`).expect(HttpStatus.OK); 43 | }); 44 | }); 45 | 46 | describe('CREATE_MANY', () => { 47 | it('creates many entities and returns all', async () => { 48 | const toCreate = [{ name: 'name1' }, { name: 'name2' }]; 49 | 50 | const response = await request(app.getHttpServer()).post('/base').send(toCreate); 51 | 52 | expect(response.statusCode).toEqual(HttpStatus.CREATED); 53 | expect(response.body).toHaveLength(toCreate.length); 54 | 55 | const { uuid1, uuid2 } = response.body[0]; 56 | await request(app.getHttpServer()).get(`/base/${uuid1}/${uuid2}`).expect(HttpStatus.OK); 57 | }); 58 | 59 | it('create value of unknown key', async () => { 60 | const toCreate = [{ name: 'name1' }, { name: 'name2', nonamed: 2 }]; 61 | await request(app.getHttpServer()).post('/base').send(toCreate).expect(HttpStatus.UNPROCESSABLE_ENTITY); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /spec/multiple-primary-key/multiple-primary-key.controller.delete.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { MultiplePrimaryKeyEntity } from './multiple-primary-key.entity'; 6 | import { MultiplePrimaryKeyModule } from './multiple-primary-key.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('MultiplePrimaryKey - Delete', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [MultiplePrimaryKeyModule, TestHelper.getTypeOrmMysqlModule([MultiplePrimaryKeyEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('DELETE', () => { 30 | it('should be provided /:uuid1/:uuid2', async () => { 31 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 32 | expect(routerPathList.delete).toEqual(expect.arrayContaining(['/base/:uuid1/:uuid2'])); 33 | }); 34 | 35 | it('removes one entity', async () => { 36 | const name = 'name1'; 37 | const created = await request(app.getHttpServer()).post('/base').send({ name }); 38 | expect(created.statusCode).toEqual(HttpStatus.CREATED); 39 | const uuid1 = created.body.uuid1; 40 | const uuid2 = created.body.uuid2; 41 | 42 | await request(app.getHttpServer()).delete(`/base/${uuid1}/${uuid2}`).expect(HttpStatus.OK); 43 | 44 | await request(app.getHttpServer()).get(`/base/${uuid1}/${uuid2}`).expect(HttpStatus.NOT_FOUND); 45 | 46 | await request(app.getHttpServer()).delete(`/base/${uuid1}/${uuid2}`).expect(HttpStatus.NOT_FOUND); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /spec/multiple-primary-key/multiple-primary-key.controller.recover.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { MultiplePrimaryKeyEntity } from './multiple-primary-key.entity'; 6 | import { MultiplePrimaryKeyModule } from './multiple-primary-key.module'; 7 | import { MultiplePrimaryKeyService } from './multiple-primary-key.service'; 8 | import { TestHelper } from '../test.helper'; 9 | 10 | import type { INestApplication } from '@nestjs/common'; 11 | import type { TestingModule } from '@nestjs/testing'; 12 | 13 | describe('MultiplePrimaryKey - Recover', () => { 14 | let app: INestApplication; 15 | let service: MultiplePrimaryKeyService; 16 | let entities: MultiplePrimaryKeyEntity[]; 17 | 18 | beforeAll(async () => { 19 | const moduleFixture: TestingModule = await Test.createTestingModule({ 20 | imports: [MultiplePrimaryKeyModule, TestHelper.getTypeOrmMysqlModule([MultiplePrimaryKeyEntity])], 21 | }).compile(); 22 | app = moduleFixture.createNestApplication(); 23 | 24 | service = moduleFixture.get(MultiplePrimaryKeyService); 25 | entities = await Promise.all( 26 | ['name1', 'name2'].map((name: string) => service.repository.save(service.repository.create({ name }))), 27 | ); 28 | 29 | await app.init(); 30 | }); 31 | 32 | afterAll(async () => { 33 | await TestHelper.dropTypeOrmEntityTables(); 34 | await app?.close(); 35 | }); 36 | 37 | describe('RECOVER', () => { 38 | it('should be provided /:uuid1/:uuid2/recover', async () => { 39 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 40 | expect(routerPathList.post).toEqual(expect.arrayContaining(['/base/:uuid1/:uuid2/recover'])); 41 | }); 42 | 43 | it('recover the entity after delete', async () => { 44 | const { uuid1, uuid2 } = entities[0]; 45 | await request(app.getHttpServer()).get(`/base/${uuid1}/${uuid2}`).expect(HttpStatus.OK); 46 | 47 | await request(app.getHttpServer()).delete(`/base/${uuid1}/${uuid2}`).expect(HttpStatus.OK); 48 | 49 | await request(app.getHttpServer()).get(`/base/${uuid1}/${uuid2}`).expect(HttpStatus.NOT_FOUND); 50 | 51 | const { body } = await request(app.getHttpServer()).get('/base').expect(HttpStatus.OK); 52 | expect(body.data.some((entity: MultiplePrimaryKeyEntity) => entity.uuid1 === uuid1 && entity.uuid2 === uuid2)).toBeFalsy(); 53 | 54 | await request(app.getHttpServer()).post(`/base/${uuid1}/${uuid2}/recover`).expect(HttpStatus.CREATED); 55 | 56 | await request(app.getHttpServer()).get(`/base/${uuid1}/${uuid2}`).expect(HttpStatus.OK); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /spec/multiple-primary-key/multiple-primary-key.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { MultiplePrimaryKeyEntity } from './multiple-primary-key.entity'; 4 | import { MultiplePrimaryKeyService } from './multiple-primary-key.service'; 5 | import { Crud } from '../../src/lib/crud.decorator'; 6 | import { CrudController } from '../../src/lib/interface'; 7 | 8 | @Crud({ 9 | entity: MultiplePrimaryKeyEntity, 10 | }) 11 | @Controller('base') 12 | export class MultiplePrimaryKeyController implements CrudController { 13 | constructor(public readonly crudService: MultiplePrimaryKeyService) {} 14 | } 15 | -------------------------------------------------------------------------------- /spec/multiple-primary-key/multiple-primary-key.controller.upsert.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { MultiplePrimaryKeyEntity } from './multiple-primary-key.entity'; 6 | import { MultiplePrimaryKeyModule } from './multiple-primary-key.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('MultiplePrimaryKey - Upsert', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [MultiplePrimaryKeyModule, TestHelper.getTypeOrmMysqlModule([MultiplePrimaryKeyEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('UPSERT', () => { 30 | it('should be provided /:uuid1/:uuid2', async () => { 31 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 32 | expect(routerPathList.put).toEqual(expect.arrayContaining(['/base/:uuid1/:uuid2'])); 33 | }); 34 | 35 | it('upsert a new entity(create) and upsert the same entity(update)', async () => { 36 | const name = 'name1'; 37 | const responseUpsertCreateAction = await request(app.getHttpServer()).put('/base/uuid1/uuid2').send({ name }); 38 | 39 | expect(responseUpsertCreateAction.statusCode).toEqual(HttpStatus.OK); 40 | expect(responseUpsertCreateAction.body.name).toEqual(name); 41 | 42 | const responseAfterCreate = await request(app.getHttpServer()).get('/base/uuid1/uuid2').expect(HttpStatus.OK); 43 | expect(responseAfterCreate.body.name).toEqual(name); 44 | 45 | const responseUpsertUpdateAction = await request(app.getHttpServer()).put('/base/uuid1/uuid2').send({ name: 'changed' }); 46 | 47 | expect(responseUpsertUpdateAction.statusCode).toEqual(HttpStatus.OK); 48 | expect(responseUpsertUpdateAction.body.name).toEqual('changed'); 49 | 50 | const responseAfterUpdate = await request(app.getHttpServer()) 51 | .get(`/base/${responseUpsertCreateAction.body.uuid1}/${responseUpsertCreateAction.body.uuid2}`) 52 | .expect(HttpStatus.OK); 53 | expect(responseAfterUpdate.body.name).toEqual('changed'); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /spec/multiple-primary-key/multiple-primary-key.entity.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | import { IsOptional, IsString } from 'class-validator'; 4 | import { BaseEntity, BeforeInsert, Column, DeleteDateColumn, Entity, PrimaryColumn } from 'typeorm'; 5 | 6 | import { GROUP } from '../../src/lib/interface'; 7 | 8 | @Entity('base') 9 | export class MultiplePrimaryKeyEntity extends BaseEntity { 10 | @PrimaryColumn() 11 | @IsString({ groups: [GROUP.PARAMS] }) 12 | uuid1: string; 13 | 14 | @PrimaryColumn() 15 | @IsString({ groups: [GROUP.PARAMS] }) 16 | uuid2: string; 17 | 18 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.UPSERT] }) 19 | @IsOptional({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.UPSERT] }) 20 | @Column('varchar', { nullable: true }) 21 | name: string; 22 | 23 | @DeleteDateColumn() 24 | deletedAt?: Date; 25 | 26 | @BeforeInsert() 27 | setPrimaryKey() { 28 | this.uuid1 = this.uuid1 ?? `${crypto.getRandomValues(new Uint16Array(1))[0]}${Date.now()}`; 29 | this.uuid2 = this.uuid2 ?? `${crypto.getRandomValues(new Uint16Array(1))[0]}${Date.now()}`; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /spec/multiple-primary-key/multiple-primary-key.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { MultiplePrimaryKeyController } from './multiple-primary-key.controller'; 5 | import { MultiplePrimaryKeyEntity } from './multiple-primary-key.entity'; 6 | import { MultiplePrimaryKeyService } from './multiple-primary-key.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([MultiplePrimaryKeyEntity])], 10 | controllers: [MultiplePrimaryKeyController], 11 | providers: [MultiplePrimaryKeyService], 12 | }) 13 | export class MultiplePrimaryKeyModule {} 14 | -------------------------------------------------------------------------------- /spec/multiple-primary-key/multiple-primary-key.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { MultiplePrimaryKeyEntity } from './multiple-primary-key.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class MultiplePrimaryKeyService extends CrudService { 10 | constructor(@InjectRepository(MultiplePrimaryKeyEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | 14 | async getAll(): Promise { 15 | const entities = await this.repository.find(); 16 | return entities; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spec/naming-strategy/embedded-entites.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { SnakeNamingStrategy } from './snake-naming.strategy'; 6 | import { EmbeddedEntitiesModule } from '../embedded-entities/embedded-entities.module'; 7 | import { EmployeeEntity } from '../embedded-entities/employee.entity'; 8 | import { EmployeeService } from '../embedded-entities/employee.service'; 9 | import { UserEntity } from '../embedded-entities/user.entity'; 10 | import { TestHelper } from '../test.helper'; 11 | 12 | import type { INestApplication } from '@nestjs/common'; 13 | import type { TestingModule } from '@nestjs/testing'; 14 | 15 | describe('Embedded-entities using NamingStrategy', () => { 16 | let app: INestApplication; 17 | 18 | beforeAll(async () => { 19 | const moduleFixture: TestingModule = await Test.createTestingModule({ 20 | imports: [EmbeddedEntitiesModule, TestHelper.getTypeOrmPgsqlModule([UserEntity, EmployeeEntity], new SnakeNamingStrategy())], 21 | }).compile(); 22 | app = moduleFixture.createNestApplication(); 23 | await app.init(); 24 | }); 25 | 26 | afterAll(async () => { 27 | const repository = app.get(EmployeeService).repository; 28 | await repository.query('DROP TABLE IF EXISTS user_entity'); 29 | await repository.query('DROP TABLE IF EXISTS employee_entity'); 30 | await app?.close(); 31 | }); 32 | 33 | it('should be provided url', async () => { 34 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 35 | expect(routerPathList.get).toEqual(expect.arrayContaining(['/user/:id', '/user', '/employee/:id', '/employee'])); 36 | }); 37 | 38 | it('should be used embedded entity', async () => { 39 | const { body: created } = await request(app.getHttpServer()) 40 | .post('/user') 41 | .send({ isActive: true, name: { first: 'firstUserName', last: 'lastUserName' } }) 42 | .expect(HttpStatus.CREATED); 43 | 44 | const { body: readOne } = await request(app.getHttpServer()).get(`/user/${created.id}`).expect(HttpStatus.OK); 45 | expect(readOne).toEqual({ 46 | id: created.id, 47 | isActive: true, 48 | name: { first: 'firstUserName', last: 'lastUserName' }, 49 | }); 50 | 51 | const { body: readMany } = await request(app.getHttpServer()).get('/user').expect(HttpStatus.OK); 52 | expect(readMany.data[0].name).toBeDefined(); 53 | 54 | const { body: updated } = await request(app.getHttpServer()) 55 | .patch(`/user/${created.id}`) 56 | .send({ isActive: false, name: { first: 'updatedFirstUserName', last: 'updatedLastUserName' } }) 57 | .expect(HttpStatus.OK); 58 | expect(updated).toEqual({ 59 | id: created.id, 60 | isActive: false, 61 | name: { first: 'updatedFirstUserName', last: 'updatedLastUserName' }, 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /spec/naming-strategy/snake-naming.strategy.ts: -------------------------------------------------------------------------------- 1 | import { DefaultNamingStrategy } from 'typeorm'; 2 | import { snakeCase } from 'typeorm/util/StringUtils'; 3 | 4 | import type { NamingStrategyInterface } from 'typeorm'; 5 | 6 | export class SnakeNamingStrategy extends DefaultNamingStrategy implements NamingStrategyInterface { 7 | tableName(className: string, customName: string): string { 8 | return customName || snakeCase(className); 9 | } 10 | 11 | columnName(propertyName: string, customName: string, embeddedPrefixes: string[]): string { 12 | return snakeCase(embeddedPrefixes.join('_')) + (customName || snakeCase(propertyName)); 13 | } 14 | 15 | relationName(propertyName: string): string { 16 | return snakeCase(propertyName); 17 | } 18 | 19 | joinColumnName(relationName: string, referencedColumnName: string): string { 20 | return snakeCase(`${relationName}_${referencedColumnName}`); 21 | } 22 | 23 | joinTableName(firstTableName: string, secondTableName: string, firstPropertyName: string): string { 24 | return snakeCase(`${firstTableName}_${firstPropertyName.replace(/\./gi, '_')}_${secondTableName}`); 25 | } 26 | 27 | joinTableColumnName(tableName: string, propertyName: string, columnName?: string): string { 28 | return snakeCase(`${tableName}_${columnName ?? propertyName}`); 29 | } 30 | 31 | classTableInheritanceParentColumnName(parentTableName: string, parentTableIdPropertyName: string): string { 32 | return snakeCase(`${parentTableName}_${parentTableIdPropertyName}`); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /spec/pagination/pagination.module.ts: -------------------------------------------------------------------------------- 1 | import { Controller, forwardRef, Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { Crud, CrudController, CrudOptions, PaginationType } from '../../src'; 5 | import { BaseEntity } from '../base/base.entity'; 6 | import { BaseService } from '../base/base.service'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | export function PaginationModule(crudOptions?: Record) { 10 | function offsetController() { 11 | @Crud({ entity: BaseEntity, routes: crudOptions?.[PaginationType.OFFSET], logging: true }) 12 | @Controller(`${PaginationType.OFFSET}`) 13 | class OffsetController implements CrudController { 14 | constructor(public readonly crudService: BaseService) {} 15 | } 16 | return OffsetController; 17 | } 18 | 19 | function cursorController() { 20 | @Crud({ entity: BaseEntity, routes: crudOptions?.[PaginationType.CURSOR], logging: true }) 21 | @Controller(`${PaginationType.CURSOR}`) 22 | class CursorController implements CrudController { 23 | constructor(public readonly crudService: BaseService) {} 24 | } 25 | return CursorController; 26 | } 27 | 28 | function getModule() { 29 | @Module({ 30 | imports: [forwardRef(() => TestHelper.getTypeOrmMysqlModule([BaseEntity])), TypeOrmModule.forFeature([BaseEntity])], 31 | controllers: [cursorController(), offsetController()], 32 | providers: [BaseService], 33 | }) 34 | class BaseModule {} 35 | return BaseModule; 36 | } 37 | 38 | return getModule(); 39 | } 40 | -------------------------------------------------------------------------------- /spec/pagination/read-many.request.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | import { CustomRequestInterceptor, CustomReadManyRequestOptions } from '../../src'; 5 | 6 | @Injectable() 7 | export class ReadManyRequestInterceptor extends CustomRequestInterceptor { 8 | async overrideOptions(_req: Request): Promise { 9 | return new Promise((resolve, _reject) => { 10 | resolve({ 11 | softDeleted: true, 12 | }); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /spec/pagination/view-entity-pagination.module.ts: -------------------------------------------------------------------------------- 1 | import { Controller, forwardRef, Module } from '@nestjs/common'; 2 | import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; 3 | import { Type } from 'class-transformer'; 4 | import { Repository, ViewColumn, ViewEntity } from 'typeorm'; 5 | 6 | import { Crud, CrudController, CrudOptions, CrudService, Method, PaginationType } from '../../src'; 7 | import { BaseEntity } from '../base/base.entity'; 8 | import { BaseService } from '../base/base.service'; 9 | import { TestHelper } from '../test.helper'; 10 | 11 | @ViewEntity('base_view', { 12 | expression: ` 13 | SELECT DISTINCT id, name, type % 2 as category FROM base 14 | `, 15 | }) 16 | export class BaseView { 17 | @ViewColumn() 18 | @Type(() => String) 19 | id: string; 20 | 21 | @ViewColumn() 22 | @Type(() => String) 23 | name: string; 24 | 25 | @ViewColumn() 26 | @Type(() => Number) 27 | category: number; 28 | } 29 | 30 | export class BaseViewService extends CrudService { 31 | constructor(@InjectRepository(BaseView) repository: Repository) { 32 | super(repository); 33 | } 34 | } 35 | 36 | export function ViewEntityPaginationModule(crudOptions?: Record) { 37 | function cursorController() { 38 | @Crud({ 39 | entity: BaseView, 40 | routes: crudOptions?.[PaginationType.CURSOR], 41 | only: [Method.READ_MANY, Method.SEARCH], 42 | logging: true, 43 | }) 44 | @Controller(`${PaginationType.CURSOR}`) 45 | class CursorController implements CrudController { 46 | constructor(public readonly crudService: BaseViewService) {} 47 | } 48 | return CursorController; 49 | } 50 | 51 | function offsetController() { 52 | @Crud({ entity: BaseView, routes: crudOptions?.[PaginationType.OFFSET], only: [Method.READ_MANY, Method.SEARCH], logging: true }) 53 | @Controller(`${PaginationType.OFFSET}`) 54 | class OffsetController implements CrudController { 55 | constructor(public readonly crudService: BaseViewService) {} 56 | } 57 | return OffsetController; 58 | } 59 | 60 | function getModule() { 61 | @Module({ 62 | imports: [ 63 | forwardRef(() => TestHelper.getTypeOrmMysqlModule([BaseEntity, BaseView])), 64 | TypeOrmModule.forFeature([BaseEntity, BaseView]), 65 | ], 66 | controllers: [cursorController(), offsetController()], 67 | providers: [BaseViewService, BaseService], 68 | }) 69 | class BaseViewModule {} 70 | return BaseViewModule; 71 | } 72 | 73 | return getModule(); 74 | } 75 | -------------------------------------------------------------------------------- /spec/param-option/params.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | import { CustomRequestInterceptor } from '../../src'; 5 | 6 | @Injectable() 7 | export class ParamsInterceptor extends CustomRequestInterceptor { 8 | async overrideOptions(req: Request): Promise { 9 | return new Promise((resolve, _reject) => { 10 | req.params = { name: req.params?.custom }; 11 | resolve(); 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /spec/read-many/number-of-take.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { Crud } from '../../src/lib/crud.decorator'; 4 | import { CrudController } from '../../src/lib/interface'; 5 | import { BaseEntity } from '../base/base.entity'; 6 | import { BaseService } from '../base/base.service'; 7 | 8 | @Crud({ 9 | entity: BaseEntity, 10 | routes: { readMany: { numberOfTake: 10 } }, 11 | logging: true, 12 | }) 13 | @Controller('take') 14 | export class NumberOfTakeController implements CrudController { 15 | constructor(public readonly crudService: BaseService) {} 16 | } 17 | -------------------------------------------------------------------------------- /spec/read-many/read-many.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { NumberOfTakeController } from './number-of-take.controller'; 5 | import { SortAscController } from './sort-asc.controller'; 6 | import { SortDescController } from './sort-desc.controller'; 7 | import { BaseEntity } from '../base/base.entity'; 8 | import { BaseService } from '../base/base.service'; 9 | 10 | @Module({ 11 | imports: [TypeOrmModule.forFeature([BaseEntity])], 12 | controllers: [SortAscController, SortDescController, NumberOfTakeController], 13 | providers: [BaseService], 14 | }) 15 | export class ReadManyModule {} 16 | -------------------------------------------------------------------------------- /spec/read-many/sort-asc.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { Crud } from '../../src/lib/crud.decorator'; 4 | import { CrudController, Sort } from '../../src/lib/interface'; 5 | import { BaseEntity } from '../base/base.entity'; 6 | import { BaseService } from '../base/base.service'; 7 | 8 | @Crud({ 9 | entity: BaseEntity, 10 | routes: { readMany: { sort: Sort.ASC } }, 11 | logging: true, 12 | }) 13 | @Controller('sort-asc') 14 | export class SortAscController implements CrudController { 15 | constructor(public readonly crudService: BaseService) {} 16 | } 17 | -------------------------------------------------------------------------------- /spec/read-many/sort-desc.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { Crud } from '../../src/lib/crud.decorator'; 4 | import { CrudController } from '../../src/lib/interface'; 5 | import { BaseEntity } from '../base/base.entity'; 6 | import { BaseService } from '../base/base.service'; 7 | 8 | @Crud({ 9 | entity: BaseEntity, 10 | routes: { readMany: { sort: 'DESC' } }, 11 | logging: true, 12 | }) 13 | @Controller('sort-desc') 14 | export class SortDescController implements CrudController { 15 | constructor(public readonly crudService: BaseService) {} 16 | } 17 | -------------------------------------------------------------------------------- /spec/relation-entities/README.ko.md: -------------------------------------------------------------------------------- 1 | ### Relation Test Case 2 | 3 | 다음의 Entity가 있습니다. 4 | 5 | - writerEntity: 작성자 정보를 관리합니다. 6 | - categoryEntity: 질문글의 종류를 관리합니다. 7 | - questionEntity: 질문글을 관리합니다. 8 | - CommentEntity: 질문글에 추가되는 댓글을 관리합니다. 9 | 10 | Entity는 아래와 같이 관계되어있습니다. 11 | 12 | questionEntity --ManyToOne-- writerEntity, categoryEntity 13 |
  ㄴOneToMany- CommentEntity --ManyToOne-- writerEntity 14 | -------------------------------------------------------------------------------- /spec/relation-entities/README.md: -------------------------------------------------------------------------------- 1 | ### Relation Test Case 2 | 3 | There are the fours entities 4 | 5 | - writerEntity: Manages writer information. 6 | - categoryEntity: Manages the category of the question. 7 | - questionEntity: Manages a question. 8 | - CommentEntity: Manages comments added to a question. 9 | 10 | Entities are related as follows. 11 | 12 | questionEntity --ManyToOne-- writerEntity, categoryEntity 13 |
  ㄴOneToMany- CommentEntity --ManyToOne-- writerEntity 14 | -------------------------------------------------------------------------------- /spec/relation-entities/category.entity.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsString } from 'class-validator'; 2 | import { Column, Entity } from 'typeorm'; 3 | 4 | import { GROUP } from '../../src'; 5 | import { CrudAbstractEntity } from '../crud.abstract.entity'; 6 | 7 | @Entity('category') 8 | export class CategoryEntity extends CrudAbstractEntity { 9 | @Column('varchar', { nullable: true }) 10 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.PARAMS] }) 11 | @IsOptional({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT] }) 12 | name: string; 13 | } 14 | -------------------------------------------------------------------------------- /spec/relation-entities/category.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { CategoryEntity } from './category.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class CategoryService extends CrudService { 10 | constructor(@InjectRepository(CategoryEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/relation-entities/comment-relation.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | import { CustomRequestInterceptor, CustomReadOneRequestOptions } from '../../src'; 5 | 6 | @Injectable() 7 | export class CommentRelationInterceptor extends CustomRequestInterceptor { 8 | async overrideOptions(req: Request): Promise { 9 | return new Promise((resolve, _reject) => { 10 | resolve({ 11 | relations: Number(req.params.id) % 2 === 0 ? ['writer'] : [], 12 | }); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /spec/relation-entities/comment.entity.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsString, IsOptional, IsNotEmpty, IsNumber } from 'class-validator'; 3 | import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; 4 | 5 | import { QuestionEntity } from './question.entity'; 6 | import { WriterEntity } from './writer.entity'; 7 | import { GROUP } from '../../src'; 8 | import { CrudAbstractEntity } from '../crud.abstract.entity'; 9 | 10 | @Entity('comment') 11 | export class CommentEntity extends CrudAbstractEntity { 12 | @Column('integer', { nullable: false }) 13 | @Type(() => Number) 14 | @IsNumber({}, { groups: [GROUP.CREATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.PARAMS] }) 15 | @IsNotEmpty({ groups: [GROUP.CREATE, GROUP.UPSERT] }) 16 | @IsOptional({ groups: [GROUP.READ_MANY] }) 17 | questionId: number; 18 | 19 | @ManyToOne(() => QuestionEntity) 20 | @JoinColumn({ name: 'questionId' }) 21 | question: QuestionEntity; 22 | 23 | @Column('varchar', { nullable: false }) 24 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.UPSERT] }) 25 | @IsNotEmpty({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.UPSERT] }) 26 | message: string; 27 | 28 | @Column('integer', { nullable: false }) 29 | @Type(() => Number) 30 | @IsNumber({}, { groups: [GROUP.CREATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.PARAMS] }) 31 | @IsNotEmpty({ groups: [GROUP.CREATE, GROUP.UPSERT] }) 32 | @IsOptional({ groups: [GROUP.READ_MANY] }) 33 | writerId: number; 34 | 35 | @ManyToOne(() => WriterEntity) 36 | @JoinColumn({ name: 'writerId' }) 37 | writer: WriterEntity; 38 | } 39 | -------------------------------------------------------------------------------- /spec/relation-entities/comment.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { CommentEntity } from './comment.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class CommentService extends CrudService { 10 | constructor(@InjectRepository(CommentEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/relation-entities/question.entity.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; 3 | import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; 4 | 5 | import { CategoryEntity } from './category.entity'; 6 | import { CommentEntity } from './comment.entity'; 7 | import { WriterEntity } from './writer.entity'; 8 | import { GROUP } from '../../src'; 9 | import { CrudAbstractEntity } from '../crud.abstract.entity'; 10 | 11 | @Entity('question') 12 | export class QuestionEntity extends CrudAbstractEntity { 13 | @Column('integer', { nullable: false }) 14 | @Type(() => Number) 15 | @IsNumber({}, { always: true }) 16 | @IsNotEmpty({ groups: [GROUP.CREATE, GROUP.UPSERT] }) 17 | @IsOptional({ groups: [GROUP.UPDATE, GROUP.READ_MANY, GROUP.SEARCH] }) 18 | categoryId: number; 19 | 20 | @ManyToOne(() => CategoryEntity) 21 | @JoinColumn({ name: 'categoryId' }) 22 | category: CategoryEntity; 23 | 24 | @Column('integer', { nullable: false }) 25 | @Type(() => Number) 26 | @IsNumber({}, { always: true }) 27 | @IsNotEmpty({ groups: [GROUP.CREATE, GROUP.UPSERT] }) 28 | @IsOptional({ groups: [GROUP.UPDATE, GROUP.READ_MANY, GROUP.SEARCH] }) 29 | writerId: number; 30 | 31 | @ManyToOne(() => WriterEntity) 32 | @JoinColumn({ name: 'writerId' }) 33 | writer: WriterEntity; 34 | 35 | @Column('varchar', { nullable: false }) 36 | @IsString({ always: true }) 37 | @IsNotEmpty({ groups: [GROUP.CREATE, GROUP.UPSERT] }) 38 | @IsOptional({ groups: [GROUP.UPDATE, GROUP.READ_MANY, GROUP.SEARCH] }) 39 | title: string; 40 | 41 | @Column('varchar', { nullable: false }) 42 | @IsString({ always: true }) 43 | @IsNotEmpty({ groups: [GROUP.CREATE, GROUP.UPSERT] }) 44 | @IsOptional({ groups: [GROUP.UPDATE, GROUP.SEARCH, GROUP.READ_MANY] }) 45 | content: string; 46 | 47 | @OneToMany(() => CommentEntity, (comment) => comment.question) 48 | comments: CommentEntity[]; 49 | } 50 | -------------------------------------------------------------------------------- /spec/relation-entities/question.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { QuestionEntity } from './question.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class QuestionService extends CrudService { 10 | constructor(@InjectRepository(QuestionEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/relation-entities/relation-entities.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { RelationEntitiesModule } from './relation-entities.module'; 4 | import { TestHelper } from '../test.helper'; 5 | 6 | import type { INestApplication } from '@nestjs/common'; 7 | import type { TestingModule } from '@nestjs/testing'; 8 | 9 | describe('Relation Entities Routes', () => { 10 | let app: INestApplication; 11 | 12 | beforeAll(async () => { 13 | const moduleFixture: TestingModule = await Test.createTestingModule({ 14 | imports: [ 15 | RelationEntitiesModule({ 16 | category: {}, 17 | writer: {}, 18 | question: {}, 19 | comment: {}, 20 | }), 21 | ], 22 | }).compile(); 23 | app = moduleFixture.createNestApplication(); 24 | await app.init(); 25 | }); 26 | 27 | afterAll(async () => { 28 | await TestHelper.dropTypeOrmEntityTables(); 29 | await app?.close(); 30 | }); 31 | 32 | it('should be provided route path for each', async () => { 33 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 34 | expect(routerPathList.get).toHaveLength(8); 35 | expect(routerPathList.get).toEqual( 36 | expect.arrayContaining([ 37 | '/category/:id', 38 | '/category', 39 | '/comment/:id', 40 | '/comment', 41 | '/question/:id', 42 | '/question', 43 | '/writer/:id', 44 | '/writer', 45 | ]), 46 | ); 47 | expect(routerPathList.post).toHaveLength(12); 48 | expect(routerPathList.post).toEqual( 49 | expect.arrayContaining([ 50 | '/category', 51 | '/category/search', 52 | '/category/:id/recover', 53 | '/comment', 54 | '/comment/search', 55 | '/comment/:id/recover', 56 | '/question', 57 | '/question/search', 58 | '/question/:id/recover', 59 | '/writer', 60 | '/writer/search', 61 | '/writer/:id/recover', 62 | ]), 63 | ); 64 | 65 | expect(routerPathList.patch).toHaveLength(4); 66 | expect(routerPathList.patch).toEqual(expect.arrayContaining(['/category/:id', '/comment/:id', '/question/:id', '/writer/:id'])); 67 | 68 | expect(routerPathList.delete).toHaveLength(4); 69 | expect(routerPathList.delete).toEqual(expect.arrayContaining(['/category/:id', '/comment/:id', '/question/:id', '/writer/:id'])); 70 | 71 | expect(routerPathList.put).toHaveLength(4); 72 | expect(routerPathList.put).toEqual(expect.arrayContaining(['/category/:id', '/comment/:id', '/question/:id', '/writer/:id'])); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /spec/relation-entities/writer.entity.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsOptional } from 'class-validator'; 2 | import { Column, Entity } from 'typeorm'; 3 | 4 | import { GROUP } from '../../src'; 5 | import { CrudAbstractEntity } from '../crud.abstract.entity'; 6 | 7 | @Entity('writer') 8 | export class WriterEntity extends CrudAbstractEntity { 9 | @Column('varchar', { nullable: true }) 10 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.PARAMS] }) 11 | @IsOptional({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT] }) 12 | name: string; 13 | } 14 | -------------------------------------------------------------------------------- /spec/relation-entities/writer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { WriterEntity } from './writer.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class WriterService extends CrudService { 10 | constructor(@InjectRepository(WriterEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/request-interceptor/delete.request.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | import { CustomRequestInterceptor, CustomDeleteRequestOptions } from '../../src'; 5 | 6 | @Injectable() 7 | export class DeleteRequestInterceptor extends CustomRequestInterceptor { 8 | async overrideOptions(req: Request): Promise { 9 | const softDeleted = Number(req.params.id) > 2 ? undefined : false; 10 | 11 | return new Promise((resolve, _reject) => { 12 | resolve({ 13 | softDeleted, 14 | }); 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /spec/request-interceptor/read-many.request.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | import { CustomRequestInterceptor, CustomReadManyRequestOptions } from '../../src'; 5 | 6 | @Injectable() 7 | export class ReadManyRequestInterceptor extends CustomRequestInterceptor { 8 | async overrideOptions(_req: Request): Promise { 9 | return new Promise((resolve, _reject) => { 10 | resolve({ 11 | softDeleted: true, 12 | }); 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /spec/request-interceptor/read-one.request.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | import { CustomRequestInterceptor, CustomReadOneRequestOptions } from '../../src'; 5 | 6 | @Injectable() 7 | export class ReadOneRequestInterceptor extends CustomRequestInterceptor { 8 | async overrideOptions(req: Request): Promise { 9 | return new Promise((resolve, _reject) => { 10 | resolve({ 11 | fields: req.params.id === '1' ? ['name', 'createdAt'] : undefined, 12 | softDeleted: Number(req.params.id) % 2 === 0, 13 | }); 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spec/request-interceptor/request-interceptor.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { DeleteRequestInterceptor } from './delete.request.interceptor'; 4 | import { ReadManyRequestInterceptor } from './read-many.request.interceptor'; 5 | import { ReadOneRequestInterceptor } from './read-one.request.interceptor'; 6 | import { Crud } from '../../src/lib/crud.decorator'; 7 | import { CrudController } from '../../src/lib/interface'; 8 | import { BaseEntity } from '../base/base.entity'; 9 | import { BaseService } from '../base/base.service'; 10 | 11 | @Crud({ 12 | entity: BaseEntity, 13 | routes: { 14 | readOne: { 15 | interceptors: [ReadOneRequestInterceptor], 16 | }, 17 | readMany: { 18 | interceptors: [ReadManyRequestInterceptor], 19 | }, 20 | delete: { 21 | interceptors: [DeleteRequestInterceptor], 22 | }, 23 | }, 24 | }) 25 | @Controller('base') 26 | export class RequestInterceptorController implements CrudController { 27 | constructor(public readonly crudService: BaseService) {} 28 | } 29 | -------------------------------------------------------------------------------- /spec/request-interceptor/request-interceptor.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { RequestInterceptorController } from './request-interceptor.controller'; 5 | import { BaseEntity } from '../base/base.entity'; 6 | import { BaseService } from '../base/base.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([BaseEntity])], 10 | controllers: [RequestInterceptorController], 11 | providers: [BaseService], 12 | }) 13 | export class RequestInterceptorModule {} 14 | -------------------------------------------------------------------------------- /spec/reserved-words/reserved-name.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import type { CrudAbstractEntity } from '../crud.abstract.entity'; 2 | 3 | describe('ReservedNameController', () => { 4 | it('should be do not use reserved name on controller', () => { 5 | try { 6 | // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/naming-convention 7 | const { ReservedNameController } = require('./reserved-name.controller'); 8 | new ReservedNameController({} as CrudAbstractEntity); 9 | throw new Error('fail'); 10 | } catch (error) { 11 | expect(error).toBeInstanceOf(Error); 12 | if (error instanceof Error) { 13 | expect(error.message).toBe('reservedReadOne is a reserved word. cannot use'); 14 | } 15 | } 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /spec/reserved-words/reserved-name.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { Crud } from '../../src/lib/crud.decorator'; 4 | import { CrudController } from '../../src/lib/interface'; 5 | import { BaseEntity } from '../base/base.entity'; 6 | import { BaseService } from '../base/base.service'; 7 | 8 | @Crud({ 9 | entity: BaseEntity, 10 | }) 11 | @Controller('test') 12 | export class ReservedNameController implements CrudController { 13 | constructor(public readonly crudService: BaseService) {} 14 | 15 | reservedReadOne() { 16 | return 'reservedMethodName'; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /spec/response-interceptor/response-custom.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import _ from 'lodash'; 3 | import { Observable, map } from 'rxjs'; 4 | 5 | @Injectable() 6 | export class ResponseCustomInterceptor implements NestInterceptor { 7 | intercept(_context: ExecutionContext, next: CallHandler): Observable { 8 | return next.handle().pipe( 9 | map((data) => { 10 | if (_.isNil(data)) { 11 | return data; 12 | } 13 | return data.id === 2 14 | ? data 15 | : { 16 | ..._.omit(data, ['deletedAt', 'lastModifiedAt']), 17 | createdAt: Number(data.createdAt), 18 | custom: Date.now(), 19 | }; 20 | }), 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /spec/response-interceptor/response-interceptor.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { ResponseCustomInterceptor } from './response-custom.interceptor'; 4 | import { Crud } from '../../src/lib/crud.decorator'; 5 | import { CrudController } from '../../src/lib/interface'; 6 | import { BaseEntity } from '../base/base.entity'; 7 | import { BaseService } from '../base/base.service'; 8 | 9 | @Crud({ 10 | entity: BaseEntity, 11 | routes: { 12 | readOne: { 13 | interceptors: [ResponseCustomInterceptor], 14 | }, 15 | readMany: { 16 | interceptors: [ResponseCustomInterceptor], 17 | }, 18 | create: { 19 | interceptors: [ResponseCustomInterceptor], 20 | }, 21 | delete: { 22 | interceptors: [ResponseCustomInterceptor], 23 | }, 24 | recover: { 25 | interceptors: [ResponseCustomInterceptor], 26 | }, 27 | update: { 28 | interceptors: [ResponseCustomInterceptor], 29 | }, 30 | upsert: { 31 | interceptors: [ResponseCustomInterceptor], 32 | }, 33 | }, 34 | }) 35 | @Controller('base') 36 | export class BaseController implements CrudController { 37 | constructor(public readonly crudService: BaseService) {} 38 | } 39 | -------------------------------------------------------------------------------- /spec/response-interceptor/response-interceptor.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { BaseController } from './response-interceptor.controller'; 5 | import { BaseEntity } from '../base/base.entity'; 6 | import { BaseService } from '../base/base.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([BaseEntity])], 10 | controllers: [BaseController], 11 | providers: [BaseService], 12 | }) 13 | export class BaseModule {} 14 | -------------------------------------------------------------------------------- /spec/search-json-column/fixture.ts: -------------------------------------------------------------------------------- 1 | import type { Address, Person } from './interface'; 2 | 3 | export const fixtures = [ 4 | { 5 | colors: ['Red', 'Violet', 'Black'], 6 | friends: [], 7 | address: { city: 'Stockholm', street: '0 Hoard Circle', zip: '111 95' } as Address, 8 | }, 9 | { 10 | colors: ['Orange', 'Blue', 'Yellow'], 11 | friends: [ 12 | { id: 7, firstName: 'Katharyn', lastName: 'Davidovsky', email: 'kdavidovsky6@tmall.com', gender: 'Female' }, 13 | { id: 6, firstName: 'Valeria', lastName: 'Loidl', email: 'vloidl5@nasa.gov', gender: 'Female' }, 14 | { id: 9, firstName: 'Antone', lastName: 'Hartzogs', email: 'ahartzogs8@cdc.gov', gender: 'Male' }, 15 | ] as Person[], 16 | address: { city: 'Bali', street: '27996 Declaration Lane', zip: '787-0150' } as Address, 17 | }, 18 | { 19 | colors: ['Orange', 'Green', 'Black'], 20 | friends: [ 21 | { id: 2, firstName: 'Taylor', lastName: 'Ruffles', email: 'truffles1@google.pl', gender: 'Male' }, 22 | { id: 3, firstName: 'Maggi', lastName: 'Bon', email: 'mbon2@pagesperso-orange.fr', gender: 'Female' }, 23 | ] as Person[], 24 | address: { city: 'Paris 19', street: '1250 Monica Parkway', zip: '75166 CEDEX 19' } as Address, 25 | }, 26 | ]; 27 | -------------------------------------------------------------------------------- /spec/search-json-column/interface.ts: -------------------------------------------------------------------------------- 1 | export interface Person { 2 | id: number; 3 | firstName: string; 4 | lastName: string; 5 | email: string; 6 | gender: string; 7 | } 8 | 9 | export interface Address { 10 | street: string; 11 | city: string; 12 | zip: string; 13 | } 14 | -------------------------------------------------------------------------------- /spec/search-json-column/json.module.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Injectable, Module } from '@nestjs/common'; 2 | import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; 3 | import { IsOptional } from 'class-validator'; 4 | import { BaseEntity, Column, Entity, PrimaryGeneratedColumn, Repository } from 'typeorm'; 5 | 6 | import { Address, Person } from './interface'; 7 | import { Crud, CrudService, CrudController, GROUP } from '../../src'; 8 | 9 | @Entity('json_column_entity') 10 | export class JsonColumnEntity extends BaseEntity { 11 | @PrimaryGeneratedColumn({ type: 'bigint' }) 12 | id: number; 13 | 14 | @Column({ type: 'json' }) 15 | colors: string[]; 16 | 17 | @Column({ type: 'json', nullable: true }) 18 | @IsOptional({ groups: [GROUP.SEARCH] }) 19 | friends: Person[]; 20 | 21 | @Column({ type: 'json' }) 22 | @IsOptional({ groups: [GROUP.SEARCH] }) 23 | address: Address; 24 | } 25 | 26 | @Injectable() 27 | export class JsonColumnService extends CrudService { 28 | constructor(@InjectRepository(JsonColumnEntity) repository: Repository) { 29 | super(repository); 30 | } 31 | } 32 | 33 | @Crud({ entity: JsonColumnEntity }) 34 | @Controller('json') 35 | export class JsonColumnController implements CrudController { 36 | constructor(public readonly crudService: JsonColumnService) {} 37 | } 38 | 39 | @Module({ 40 | imports: [TypeOrmModule.forFeature([JsonColumnEntity])], 41 | controllers: [JsonColumnController], 42 | providers: [JsonColumnService], 43 | }) 44 | export class JsonColumnModule {} 45 | -------------------------------------------------------------------------------- /spec/search-json-column/jsonb.module.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Injectable, Module } from '@nestjs/common'; 2 | import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; 3 | import { IsOptional } from 'class-validator'; 4 | import { BaseEntity, Column, Entity, PrimaryGeneratedColumn, Repository } from 'typeorm'; 5 | 6 | import { Address, Person } from './interface'; 7 | import { Crud, CrudService, CrudController, GROUP } from '../../src'; 8 | 9 | @Entity('jsonb_column_entity') 10 | export class JsonbColumnEntity extends BaseEntity { 11 | @PrimaryGeneratedColumn({ type: 'bigint' }) 12 | id: number; 13 | 14 | @Column({ type: 'jsonb' }) 15 | @IsOptional({ groups: [GROUP.SEARCH] }) 16 | colors: string[]; 17 | 18 | @Column({ type: 'jsonb', nullable: true }) 19 | @IsOptional({ groups: [GROUP.SEARCH] }) 20 | friends: Person[]; 21 | 22 | @Column({ type: 'json' }) 23 | @IsOptional({ groups: [GROUP.SEARCH] }) 24 | address: Address; 25 | } 26 | 27 | @Injectable() 28 | export class JsonbColumnService extends CrudService { 29 | constructor(@InjectRepository(JsonbColumnEntity) repository: Repository) { 30 | super(repository); 31 | } 32 | } 33 | 34 | @Crud({ entity: JsonbColumnEntity }) 35 | @Controller('jsonb') 36 | export class JsonbColumnController implements CrudController { 37 | constructor(public readonly crudService: JsonbColumnService) {} 38 | } 39 | 40 | @Module({ 41 | imports: [TypeOrmModule.forFeature([JsonbColumnEntity])], 42 | controllers: [JsonbColumnController], 43 | providers: [JsonbColumnService], 44 | }) 45 | export class JsonbColumnModule {} 46 | -------------------------------------------------------------------------------- /spec/search/module.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Injectable, Module } from '@nestjs/common'; 2 | import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; 3 | import { IsOptional } from 'class-validator'; 4 | import { Entity, BaseEntity, PrimaryColumn, Column } from 'typeorm'; 5 | 6 | import { Crud } from '../../src/lib/crud.decorator'; 7 | import { CrudService } from '../../src/lib/crud.service'; 8 | import { GROUP } from '../../src/lib/interface'; 9 | 10 | import type { CrudController } from '../../src/lib/interface'; 11 | import type { Repository } from 'typeorm'; 12 | 13 | @Entity('test') 14 | export class TestEntity extends BaseEntity { 15 | @PrimaryColumn() 16 | @IsOptional({ groups: [GROUP.SEARCH] }) 17 | col1: string; 18 | 19 | @Column() 20 | @IsOptional({ groups: [GROUP.SEARCH] }) 21 | col2: number; 22 | 23 | @Column({ nullable: true }) 24 | @IsOptional({ groups: [GROUP.SEARCH] }) 25 | col3: number; 26 | 27 | @Column({ nullable: true }) 28 | @IsOptional({ groups: [GROUP.SEARCH] }) 29 | col4: Date; 30 | } 31 | 32 | @Injectable() 33 | export class TestService extends CrudService { 34 | constructor(@InjectRepository(TestEntity) repository: Repository) { 35 | super(repository); 36 | } 37 | } 38 | 39 | @Crud({ 40 | entity: TestEntity, 41 | routes: { 42 | search: { 43 | numberOfTake: 5, 44 | limitOfTake: 100, 45 | }, 46 | }, 47 | logging: true, 48 | }) 49 | @Controller('base') 50 | export class TestController implements CrudController { 51 | constructor(public readonly crudService: TestService) {} 52 | } 53 | 54 | @Module({ 55 | imports: [TypeOrmModule.forFeature([TestEntity])], 56 | controllers: [TestController], 57 | providers: [TestService], 58 | }) 59 | export class TestModule {} 60 | -------------------------------------------------------------------------------- /spec/search/search-with-params.spec.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Injectable, Module, HttpStatus, INestApplication } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; 4 | import { IsOptional } from 'class-validator'; 5 | import request from 'supertest'; 6 | import { Entity, BaseEntity, Repository, PrimaryColumn, Column, ObjectLiteral } from 'typeorm'; 7 | 8 | import { Crud } from '../../src/lib/crud.decorator'; 9 | import { CrudService } from '../../src/lib/crud.service'; 10 | import { CrudController } from '../../src/lib/interface'; 11 | import { TestHelper } from '../test.helper'; 12 | 13 | @Entity('test') 14 | class TestEntity extends BaseEntity { 15 | @PrimaryColumn() 16 | @IsOptional({ always: true }) 17 | col1: number; 18 | 19 | @Column({ type: 'jsonb', nullable: true }) 20 | @IsOptional({ always: true }) 21 | col2: ObjectLiteral; 22 | 23 | @Column({ type: 'jsonb', nullable: true }) 24 | @IsOptional({ always: true }) 25 | col3: ObjectLiteral; 26 | 27 | @Column() 28 | @IsOptional({ always: true }) 29 | key: string; 30 | } 31 | 32 | @Injectable() 33 | class TestService extends CrudService { 34 | constructor(@InjectRepository(TestEntity) repository: Repository) { 35 | super(repository); 36 | } 37 | } 38 | 39 | @Crud({ entity: TestEntity }) 40 | @Controller('base/:key') 41 | class TestController implements CrudController { 42 | constructor(public readonly crudService: TestService) {} 43 | } 44 | 45 | @Module({ 46 | imports: [TypeOrmModule.forFeature([TestEntity])], 47 | controllers: [TestController], 48 | providers: [TestService], 49 | }) 50 | class TestModule {} 51 | 52 | describe('Search with params', () => { 53 | let app: INestApplication; 54 | 55 | beforeAll(async () => { 56 | const moduleFixture: TestingModule = await Test.createTestingModule({ 57 | imports: [TestModule, TestHelper.getTypeOrmPgsqlModule([TestEntity])], 58 | }).compile(); 59 | app = moduleFixture.createNestApplication(); 60 | await app.init(); 61 | 62 | for (let i = 0; i < 10; i++) { 63 | await request(app.getHttpServer()) 64 | .post(`/base/key-${i}`) 65 | .send({ 66 | col1: i, 67 | col2: [{ multiple2: i % 2 === 0, multiple4: i % 4 === 0 }], 68 | col3: [{ multiple3: i % 3 === 0, multiple5: i % 5 === 0 }], 69 | }) 70 | .expect(HttpStatus.CREATED); 71 | } 72 | }); 73 | 74 | afterAll(async () => { 75 | await TestHelper.dropTypeOrmEntityTables(); 76 | await app?.close(); 77 | }); 78 | 79 | it('should be search using params', async () => { 80 | const { body } = await request(app.getHttpServer()).post('/base/key-1/search').send({}).expect(HttpStatus.OK); 81 | expect(body.data).toHaveLength(1); 82 | expect(body.data[0].key).toEqual('key-1'); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /spec/soft-delete-and-recover/delete-and-get-soft-deleted.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { Crud } from '../../src/lib/crud.decorator'; 4 | import { BaseEntity } from '../base/base.entity'; 5 | import { BaseService } from '../base/base.service'; 6 | 7 | @Crud({ 8 | entity: BaseEntity, 9 | routes: { 10 | readOne: { 11 | softDelete: true, 12 | }, 13 | readMany: { 14 | softDelete: true, 15 | }, 16 | delete: { 17 | softDelete: false, 18 | }, 19 | }, 20 | }) 21 | @Controller('delete-and-get-soft-deleted') 22 | export class DeleteAndGetSoftDeletedController { 23 | constructor(public readonly crudService: BaseService) {} 24 | } 25 | -------------------------------------------------------------------------------- /spec/soft-delete-and-recover/delete-and-ignore-soft-deleted.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { Crud } from '../../src/lib/crud.decorator'; 4 | import { BaseEntity } from '../base/base.entity'; 5 | import { BaseService } from '../base/base.service'; 6 | 7 | @Crud({ 8 | entity: BaseEntity, 9 | routes: { 10 | readOne: { 11 | softDelete: false, 12 | }, 13 | readMany: { 14 | softDelete: false, 15 | }, 16 | delete: { 17 | softDelete: false, 18 | }, 19 | }, 20 | }) 21 | @Controller('delete-and-ignore-soft-deleted') 22 | export class DeleteAndIgnoreSoftDeletedController { 23 | constructor(public readonly crudService: BaseService) {} 24 | } 25 | -------------------------------------------------------------------------------- /spec/soft-delete-and-recover/soft-delete-and-get-soft-deleted.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { Crud } from '../../src/lib/crud.decorator'; 4 | import { BaseEntity } from '../base/base.entity'; 5 | import { BaseService } from '../base/base.service'; 6 | 7 | @Crud({ 8 | entity: BaseEntity, 9 | routes: { 10 | readOne: { 11 | softDelete: true, 12 | }, 13 | readMany: { 14 | softDelete: true, 15 | }, 16 | delete: { 17 | softDelete: true, 18 | }, 19 | }, 20 | }) 21 | @Controller('soft-delete-and-get-soft-deleted') 22 | export class SoftDeleteAndGetSoftDeletedController { 23 | constructor(public readonly crudService: BaseService) {} 24 | } 25 | -------------------------------------------------------------------------------- /spec/soft-delete-and-recover/soft-delete-and-ignore-soft-deleted.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { Crud } from '../../src/lib/crud.decorator'; 4 | import { BaseEntity } from '../base/base.entity'; 5 | import { BaseService } from '../base/base.service'; 6 | 7 | @Crud({ 8 | entity: BaseEntity, 9 | routes: { 10 | readOne: { 11 | softDelete: false, 12 | }, 13 | readMany: { 14 | softDelete: false, 15 | }, 16 | delete: { 17 | softDelete: true, 18 | }, 19 | }, 20 | }) 21 | @Controller('soft-delete-and-ignore-soft-deleted') 22 | export class SoftDeleteAndIgnoreSoftDeletedController { 23 | constructor(public readonly crudService: BaseService) {} 24 | } 25 | -------------------------------------------------------------------------------- /spec/soft-delete-and-recover/soft-delete-and-recover.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { DeleteAndGetSoftDeletedController } from './delete-and-get-soft-deleted.controller'; 5 | import { DeleteAndIgnoreSoftDeletedController } from './delete-and-ignore-soft-deleted.controller'; 6 | import { SoftDeleteAndGetSoftDeletedController } from './soft-delete-and-get-soft-deleted.controller'; 7 | import { SoftDeleteAndIgnoreSoftDeletedController } from './soft-delete-and-ignore-soft-deleted.controller'; 8 | import { BaseEntity } from '../base/base.entity'; 9 | import { BaseService } from '../base/base.service'; 10 | 11 | @Module({ 12 | imports: [TypeOrmModule.forFeature([BaseEntity])], 13 | controllers: [ 14 | SoftDeleteAndGetSoftDeletedController, 15 | SoftDeleteAndIgnoreSoftDeletedController, 16 | DeleteAndGetSoftDeletedController, 17 | DeleteAndIgnoreSoftDeletedController, 18 | ], 19 | providers: [BaseService], 20 | }) 21 | export class SoftDeleteAndRecoverModule {} 22 | -------------------------------------------------------------------------------- /spec/sub-path/depth-one.entity.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsOptional } from 'class-validator'; 2 | import { Column, Entity } from 'typeorm'; 3 | 4 | import { GROUP } from '../../src'; 5 | import { CrudAbstractEntity } from '../crud.abstract.entity'; 6 | 7 | @Entity('depth_one') 8 | export class DepthOneEntity extends CrudAbstractEntity { 9 | @Column() 10 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.PARAMS] }) 11 | @IsOptional({ groups: [GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPDATE, GROUP.SEARCH] }) 12 | parentId: string; 13 | 14 | @Column({ nullable: true }) 15 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.PARAMS] }) 16 | @IsOptional({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.SEARCH] }) 17 | name: string; 18 | } 19 | -------------------------------------------------------------------------------- /spec/sub-path/depth-one.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { DepthOneEntity } from './depth-one.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class DepthOneService extends CrudService { 10 | constructor(@InjectRepository(DepthOneEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/sub-path/depth-two.entity.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsString, IsOptional, IsNumber } from 'class-validator'; 3 | import { Column, Entity } from 'typeorm'; 4 | 5 | import { GROUP } from '../../src'; 6 | import { CrudAbstractEntity } from '../crud.abstract.entity'; 7 | 8 | @Entity('depth_two') 9 | export class DepthTwoEntity extends CrudAbstractEntity { 10 | @Column('varchar', { nullable: true }) 11 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.PARAMS] }) 12 | @IsOptional({ groups: [GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPDATE, GROUP.SEARCH] }) 13 | parentId: string; 14 | 15 | @Column({ nullable: true }) 16 | @Type(() => Number) 17 | @IsNumber({}, { groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.PARAMS] }) 18 | @IsOptional({ groups: [GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPDATE, GROUP.SEARCH] }) 19 | subId: number; 20 | 21 | @Column('varchar', { nullable: true }) 22 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.PARAMS] }) 23 | @IsOptional({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT, GROUP.SEARCH] }) 24 | name: string; 25 | } 26 | -------------------------------------------------------------------------------- /spec/sub-path/depth-two.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { DepthTwoEntity } from './depth-two.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class DepthTwoService extends CrudService { 10 | constructor(@InjectRepository(DepthTwoEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /spec/sub-path/sub-path.module.ts: -------------------------------------------------------------------------------- 1 | import { Controller, forwardRef, Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { DepthOneEntity } from './depth-one.entity'; 5 | import { DepthOneService } from './depth-one.service'; 6 | import { DepthTwoEntity } from './depth-two.entity'; 7 | import { DepthTwoService } from './depth-two.service'; 8 | import { Crud, CrudController } from '../../src'; 9 | import { TestHelper } from '../test.helper'; 10 | 11 | export function SubPathModule() { 12 | function depthOneController() { 13 | @Crud({ entity: DepthOneEntity }) 14 | @Controller(':parentId/child') 15 | class DepthOneController implements CrudController { 16 | constructor(public readonly crudService: DepthOneService) {} 17 | } 18 | return DepthOneController; 19 | } 20 | 21 | function depthTwoController() { 22 | @Crud({ entity: DepthTwoEntity }) 23 | @Controller(':parentId/sub/:subId/child') 24 | class DepthOneController implements CrudController { 25 | constructor(public readonly crudService: DepthTwoService) {} 26 | } 27 | return DepthOneController; 28 | } 29 | 30 | function getModule() { 31 | @Module({ 32 | imports: [ 33 | forwardRef(() => TestHelper.getTypeOrmMysqlModule([DepthOneEntity, DepthTwoEntity])), 34 | TypeOrmModule.forFeature([DepthOneEntity, DepthTwoEntity]), 35 | ], 36 | controllers: [depthOneController(), depthTwoController()], 37 | providers: [DepthOneService, DepthTwoService], 38 | }) 39 | class SubPathModule {} 40 | return SubPathModule; 41 | } 42 | return getModule(); 43 | } 44 | -------------------------------------------------------------------------------- /spec/sub-path/sub-path.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | 3 | import { SubPathModule } from './sub-path.module'; 4 | import { TestHelper } from '../test.helper'; 5 | 6 | import type { INestApplication } from '@nestjs/common'; 7 | import type { TestingModule } from '@nestjs/testing'; 8 | 9 | describe('Subpath', () => { 10 | let app: INestApplication; 11 | 12 | beforeAll(async () => { 13 | const moduleFixture: TestingModule = await Test.createTestingModule({ 14 | imports: [SubPathModule()], 15 | }).compile(); 16 | app = moduleFixture.createNestApplication(); 17 | await app.init(); 18 | }); 19 | 20 | afterAll(async () => { 21 | await TestHelper.dropTypeOrmEntityTables(); 22 | await app?.close(); 23 | }); 24 | 25 | it('should be provided methods', async () => { 26 | const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); 27 | const expected: Record = { 28 | get: ['/:parentId/child/:id', '/:parentId/child', '/:parentId/sub/:subId/child/:id', '/:parentId/sub/:subId/child'], 29 | post: [ 30 | '/:parentId/child', 31 | '/:parentId/child/:id/recover', 32 | '/:parentId/child/search', 33 | '/:parentId/sub/:subId/child', 34 | '/:parentId/sub/:subId/child/:id/recover', 35 | '/:parentId/sub/:subId/child/search', 36 | ], 37 | patch: ['/:parentId/child/:id', '/:parentId/sub/:subId/child/:id'], 38 | delete: ['/:parentId/child/:id', '/:parentId/sub/:subId/child/:id'], 39 | put: ['/:parentId/child/:id', '/:parentId/sub/:subId/child/:id'], 40 | }; 41 | for (const [method, path] of Object.entries(expected)) { 42 | expect(routerPathList[method]).toHaveLength(path.length); 43 | expect(routerPathList[method]).toEqual(expect.arrayContaining(path)); 44 | } 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /spec/swagger-decorator/params.request.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | 4 | import { CustomRequestInterceptor } from '../../src'; 5 | 6 | @Injectable() 7 | export class ParamsRequestInterceptor extends CustomRequestInterceptor { 8 | async overrideOptions(req: Request): Promise { 9 | return new Promise((resolve, _reject) => { 10 | if (req.params?.['key']) { 11 | delete req.params.key; 12 | } 13 | resolve(); 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /spec/swagger-decorator/swagger-decorator.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | import { ApiParam } from '@nestjs/swagger'; 3 | 4 | import { ParamsRequestInterceptor } from './params.request.interceptor'; 5 | import { UpdateRequestDto } from './update-request.dto'; 6 | import { Crud } from '../../src/lib/crud.decorator'; 7 | import { CrudController } from '../../src/lib/interface'; 8 | import { BaseEntity } from '../base/base.entity'; 9 | import { BaseService } from '../base/base.service'; 10 | 11 | @Crud({ 12 | entity: BaseEntity, 13 | routes: { 14 | readOne: { 15 | interceptors: [ParamsRequestInterceptor], 16 | decorators: [ApiParam({ name: 'key', required: true })], 17 | }, 18 | readMany: { 19 | interceptors: [ParamsRequestInterceptor], 20 | decorators: [ApiParam({ name: 'key', required: true })], 21 | }, 22 | create: { 23 | interceptors: [ParamsRequestInterceptor], 24 | decorators: [ApiParam({ name: 'key', required: true })], 25 | }, 26 | update: { 27 | swagger: { 28 | body: UpdateRequestDto, 29 | }, 30 | }, 31 | }, 32 | }) 33 | @Controller('swagger-decorator/:key') 34 | export class SwaggerDecoratorController implements CrudController { 35 | constructor(public readonly crudService: BaseService) {} 36 | } 37 | -------------------------------------------------------------------------------- /spec/swagger-decorator/swagger-decorator.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { SwaggerDecoratorController } from './swagger-decorator.controller'; 5 | import { BaseEntity } from '../base/base.entity'; 6 | import { BaseService } from '../base/base.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([BaseEntity])], 10 | controllers: [SwaggerDecoratorController], 11 | providers: [BaseService], 12 | }) 13 | export class SwaggerDecoratorModule {} 14 | -------------------------------------------------------------------------------- /spec/swagger-decorator/update-request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | 3 | import { BaseEntity } from '../base/base.entity'; 4 | 5 | export class UpdateRequestDto implements Partial { 6 | @ApiPropertyOptional({ description: 'optional name', type: String }) 7 | name: string; 8 | } 9 | -------------------------------------------------------------------------------- /spec/unique-key/unique.controller.create.mysql.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { UniqueEntity } from './unique.entity'; 6 | import { UniqueModule } from './unique.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('UniqueController', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [UniqueModule, TestHelper.getTypeOrmMysqlModule([UniqueEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('CREATE_ONE', () => { 30 | it('should throw when cannot create duplicated entities', async () => { 31 | const name = 'name1'; 32 | await request(app.getHttpServer()).post('/unique').send({ name }).expect(HttpStatus.CREATED); 33 | await request(app.getHttpServer()).post('/unique').send({ name }).expect(HttpStatus.CONFLICT); 34 | await request(app.getHttpServer()).post('/unique').send({ name: 'name2' }).expect(HttpStatus.CREATED); 35 | }); 36 | }); 37 | 38 | describe('CREATE_MANY', () => { 39 | it('should throw when cannot create every entities', async () => { 40 | const toCreate = [{ name: 'name1' }, { name: 'name2' }, { name: 'name1' }, { name: 'name3' }]; 41 | await request(app.getHttpServer()).post('/unique').send(toCreate).expect(HttpStatus.CONFLICT); 42 | }); 43 | }); 44 | 45 | describe('UPDATE', () => { 46 | it('should throw when cannot update duplicated entities', async () => { 47 | const name = `name-${Date.now()}`; 48 | await request(app.getHttpServer()).post('/unique').send({ name }).expect(HttpStatus.CREATED); 49 | const { body: created } = await request(app.getHttpServer()) 50 | .post('/unique') 51 | .send({ name: name + '2' }) 52 | .expect(HttpStatus.CREATED); 53 | await request(app.getHttpServer()).patch(`/unique/${created.id}`).send({ name }).expect(HttpStatus.CONFLICT); 54 | await request(app.getHttpServer()) 55 | .patch(`/unique/${created.id}`) 56 | .send({ name: name + '3' }) 57 | .expect(HttpStatus.OK); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /spec/unique-key/unique.controller.create.pgsql.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { UniqueEntity } from './unique.entity'; 6 | import { UniqueModule } from './unique.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('UniqueController', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [UniqueModule, TestHelper.getTypeOrmPgsqlModule([UniqueEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('CREATE_ONE', () => { 30 | it('should throw when cannot create duplicated entities', async () => { 31 | const name = 'name1'; 32 | await request(app.getHttpServer()).post('/unique').send({ name }).expect(HttpStatus.CREATED); 33 | await request(app.getHttpServer()).post('/unique').send({ name }).expect(HttpStatus.CONFLICT); 34 | await request(app.getHttpServer()).post('/unique').send({ name: 'name2' }).expect(HttpStatus.CREATED); 35 | 36 | await request(app.getHttpServer()).post('/unique').send({ name: 'name2' }).expect(HttpStatus.CONFLICT); 37 | }); 38 | }); 39 | 40 | describe('CREATE_MANY', () => { 41 | it('should throw when cannot create every entities', async () => { 42 | const toCreate = [{ name: 'name1' }, { name: 'name2' }, { name: 'name1' }, { name: 'name3' }]; 43 | await request(app.getHttpServer()).post('/unique').send(toCreate).expect(HttpStatus.CONFLICT); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /spec/unique-key/unique.controller.create.spec.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from '@nestjs/common'; 2 | import { Test } from '@nestjs/testing'; 3 | import request from 'supertest'; 4 | 5 | import { UniqueEntity } from './unique.entity'; 6 | import { UniqueModule } from './unique.module'; 7 | import { TestHelper } from '../test.helper'; 8 | 9 | import type { INestApplication } from '@nestjs/common'; 10 | import type { TestingModule } from '@nestjs/testing'; 11 | 12 | describe('UniqueController', () => { 13 | let app: INestApplication; 14 | 15 | beforeAll(async () => { 16 | const moduleFixture: TestingModule = await Test.createTestingModule({ 17 | imports: [UniqueModule, TestHelper.getTypeOrmMysqlModule([UniqueEntity])], 18 | }).compile(); 19 | app = moduleFixture.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterAll(async () => { 25 | await TestHelper.dropTypeOrmEntityTables(); 26 | await app?.close(); 27 | }); 28 | 29 | describe('CREATE_ONE', () => { 30 | it('should throw when cannot create duplicated entities', async () => { 31 | const name = 'name1'; 32 | await request(app.getHttpServer()).post('/unique').send({ name }).expect(HttpStatus.CREATED); 33 | await request(app.getHttpServer()).post('/unique').send({ name }).expect(HttpStatus.CONFLICT); 34 | await request(app.getHttpServer()).post('/unique').send({ name: 'name2' }).expect(HttpStatus.CREATED); 35 | }); 36 | }); 37 | 38 | describe('CREATE_MANY', () => { 39 | it('should throw when cannot create every entities', async () => { 40 | const toCreate = [{ name: 'name1' }, { name: 'name2' }, { name: 'name1' }, { name: 'name3' }]; 41 | await request(app.getHttpServer()).post('/unique').send(toCreate).expect(HttpStatus.CONFLICT); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /spec/unique-key/unique.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | 3 | import { UniqueEntity } from './unique.entity'; 4 | import { UniqueService } from './unique.service'; 5 | import { Crud } from '../../src/lib/crud.decorator'; 6 | import { CrudController } from '../../src/lib/interface'; 7 | 8 | @Crud({ 9 | entity: UniqueEntity, 10 | }) 11 | @Controller('unique') 12 | export class UniqueController implements CrudController { 13 | constructor(public readonly crudService: UniqueService) {} 14 | } 15 | -------------------------------------------------------------------------------- /spec/unique-key/unique.entity.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsString } from 'class-validator'; 3 | import { Column, Entity } from 'typeorm'; 4 | 5 | import { GROUP } from '../../src'; 6 | import { CrudAbstractEntity } from '../crud.abstract.entity'; 7 | 8 | @Entity('base') 9 | export class UniqueEntity extends CrudAbstractEntity { 10 | @Column('varchar', { unique: true }) 11 | @IsString({ groups: [GROUP.CREATE, GROUP.UPDATE, GROUP.READ_MANY, GROUP.UPSERT] }) 12 | @Type(() => String) 13 | name: string; 14 | } 15 | -------------------------------------------------------------------------------- /spec/unique-key/unique.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { UniqueController } from './unique.controller'; 5 | import { UniqueEntity } from './unique.entity'; 6 | import { UniqueService } from './unique.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([UniqueEntity])], 10 | controllers: [UniqueController], 11 | providers: [UniqueService], 12 | }) 13 | export class UniqueModule {} 14 | -------------------------------------------------------------------------------- /spec/unique-key/unique.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | 5 | import { UniqueEntity } from './unique.entity'; 6 | import { CrudService } from '../../src/lib/crud.service'; 7 | 8 | @Injectable() 9 | export class UniqueService extends CrudService { 10 | constructor(@InjectRepository(UniqueEntity) repository: Repository) { 11 | super(repository); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/crud.decorator'; 2 | export * from './lib/crud.service'; 3 | export * from './lib/interface'; 4 | export * from './lib/abstract'; 5 | export * from './lib/interceptor'; 6 | export * from './lib/provider'; 7 | export * from './lib/constants'; 8 | -------------------------------------------------------------------------------- /src/lib/abstract/abstract.pagination.spec.ts: -------------------------------------------------------------------------------- 1 | import { AbstractPaginationRequest } from './abstract.pagination'; 2 | 3 | import type { PaginationResponse } from '../interface'; 4 | 5 | describe('AbstractPaginationRequest', () => { 6 | class PaginationRequest extends AbstractPaginationRequest { 7 | nextTotal(): number { 8 | throw new Error('Method not implemented.'); 9 | } 10 | metadata(_take: number, _dataLength: number, _total: number, _nextCursor: string): PaginationResponse['metadata'] { 11 | throw new Error('Method not implemented.'); 12 | } 13 | } 14 | it('should do nothing when where is undefined', () => { 15 | const paginationRequest = new PaginationRequest(); 16 | paginationRequest.setWhere(undefined); 17 | expect(paginationRequest.where).toBeUndefined(); 18 | 19 | paginationRequest.setWhere('where'); 20 | expect(paginationRequest.where).toEqual('where'); 21 | 22 | paginationRequest.setWhere(undefined); 23 | expect(paginationRequest.where).toEqual('where'); 24 | }); 25 | 26 | it('should do nothing when query is invalid', () => { 27 | const paginationRequest = new PaginationRequest(); 28 | const isQueryValid = paginationRequest.setQuery('invalid'); 29 | expect(isQueryValid).toEqual(false); 30 | expect(paginationRequest.isNext).toBeFalsy(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/lib/abstract/abstract.pagination.ts: -------------------------------------------------------------------------------- 1 | import { Expose } from 'class-transformer'; 2 | import { IsString, IsOptional } from 'class-validator'; 3 | 4 | import { PaginationResponse, PaginationType } from '../interface'; 5 | 6 | interface PaginationQuery { 7 | where: string; 8 | nextCursor: string; 9 | total: number; 10 | } 11 | const encoding = 'base64'; 12 | 13 | export interface PaginationAbstractResponse { 14 | data: T[]; 15 | } 16 | 17 | export abstract class AbstractPaginationRequest { 18 | private _isNext: boolean = false; 19 | private _where: string; 20 | private _total: number; 21 | private _nextCursor: string; 22 | 23 | type: PaginationType; 24 | 25 | @Expose({ name: 'nextCursor' }) 26 | @IsString() 27 | @IsOptional() 28 | query: string; 29 | 30 | setWhere(where: string | undefined): void { 31 | if (!where) { 32 | return; 33 | } 34 | this._where = where; 35 | } 36 | 37 | makeQuery(total: number, nextCursor: string): string { 38 | return Buffer.from( 39 | JSON.stringify({ 40 | where: this._where, 41 | nextCursor, 42 | total, 43 | }), 44 | ).toString(encoding); 45 | } 46 | 47 | setQuery(query: string): boolean { 48 | const paginationQuery: PaginationQuery | null = (() => { 49 | try { 50 | return JSON.parse(Buffer.from(query, encoding).toString()); 51 | } catch { 52 | return null; 53 | } 54 | })(); 55 | if (paginationQuery == null) { 56 | return false; 57 | } 58 | 59 | this._where = paginationQuery.where; 60 | this._total = paginationQuery.total; 61 | this._nextCursor = paginationQuery.nextCursor; 62 | 63 | this._isNext = true; 64 | 65 | return true; 66 | } 67 | 68 | protected get total(): number { 69 | return this._total; 70 | } 71 | 72 | get where(): string { 73 | return this._where; 74 | } 75 | 76 | get isNext(): boolean { 77 | return this._isNext && this.total != null; 78 | } 79 | 80 | get nextCursor(): string { 81 | return this._nextCursor; 82 | } 83 | 84 | abstract nextTotal(): number; 85 | abstract metadata(take: number, dataLength: number, total: number, nextCursor: string): PaginationResponse['metadata']; 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/abstract/abstract.request.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { validate } from 'class-validator'; 4 | import _ from 'lodash'; 5 | 6 | import { CreateParamsDto } from '../dto/params.dto'; 7 | import { GROUP } from '../interface'; 8 | 9 | import type { Author, Column, CrudOptions, EntityType, Method } from '../interface'; 10 | import type { CrudLogger } from '../provider/crud-logger'; 11 | import type { Request } from 'express'; 12 | 13 | export abstract class RequestAbstractInterceptor { 14 | constructor(public readonly crudLogger: CrudLogger) {} 15 | 16 | async checkParams( 17 | entity: EntityType, 18 | params: Record, 19 | factoryColumns: Column[] = [], 20 | exception = new NotFoundException(), 21 | ): Promise>> { 22 | if (_.isNil(params)) { 23 | return {}; 24 | } 25 | const columns = factoryColumns.map(({ name }) => name); 26 | const paramsKey = Object.keys(params); 27 | const invalidColumns = _.difference(paramsKey, columns); 28 | if (invalidColumns.length > 0) { 29 | this.crudLogger.log(`Invalid query params: ${invalidColumns.toLocaleString()}`); 30 | throw exception; 31 | } 32 | const transformed = plainToInstance(CreateParamsDto(entity, paramsKey as unknown as Array), params); 33 | const errorList = await validate(transformed, { groups: [GROUP.PARAMS], forbidUnknownValues: false }); 34 | if (errorList.length > 0) { 35 | this.crudLogger.log(errorList, 'ValidationError'); 36 | throw exception; 37 | } 38 | return Object.assign({}, transformed); 39 | } 40 | 41 | getAuthor( 42 | request: Request | Record, 43 | crudOptions: CrudOptions, 44 | method: Exclude, 45 | ): Author | undefined { 46 | const author = crudOptions.routes?.[method]?.author; 47 | 48 | if (!author) { 49 | return; 50 | } 51 | 52 | return { ...author, value: author.value ?? _.get(request, author.filter ?? '', author.value) }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/abstract/index.ts: -------------------------------------------------------------------------------- 1 | export * from './abstract.request.interceptor'; 2 | export * from './abstract.pagination'; 3 | -------------------------------------------------------------------------------- /src/lib/capitalize-first-letter.ts: -------------------------------------------------------------------------------- 1 | export const capitalizeFirstLetter = (raw: string): string => `${raw.charAt(0).toUpperCase()}${raw.slice(1)}`; 2 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const CRUD_ROUTE_ARGS = 'RESERVED_CRUD_ROUTE_ARGS'; 2 | export const CUSTOM_REQUEST_OPTIONS = 'RESERVED_CRUD_CUSTOM_REQUEST_OPTIONS'; 3 | -------------------------------------------------------------------------------- /src/lib/crud.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { BaseEntity, Entity, Repository } from 'typeorm'; 2 | 3 | import { Crud } from './crud.decorator'; 4 | import { CrudService } from './crud.service'; 5 | 6 | describe('Crud.Decorator', () => { 7 | @Entity('test') 8 | class TestEntity extends BaseEntity {} 9 | 10 | @Crud({ 11 | entity: TestEntity, 12 | }) 13 | class TestController { 14 | constructor(public readonly crudService: any) {} 15 | } 16 | 17 | describe('should check crudService is included as a member of target, and is instance of CrudService', () => { 18 | it('crudService(instance of CrudService) not throw error', () => { 19 | const mockRepository = { 20 | metadata: { 21 | primaryColumns: [], 22 | columns: [], 23 | }, 24 | }; 25 | const crudService = new CrudService(mockRepository as unknown as Repository); 26 | expect(() => new TestController(crudService)).not.toThrow(); 27 | }); 28 | 29 | test.each([undefined, null, 'wrong', {}, []])('crudService(%p) throw error', (crudService: any) => { 30 | expect(() => new TestController(crudService)).toThrow( 31 | new TypeError('controller should include member crudService, which is instance of CrudService'), 32 | ); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/lib/crud.decorator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any */ 2 | import { CrudRouteFactory } from './crud.route.factory'; 3 | import { CrudService } from './crud.service'; 4 | 5 | import type { CrudOptions } from './interface'; 6 | 7 | type Constructor = new (...args: any[]) => any; 8 | 9 | export const Crud = 10 | (options: CrudOptions) => 11 | (target: T) => { 12 | const crudRouteFactory = new CrudRouteFactory(target, options); 13 | crudRouteFactory.init(); 14 | 15 | const ValidatedController = class extends target { 16 | constructor(...args: any[]) { 17 | super(...args); 18 | if (!(this.crudService instanceof CrudService)) { 19 | throw new TypeError('controller should include member crudService, which is instance of CrudService'); 20 | } 21 | } 22 | }; 23 | Object.defineProperty(ValidatedController, 'name', { value: target.name }); 24 | return ValidatedController; 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/crud.route.factory.spec.ts: -------------------------------------------------------------------------------- 1 | import { UnprocessableEntityException } from '@nestjs/common'; 2 | import { BaseEntity, Entity } from 'typeorm'; 3 | 4 | import { CrudRouteFactory } from './crud.route.factory'; 5 | import { PaginationType } from './interface'; 6 | 7 | describe('CrudRouteFactory', () => { 8 | @Entity('test') 9 | class TestEntity extends BaseEntity {} 10 | 11 | it('should check tableName in TypeORM', () => { 12 | expect(() => new CrudRouteFactory({ prototype: {} }, { entity: {} as typeof BaseEntity })).toThrow( 13 | new Error('Cannot find Table from TypeORM'), 14 | ); 15 | }); 16 | 17 | describe('should be checked paginationType', () => { 18 | test.each(['readMany', 'search'])('route(%s)', (route) => { 19 | expect(() => 20 | new CrudRouteFactory( 21 | { prototype: {} }, 22 | { 23 | entity: TestEntity, 24 | routes: { [route]: { paginationType: 'wrong' as unknown as PaginationType } }, 25 | }, 26 | ).init(), 27 | ).toThrow(new TypeError('invalid PaginationType wrong')); 28 | }); 29 | }); 30 | 31 | describe('should be checked paginationKeys included in entity columns', () => { 32 | test.each(['readMany', 'search'])('route(%s)', (route) => { 33 | expect(() => 34 | new CrudRouteFactory( 35 | { prototype: {} }, 36 | { 37 | entity: TestEntity, 38 | routes: { [route]: { paginationKeys: ['wrong'] } }, 39 | }, 40 | ).init(), 41 | ).toThrow(new UnprocessableEntityException('pagination key wrong is unknown')); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/lib/crud.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConflictException } from '@nestjs/common'; 2 | 3 | import { CrudService } from './crud.service'; 4 | 5 | import type { EntityType } from './interface'; 6 | import type { BaseEntity, Repository } from 'typeorm'; 7 | 8 | describe('CrudService', () => { 9 | describe('reservedReadOne', () => { 10 | const mockRepository = { 11 | metadata: { 12 | primaryColumns: [{ propertyName: 'id' }], 13 | columns: [{ databaseName: 'id' }, { databaseName: 'name1' }], 14 | }, 15 | findOne: jest.fn(), 16 | }; 17 | const crudService = new CrudService(mockRepository as unknown as Repository); 18 | const mockEntity = { id: 1, name: 'name1' }; 19 | 20 | beforeAll(() => { 21 | mockRepository.findOne.mockResolvedValueOnce(mockEntity); 22 | }); 23 | 24 | it('should be defined', () => { 25 | expect(crudService).toBeDefined(); 26 | }); 27 | 28 | it('should return entity', async () => { 29 | await expect( 30 | crudService.reservedReadOne({ 31 | params: { id: mockEntity.id } as Partial, 32 | relations: [], 33 | }), 34 | ).resolves.toEqual(mockEntity); 35 | }); 36 | }); 37 | 38 | describe('reservedDelete', () => { 39 | const mockRepository = { 40 | metadata: { 41 | primaryColumns: [], 42 | columns: [{ databaseName: 'id' }, { databaseName: 'name1' }], 43 | }, 44 | }; 45 | const crudService = new CrudService(mockRepository as unknown as Repository); 46 | 47 | it('should be defined', () => { 48 | expect(crudService).toBeDefined(); 49 | }); 50 | 51 | it('should not be delete entity, when primary keys not exists ', async () => { 52 | await expect( 53 | crudService.reservedDelete({ 54 | params: {}, 55 | softDeleted: false, 56 | exclude: new Set(), 57 | saveOptions: {}, 58 | }), 59 | ).rejects.toThrow(ConflictException); 60 | }); 61 | }); 62 | 63 | describe('reservedReadMany', () => { 64 | it('should log error and throw error when error occurred', async () => { 65 | const mockRepository = { 66 | metadata: { 67 | primaryColumns: [{ propertyName: 'id' }], 68 | columns: [{ databaseName: 'id' }, { databaseName: 'name1' }], 69 | }, 70 | find: jest.fn(), 71 | }; 72 | const crudService = new CrudService(mockRepository as unknown as Repository); 73 | await expect(crudService.reservedReadMany({ key: 'value', array: [{ key: 'value' }] } as any)).rejects.toThrow(Error); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/lib/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './pagination-cursor.dto'; 2 | export * from './pagination-offset.dto'; 3 | export * from './params.dto'; 4 | export * from './request-fields.dto'; 5 | export * from './request-search.dto'; 6 | export * from './request.dto'; 7 | -------------------------------------------------------------------------------- /src/lib/dto/pagination-cursor.dto.ts: -------------------------------------------------------------------------------- 1 | import { AbstractPaginationRequest } from '../abstract'; 2 | import { PaginationType } from '../interface'; 3 | 4 | import type { CursorPaginationResponse } from '../interface'; 5 | 6 | export class PaginationCursorDto extends AbstractPaginationRequest { 7 | type: PaginationType.CURSOR = PaginationType.CURSOR; 8 | 9 | nextTotal(): number { 10 | return this.total; 11 | } 12 | 13 | metadata(take: number, _dataLength: number, total: number, nextCursor: string): CursorPaginationResponse['metadata'] { 14 | return { 15 | limit: take, 16 | total, 17 | nextCursor: this.makeQuery(total, nextCursor), 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/dto/pagination-offset.dto.ts: -------------------------------------------------------------------------------- 1 | import { Expose, Transform, Type } from 'class-transformer'; 2 | import { IsNumber, IsOptional } from 'class-validator'; 3 | 4 | import { AbstractPaginationRequest } from '../abstract'; 5 | import { OffsetPaginationResponse, PaginationType } from '../interface'; 6 | 7 | export class PaginationOffsetDto extends AbstractPaginationRequest { 8 | type: PaginationType.OFFSET = PaginationType.OFFSET; 9 | 10 | @Expose({ name: 'limit' }) 11 | @Type(() => Number) 12 | @Transform(({ value }) => (value && value < 0 ? 0 : value)) 13 | @IsNumber() 14 | @IsOptional() 15 | limit?: number; 16 | 17 | @Expose({ name: 'offset' }) 18 | @IsNumber() 19 | @Transform(({ value }) => (value && value < 0 ? 0 : value)) 20 | @Type(() => Number) 21 | @IsOptional() 22 | offset?: number; 23 | 24 | nextTotal(): number { 25 | return this.total; 26 | } 27 | 28 | metadata(take: number, dataLength: number, total: number, nextCursor: string): OffsetPaginationResponse['metadata'] { 29 | return { 30 | page: this.offset ? Math.floor(this.offset / take) + 1 : 1, 31 | pages: total ? Math.ceil(total / take) : 1, 32 | offset: (this.offset ?? 0) + dataLength, 33 | total, 34 | nextCursor: this.makeQuery(total, nextCursor), 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/dto/params.dto.ts: -------------------------------------------------------------------------------- 1 | import { mixin } from '@nestjs/common'; 2 | import { PickType } from '@nestjs/swagger'; 3 | 4 | import type { EntityType } from '../interface'; 5 | import type { Type } from '@nestjs/common'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 8 | export function CreateParamsDto(parentClass: EntityType, keys: Array) { 9 | class ParamsDto extends PickType(parentClass as Type, keys) {} 10 | return mixin(ParamsDto); 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/dto/request-fields.dto.spec.ts: -------------------------------------------------------------------------------- 1 | import { plainToInstance } from 'class-transformer'; 2 | import { validateSync } from 'class-validator'; 3 | 4 | import { RequestFieldsDto } from './request-fields.dto'; 5 | 6 | describe('RequestFieldsDto', () => { 7 | it('should be a string', () => { 8 | const requestFieldsDto = plainToInstance(RequestFieldsDto, {}); 9 | const error = validateSync(requestFieldsDto); 10 | expect(error[0].constraints?.isString).toBeDefined(); 11 | }); 12 | 13 | it('should be able to use single string', () => { 14 | const requestFieldsDto = plainToInstance(RequestFieldsDto, { fields: 'single' }); 15 | const error = validateSync(requestFieldsDto); 16 | expect(error).toEqual([]); 17 | expect(requestFieldsDto).toEqual({ fields: ['single'] }); 18 | }); 19 | 20 | it('should be able to use comma string', () => { 21 | const requestFieldsDto = plainToInstance(RequestFieldsDto, { fields: 'one,two,three' }); 22 | const error = validateSync(requestFieldsDto); 23 | expect(error).toEqual([]); 24 | expect(requestFieldsDto).toEqual({ fields: ['one', 'two', 'three'] }); 25 | }); 26 | 27 | it('should be able to use array string', () => { 28 | const requestFieldsDto = plainToInstance(RequestFieldsDto, { fields: ['1', '2', '3'] }); 29 | const error = validateSync(requestFieldsDto); 30 | expect(error).toEqual([]); 31 | expect(requestFieldsDto).toEqual({ fields: ['1', '2', '3'] }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/lib/dto/request-fields.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class RequestFieldsDto { 5 | @Transform(({ value }) => (typeof value === 'string' ? value.split(',') : value)) 6 | @IsString({ each: true }) 7 | fields: string[]; 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/dto/request-search-first-cursor.dto.ts: -------------------------------------------------------------------------------- 1 | import { PickType } from '@nestjs/swagger'; 2 | 3 | import { RequestSearchDto } from './request-search.dto'; 4 | 5 | export class RequestSearchFirstCursorDto extends PickType(RequestSearchDto, ['select', 'where', 'order', 'withDeleted', 'take']) { 6 | static getExample(): RequestSearchFirstCursorDto { 7 | return { 8 | select: ['field1'] as Array>, 9 | where: [{ field1: { operator: '=', operand: 'value', not: true } }], 10 | order: { field1: 'ASC' }, 11 | withDeleted: false, 12 | take: 20, 13 | }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/dto/request-search-first-offset.dto.ts: -------------------------------------------------------------------------------- 1 | import { PickType } from '@nestjs/swagger'; 2 | 3 | import { RequestSearchDto } from './request-search.dto'; 4 | 5 | export class RequestSearchFirstOffsetDto extends PickType(RequestSearchDto, [ 6 | 'select', 7 | 'where', 8 | 'order', 9 | 'withDeleted', 10 | 'limit', 11 | 'offset', 12 | ]) { 13 | static getExample(): RequestSearchFirstOffsetDto { 14 | return { 15 | select: ['field1'] as Array>, 16 | where: [{ field1: { operator: '=', operand: 'value', not: true } }], 17 | order: { field1: 'ASC' }, 18 | withDeleted: false, 19 | limit: 20, 20 | offset: 0, 21 | }; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/dto/request-search-next-cursor.dto.ts: -------------------------------------------------------------------------------- 1 | import { PickType } from '@nestjs/swagger'; 2 | 3 | import { RequestSearchDto } from './request-search.dto'; 4 | 5 | export class RequestSearchNextCursorDto extends PickType(RequestSearchDto, ['nextCursor']) { 6 | static getExample(): RequestSearchNextCursorDto { 7 | return { 8 | nextCursor: 'next_cursor', 9 | }; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/dto/request-search-next-offset.dto.ts: -------------------------------------------------------------------------------- 1 | import { PickType } from '@nestjs/swagger'; 2 | 3 | import { RequestSearchDto } from './request-search.dto'; 4 | 5 | export class RequestSearchNextOffsetDto extends PickType(RequestSearchDto, ['limit', 'offset', 'nextCursor'] as const) { 6 | static getExample(): RequestSearchNextOffsetDto { 7 | return { 8 | limit: 20, 9 | offset: 20, 10 | nextCursor: 'next_cursor', 11 | }; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/dto/request-search.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | 3 | import { Sort } from '../interface'; 4 | import { QueryFilter, operators } from '../interface/query-operation.interface'; 5 | 6 | export class RequestSearchDto { 7 | @ApiPropertyOptional({ description: 'select fields', isArray: true, type: String }) 8 | select?: Array>; 9 | 10 | @ApiPropertyOptional({ 11 | description: 'where conditions', 12 | isArray: true, 13 | type: 'object', 14 | properties: { 15 | operator: { 16 | description: 'operator', 17 | type: 'string', 18 | enum: operators, 19 | }, 20 | operand: { 21 | description: 'operand', 22 | type: 'any', 23 | examples: ['value', 1, true], 24 | }, 25 | not: { 26 | description: 'not operator', 27 | type: 'boolean', 28 | example: true, 29 | }, 30 | }, 31 | }) 32 | where?: Array>; 33 | 34 | @ApiPropertyOptional({ description: 'order', type: Object }) 35 | order?: { 36 | [key in keyof Partial]: Sort | `${Sort}`; 37 | }; 38 | 39 | @ApiPropertyOptional({ description: 'withDeleted', type: Boolean }) 40 | withDeleted?: boolean; 41 | 42 | @ApiPropertyOptional({ description: 'take', type: Number, example: 20 }) 43 | take?: number; 44 | 45 | @ApiPropertyOptional({ description: 'Use to search the next page', type: String }) 46 | nextCursor?: string; 47 | 48 | @ApiPropertyOptional({ description: 'limit', type: Number, default: 20 }) 49 | limit?: number; 50 | 51 | @ApiPropertyOptional({ description: 'offset', type: Number, default: 0 }) 52 | offset?: number; 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/dto/request.dto.ts: -------------------------------------------------------------------------------- 1 | import { mixin } from '@nestjs/common'; 2 | import { PickType } from '@nestjs/swagger'; 3 | import { getMetadataStorage } from 'class-validator'; 4 | 5 | import { capitalizeFirstLetter } from '../capitalize-first-letter'; 6 | 7 | import type { EntityType, Method } from '../interface'; 8 | import type { Type } from '@nestjs/common'; 9 | import type { MetadataStorage } from 'class-validator'; 10 | import type { ValidationMetadata } from 'class-validator/types/metadata/ValidationMetadata'; 11 | 12 | export function CreateRequestDto(parentClass: EntityType, group: Method): Type { 13 | const propertyNamesAppliedValidation = getPropertyNamesFromMetadata(parentClass, group); 14 | 15 | class PickClass extends PickType(parentClass as Type, propertyNamesAppliedValidation as Array) {} 16 | const requestDto = mixin(PickClass); 17 | Object.defineProperty(requestDto, 'name', { 18 | value: `${capitalizeFirstLetter(group)}${parentClass.name}Dto`, 19 | }); 20 | 21 | return requestDto; 22 | } 23 | 24 | export function getPropertyNamesFromMetadata(parentClass: EntityType, group: Method): string[] { 25 | const metadataStorage: MetadataStorage = getMetadataStorage(); 26 | 27 | const getTargetValidationMetadatasArgs = [parentClass, null!, false, false]; 28 | const targetMetadata: ReturnType = ( 29 | metadataStorage.getTargetValidationMetadatas as (...args: unknown[]) => ValidationMetadata[] 30 | )(...getTargetValidationMetadatasArgs); 31 | 32 | const propertyNamesAppliedValidation = [ 33 | ...new Set( 34 | targetMetadata 35 | .filter(({ groups, always }) => always === true || (groups ?? []).includes(group)) 36 | .map(({ propertyName }) => propertyName), 37 | ), 38 | ]; 39 | return propertyNamesAppliedValidation; 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/interceptor/custom-request.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { of } from 'rxjs'; 2 | 3 | import { CustomRequestInterceptor } from './custom-request.interceptor'; 4 | import { CUSTOM_REQUEST_OPTIONS } from '../constants'; 5 | import { ExecutionContextHost } from '../provider'; 6 | 7 | import type { CallHandler } from '@nestjs/common'; 8 | 9 | describe('CustomRequestInterceptor', () => { 10 | it('should intercept', async () => { 11 | const handler: CallHandler = { 12 | handle: () => of('test'), 13 | }; 14 | const interceptor: CustomRequestInterceptor = new CustomRequestInterceptor(); 15 | 16 | const context = new ExecutionContextHost([{}]); 17 | 18 | await interceptor.intercept(context, handler); 19 | expect(context.switchToHttp().getRequest()).toHaveProperty(CUSTOM_REQUEST_OPTIONS); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/lib/interceptor/custom-request.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CUSTOM_REQUEST_OPTIONS } from '../constants'; 2 | 3 | import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common'; 4 | import type { Request } from 'express'; 5 | import type { Observable } from 'rxjs'; 6 | 7 | export interface CustomReadOneRequestOptions { 8 | fields?: string[]; 9 | softDeleted?: boolean; 10 | relations?: string[]; 11 | } 12 | export interface CustomReadManyRequestOptions { 13 | softDeleted?: boolean; 14 | relations?: string[]; 15 | } 16 | export interface CustomDeleteRequestOptions { 17 | softDeleted?: boolean; 18 | } 19 | export interface CustomSearchRequestOptions { 20 | relations?: string[]; 21 | } 22 | 23 | export class CustomRequestInterceptor implements NestInterceptor { 24 | async intercept(context: ExecutionContext, next: CallHandler): Promise> { 25 | const req = context.switchToHttp().getRequest(); 26 | (req as unknown as Record)[CUSTOM_REQUEST_OPTIONS] = await this.overrideOptions(req); 27 | return next.handle(); 28 | } 29 | 30 | /** 31 | * @description 32 | * [EN] modify request by override this method. If exists requestOption interface by method, Additional control. 33 | * [KR] 이 메소드를 오버라이드 하여, Request를 수정할 수 있습니다. 각 메소드에서 제공하는 Options을 통해 추가로 제어할 수 있습니다. 34 | * @example 35 | * class MyAuthInterceptor extends CrudCustomRequestInterceptor { 36 | * constructor(authService: AuthServer) {} 37 | * 38 | * async overrideOptions(req: Request): Promise { 39 | * await this.authService.check(req); 40 | * return { fields: ['col1', 'col2', 'col3'], softDeleted: true }; 41 | * } 42 | * } 43 | */ 44 | protected async overrideOptions( 45 | _req: Request, 46 | ): Promise< 47 | | CustomReadOneRequestOptions 48 | | CustomReadManyRequestOptions 49 | | CustomDeleteRequestOptions 50 | | CustomSearchRequestOptions 51 | | undefined 52 | | void 53 | > { 54 | return; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/interceptor/delete-request.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { of } from 'rxjs'; 2 | import { BaseEntity } from 'typeorm'; 3 | 4 | import { DeleteRequestInterceptor } from './delete-request.interceptor'; 5 | import { CRUD_ROUTE_ARGS } from '../constants'; 6 | import { ExecutionContextHost } from '../provider'; 7 | import { CrudLogger } from '../provider/crud-logger'; 8 | 9 | import type { CallHandler } from '@nestjs/common'; 10 | 11 | describe('DeleteRequestInterceptor', () => { 12 | it('should intercept', async () => { 13 | class BodyDto extends BaseEntity { 14 | col1: string; 15 | } 16 | // eslint-disable-next-line @typescript-eslint/naming-convention 17 | const Interceptor = DeleteRequestInterceptor({ entity: BodyDto }, { relations: [], logger: new CrudLogger(), primaryKeys: [] }); 18 | const interceptor = new Interceptor(); 19 | 20 | const handler: CallHandler = { 21 | handle: () => of('test'), 22 | }; 23 | const context = new ExecutionContextHost([{}]); 24 | await interceptor.intercept(context, handler); 25 | expect(context.switchToHttp().getRequest()).toHaveProperty(CRUD_ROUTE_ARGS); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/lib/interceptor/delete-request.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { mixin } from '@nestjs/common'; 2 | import _ from 'lodash'; 3 | 4 | import { RequestAbstractInterceptor } from '../abstract'; 5 | import { CRUD_ROUTE_ARGS, CUSTOM_REQUEST_OPTIONS } from '../constants'; 6 | import { CRUD_POLICY } from '../crud.policy'; 7 | import { Method } from '../interface'; 8 | 9 | import type { CustomDeleteRequestOptions } from './custom-request.interceptor'; 10 | import type { CrudDeleteOneRequest, CrudOptions, FactoryOption } from '../interface'; 11 | import type { CallHandler, ExecutionContext, NestInterceptor, Type } from '@nestjs/common'; 12 | import type { Request } from 'express'; 13 | import type { Observable } from 'rxjs'; 14 | 15 | const method = Method.DELETE; 16 | export function DeleteRequestInterceptor(crudOptions: CrudOptions, factoryOption: FactoryOption): Type { 17 | class MixinInterceptor extends RequestAbstractInterceptor implements NestInterceptor { 18 | constructor() { 19 | super(factoryOption.logger); 20 | } 21 | 22 | async intercept(context: ExecutionContext, next: CallHandler): Promise> { 23 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 24 | const req: Record = context.switchToHttp().getRequest(); 25 | const deleteOptions = crudOptions.routes?.[method] ?? {}; 26 | const customDeleteRequestOptions: CustomDeleteRequestOptions = req[CUSTOM_REQUEST_OPTIONS]; 27 | 28 | const softDeleted = _.isBoolean(customDeleteRequestOptions?.softDeleted) 29 | ? customDeleteRequestOptions.softDeleted 30 | : deleteOptions.softDelete ?? CRUD_POLICY[method].default.softDeleted; 31 | 32 | const params = await this.checkParams(crudOptions.entity, req.params, factoryOption.columns); 33 | const crudDeleteOneRequest: CrudDeleteOneRequest = { 34 | params, 35 | softDeleted, 36 | author: this.getAuthor(req, crudOptions, method), 37 | exclude: new Set(deleteOptions.exclude ?? []), 38 | saveOptions: { 39 | listeners: deleteOptions.listeners, 40 | }, 41 | }; 42 | 43 | this.crudLogger.logRequest(req, crudDeleteOneRequest); 44 | req[CRUD_ROUTE_ARGS] = crudDeleteOneRequest; 45 | 46 | return next.handle(); 47 | } 48 | } 49 | 50 | return mixin(MixinInterceptor); 51 | } 52 | -------------------------------------------------------------------------------- /src/lib/interceptor/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-request.interceptor'; 2 | export * from './custom-request.interceptor'; 3 | export * from './delete-request.interceptor'; 4 | export * from './read-many-request.interceptor'; 5 | export * from './read-one-request.interceptor'; 6 | export * from './recover-request.interceptor'; 7 | export * from './search-request.interceptor'; 8 | export * from './update-request.interceptor'; 9 | export * from './upsert-request.interceptor'; 10 | -------------------------------------------------------------------------------- /src/lib/interceptor/recover-request.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { of } from 'rxjs'; 2 | import { BaseEntity } from 'typeorm'; 3 | 4 | import { RecoverRequestInterceptor } from './recover-request.interceptor'; 5 | import { CRUD_ROUTE_ARGS } from '../constants'; 6 | import { ExecutionContextHost } from '../provider'; 7 | import { CrudLogger } from '../provider/crud-logger'; 8 | 9 | import type { CallHandler } from '@nestjs/common'; 10 | 11 | describe('RecoverRequestInterceptor', () => { 12 | it('should intercept', async () => { 13 | class BodyDto extends BaseEntity { 14 | col1: string; 15 | } 16 | // eslint-disable-next-line @typescript-eslint/naming-convention 17 | const Interceptor = RecoverRequestInterceptor({ entity: BodyDto }, { relations: [], logger: new CrudLogger(), primaryKeys: [] }); 18 | const interceptor = new Interceptor(); 19 | const handler: CallHandler = { 20 | handle: () => of('test'), 21 | }; 22 | const context = new ExecutionContextHost([{}]); 23 | await interceptor.intercept(context, handler); 24 | expect(context.switchToHttp().getRequest()).toHaveProperty(CRUD_ROUTE_ARGS); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/lib/interceptor/recover-request.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { mixin } from '@nestjs/common'; 2 | 3 | import { RequestAbstractInterceptor } from '../abstract'; 4 | import { CRUD_ROUTE_ARGS, CUSTOM_REQUEST_OPTIONS } from '../constants'; 5 | import { Method } from '../interface'; 6 | 7 | import type { CrudOptions, CrudRecoverRequest, FactoryOption } from '../interface'; 8 | import type { CallHandler, ExecutionContext, NestInterceptor, Type } from '@nestjs/common'; 9 | import type { Request } from 'express'; 10 | import type { Observable } from 'rxjs'; 11 | 12 | const method = Method.RECOVER; 13 | export function RecoverRequestInterceptor(crudOptions: CrudOptions, factoryOption: FactoryOption): Type { 14 | class MixinInterceptor extends RequestAbstractInterceptor implements NestInterceptor { 15 | constructor() { 16 | super(factoryOption.logger); 17 | } 18 | 19 | async intercept(context: ExecutionContext, next: CallHandler): Promise> { 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | const req: Record = context.switchToHttp().getRequest(); 22 | const recoverOptions = crudOptions.routes?.[method] ?? {}; 23 | 24 | const customRequestOption = req[CUSTOM_REQUEST_OPTIONS]; 25 | const params = await this.checkParams(crudOptions.entity, customRequestOption?.params ?? req.params, factoryOption.columns); 26 | const crudRecoverRequest: CrudRecoverRequest = { 27 | params, 28 | author: this.getAuthor(req, crudOptions, method), 29 | exclude: new Set(recoverOptions.exclude ?? []), 30 | saveOptions: { 31 | listeners: recoverOptions.listeners, 32 | }, 33 | }; 34 | 35 | this.crudLogger.logRequest(req, crudRecoverRequest); 36 | req[CRUD_ROUTE_ARGS] = crudRecoverRequest; 37 | return next.handle(); 38 | } 39 | } 40 | 41 | return mixin(MixinInterceptor); 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/interface/author.interface.ts: -------------------------------------------------------------------------------- 1 | export interface Author { 2 | /** 3 | * The name of property(field) to be updated after the operation completed. 4 | * For example, 'createdBy', 'updatedBy', 'lastModifiedBy', 'deletedBy' etc. 5 | */ 6 | property: string; 7 | /** 8 | * Name of property to access from express's Request object 9 | * 10 | * @example 11 | * Assume that authentication layer validates requests and attaches a user entity to the request object 12 | * 13 | * ```typescript 14 | * { 15 | * "id": 101, 16 | * "username": "Donghyuk", 17 | * "email": "donghyuk@email.com", 18 | * } 19 | * ``` 20 | * 21 | * Then, you can use 'user' as a filter to access the user object from the Request 22 | * ```typescript 23 | * { 24 | * "filter": "user", 25 | * } 26 | * ``` 27 | */ 28 | filter?: string; 29 | /** 30 | * Default value to use if filter is not found in express's Request object 31 | */ 32 | value?: unknown; 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/interface/controller.interface.ts: -------------------------------------------------------------------------------- 1 | import type { EntityType } from '.'; 2 | import type { CrudService } from '../crud.service'; 3 | 4 | export interface CrudController { 5 | crudService: CrudService; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/interface/entity.ts: -------------------------------------------------------------------------------- 1 | import type { ObjectLiteral } from 'typeorm'; 2 | 3 | export type EntityType = ObjectLiteral; 4 | -------------------------------------------------------------------------------- /src/lib/interface/factory-option.interface.ts: -------------------------------------------------------------------------------- 1 | import type { CrudLogger } from '../provider/crud-logger'; 2 | import type { ColumnType } from 'typeorm'; 3 | 4 | export interface Column { 5 | name: string; 6 | type?: ColumnType; 7 | isPrimary: boolean; 8 | } 9 | 10 | export interface FactoryOption { 11 | logger: CrudLogger; 12 | columns?: Column[]; 13 | relations: string[]; 14 | primaryKeys: Array>; 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/interface/index.ts: -------------------------------------------------------------------------------- 1 | export * from './controller.interface'; 2 | export * from './decorator-option.interface'; 3 | export * from './factory-option.interface'; 4 | export * from './method'; 5 | export * from './pagination.interface'; 6 | export * from './request.interface'; 7 | export * from './sort'; 8 | export * from './author.interface'; 9 | export * from './entity'; 10 | -------------------------------------------------------------------------------- /src/lib/interface/method.ts: -------------------------------------------------------------------------------- 1 | export enum Method { 2 | READ_ONE = 'readOne', 3 | READ_MANY = 'readMany', 4 | CREATE = 'create', 5 | UPDATE = 'update', 6 | DELETE = 'delete', 7 | UPSERT = 'upsert', 8 | RECOVER = 'recover', 9 | SEARCH = 'search', 10 | } 11 | 12 | export const GROUP = { ...Method, PARAMS: 'params' as const }; 13 | -------------------------------------------------------------------------------- /src/lib/interface/pagination.interface.ts: -------------------------------------------------------------------------------- 1 | import type { PaginationAbstractResponse } from '../abstract'; 2 | import type { PaginationCursorDto } from '../dto/pagination-cursor.dto'; 3 | import type { PaginationOffsetDto } from '../dto/pagination-offset.dto'; 4 | 5 | export enum PaginationType { 6 | OFFSET = 'offset', 7 | CURSOR = 'cursor', 8 | } 9 | 10 | export const PAGINATION_SWAGGER_QUERY: Record> = { 11 | [PaginationType.OFFSET]: [ 12 | { name: 'limit', type: 'integer' }, 13 | { name: 'offset', type: 'integer' }, 14 | { name: 'nextCursor', type: 'string' }, 15 | ], 16 | [PaginationType.CURSOR]: [{ name: 'nextCursor', type: 'string' }], 17 | }; 18 | 19 | export interface CursorPaginationResponse extends PaginationAbstractResponse { 20 | metadata: { 21 | limit: number; 22 | total: number; 23 | nextCursor: string; 24 | }; 25 | } 26 | 27 | export interface OffsetPaginationResponse extends PaginationAbstractResponse { 28 | metadata: { 29 | /** 30 | * Current page number 31 | */ 32 | page: number; 33 | /** 34 | * Total page count 35 | */ 36 | pages: number; 37 | /** 38 | * Total data count 39 | */ 40 | total: number; 41 | /** 42 | * Maximum number of data on a page 43 | */ 44 | offset: number; 45 | /** 46 | * cursor token for next page 47 | */ 48 | nextCursor: string; 49 | }; 50 | } 51 | 52 | export type PaginationRequest = PaginationCursorDto | PaginationOffsetDto; 53 | 54 | export type PaginationResponse = CursorPaginationResponse | OffsetPaginationResponse; 55 | -------------------------------------------------------------------------------- /src/lib/interface/query-operation.interface.ts: -------------------------------------------------------------------------------- 1 | const commonOperatorList = ['=', '!=', '>', '>=', '<', '<=', 'LIKE', 'ILIKE'] as const; 2 | const postgreSQLSpecificOperatorList = ['?', '@>'] as const; 3 | const mySQLSpecificOperatorList = ['JSON_CONTAINS'] as const; 4 | 5 | export const operatorList = [...commonOperatorList, ...postgreSQLSpecificOperatorList, ...mySQLSpecificOperatorList] as const; 6 | 7 | export const operatorBetween = 'BETWEEN'; 8 | export const operatorIn = 'IN'; 9 | export const operatorNull = 'NULL'; 10 | export const operators = [...operatorList, operatorBetween, operatorIn, operatorNull]; 11 | export type OperatorUnion = (typeof operatorList)[number]; 12 | 13 | export type QueryFilterOperation = 14 | | { operator: OperatorUnion; operand: unknown; not?: boolean } 15 | | { 16 | operator: typeof operatorBetween; 17 | operand: [unknown, unknown]; 18 | not?: boolean; 19 | } 20 | | { 21 | operator: typeof operatorIn; 22 | operand: unknown[]; 23 | not?: boolean; 24 | } 25 | | { operator: typeof operatorNull; not?: boolean }; 26 | 27 | export type QueryFilter = { 28 | [key in keyof Partial]: QueryFilterOperation; 29 | }; 30 | -------------------------------------------------------------------------------- /src/lib/interface/request.interface.ts: -------------------------------------------------------------------------------- 1 | import type { Author, SaveOptions } from '.'; 2 | import type { RequestSearchDto } from '../dto/request-search.dto'; 3 | import type { DeepPartial } from 'typeorm'; 4 | 5 | export type CrudRequestId = keyof T | Array; 6 | 7 | export interface CrudRequestBase { 8 | author?: Author; 9 | } 10 | 11 | export interface CrudReadRequestBase extends CrudRequestBase { 12 | softDeleted?: boolean; 13 | relations: string[]; 14 | } 15 | 16 | export interface CrudReadOneRequest extends CrudReadRequestBase { 17 | selectColumns?: string[]; 18 | excludedColumns?: string[]; 19 | params: Partial>; 20 | } 21 | 22 | export interface CrudSearchRequest extends CrudRequestBase { 23 | requestSearchDto: RequestSearchDto; 24 | relations: string[]; 25 | } 26 | 27 | export interface CrudCreateOneRequest extends CrudRequestBase { 28 | body: DeepPartial; 29 | exclude: Set; 30 | saveOptions?: SaveOptions; 31 | } 32 | 33 | export interface CrudCreateManyRequest extends CrudRequestBase { 34 | body: Array>; 35 | exclude: Set; 36 | saveOptions?: SaveOptions; 37 | } 38 | 39 | export function isCrudCreateManyRequest(x: CrudCreateOneRequest | CrudCreateManyRequest): x is CrudCreateManyRequest { 40 | return Array.isArray(x.body); 41 | } 42 | 43 | export type CrudCreateRequest = CrudCreateOneRequest | CrudCreateManyRequest; 44 | 45 | export interface CrudUpsertRequest extends CrudCreateOneRequest { 46 | params: Partial>; 47 | saveOptions: SaveOptions; 48 | } 49 | 50 | export interface CrudUpdateOneRequest extends CrudCreateOneRequest { 51 | params: Partial>; 52 | saveOptions: SaveOptions; 53 | } 54 | 55 | export interface CrudDeleteOneRequest extends CrudRequestBase { 56 | params: Partial>; 57 | softDeleted: boolean; 58 | exclude: Set; 59 | saveOptions: SaveOptions; 60 | } 61 | 62 | export interface CrudRecoverRequest extends CrudRequestBase { 63 | params: Partial>; 64 | exclude: Set; 65 | saveOptions: SaveOptions; 66 | } 67 | -------------------------------------------------------------------------------- /src/lib/interface/sort.ts: -------------------------------------------------------------------------------- 1 | export enum Sort { 2 | ASC = 'ASC', 3 | DESC = 'DESC', 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/provider/crud-logger.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | 3 | import type { Request } from 'express'; 4 | 5 | export class CrudLogger { 6 | constructor(private readonly enabled: boolean = false) {} 7 | 8 | log(message: unknown, context?: string): void { 9 | if (!this.enabled) { 10 | return; 11 | } 12 | Logger.debug(message, ['CRUD', context].filter(Boolean).join(' ')); 13 | } 14 | 15 | logRequest(req: Request | Record, routeArg: unknown): void { 16 | this.log(routeArg, `${req.method} ${req.url}`); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/provider/execution-context-host.mock.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | import type { ContextType, ExecutionContext, Type } from '@nestjs/common'; 3 | import type { HttpArgumentsHost, RpcArgumentsHost, WsArgumentsHost } from '@nestjs/common/interfaces'; 4 | 5 | /** 6 | * util class to mocking ExecutionContext 7 | */ 8 | export class ExecutionContextHost implements ExecutionContext { 9 | private contextType = 'http'; 10 | 11 | constructor( 12 | private readonly args: unknown[], 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | private readonly constructorRef?: Type, 15 | private readonly handler?: Function, 16 | ) {} 17 | 18 | setType(type: TContext): void { 19 | type && (this.contextType = type); 20 | } 21 | 22 | getType(): TContext { 23 | return this.contextType as TContext; 24 | } 25 | 26 | getClass(): Type { 27 | return this.constructorRef!; 28 | } 29 | 30 | getHandler(): Function { 31 | return this.handler!; 32 | } 33 | 34 | getArgs(): T { 35 | return this.args as T; 36 | } 37 | 38 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 39 | getArgByIndex(index: number): T { 40 | return this.args[index] as T; 41 | } 42 | 43 | switchToRpc(): RpcArgumentsHost { 44 | return Object.assign(this, { 45 | getData: () => this.getArgByIndex(0), 46 | getContext: () => this.getArgByIndex(1), 47 | }); 48 | } 49 | 50 | switchToHttp(): HttpArgumentsHost { 51 | return Object.assign(this, { 52 | getRequest: () => this.getArgByIndex(0), 53 | getResponse: () => this.getArgByIndex(1), 54 | getNext: () => this.getArgByIndex(2), 55 | }); 56 | } 57 | 58 | switchToWs(): WsArgumentsHost { 59 | return Object.assign(this, { 60 | getClient: () => this.getArgByIndex(0), 61 | getData: () => this.getArgByIndex(1), 62 | getPattern: () => this.getArgByIndex(2), 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/provider/index.ts: -------------------------------------------------------------------------------- 1 | export * from './execution-context-host.mock'; 2 | export * from './pagination.helper'; 3 | export * from './typeorm-query-builder.helper'; 4 | export * from './crud-logger'; 5 | -------------------------------------------------------------------------------- /src/lib/provider/pagination.helper.spec.ts: -------------------------------------------------------------------------------- 1 | import { UnprocessableEntityException } from '@nestjs/common'; 2 | 3 | import { PaginationHelper } from './pagination.helper'; 4 | import { PaginationType } from '../interface'; 5 | 6 | describe('Pagination Helper', () => { 7 | it('should serialize entity', () => { 8 | expect(PaginationHelper.serialize({ id: 1, name: 'test' })).toEqual(expect.any(String)); 9 | }); 10 | 11 | it('should deserialize cursor', () => { 12 | const cursor = PaginationHelper.serialize({ id: 1 }); 13 | expect(PaginationHelper.deserialize(cursor)).toEqual({ id: 1 }); 14 | }); 15 | 16 | it('should return empty object when cursor is malformed', () => { 17 | const cursor = 'malformed'; 18 | expect(PaginationHelper.deserialize(cursor)).toEqual({}); 19 | }); 20 | 21 | it('should be able to return pagination for GET_MORE type', () => { 22 | expect(PaginationHelper.getPaginationRequest(PaginationType.CURSOR, { key: 'value', nextCursor: 'token' })).toEqual({ 23 | query: 'token', 24 | type: 'cursor', 25 | _isNext: false, 26 | }); 27 | 28 | expect(PaginationHelper.getPaginationRequest(PaginationType.CURSOR, { key: 'value' })).toEqual({ 29 | query: undefined, 30 | type: 'cursor', 31 | _isNext: false, 32 | }); 33 | 34 | expect(PaginationHelper.getPaginationRequest(PaginationType.CURSOR, { query: 'query' })).toEqual({ 35 | query: undefined, 36 | type: 'cursor', 37 | _isNext: false, 38 | }); 39 | }); 40 | 41 | it('should be validate pagination query', () => { 42 | expect(PaginationHelper.getPaginationRequest(PaginationType.CURSOR, undefined as any)).toEqual({ 43 | type: 'cursor', 44 | query: undefined, 45 | _isNext: false, 46 | }); 47 | 48 | expect(() => PaginationHelper.getPaginationRequest(PaginationType.CURSOR, { nextCursor: 3 })).toThrow(UnprocessableEntityException); 49 | 50 | expect(PaginationHelper.getPaginationRequest(PaginationType.OFFSET, undefined as any)).toEqual({ 51 | type: 'offset', 52 | limit: undefined, 53 | offset: undefined, 54 | query: undefined, 55 | _isNext: false, 56 | }); 57 | 58 | expect(() => PaginationHelper.getPaginationRequest(PaginationType.OFFSET, { nextCursor: 3 })).toThrow(UnprocessableEntityException); 59 | }); 60 | 61 | it('should be return empty object when nextCursor is undefined', () => { 62 | expect(PaginationHelper.deserialize(undefined as any)).toEqual({}); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/lib/provider/pagination.helper.ts: -------------------------------------------------------------------------------- 1 | import { UnprocessableEntityException } from '@nestjs/common'; 2 | import { plainToInstance } from 'class-transformer'; 3 | import { validateSync } from 'class-validator'; 4 | 5 | import { PaginationCursorDto, PaginationOffsetDto } from '../dto'; 6 | import { PaginationType } from '../interface'; 7 | 8 | import type { RequestSearchDto } from '../dto'; 9 | import type { PaginationRequest } from '../interface'; 10 | import type { FindOptionsWhere } from 'typeorm'; 11 | 12 | const encoding = 'base64'; 13 | 14 | export class PaginationHelper { 15 | static serialize(entity: FindOptionsWhere | Array> | RequestSearchDto | Record): string { 16 | return Buffer.from(JSON.stringify(entity)).toString(encoding); 17 | } 18 | 19 | static deserialize(nextCursor?: string): T { 20 | if (!nextCursor) { 21 | return {} as T; 22 | } 23 | try { 24 | return JSON.parse(Buffer.from(nextCursor, encoding).toString()); 25 | } catch { 26 | return {} as T; 27 | } 28 | } 29 | 30 | static getPaginationRequest(paginationType: PaginationType, query: Record): PaginationRequest { 31 | const plain = query ?? {}; 32 | const transformed = 33 | paginationType === PaginationType.OFFSET 34 | ? plainToInstance(PaginationOffsetDto, plain, { excludeExtraneousValues: true }) 35 | : plainToInstance(PaginationCursorDto, plain, { excludeExtraneousValues: true }); 36 | const [error] = validateSync(transformed, { stopAtFirstError: true }); 37 | 38 | if (error) { 39 | throw new UnprocessableEntityException(error); 40 | } 41 | 42 | return transformed; 43 | } 44 | 45 | /** 46 | * [EN] Check if the request is requesting the next page. 47 | * [KR] Request 요청이 다음 페이지를 요청하는지 확인합니다. 48 | * @param paginationRequest 49 | * @returns boolean 50 | */ 51 | static isNextPage(paginationRequest: PaginationRequest): boolean { 52 | return paginationRequest.query != null; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/request/index.ts: -------------------------------------------------------------------------------- 1 | export * from './read-many.request'; 2 | -------------------------------------------------------------------------------- /src/lib/request/read-many.request.spec.ts: -------------------------------------------------------------------------------- 1 | import { CrudReadManyRequest } from '.'; 2 | 3 | describe('CrudReadManyRequest', () => { 4 | it('should be defined', () => { 5 | const crudReadManyRequest = new CrudReadManyRequest(); 6 | expect(crudReadManyRequest).toBeDefined(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "types": ["node"], 6 | "outDir": "dist/cjs", 7 | "module": "CommonJS", 8 | "target": "ES2015" 9 | }, 10 | "exclude": ["jest.config.ts", "jest.setup.ts", "spec", "**/*.spec.ts", "**/*.test.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "sourceMap": true, 5 | "declaration": true, 6 | "moduleResolution": "node", 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "importHelpers": true, 10 | "target": "ES2015", 11 | "lib": ["ES2017"], 12 | "strict": true, 13 | "strictNullChecks": true, 14 | "strictPropertyInitialization": false, 15 | "skipLibCheck": true, 16 | "skipDefaultLibCheck": true, 17 | "esModuleInterop": true, 18 | "baseUrl": ".", 19 | "outDir": "dist", 20 | "paths": {} 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.mjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src", 5 | "types": ["node"], 6 | "outDir": "dist/mjs", 7 | "module": "esnext", 8 | "target": "esnext" 9 | }, 10 | "exclude": ["jest.config.ts", "jest.setup.ts", "spec", "**/*.spec.ts", "**/*.test.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "types": ["jest", "node"], 6 | "noImplicitAny": false, 7 | "noEmit": true, 8 | "isolatedModules": true 9 | }, 10 | "include": ["**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] 11 | } 12 | --------------------------------------------------------------------------------