├── .nvmrc ├── jest.setup.ts ├── .gitignore ├── src ├── index.ts ├── swagger │ ├── index.ts │ ├── api-paginated-swagger-docs.decorator.ts │ ├── api-ok-paginated-response.decorator.ts │ ├── paginated-swagger.type.ts │ ├── api-paginated-query.decorator.ts │ ├── pagination-docs.spec.ts │ └── resources │ │ └── full-openapi-definition.json ├── __tests__ │ ├── size.embed.ts │ ├── cat-home-pillow-brand.entity.ts │ ├── toy-shop-address.entity.ts │ ├── toy-shop.entity.ts │ ├── column-option.ts │ ├── cat-home-pillow.entity.ts │ ├── cat-hair.entity.ts │ ├── cat-toy.entity.ts │ ├── cat-home.entity.ts │ └── cat.entity.ts ├── global-config.ts ├── decorator.ts ├── decorator.spec.ts ├── helper.ts ├── filter.ts └── paginate.ts ├── .vscode ├── settings.json └── launch.json ├── .editorconfig ├── .husky └── pre-commit ├── .prettierrc ├── renovate.json ├── docker-compose.yml ├── tsconfig.json ├── .eslintrc.json ├── LICENSE ├── .github └── workflows │ └── main.yml ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | 3 | dotenv.config({ path: '.env' }) 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | coverage/ 4 | .DS_Store 5 | npm-debug.log 6 | .history 7 | .idea/ 8 | .env 9 | test.sql -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './decorator' 2 | export * from './paginate' 3 | export * from './swagger' 4 | export * from './global-config' 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/swagger/index.ts: -------------------------------------------------------------------------------- 1 | export * from './api-paginated-query.decorator' 2 | export * from './api-ok-paginated-response.decorator' 3 | export * from './paginated-swagger.type' 4 | export * from './api-paginated-swagger-docs.decorator' 5 | -------------------------------------------------------------------------------- /src/__tests__/size.embed.ts: -------------------------------------------------------------------------------- 1 | import { Column } from 'typeorm' 2 | 3 | export class SizeEmbed { 4 | @Column() 5 | height: number 6 | 7 | @Column() 8 | width: number 9 | 10 | @Column() 11 | length: number 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | [*] 3 | insert_final_newline = true 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | indent_style = space 8 | indent_size = 4 9 | 10 | [*.{json,js,yml,md}] 11 | indent_size = 2 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | branch="$(git rev-parse --abbrev-ref HEAD)" 5 | 6 | if [ "$branch" = "master" ]; then 7 | echo "You can't commit directly to master branch" 8 | exit 1 9 | fi 10 | 11 | npm run format 12 | npm run lint 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "printWidth": 120, 4 | "semi": false, 5 | "trailingComma": "es5", 6 | "singleQuote": true, 7 | "overrides": [ 8 | { 9 | "files": "{*.json,*.md,.prettierrc,.*.yml}", 10 | "options": { 11 | "tabWidth": 2 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/__tests__/cat-home-pillow-brand.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm' 2 | 3 | @Entity() 4 | export class CatHomePillowBrandEntity { 5 | @PrimaryGeneratedColumn() 6 | id: number 7 | 8 | @Column() 9 | name: string 10 | 11 | @Column({ nullable: true }) 12 | quality: string 13 | } 14 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/renovate", 3 | "extends": ["config:base"], 4 | "updateLockFiles": true, 5 | "rangeStrategy": "bump", 6 | "packageRules": [ 7 | { 8 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 9 | "automerge": true 10 | }, 11 | { 12 | "depTypeList": ["peerDependencies"], 13 | "ignoreDeps": ["*"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/__tests__/toy-shop-address.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm' 2 | import { DateColumnNotNull } from './column-option' 3 | 4 | @Entity() 5 | export class ToyShopAddressEntity { 6 | @PrimaryGeneratedColumn() 7 | id: number 8 | 9 | @Column() 10 | address: string 11 | 12 | @CreateDateColumn(DateColumnNotNull) 13 | createdAt: string 14 | } 15 | -------------------------------------------------------------------------------- /src/global-config.ts: -------------------------------------------------------------------------------- 1 | export interface NestjsPaginateGlobalConfig { 2 | defaultOrigin: string | undefined 3 | defaultLimit: number 4 | defaultMaxLimit: number 5 | } 6 | 7 | const globalConfig: NestjsPaginateGlobalConfig = { 8 | defaultOrigin: undefined, 9 | defaultLimit: 20, 10 | defaultMaxLimit: 100, 11 | } 12 | 13 | export const updateGlobalConfig = (newConfig: Partial) => { 14 | Object.assign(globalConfig, newConfig) 15 | } 16 | 17 | export default globalConfig 18 | -------------------------------------------------------------------------------- /src/swagger/api-paginated-swagger-docs.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, Type } from '@nestjs/common' 2 | import { PaginateConfig } from '../paginate' 3 | import { ApiPaginationQuery } from './api-paginated-query.decorator' 4 | import { ApiOkPaginatedResponse } from './api-ok-paginated-response.decorator' 5 | 6 | export function PaginatedSwaggerDocs>(dto: DTO, paginatedConfig: PaginateConfig) { 7 | return applyDecorators(ApiOkPaginatedResponse(dto, paginatedConfig), ApiPaginationQuery(paginatedConfig)) 8 | } 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | container_name: postgres_nestjs_paginate 4 | image: postgres:latest 5 | environment: 6 | POSTGRES_USER: root 7 | POSTGRES_PASSWORD: pass 8 | POSTGRES_DB: test 9 | ports: 10 | - "${POSTGRESS_DB_PORT:-5432}:5432" 11 | 12 | mariadb: 13 | container_name: mariadb_nestjs_paginate 14 | image: mariadb:latest 15 | environment: 16 | MYSQL_ROOT_PASSWORD: pass 17 | MYSQL_DATABASE: test 18 | ports: 19 | - "${MARIA_DB_PORT:-3306}:3306" 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "allowSyntheticDefaultImports": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "outDir": "./lib", 11 | "baseUrl": "./", 12 | "incremental": true, 13 | "skipLibCheck": true // https://stackoverflow.com/questions/55680391/typescript-error-ts2403-subsequent-variable-declarations-must-have-the-same-typ 14 | }, 15 | "include": ["src"] 16 | } 17 | -------------------------------------------------------------------------------- /src/__tests__/toy-shop.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm' 2 | import { DateColumnNotNull } from './column-option' 3 | import { ToyShopAddressEntity } from './toy-shop-address.entity' 4 | 5 | @Entity() 6 | export class ToyShopEntity { 7 | @PrimaryGeneratedColumn() 8 | id: number 9 | 10 | @Column() 11 | shopName: string 12 | 13 | @OneToOne(() => ToyShopAddressEntity, { nullable: true }) 14 | @JoinColumn() 15 | address: ToyShopAddressEntity 16 | 17 | @CreateDateColumn(DateColumnNotNull) 18 | createdAt: string 19 | } 20 | -------------------------------------------------------------------------------- /src/__tests__/column-option.ts: -------------------------------------------------------------------------------- 1 | import { ColumnOptions } from 'typeorm' 2 | 3 | const getDateColumnType = () => { 4 | switch (process.env.DB) { 5 | case 'postgres': 6 | case 'cockroachdb': 7 | return 'timestamptz' 8 | case 'mysql': 9 | case 'mariadb': 10 | return 'timestamp' 11 | case 'sqlite': 12 | return 'datetime' 13 | default: 14 | return 'timestamp' 15 | } 16 | } 17 | 18 | export const DateColumnNotNull: ColumnOptions = { 19 | type: getDateColumnType(), 20 | } 21 | 22 | export const DateColumnNullable: ColumnOptions = { 23 | ...DateColumnNotNull, 24 | nullable: true, 25 | } 26 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "tsconfig.json", 5 | "sourceType": "module" 6 | }, 7 | "plugins": ["@typescript-eslint/eslint-plugin"], 8 | "extends": ["plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", "prettier"], 9 | "root": true, 10 | "env": { 11 | "node": true, 12 | "jest": true 13 | }, 14 | "rules": { 15 | "@typescript-eslint/interface-name-prefix": "off", 16 | "@typescript-eslint/explicit-function-return-type": "off", 17 | "@typescript-eslint/no-explicit-any": "off", 18 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Test Debug", 8 | "runtimeExecutable": "npm", 9 | "runtimeArgs": [ 10 | "--preserve-symlinks", 11 | "run", 12 | "test:watch", 13 | "--", 14 | "--inspect-brk", 15 | ], 16 | "console": "integratedTerminal", 17 | "restart": true, 18 | "sourceMaps": true, 19 | "cwd": "${workspaceRoot}", 20 | "autoAttachChildProcesses": true, 21 | "envFile": "${workspaceFolder}/.env" 22 | }, 23 | ] 24 | } -------------------------------------------------------------------------------- /src/__tests__/cat-home-pillow.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' 2 | import { CatHomePillowBrandEntity } from './cat-home-pillow-brand.entity' 3 | import { CatHomeEntity } from './cat-home.entity' 4 | import { DateColumnNotNull } from './column-option' 5 | 6 | @Entity() 7 | export class CatHomePillowEntity { 8 | @PrimaryGeneratedColumn() 9 | id: number 10 | 11 | @ManyToOne(() => CatHomeEntity, (home) => home.pillows) 12 | home: CatHomeEntity 13 | 14 | @Column() 15 | color: string 16 | 17 | @ManyToOne(() => CatHomePillowBrandEntity) 18 | brand: CatHomePillowBrandEntity 19 | 20 | @CreateDateColumn(DateColumnNotNull) 21 | createdAt: string 22 | } 23 | -------------------------------------------------------------------------------- /src/__tests__/cat-hair.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm' 2 | import { DateColumnNotNull } from './column-option' 3 | 4 | @Entity() 5 | export class CatHairEntity { 6 | @PrimaryGeneratedColumn() 7 | id: number 8 | 9 | @Column() 10 | name: string 11 | 12 | @Column({ type: 'text', array: true, default: '{}' }) 13 | colors: string[] 14 | 15 | @CreateDateColumn(DateColumnNotNull) 16 | createdAt: string 17 | 18 | @Column({ type: 'jsonb', nullable: true }) 19 | metadata: Record 20 | 21 | @OneToOne(() => CatHairEntity, (catFur) => catFur.underCoat, { nullable: true }) 22 | @JoinColumn() 23 | underCoat: CatHairEntity 24 | } 25 | -------------------------------------------------------------------------------- /src/__tests__/cat-toy.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' 2 | import { CatEntity } from './cat.entity' 3 | import { DateColumnNotNull } from './column-option' 4 | import { SizeEmbed } from './size.embed' 5 | import { ToyShopEntity } from './toy-shop.entity' 6 | 7 | @Entity() 8 | export class CatToyEntity { 9 | @PrimaryGeneratedColumn() 10 | id: number 11 | 12 | @Column() 13 | name: string 14 | 15 | @Column(() => SizeEmbed) 16 | size: SizeEmbed 17 | 18 | @ManyToOne(() => ToyShopEntity, { nullable: true }) 19 | @JoinColumn() 20 | shop?: ToyShopEntity 21 | 22 | @ManyToOne(() => CatEntity, (cat) => cat.toys) 23 | @JoinColumn() 24 | cat: CatEntity 25 | 26 | @CreateDateColumn(DateColumnNotNull) 27 | createdAt: string 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Philipp Petzold (https://github.com/ppetzold) 4 | Copyright (c) 2020 jyutzio (https://github.com/jyutzio) 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/__tests__/cat-home.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | ManyToOne, 6 | OneToMany, 7 | OneToOne, 8 | PrimaryGeneratedColumn, 9 | VirtualColumn, 10 | } from 'typeorm' 11 | import { CatHomePillowEntity } from './cat-home-pillow.entity' 12 | import { CatEntity } from './cat.entity' 13 | import { DateColumnNotNull } from './column-option' 14 | 15 | @Entity() 16 | export class CatHomeEntity { 17 | @PrimaryGeneratedColumn() 18 | id: number 19 | 20 | @Column() 21 | name: string 22 | 23 | @Column({ nullable: true }) 24 | street: string | null 25 | 26 | @OneToOne(() => CatEntity, (cat) => cat.home) 27 | cat: CatEntity 28 | 29 | @OneToMany(() => CatHomePillowEntity, (pillow) => pillow.home) 30 | pillows: CatHomePillowEntity[] 31 | 32 | @ManyToOne(() => CatHomePillowEntity, { nullable: true }) 33 | naptimePillow: CatHomePillowEntity | null 34 | 35 | @CreateDateColumn(DateColumnNotNull) 36 | createdAt: string 37 | 38 | @VirtualColumn({ 39 | query: (alias) => { 40 | const tck = process.env.DB === 'mariadb' ? '`' : '"' 41 | const intType = process.env.DB === 'mariadb' ? 'UNSIGNED' : 'INT' 42 | return `SELECT CAST(COUNT(*) AS ${intType}) FROM ${tck}cat${tck} WHERE ${tck}cat${tck}.${tck}homeId${tck} = ${alias}.id` 43 | }, 44 | }) 45 | countCat: number 46 | } 47 | -------------------------------------------------------------------------------- /src/__tests__/cat.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterLoad, 3 | Column, 4 | CreateDateColumn, 5 | DeleteDateColumn, 6 | Entity, 7 | JoinColumn, 8 | JoinTable, 9 | ManyToMany, 10 | OneToMany, 11 | OneToOne, 12 | PrimaryGeneratedColumn, 13 | } from 'typeorm' 14 | import { CatHomeEntity } from './cat-home.entity' 15 | import { CatToyEntity } from './cat-toy.entity' 16 | import { DateColumnNotNull, DateColumnNullable } from './column-option' 17 | import { SizeEmbed } from './size.embed' 18 | 19 | export enum CutenessLevel { 20 | LOW = 'low', 21 | MEDIUM = 'medium', 22 | HIGH = 'high', 23 | } 24 | 25 | @Entity({ name: 'cat' }) 26 | export class CatEntity { 27 | @PrimaryGeneratedColumn() 28 | id: number 29 | 30 | @Column() 31 | name: string 32 | 33 | @Column() 34 | color: string 35 | 36 | @Column({ nullable: true }) 37 | age: number | null 38 | 39 | @Column({ type: 'text' }) // We don't use enum type as it makes it easier when testing across different db drivers. 40 | cutenessLevel: CutenessLevel 41 | 42 | @Column(DateColumnNullable) 43 | lastVetVisit: Date | null 44 | 45 | @Column(() => SizeEmbed) 46 | size: SizeEmbed 47 | 48 | @OneToMany(() => CatToyEntity, (catToy) => catToy.cat, { 49 | eager: true, 50 | }) 51 | toys: CatToyEntity[] 52 | 53 | @OneToOne(() => CatHomeEntity, (catHome) => catHome.cat, { nullable: true }) 54 | @JoinColumn() 55 | home: CatHomeEntity 56 | 57 | @CreateDateColumn(DateColumnNotNull) 58 | createdAt: string 59 | 60 | @DeleteDateColumn(DateColumnNullable) 61 | deletedAt?: string 62 | 63 | @ManyToMany(() => CatEntity) 64 | @JoinTable() 65 | friends: CatEntity[] 66 | 67 | @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) 68 | weightChange: number | null 69 | 70 | @AfterLoad() 71 | // Fix due to typeorm bug that doesn't set entity to null 72 | // when the reletated entity have only the virtual column property with a value different from null 73 | private afterLoad() { 74 | if (this.home && !this.home?.id) { 75 | this.home = null 76 | } 77 | 78 | if (this.weightChange) { 79 | this.weightChange = Number(this.weightChange) // convert value returned as character to number 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [20.x, 22.x] 16 | max-parallel: 1 17 | 18 | services: 19 | postgres: 20 | image: postgres:latest 21 | env: 22 | POSTGRES_USER: root 23 | POSTGRES_PASSWORD: pass 24 | POSTGRES_DB: test 25 | ports: 26 | - 5432:5432 27 | options: --health-cmd pg_isready 28 | --health-interval 10s 29 | --health-timeout 5s 30 | --health-retries 5 31 | 32 | mariadb: 33 | image: mariadb:latest 34 | env: 35 | MYSQL_ROOT_PASSWORD: pass 36 | MYSQL_DATABASE: test 37 | ports: 38 | - 3306:3306 39 | options: --health-cmd "mariadb-admin ping" 40 | --health-interval 10s 41 | --health-timeout 5s 42 | --health-retries 5 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | - name: Use Node.js ${{ matrix.node-version }} 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: ${{ matrix.node-version }} 50 | cache: npm 51 | - run: npm ci 52 | - run: npm run format:ci 53 | - run: npm run lint 54 | - run: npm run build 55 | 56 | # TODO: run postgres and sqlite in parallel 57 | - run: DB=postgres npm run test:cov 58 | - run: DB=mariadb npm run test:cov 59 | - run: DB=sqlite npm run test:cov 60 | - run: 'bash <(curl -s https://codecov.io/bash)' 61 | if: github.event_name == 'push' && matrix.node-version == '20.x' 62 | - name: Semantic Release 63 | if: github.event_name == 'push' && matrix.node-version == '20.x' 64 | uses: cycjimmy/semantic-release-action@v3 65 | env: 66 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 67 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 68 | -------------------------------------------------------------------------------- /src/swagger/api-ok-paginated-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, Type } from '@nestjs/common' 2 | import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger' 3 | import { ReferenceObject, SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface' 4 | import { PaginateConfig } from '../paginate' 5 | import { PaginatedDocumented } from './paginated-swagger.type' 6 | 7 | export const ApiOkPaginatedResponse = >( 8 | dataDto: DTO, 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | paginatedConfig: PaginateConfig 11 | ) => { 12 | const cols = paginatedConfig?.filterableColumns || {} 13 | 14 | return applyDecorators( 15 | ApiExtraModels(PaginatedDocumented, dataDto), 16 | ApiOkResponse({ 17 | schema: { 18 | allOf: [ 19 | { $ref: getSchemaPath(PaginatedDocumented) }, 20 | { 21 | properties: { 22 | data: { 23 | type: 'array', 24 | items: { $ref: getSchemaPath(dataDto) }, 25 | }, 26 | meta: { 27 | properties: { 28 | select: { 29 | type: 'array', 30 | items: { 31 | type: 'string', 32 | enum: paginatedConfig?.select, 33 | }, 34 | }, 35 | filter: { 36 | type: 'object', 37 | properties: Object.keys(cols).reduce( 38 | (acc, key) => { 39 | acc[key] = { 40 | oneOf: [ 41 | { 42 | type: 'string', 43 | }, 44 | { 45 | type: 'array', 46 | items: { 47 | type: 'string', 48 | }, 49 | }, 50 | ], 51 | } 52 | return acc 53 | }, 54 | {} as Record 55 | ), 56 | }, 57 | }, 58 | }, 59 | }, 60 | }, 61 | ], 62 | }, 63 | }) 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-paginate", 3 | "version": "0.1.0", 4 | "author": "Philipp Petzold ", 5 | "license": "MIT", 6 | "main": "lib/index.js", 7 | "typings": "lib/index.d.ts", 8 | "files": [ 9 | "lib/**/*" 10 | ], 11 | "description": "Pagination and filtering helper method for TypeORM repositories or query builders using Nest.js framework.", 12 | "keywords": [ 13 | "nestjs", 14 | "typeorm", 15 | "express", 16 | "pagination", 17 | "paginate", 18 | "filtering", 19 | "search" 20 | ], 21 | "scripts": { 22 | "prebuild": "rimraf lib", 23 | "build": "tsc", 24 | "prepare": "tsc && husky", 25 | "dev:yalc": "nodemon --watch src --ext ts --exec 'npm run build && yalc push'", 26 | "format": "prettier --write \"src/**/*.ts\"", 27 | "format:ci": "prettier --list-different \"src/**/*.ts\"", 28 | "lint": "eslint -c .eslintrc.json --ext .ts --max-warnings 0 src", 29 | "test": "jest", 30 | "test:watch": "jest --watch ", 31 | "test:cov": "jest --coverage", 32 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand" 33 | }, 34 | "devDependencies": { 35 | "@nestjs/common": "^11.0.11", 36 | "@nestjs/platform-express": "^11.0.11", 37 | "@nestjs/testing": "^11.0.11", 38 | "@types/express": "^5.0.0", 39 | "@types/jest": "^29.5.14", 40 | "@types/lodash": "^4.17.16", 41 | "@types/node": "^22.13.10", 42 | "@typescript-eslint/eslint-plugin": "^7.18.0", 43 | "@typescript-eslint/parser": "^7.18.0", 44 | "dotenv": "^16.4.7", 45 | "eslint": "^8.57.1", 46 | "eslint-config-prettier": "^9.1.0", 47 | "eslint-plugin-prettier": "^5.2.3", 48 | "fastify": "^5.2.1", 49 | "husky": "^9.1.7", 50 | "jest": "^29.7.0", 51 | "mysql2": "^3.14.0", 52 | "pg": "^8.14.0", 53 | "prettier": "^3.0.3", 54 | "reflect-metadata": "^0.2.2", 55 | "rxjs": "^7.8.2", 56 | "sqlite3": "^5.1.7", 57 | "ts-jest": "^29.2.6", 58 | "ts-node": "^10.9.2", 59 | "typeorm": "^0.3.17", 60 | "typescript": "^5.5.4" 61 | }, 62 | "dependencies": { 63 | "lodash": "^4.17.21" 64 | }, 65 | "peerDependencies": { 66 | "@nestjs/common": "^10.0.0 || ^11.0.0", 67 | "@nestjs/swagger": "^8.0.0 || ^11.0.0", 68 | "express": "^4.21.2 || ^5.0.0", 69 | "fastify": "^4.0.0 || ^5.0.0", 70 | "typeorm": "^0.3.17" 71 | }, 72 | "jest": { 73 | "moduleFileExtensions": [ 74 | "js", 75 | "json", 76 | "ts" 77 | ], 78 | "rootDir": "src", 79 | "testRegex": ".spec.ts$", 80 | "transform": { 81 | "^.+\\.(t|j)s$": "ts-jest" 82 | }, 83 | "coverageDirectory": "../coverage", 84 | "testEnvironment": "node", 85 | "setupFiles": [ 86 | "/../jest.setup.ts" 87 | ] 88 | }, 89 | "repository": { 90 | "type": "git", 91 | "url": "git+https://github.com/ppetzold/nestjs-paginate.git" 92 | }, 93 | "homepage": "https://github.com/ppetzold/nestjs-paginate#readme", 94 | "bugs": { 95 | "url": "https://github.com/ppetzold/nestjs-paginate/issues" 96 | }, 97 | "publishConfig": { 98 | "access": "public" 99 | }, 100 | "release": { 101 | "branches": [ 102 | "master" 103 | ] 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/swagger/paginated-swagger.type.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger' 2 | import { Column, SortBy } from '../helper' 3 | import { Paginated } from '../paginate' 4 | 5 | class PaginatedLinksDocumented { 6 | @ApiProperty({ 7 | title: 'Link to first page', 8 | required: false, 9 | type: 'string', 10 | }) 11 | first?: string 12 | 13 | @ApiProperty({ 14 | title: 'Link to previous page', 15 | required: false, 16 | type: 'string', 17 | }) 18 | previous?: string 19 | 20 | @ApiProperty({ 21 | title: 'Link to current page', 22 | required: false, 23 | type: 'string', 24 | }) 25 | current!: string 26 | 27 | @ApiProperty({ 28 | title: 'Link to next page', 29 | required: false, 30 | type: 'string', 31 | }) 32 | next?: string 33 | 34 | @ApiProperty({ 35 | title: 'Link to last page', 36 | required: false, 37 | type: 'string', 38 | }) 39 | last?: string 40 | } 41 | 42 | export class PaginatedMetaDocumented { 43 | @ApiProperty({ 44 | title: 'Number of items per page', 45 | required: true, 46 | type: 'number', 47 | }) 48 | itemsPerPage!: number 49 | 50 | @ApiProperty({ 51 | title: 'Total number of items', 52 | required: true, 53 | type: 'number', 54 | }) 55 | totalItems!: number 56 | 57 | @ApiProperty({ 58 | title: 'Current requested page', 59 | required: true, 60 | type: 'number', 61 | }) 62 | currentPage!: number 63 | 64 | @ApiProperty({ 65 | title: 'Total number of pages', 66 | required: true, 67 | type: 'number', 68 | }) 69 | totalPages!: number 70 | 71 | @ApiProperty({ 72 | title: 'Sorting by columns', 73 | required: false, 74 | type: 'array', 75 | items: { 76 | type: 'array', 77 | items: { 78 | oneOf: [ 79 | { 80 | type: 'string', 81 | }, 82 | { 83 | type: 'string', 84 | enum: ['ASC', 'DESC'], 85 | }, 86 | ], 87 | }, 88 | }, 89 | }) 90 | sortBy!: SortBy 91 | 92 | @ApiProperty({ 93 | title: 'Search by fields', 94 | required: false, 95 | isArray: true, 96 | type: 'string', 97 | }) 98 | searchBy!: Column[] 99 | 100 | @ApiProperty({ 101 | title: 'Search term', 102 | required: false, 103 | type: 'string', 104 | }) 105 | search!: string 106 | 107 | @ApiProperty({ 108 | title: 'List of selected fields', 109 | required: false, 110 | isArray: true, 111 | type: 'string', 112 | }) 113 | select!: string[] 114 | 115 | @ApiProperty({ 116 | title: 'Filters that applied to the query', 117 | selfRequired: false, 118 | required: [], 119 | isArray: false, 120 | type: 'object', 121 | additionalProperties: false, 122 | }) 123 | filter?: { 124 | [p: string]: string | string[] 125 | } 126 | } 127 | 128 | export class PaginatedDocumented extends Paginated { 129 | @ApiProperty({ 130 | isArray: true, 131 | selfRequired: true, 132 | title: 'Array of entities', 133 | type: 'object', 134 | additionalProperties: false, 135 | }) 136 | override data!: T[] 137 | 138 | @ApiProperty({ 139 | title: 'Pagination Metadata', 140 | required: true, 141 | }) 142 | override meta!: PaginatedMetaDocumented 143 | 144 | @ApiProperty({ 145 | title: 'Links to pages', 146 | required: true, 147 | }) 148 | override links!: PaginatedLinksDocumented 149 | } 150 | -------------------------------------------------------------------------------- /src/decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common' 2 | import type { Request as ExpressRequest } from 'express' 3 | import type { FastifyRequest } from 'fastify' 4 | import { Dictionary, isString, mapKeys, pickBy } from 'lodash' 5 | import { isNil } from './helper' 6 | 7 | function isRecord(data: unknown): data is Record { 8 | return data !== null && typeof data === 'object' && !Array.isArray(data) 9 | } 10 | 11 | function isExpressRequest(request: unknown): request is ExpressRequest { 12 | return isRecord(request) && typeof request.get === 'function' 13 | } 14 | 15 | export interface PaginateQuery { 16 | page?: number 17 | limit?: number 18 | sortBy?: [string, string][] 19 | searchBy?: string[] 20 | search?: string 21 | filter?: { [column: string]: string | string[] } 22 | select?: string[] 23 | cursor?: string 24 | withDeleted?: boolean 25 | path: string 26 | } 27 | 28 | const singleSplit = (param: string, res: any[]) => res.push(param) 29 | 30 | const multipleSplit = (param: string, res: any[]) => { 31 | const items = param.split(':') 32 | if (items.length === 2) { 33 | res.push(items as [string, string]) 34 | } 35 | } 36 | 37 | const multipleAndCommaSplit = (param: string, res: any[]) => { 38 | const set = new Set(param.split(',')) 39 | set.forEach((item) => res.push(item)) 40 | } 41 | 42 | function parseParam(queryParam: unknown, parserLogic: (param: string, res: any[]) => void): T[] | undefined { 43 | const res = [] 44 | if (queryParam) { 45 | const params = !Array.isArray(queryParam) ? [queryParam] : queryParam 46 | for (const param of params) { 47 | if (isString(param)) { 48 | parserLogic(param, res) 49 | } 50 | } 51 | } 52 | return res.length ? res : undefined 53 | } 54 | 55 | function parseIntParam(v: unknown): number | undefined { 56 | if (isNil(v)) { 57 | return undefined 58 | } 59 | 60 | const result = Number.parseInt(v.toString(), 10) 61 | 62 | if (Number.isNaN(result)) { 63 | return undefined 64 | } 65 | return result 66 | } 67 | 68 | export const Paginate = createParamDecorator((_data: unknown, ctx: ExecutionContext): PaginateQuery => { 69 | let path: string 70 | let query: Record 71 | 72 | switch (ctx.getType()) { 73 | case 'http': 74 | const request: ExpressRequest | FastifyRequest = ctx.switchToHttp().getRequest() 75 | query = request.query as Record 76 | 77 | // Determine if Express or Fastify to rebuild the original url and reduce down to protocol, host and base url 78 | let originalUrl: string 79 | if (isExpressRequest(request)) { 80 | originalUrl = request.protocol + '://' + request.get('host') + request.originalUrl 81 | } else { 82 | originalUrl = request.protocol + '://' + request.hostname + request.url 83 | } 84 | 85 | const urlParts = new URL(originalUrl) 86 | path = urlParts.protocol + '//' + urlParts.host + urlParts.pathname 87 | break 88 | case 'ws': 89 | query = ctx.switchToWs().getData() 90 | path = null 91 | break 92 | case 'rpc': 93 | query = ctx.switchToRpc().getData() 94 | path = null 95 | break 96 | } 97 | 98 | const searchBy = parseParam(query.searchBy, singleSplit) 99 | const sortBy = parseParam<[string, string]>(query.sortBy, multipleSplit) 100 | const select = parseParam(query.select, multipleAndCommaSplit) 101 | 102 | const filter = mapKeys( 103 | pickBy( 104 | query, 105 | (param, name) => 106 | name.includes('filter.') && 107 | (isString(param) || (Array.isArray(param) && (param as any[]).every((p) => isString(p)))) 108 | ) as Dictionary, 109 | (_param, name) => name.replace('filter.', '') 110 | ) 111 | 112 | return { 113 | page: parseIntParam(query.page), 114 | limit: parseIntParam(query.limit), 115 | sortBy, 116 | search: query.search ? query.search.toString() : undefined, 117 | searchBy, 118 | filter: Object.keys(filter).length ? filter : undefined, 119 | select, 120 | cursor: query.cursor ? query.cursor.toString() : undefined, 121 | withDeleted: query.withDeleted === 'true' ? true : query.withDeleted === 'false' ? false : undefined, 122 | path, 123 | } 124 | }) 125 | -------------------------------------------------------------------------------- /src/swagger/api-paginated-query.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common' 2 | import { ApiQuery } from '@nestjs/swagger' 3 | import { FilterComparator } from '../filter' 4 | import { FilterOperator, FilterSuffix, PaginateConfig } from '../paginate' 5 | import globalConfig from '../global-config' 6 | import { isNil } from '../helper' 7 | 8 | const DEFAULT_VALUE_KEY = 'Default Value' 9 | 10 | const allFilterSuffixes = Object.values(FilterSuffix).map((v) => v.toString()) 11 | 12 | function p(key: string | 'Format' | 'Example' | 'Default Value' | 'Max Value', value: string) { 13 | return ` 14 | **${key}:** ${value} 15 | ` 16 | } 17 | 18 | function li(key: string | 'Available Fields', values: string[]) { 19 | return `**${key}** 20 | ${values.map((v) => `- ${v}`).join('\n\n')}` 21 | } 22 | 23 | export function SortBy(paginationConfig: PaginateConfig) { 24 | const sortableColumnNotAvailable = 25 | isNil(paginationConfig.sortableColumns) || paginationConfig.sortableColumns.length === 0 26 | 27 | if (isNil(paginationConfig.defaultSortBy) && sortableColumnNotAvailable) { 28 | // no sorting allowed or predefined 29 | return undefined 30 | } 31 | 32 | const defaultSortMessage = paginationConfig.defaultSortBy 33 | ? paginationConfig.defaultSortBy.map(([col, order]) => `${col}:${order}`).join(',') 34 | : 'No default sorting specified, the result order is not guaranteed if not provided' 35 | 36 | const sortBy = paginationConfig.sortableColumns.reduce((prev, curr) => { 37 | return [...prev, `${curr}:ASC`, `${curr}:DESC`] 38 | }, []) 39 | 40 | const exampleValue = sortableColumnNotAvailable 41 | ? 'Allowed sortable columns are not provided, only default sorting will be used' 42 | : paginationConfig.sortableColumns 43 | .slice(0, 2) 44 | .map((col) => `sortBy=${col}:DESC`) 45 | .join('&') 46 | 47 | return ApiQuery({ 48 | name: 'sortBy', 49 | isArray: true, 50 | enum: sortBy, 51 | description: `Parameter to sort by. 52 | To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting 53 | ${p('Format', '{fieldName}:{DIRECTION}')} 54 | ${p('Example', exampleValue)} 55 | ${p('Default Value', defaultSortMessage)} 56 | ${li('Available Fields', paginationConfig.sortableColumns)} 57 | `, 58 | required: false, 59 | type: 'string', 60 | }) 61 | } 62 | 63 | export function Limit(paginationConfig: PaginateConfig) { 64 | return ApiQuery({ 65 | name: 'limit', 66 | description: `Number of records per page. 67 | 68 | ${p('Example', globalConfig.defaultLimit.toString())} 69 | 70 | ${p(DEFAULT_VALUE_KEY, paginationConfig?.defaultLimit?.toString() || globalConfig.defaultLimit.toString())} 71 | 72 | ${p('Max Value', paginationConfig.maxLimit?.toString() || globalConfig.defaultMaxLimit.toString())} 73 | 74 | If provided value is greater than max value, max value will be applied. 75 | `, 76 | required: false, 77 | type: 'number', 78 | }) 79 | } 80 | 81 | export function Select(paginationConfig: PaginateConfig) { 82 | if (!paginationConfig.select) { 83 | return 84 | } 85 | 86 | return ApiQuery({ 87 | name: 'select', 88 | description: `List of fields to select. 89 | ${p('Example', paginationConfig.select.slice(0, 5).join(','))} 90 | ${p( 91 | DEFAULT_VALUE_KEY, 92 | 'By default all fields returns. If you want to select only some fields, provide them in query param' 93 | )} 94 | `, 95 | required: false, 96 | type: 'string', 97 | }) 98 | } 99 | 100 | export function Where(paginationConfig: PaginateConfig) { 101 | if (!paginationConfig.filterableColumns) return 102 | 103 | const allColumnsDecorators = Object.entries(paginationConfig.filterableColumns) 104 | .map(([fieldName, filterOperations]) => { 105 | const operations = 106 | filterOperations === true || filterOperations === undefined 107 | ? [...Object.values(FilterOperator), ...Object.values(FilterSuffix)] 108 | : filterOperations.map((fo) => fo.toString()) 109 | 110 | const operationsForExample = 111 | operations 112 | .filter((v) => !allFilterSuffixes.includes(v)) 113 | .sort() 114 | .slice(0, 2) || [] 115 | 116 | return ApiQuery({ 117 | name: `filter.${fieldName}`, 118 | description: `Filter by ${fieldName} query param. 119 | ${p('Format', `filter.${fieldName}={$not}:OPERATION:VALUE`)} 120 | 121 | ${p( 122 | 'Example', 123 | operationsForExample.length === 0 124 | ? 'No filtering allowed' 125 | : operationsForExample.map((v) => `filter.${fieldName}=${v}:John Doe`).join('&') 126 | )} 127 | ${li('Available Operations', [...operations, ...Object.values(FilterComparator)])}`, 128 | required: false, 129 | type: 'string', 130 | isArray: true, 131 | }) 132 | }) 133 | .filter((v) => v !== undefined) 134 | 135 | return applyDecorators(...allColumnsDecorators) 136 | } 137 | 138 | export function Page() { 139 | return ApiQuery({ 140 | name: 'page', 141 | description: `Page number to retrieve. If you provide invalid value the default page number will applied 142 | ${p('Example', '1')} 143 | ${p(DEFAULT_VALUE_KEY, '1')} 144 | `, 145 | required: false, 146 | type: 'number', 147 | }) 148 | } 149 | 150 | export function Search(paginateConfig: PaginateConfig) { 151 | if (!paginateConfig.searchableColumns) return 152 | 153 | return ApiQuery({ 154 | name: 'search', 155 | description: `Search term to filter result values 156 | ${p('Example', 'John')} 157 | ${p(DEFAULT_VALUE_KEY, 'No default value')} 158 | `, 159 | required: false, 160 | type: 'string', 161 | }) 162 | } 163 | 164 | export function SearchBy(paginateConfig: PaginateConfig) { 165 | if (!paginateConfig.searchableColumns) return 166 | 167 | return ApiQuery({ 168 | name: 'searchBy', 169 | description: `List of fields to search by term to filter result values 170 | ${p( 171 | 'Example', 172 | paginateConfig.searchableColumns.slice(0, Math.min(5, paginateConfig.searchableColumns.length)).join(',') 173 | )} 174 | ${p(DEFAULT_VALUE_KEY, 'By default all fields mentioned below will be used to search by term')} 175 | ${li('Available Fields', paginateConfig.searchableColumns)} 176 | `, 177 | required: false, 178 | isArray: true, 179 | type: 'string', 180 | }) 181 | } 182 | 183 | export function WithDeleted(paginateConfig: PaginateConfig) { 184 | if (!paginateConfig.allowWithDeletedInQuery) return 185 | 186 | return ApiQuery({ 187 | name: 'withDeleted', 188 | description: `Retrieve records including soft deleted ones`, 189 | required: false, 190 | type: 'boolean', 191 | }) 192 | } 193 | 194 | export const ApiPaginationQuery = (paginationConfig: PaginateConfig) => { 195 | return applyDecorators( 196 | ...[ 197 | Page(), 198 | Limit(paginationConfig), 199 | Where(paginationConfig), 200 | SortBy(paginationConfig), 201 | Search(paginationConfig), 202 | SearchBy(paginationConfig), 203 | Select(paginationConfig), 204 | WithDeleted(paginationConfig), 205 | ].filter((v): v is MethodDecorator => v !== undefined) 206 | ) 207 | } 208 | -------------------------------------------------------------------------------- /src/decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants' 2 | import { 3 | CustomParamFactory, 4 | ExecutionContext, 5 | HttpArgumentsHost, 6 | RpcArgumentsHost, 7 | Type, 8 | WsArgumentsHost, 9 | } from '@nestjs/common/interfaces' 10 | import { Request as ExpressRequest } from 'express' 11 | import { FastifyRequest } from 'fastify' 12 | import { Paginate, PaginateQuery } from './decorator' 13 | 14 | // eslint-disable-next-line @typescript-eslint/ban-types 15 | function getParamDecoratorFactory(decorator: Function): CustomParamFactory { 16 | class Test { 17 | public test(@decorator() _value: T): void { 18 | // 19 | } 20 | } 21 | const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, Test, 'test') 22 | return args[Object.keys(args)[0]].factory 23 | } 24 | const decoratorfactory = getParamDecoratorFactory(Paginate) 25 | 26 | function expressContextFactory(query: ExpressRequest['query']): ExecutionContext { 27 | const mockContext: ExecutionContext = { 28 | getType: () => 'http' as ContextType, 29 | switchToHttp: (): HttpArgumentsHost => 30 | Object({ 31 | getRequest: (): Partial => 32 | Object({ 33 | protocol: 'http', 34 | get: () => 'localhost', 35 | originalUrl: '/items?search=2423', 36 | query: query, 37 | }), 38 | }), 39 | getClass: (): Type => { 40 | throw new Error('Function not implemented.') 41 | }, 42 | getHandler: (): (() => void) => { 43 | throw new Error('Function not implemented.') 44 | }, 45 | getArgs: = any[]>(): T => { 46 | throw new Error('Function not implemented.') 47 | }, 48 | getArgByIndex: (): T => { 49 | throw new Error('Function not implemented.') 50 | }, 51 | switchToRpc: (): RpcArgumentsHost => { 52 | throw new Error('Function not implemented.') 53 | }, 54 | switchToWs: (): WsArgumentsHost => { 55 | throw new Error('Function not implemented.') 56 | }, 57 | } 58 | return mockContext 59 | } 60 | 61 | function fastifyContextFactory(query: FastifyRequest['query']): ExecutionContext { 62 | const mockContext: ExecutionContext = { 63 | getType: () => 'http' as ContextType, 64 | switchToHttp: (): HttpArgumentsHost => 65 | Object({ 66 | getRequest: (): Partial => 67 | Object({ 68 | protocol: 'http', 69 | hostname: 'localhost', 70 | url: '/items?search=2423', 71 | originalUrl: '/items?search=2423', 72 | query: query, 73 | }), 74 | }), 75 | getClass: (): Type => { 76 | throw new Error('Function not implemented.') 77 | }, 78 | getHandler: (): (() => void) => { 79 | throw new Error('Function not implemented.') 80 | }, 81 | getArgs: = any[]>(): T => { 82 | throw new Error('Function not implemented.') 83 | }, 84 | getArgByIndex: (): T => { 85 | throw new Error('Function not implemented.') 86 | }, 87 | switchToRpc: (): RpcArgumentsHost => { 88 | throw new Error('Function not implemented.') 89 | }, 90 | switchToWs: (): WsArgumentsHost => { 91 | throw new Error('Function not implemented.') 92 | }, 93 | } 94 | return mockContext 95 | } 96 | 97 | describe('Decorator', () => { 98 | it('should handle express undefined query fields', () => { 99 | const context = expressContextFactory({}) 100 | 101 | const result: PaginateQuery = decoratorfactory(null, context) 102 | 103 | expect(result).toStrictEqual({ 104 | page: undefined, 105 | limit: undefined, 106 | sortBy: undefined, 107 | search: undefined, 108 | searchBy: undefined, 109 | filter: undefined, 110 | select: undefined, 111 | cursor: undefined, 112 | withDeleted: undefined, 113 | path: 'http://localhost/items', 114 | }) 115 | }) 116 | 117 | it('should handle fastify undefined query fields', () => { 118 | const context = fastifyContextFactory({}) 119 | 120 | const result: PaginateQuery = decoratorfactory(null, context) 121 | 122 | expect(result).toStrictEqual({ 123 | page: undefined, 124 | limit: undefined, 125 | sortBy: undefined, 126 | search: undefined, 127 | searchBy: undefined, 128 | filter: undefined, 129 | select: undefined, 130 | cursor: undefined, 131 | withDeleted: undefined, 132 | path: 'http://localhost/items', 133 | }) 134 | }) 135 | 136 | it('should handle express defined query fields', () => { 137 | const context = expressContextFactory({ 138 | page: '1', 139 | limit: '20', 140 | sortBy: ['id:ASC', 'createdAt:DESC'], 141 | search: 'white', 142 | withDeleted: 'true', 143 | 'filter.name': '$not:$eq:Kitty', 144 | 'filter.createdAt': ['$gte:2020-01-01', '$lte:2020-12-31'], 145 | select: ['name', 'createdAt'], 146 | cursor: 'abc123', 147 | }) 148 | 149 | const result: PaginateQuery = decoratorfactory(null, context) 150 | 151 | expect(result).toStrictEqual({ 152 | page: 1, 153 | limit: 20, 154 | sortBy: [ 155 | ['id', 'ASC'], 156 | ['createdAt', 'DESC'], 157 | ], 158 | search: 'white', 159 | searchBy: undefined, 160 | withDeleted: true, 161 | select: ['name', 'createdAt'], 162 | path: 'http://localhost/items', 163 | filter: { 164 | name: '$not:$eq:Kitty', 165 | createdAt: ['$gte:2020-01-01', '$lte:2020-12-31'], 166 | }, 167 | cursor: 'abc123', 168 | }) 169 | }) 170 | 171 | it('should handle fastify defined query fields', () => { 172 | const context = fastifyContextFactory({ 173 | page: '1', 174 | limit: '20', 175 | sortBy: ['id:ASC', 'createdAt:DESC'], 176 | search: 'white', 177 | withDeleted: 'false', 178 | 'filter.name': '$not:$eq:Kitty', 179 | 'filter.createdAt': ['$gte:2020-01-01', '$lte:2020-12-31'], 180 | select: ['name', 'createdAt'], 181 | cursor: 'abc123', 182 | }) 183 | 184 | const result: PaginateQuery = decoratorfactory(null, context) 185 | 186 | expect(result).toStrictEqual({ 187 | page: 1, 188 | limit: 20, 189 | sortBy: [ 190 | ['id', 'ASC'], 191 | ['createdAt', 'DESC'], 192 | ], 193 | search: 'white', 194 | searchBy: undefined, 195 | withDeleted: false, 196 | path: 'http://localhost/items', 197 | filter: { 198 | name: '$not:$eq:Kitty', 199 | createdAt: ['$gte:2020-01-01', '$lte:2020-12-31'], 200 | }, 201 | select: ['name', 'createdAt'], 202 | cursor: 'abc123', 203 | }) 204 | }) 205 | 206 | it('should use default params if not valid values provided', () => { 207 | const context = fastifyContextFactory({ 208 | page: 'NOTANUMBER', 209 | limit: 'NOTANUMBER', 210 | sortBy: ['NOTEXISTEN:BLABLA'], 211 | search: 'white', 212 | 'filter.notUsed': '$fake:$eqaa:Kitty', 213 | 'filter.notUsedSecond': 'something', 214 | select: ['notExisted'], 215 | cursor: 'abc123', 216 | }) 217 | 218 | const result: PaginateQuery = decoratorfactory(null, context) 219 | 220 | expect(result).toStrictEqual({ 221 | page: undefined, 222 | limit: undefined, 223 | sortBy: [['NOTEXISTEN', 'BLABLA']], 224 | search: 'white', 225 | searchBy: undefined, 226 | withDeleted: undefined, 227 | path: 'http://localhost/items', 228 | filter: { 229 | notUsed: '$fake:$eqaa:Kitty', 230 | notUsedSecond: 'something', 231 | }, 232 | select: ['notExisted'], 233 | cursor: 'abc123', 234 | }) 235 | }) 236 | }) 237 | -------------------------------------------------------------------------------- /src/helper.ts: -------------------------------------------------------------------------------- 1 | import { mergeWith } from 'lodash' 2 | import { 3 | FindOperator, 4 | FindOptionsRelationByString, 5 | FindOptionsRelations, 6 | Repository, 7 | SelectQueryBuilder, 8 | } from 'typeorm' 9 | import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata' 10 | import { OrmUtils } from 'typeorm/util/OrmUtils' 11 | 12 | /** 13 | * Joins 2 keys as `K`, `K.P`, `K.(P` or `K.P)` 14 | * The parenthesis notation is included for embedded columns 15 | */ 16 | type Join = K extends string 17 | ? P extends string 18 | ? `${K}${'' extends P ? '' : '.'}${P | `(${P}` | `${P})`}` 19 | : never 20 | : never 21 | 22 | /** 23 | * Get the previous number between 0 and 10. Examples: 24 | * Prev[3] = 2 25 | * Prev[0] = never. 26 | * Prev[20] = 0 27 | */ 28 | type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]] 29 | 30 | /** 31 | * Unwrap Promise to T 32 | */ 33 | type UnwrapPromise = T extends Promise ? UnwrapPromise : T 34 | 35 | /** 36 | * Unwrap Array to T 37 | */ 38 | type UnwrapArray = T extends Array ? UnwrapArray : T 39 | 40 | /** 41 | * Find all the dotted path properties for a given column. 42 | * 43 | * T: The column 44 | * D: max depth 45 | */ 46 | // v Have we reached max depth? 47 | export type Column = [D] extends [never] 48 | ? // yes, stop recursing 49 | never 50 | : // Are we extending something with keys? 51 | T extends Record 52 | ? { 53 | // For every keyof T, find all possible properties as a string union 54 | [K in keyof T]-?: K extends string 55 | ? // Is it string or number (includes enums)? 56 | T[K] extends string | number 57 | ? // yes, add just the key 58 | `${K}` 59 | : // Is it a Date? 60 | T[K] extends Date 61 | ? // yes, add just the key 62 | `${K}` 63 | : // no, is it an array? 64 | T[K] extends Array 65 | ? // yes, unwrap it, and recurse deeper 66 | `${K}` | Join, Prev[D]>> 67 | : // no, is it a promise? 68 | T[K] extends Promise 69 | ? // yes, try to infer its return type and recurse 70 | U extends Array 71 | ? `${K}` | Join, Prev[D]>> 72 | : `${K}` | Join, Prev[D]>> 73 | : // no, we have no more special cases, so treat it as an 74 | // object and recurse deeper on its keys 75 | `${K}` | Join> 76 | : never 77 | // Join all the string unions of each keyof T into a single string union 78 | }[keyof T] 79 | : '' 80 | 81 | export type RelationColumn = Extract< 82 | Column, 83 | { 84 | [K in Column]: K extends `${infer R}.${string}` ? R : never 85 | }[Column] 86 | > 87 | 88 | export type Order = [Column, 'ASC' | 'DESC'] 89 | export type SortBy = Order[] 90 | 91 | // eslint-disable-next-line @typescript-eslint/ban-types 92 | export type MappedColumns = { [key in Column | (string & {})]: S } 93 | export type JoinMethod = 'leftJoinAndSelect' | 'innerJoinAndSelect' 94 | export type RelationSchemaInput = FindOptionsRelations | RelationColumn[] | FindOptionsRelationByString 95 | // eslint-disable-next-line @typescript-eslint/ban-types 96 | export type RelationSchema = { [relation in Column | (string & {})]: true } 97 | 98 | export function isEntityKey(entityColumns: Column[], column: string): column is Column { 99 | return !!entityColumns.find((c) => c === column) 100 | } 101 | 102 | export const positiveNumberOrDefault = (value: number | undefined, defaultValue: number, minValue: 0 | 1 = 0) => 103 | value === undefined || value < minValue ? defaultValue : value 104 | 105 | export type ColumnProperties = { propertyPath?: string; propertyName: string; isNested: boolean; column: string } 106 | 107 | export function getPropertiesByColumnName(column: string): ColumnProperties { 108 | const propertyPath = column.split('.') 109 | if (propertyPath.length > 1) { 110 | const propertyNamePath = propertyPath.slice(1) 111 | let isNested = false, 112 | propertyName = propertyNamePath.join('.') 113 | 114 | if (!propertyName.startsWith('(') && propertyNamePath.length > 1) { 115 | isNested = true 116 | } 117 | 118 | propertyName = propertyName.replace('(', '').replace(')', '') 119 | 120 | return { 121 | propertyPath: propertyPath[0], 122 | propertyName, // the join is in case of an embedded entity 123 | isNested, 124 | column: `${propertyPath[0]}.${propertyName}`, 125 | } 126 | } else { 127 | return { propertyName: propertyPath[0], isNested: false, column: propertyPath[0] } 128 | } 129 | } 130 | 131 | export function extractVirtualProperty( 132 | qb: SelectQueryBuilder, 133 | columnProperties: ColumnProperties 134 | ): Partial { 135 | const metadata = columnProperties.propertyPath 136 | ? qb?.expressionMap?.mainAlias?.metadata?.findColumnWithPropertyPath(columnProperties.propertyPath) 137 | ?.referencedColumn?.entityMetadata // on relation 138 | : qb?.expressionMap?.mainAlias?.metadata 139 | return ( 140 | metadata?.columns?.find((column) => column.propertyName === columnProperties.propertyName) || { 141 | isVirtualProperty: false, 142 | query: undefined, 143 | } 144 | ) 145 | } 146 | 147 | export function includesAllPrimaryKeyColumns(qb: SelectQueryBuilder, propertyPath: string[]): boolean { 148 | if (!qb || !propertyPath) { 149 | return false 150 | } 151 | return qb.expressionMap.mainAlias?.metadata?.primaryColumns 152 | .map((column) => column.propertyPath) 153 | .every((column) => propertyPath.includes(column)) 154 | } 155 | 156 | export function getPrimaryKeyColumns(qb: SelectQueryBuilder, entityName?: string): string[] { 157 | return qb.expressionMap.mainAlias?.metadata?.primaryColumns.map((column) => 158 | entityName ? `${entityName}.${column.propertyName}` : column.propertyName 159 | ) 160 | } 161 | 162 | export function getMissingPrimaryKeyColumns(qb: SelectQueryBuilder, transformedCols: string[]): string[] { 163 | if (!transformedCols || transformedCols.length === 0) return [] 164 | 165 | const mainEntityPrimaryKeys = getPrimaryKeyColumns(qb) 166 | const missingPrimaryKeys: string[] = [] 167 | 168 | for (const pk of mainEntityPrimaryKeys) { 169 | const columnProperties = getPropertiesByColumnName(pk) 170 | const pkAlias = fixColumnAlias(columnProperties, qb.alias, false) 171 | 172 | if (!transformedCols.includes(pkAlias)) { 173 | missingPrimaryKeys.push(pkAlias) 174 | } 175 | } 176 | 177 | return missingPrimaryKeys 178 | } 179 | 180 | export function hasColumnWithPropertyPath( 181 | qb: SelectQueryBuilder, 182 | columnProperties: ColumnProperties 183 | ): boolean { 184 | if (!qb || !columnProperties) { 185 | return false 186 | } 187 | return !!qb.expressionMap.mainAlias?.metadata?.hasColumnWithPropertyPath(columnProperties.propertyName) 188 | } 189 | 190 | export function checkIsRelation(qb: SelectQueryBuilder, propertyPath: string): boolean { 191 | if (!qb || !propertyPath) { 192 | return false 193 | } 194 | return !!qb?.expressionMap?.mainAlias?.metadata?.hasRelationWithPropertyPath(propertyPath) 195 | } 196 | 197 | export function checkIsNestedRelation(qb: SelectQueryBuilder, propertyPath: string): boolean { 198 | let metadata = qb?.expressionMap?.mainAlias?.metadata 199 | for (const relationName of propertyPath.split('.')) { 200 | const relation = metadata?.relations.find((relation) => relation.propertyPath === relationName) 201 | if (!relation) { 202 | return false 203 | } 204 | metadata = relation.inverseEntityMetadata 205 | } 206 | return true 207 | } 208 | 209 | export function checkIsEmbedded(qb: SelectQueryBuilder, propertyPath: string): boolean { 210 | if (!qb || !propertyPath) { 211 | return false 212 | } 213 | return !!qb?.expressionMap?.mainAlias?.metadata?.hasEmbeddedWithPropertyPath(propertyPath) 214 | } 215 | 216 | export function checkIsArray(qb: SelectQueryBuilder, propertyName: string): boolean { 217 | if (!qb || !propertyName) { 218 | return false 219 | } 220 | return !!qb?.expressionMap?.mainAlias?.metadata.findColumnWithPropertyName(propertyName)?.isArray 221 | } 222 | 223 | export function checkIsJsonb(qb: SelectQueryBuilder, propertyName: string): boolean { 224 | if (!qb || !propertyName) { 225 | return false 226 | } 227 | 228 | if (propertyName.includes('.')) { 229 | const parts = propertyName.split('.') 230 | const dbColumnName = parts[parts.length - 2] 231 | 232 | return qb?.expressionMap?.mainAlias?.metadata.findColumnWithPropertyName(dbColumnName)?.type === 'jsonb' 233 | } 234 | 235 | return qb?.expressionMap?.mainAlias?.metadata.findColumnWithPropertyName(propertyName)?.type === 'jsonb' 236 | } 237 | 238 | // This function is used to fix the column alias when using relation, embedded or virtual properties 239 | export function fixColumnAlias( 240 | properties: ColumnProperties, 241 | alias: string, 242 | isRelation = false, 243 | isVirtualProperty = false, 244 | isEmbedded = false, 245 | query?: ColumnMetadata['query'] 246 | ): string { 247 | if (isRelation) { 248 | if (isVirtualProperty && query) { 249 | return `(${query(`${alias}_${properties.propertyPath}_rel`)})` // () is needed to avoid parameter conflict 250 | } else if ((isVirtualProperty && !query) || properties.isNested) { 251 | if (properties.propertyName.includes('.')) { 252 | const propertyPath = properties.propertyName.split('.') 253 | const nestedRelations = propertyPath 254 | .slice(0, -1) 255 | .map((v) => `${v}_rel`) 256 | .join('_') 257 | const nestedCol = propertyPath[propertyPath.length - 1] 258 | 259 | return `${alias}_${properties.propertyPath}_rel_${nestedRelations}.${nestedCol}` 260 | } else { 261 | return `${alias}_${properties.propertyPath}_rel_${properties.propertyName}` 262 | } 263 | } else { 264 | return `${alias}_${properties.propertyPath}_rel.${properties.propertyName}` 265 | } 266 | } else if (isVirtualProperty) { 267 | return query ? `(${query(`${alias}`)})` : `${alias}_${properties.propertyName}` 268 | } else if (isEmbedded) { 269 | return `${alias}.${properties.propertyPath}.${properties.propertyName}` 270 | } else { 271 | return `${alias}.${properties.propertyName}` 272 | } 273 | } 274 | 275 | export function getQueryUrlComponents(path: string): { queryOrigin: string; queryPath: string } { 276 | const r = new RegExp('^(?:[a-z+]+:)?//', 'i') 277 | let queryOrigin = '' 278 | let queryPath = '' 279 | if (r.test(path)) { 280 | const url = new URL(path) 281 | queryOrigin = url.origin 282 | queryPath = url.pathname 283 | } else { 284 | queryPath = path 285 | } 286 | return { queryOrigin, queryPath } 287 | } 288 | 289 | const isoDateRegExp = new RegExp( 290 | /(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/ 291 | ) 292 | 293 | export function isISODate(str: string): boolean { 294 | return isoDateRegExp.test(str) 295 | } 296 | 297 | export function isRepository(repo: unknown | Repository): repo is Repository { 298 | if (repo instanceof Repository) return true 299 | try { 300 | if (Object.getPrototypeOf(repo).constructor.name === 'Repository') return true 301 | return typeof repo === 'object' && !('connection' in repo) && 'manager' in repo 302 | } catch { 303 | return false 304 | } 305 | } 306 | 307 | export function isFindOperator(value: unknown | FindOperator): value is FindOperator { 308 | if (value instanceof FindOperator) return true 309 | try { 310 | if (Object.getPrototypeOf(value).constructor.name === 'FindOperator') return true 311 | return typeof value === 'object' && '_type' in value && '_value' in value 312 | } catch { 313 | return false 314 | } 315 | } 316 | 317 | export function createRelationSchema(configurationRelations: RelationSchemaInput): RelationSchema { 318 | return Array.isArray(configurationRelations) 319 | ? OrmUtils.propertyPathsToTruthyObject(configurationRelations) 320 | : (configurationRelations as RelationSchema) 321 | } 322 | 323 | export function mergeRelationSchema(...schemas: RelationSchema[]) { 324 | const noTrueOverride = (obj, source) => (source === true && obj !== undefined ? obj : undefined) 325 | return mergeWith({}, ...schemas, noTrueOverride) 326 | } 327 | 328 | export function getPaddedExpr(valueExpr: string, length: number, dbType: string): string { 329 | const lengthStr = String(length) 330 | if (dbType === 'postgres' || dbType === 'cockroachdb') { 331 | return `LPAD((${valueExpr})::bigint::text, ${lengthStr}, '0')` 332 | } else if (dbType === 'mysql' || dbType === 'mariadb') { 333 | return `LPAD(${valueExpr}, ${lengthStr}, '0')` 334 | } else { 335 | // sqlite 336 | const padding = '0'.repeat(length) 337 | return `SUBSTR('${padding}' || CAST(${valueExpr} AS INTEGER), -${lengthStr}, ${lengthStr})` 338 | } 339 | } 340 | 341 | export function isDateColumnType(type: any): boolean { 342 | const dateTypes = [ 343 | Date, // JavaScript Date class 344 | 'datetime', 345 | 'timestamp', 346 | 'timestamptz', 347 | ] 348 | return dateTypes.includes(type) 349 | } 350 | 351 | export function quoteVirtualColumn(columnName: string, isMySqlOrMariaDb: boolean): string { 352 | return isMySqlOrMariaDb ? `\`${columnName}\`` : `"${columnName}"` 353 | } 354 | 355 | export function isNil(v: unknown): boolean { 356 | return v === null || v === undefined 357 | } 358 | 359 | export function isNotNil(v: unknown): boolean { 360 | return !isNil(v) 361 | } 362 | -------------------------------------------------------------------------------- /src/filter.ts: -------------------------------------------------------------------------------- 1 | import { values } from 'lodash' 2 | import { 3 | ArrayContains, 4 | Between, 5 | Brackets, 6 | Equal, 7 | FindOperator, 8 | ILike, 9 | In, 10 | IsNull, 11 | JsonContains, 12 | LessThan, 13 | LessThanOrEqual, 14 | MoreThan, 15 | MoreThanOrEqual, 16 | Not, 17 | SelectQueryBuilder, 18 | } from 'typeorm' 19 | import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause' 20 | import { PaginateQuery } from './decorator' 21 | import { 22 | checkIsArray, 23 | checkIsEmbedded, 24 | checkIsJsonb, 25 | checkIsNestedRelation, 26 | checkIsRelation, 27 | extractVirtualProperty, 28 | fixColumnAlias, 29 | getPropertiesByColumnName, 30 | isDateColumnType, 31 | isISODate, 32 | JoinMethod, 33 | } from './helper' 34 | 35 | export enum FilterOperator { 36 | EQ = '$eq', 37 | GT = '$gt', 38 | GTE = '$gte', 39 | IN = '$in', 40 | NULL = '$null', 41 | LT = '$lt', 42 | LTE = '$lte', 43 | BTW = '$btw', 44 | ILIKE = '$ilike', 45 | SW = '$sw', 46 | CONTAINS = '$contains', 47 | } 48 | 49 | export function isOperator(value: unknown): value is FilterOperator { 50 | return values(FilterOperator).includes(value as any) 51 | } 52 | 53 | export enum FilterSuffix { 54 | NOT = '$not', 55 | } 56 | 57 | export function isSuffix(value: unknown): value is FilterSuffix { 58 | return values(FilterSuffix).includes(value as any) 59 | } 60 | 61 | export enum FilterComparator { 62 | AND = '$and', 63 | OR = '$or', 64 | } 65 | 66 | export function isComparator(value: unknown): value is FilterComparator { 67 | return values(FilterComparator).includes(value as any) 68 | } 69 | 70 | export const OperatorSymbolToFunction = new Map< 71 | FilterOperator | FilterSuffix, 72 | (...args: any[]) => FindOperator 73 | >([ 74 | [FilterOperator.EQ, Equal], 75 | [FilterOperator.GT, MoreThan], 76 | [FilterOperator.GTE, MoreThanOrEqual], 77 | [FilterOperator.IN, In], 78 | [FilterOperator.NULL, IsNull], 79 | [FilterOperator.LT, LessThan], 80 | [FilterOperator.LTE, LessThanOrEqual], 81 | [FilterOperator.BTW, Between], 82 | [FilterOperator.ILIKE, ILike], 83 | [FilterSuffix.NOT, Not], 84 | [FilterOperator.SW, ILike], 85 | [FilterOperator.CONTAINS, ArrayContains], 86 | ]) 87 | 88 | type Filter = { comparator: FilterComparator; findOperator: FindOperator } 89 | type ColumnFilters = { [columnName: string]: Filter[] } 90 | type ColumnJoinMethods = { [columnName: string]: JoinMethod } 91 | 92 | export interface FilterToken { 93 | comparator: FilterComparator 94 | suffix?: FilterSuffix 95 | operator: FilterOperator 96 | value: string 97 | } 98 | 99 | // This function is used to fix the query parameters when using relation, embeded or virtual properties 100 | // It will replace the column name with the alias name and return the new parameters 101 | export function fixQueryParam( 102 | alias: string, 103 | column: string, 104 | filter: Filter, 105 | condition: WherePredicateOperator, 106 | parameters: { [key: string]: string } 107 | ): { [key: string]: string } { 108 | const isNotOperator = (condition.operator as string) === 'not' 109 | 110 | const conditionFixer = ( 111 | alias: string, 112 | column: string, 113 | filter: Filter, 114 | operator: WherePredicateOperator['operator'], 115 | parameters: { [key: string]: string } 116 | ): { condition_params: any; params: any } => { 117 | let condition_params: any = undefined 118 | let params = parameters 119 | switch (operator) { 120 | case 'between': 121 | condition_params = [alias, `:${column}_from`, `:${column}_to`] 122 | params = { 123 | [column + '_from']: filter.findOperator.value[0], 124 | [column + '_to']: filter.findOperator.value[1], 125 | } 126 | break 127 | case 'in': 128 | condition_params = [alias, `:...${column}`] 129 | break 130 | default: 131 | condition_params = [alias, `:${column}`] 132 | break 133 | } 134 | return { condition_params, params } 135 | } 136 | 137 | const { condition_params, params } = conditionFixer( 138 | alias, 139 | column, 140 | filter, 141 | isNotOperator ? condition['condition']['operator'] : condition.operator, 142 | parameters 143 | ) 144 | 145 | if (isNotOperator) { 146 | condition['condition']['parameters'] = condition_params 147 | } else { 148 | condition.parameters = condition_params 149 | } 150 | 151 | return params 152 | } 153 | 154 | export function generatePredicateCondition( 155 | qb: SelectQueryBuilder, 156 | column: string, 157 | filter: Filter, 158 | alias: string, 159 | isVirtualProperty = false 160 | ): WherePredicateOperator { 161 | return qb['getWherePredicateCondition']( 162 | isVirtualProperty ? column : alias, 163 | filter.findOperator 164 | ) as WherePredicateOperator 165 | } 166 | 167 | export function addWhereCondition(qb: SelectQueryBuilder, column: string, filter: ColumnFilters) { 168 | const columnProperties = getPropertiesByColumnName(column) 169 | const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, columnProperties) 170 | const isRelation = checkIsRelation(qb, columnProperties.propertyPath) 171 | const isEmbedded = checkIsEmbedded(qb, columnProperties.propertyPath) 172 | const isArray = checkIsArray(qb, columnProperties.propertyName) 173 | 174 | const alias = fixColumnAlias(columnProperties, qb.alias, isRelation, isVirtualProperty, isEmbedded, virtualQuery) 175 | filter[column].forEach((columnFilter: Filter, index: number) => { 176 | const columnNamePerIteration = `${columnProperties.column}${index}` 177 | const condition = generatePredicateCondition( 178 | qb, 179 | columnProperties.column, 180 | columnFilter, 181 | alias, 182 | isVirtualProperty 183 | ) 184 | const parameters = fixQueryParam(alias, columnNamePerIteration, columnFilter, condition, { 185 | [columnNamePerIteration]: columnFilter.findOperator.value, 186 | }) 187 | if ( 188 | isArray && 189 | condition.parameters?.length && 190 | !['not', 'isNull', 'arrayContains'].includes(condition.operator) 191 | ) { 192 | condition.parameters[0] = `cardinality(${condition.parameters[0]})` 193 | } 194 | if (columnFilter.comparator === FilterComparator.OR) { 195 | qb.orWhere(qb['createWhereConditionExpression'](condition), parameters) 196 | } else { 197 | qb.andWhere(qb['createWhereConditionExpression'](condition), parameters) 198 | } 199 | }) 200 | } 201 | 202 | export function parseFilterToken(raw?: string): FilterToken | null { 203 | if (raw === undefined || raw === null) { 204 | return null 205 | } 206 | 207 | const token: FilterToken = { 208 | comparator: FilterComparator.AND, 209 | suffix: undefined, 210 | operator: FilterOperator.EQ, 211 | value: raw, 212 | } 213 | 214 | const MAX_OPERTATOR = 4 // max 4 operator es: $and:$not:$eq:$null 215 | const OPERAND_SEPARATOR = ':' 216 | 217 | const matches = raw.split(OPERAND_SEPARATOR) 218 | const maxOperandCount = matches.length > MAX_OPERTATOR ? MAX_OPERTATOR : matches.length 219 | const notValue: (FilterOperator | FilterSuffix | FilterComparator)[] = [] 220 | 221 | for (let i = 0; i < maxOperandCount; i++) { 222 | const match = matches[i] 223 | if (isComparator(match)) { 224 | token.comparator = match 225 | } else if (isSuffix(match)) { 226 | token.suffix = match 227 | } else if (isOperator(match)) { 228 | token.operator = match 229 | } else { 230 | break 231 | } 232 | notValue.push(match) 233 | } 234 | 235 | if (notValue.length) { 236 | token.value = 237 | token.operator === FilterOperator.NULL 238 | ? undefined 239 | : raw.replace(`${notValue.join(OPERAND_SEPARATOR)}${OPERAND_SEPARATOR}`, '') 240 | } 241 | 242 | return token 243 | } 244 | 245 | function fixColumnFilterValue(column: string, qb: SelectQueryBuilder, isJsonb = false) { 246 | const columnProperties = getPropertiesByColumnName(column) 247 | const virtualProperty = extractVirtualProperty(qb, columnProperties) 248 | const columnType = virtualProperty.type 249 | 250 | return (value: string) => { 251 | if ((isDateColumnType(columnType) || isJsonb) && isISODate(value)) { 252 | return new Date(value) 253 | } 254 | 255 | if ((columnType === Number || columnType === 'number' || isJsonb) && !Number.isNaN(value)) { 256 | return Number(value) 257 | } 258 | 259 | return value 260 | } 261 | } 262 | 263 | export function parseFilter( 264 | query: PaginateQuery, 265 | filterableColumns?: { [column: string]: (FilterOperator | FilterSuffix)[] | true }, 266 | qb?: SelectQueryBuilder 267 | ): ColumnFilters { 268 | const filter: ColumnFilters = {} 269 | if (!filterableColumns || !query.filter) { 270 | return {} 271 | } 272 | for (const column of Object.keys(query.filter)) { 273 | if (!(column in filterableColumns)) { 274 | continue 275 | } 276 | const allowedOperators = filterableColumns[column] 277 | const input = query.filter[column] 278 | const statements = !Array.isArray(input) ? [input] : input 279 | for (const raw of statements) { 280 | const token = parseFilterToken(raw) 281 | if (!token) { 282 | continue 283 | } 284 | if (allowedOperators === true) { 285 | if (token.operator && !isOperator(token.operator)) { 286 | continue 287 | } 288 | if (token.suffix && !isSuffix(token.suffix)) { 289 | continue 290 | } 291 | } else { 292 | if ( 293 | token.operator && 294 | token.operator !== FilterOperator.EQ && 295 | !allowedOperators.includes(token.operator) 296 | ) { 297 | continue 298 | } 299 | if (token.suffix && !allowedOperators.includes(token.suffix)) { 300 | continue 301 | } 302 | } 303 | 304 | const params: (typeof filter)[0][0] = { 305 | comparator: token.comparator, 306 | findOperator: undefined, 307 | } 308 | 309 | const fixValue = fixColumnFilterValue(column, qb) 310 | 311 | const columnProperties = getPropertiesByColumnName(column) 312 | const isJsonb = checkIsJsonb(qb, columnProperties.column) 313 | 314 | switch (token.operator) { 315 | case FilterOperator.BTW: 316 | params.findOperator = OperatorSymbolToFunction.get(token.operator)( 317 | ...token.value.split(',').map(fixValue) 318 | ) 319 | break 320 | case FilterOperator.IN: 321 | case FilterOperator.CONTAINS: 322 | params.findOperator = OperatorSymbolToFunction.get(token.operator)(token.value.split(',')) 323 | break 324 | case FilterOperator.ILIKE: 325 | params.findOperator = OperatorSymbolToFunction.get(token.operator)(`%${token.value}%`) 326 | break 327 | case FilterOperator.SW: 328 | params.findOperator = OperatorSymbolToFunction.get(token.operator)(`${token.value}%`) 329 | break 330 | default: 331 | params.findOperator = OperatorSymbolToFunction.get(token.operator)(fixValue(token.value)) 332 | } 333 | 334 | if (isJsonb) { 335 | const parts = column.split('.') 336 | const dbColumnName = parts[parts.length - 2] 337 | const jsonColumnName = parts[parts.length - 1] 338 | 339 | const jsonFixValue = fixColumnFilterValue(column, qb, true) 340 | 341 | const jsonParams = { 342 | comparator: params.comparator, 343 | findOperator: JsonContains({ 344 | [jsonColumnName]: jsonFixValue(token.value), 345 | //! Below seems to not be possible from my understanding, https://github.com/typeorm/typeorm/pull/9665 346 | //! This limits the functionaltiy to $eq only for json columns, which is a bit of a shame. 347 | //! If this is fixed or changed, we can use the commented line below instead. 348 | //[jsonColumnName]: params.findOperator, 349 | }), 350 | } 351 | 352 | filter[dbColumnName] = [...(filter[column] || []), jsonParams] 353 | } else { 354 | filter[column] = [...(filter[column] || []), params] 355 | } 356 | 357 | if (token.suffix) { 358 | const lastFilterElement = filter[column].length - 1 359 | filter[column][lastFilterElement].findOperator = OperatorSymbolToFunction.get(token.suffix)( 360 | filter[column][lastFilterElement].findOperator 361 | ) 362 | } 363 | } 364 | } 365 | return filter 366 | } 367 | 368 | export function addFilter( 369 | qb: SelectQueryBuilder, 370 | query: PaginateQuery, 371 | filterableColumns?: { [column: string]: (FilterOperator | FilterSuffix)[] | true } 372 | ): ColumnJoinMethods { 373 | const filter = parseFilter(query, filterableColumns, qb) 374 | 375 | const filterEntries = Object.entries(filter) 376 | const orFilters = filterEntries.filter(([_, value]) => value[0].comparator === '$or') 377 | const andFilters = filterEntries.filter(([_, value]) => value[0].comparator === '$and') 378 | 379 | qb.andWhere( 380 | new Brackets((qb: SelectQueryBuilder) => { 381 | for (const [column] of orFilters) { 382 | addWhereCondition(qb, column, filter) 383 | } 384 | }) 385 | ) 386 | 387 | for (const [column] of andFilters) { 388 | qb.andWhere( 389 | new Brackets((qb: SelectQueryBuilder) => { 390 | addWhereCondition(qb, column, filter) 391 | }) 392 | ) 393 | } 394 | 395 | // Set the join type of every relationship used in a filter to `innerJoinAndSelect` 396 | // so that records without that relationships don't show up in filters on their columns. 397 | return Object.fromEntries( 398 | filterEntries 399 | .map(([key]) => [key, getPropertiesByColumnName(key)] as const) 400 | .filter(([, properties]) => properties.propertyPath) 401 | .flatMap(([, properties]) => { 402 | const nesting = properties.column.split('.') 403 | return Array.from({ length: nesting.length - 1 }, (_, i) => nesting.slice(0, i + 1).join('.')) 404 | .filter((relation) => checkIsNestedRelation(qb, relation)) 405 | .map((relation) => [relation, 'innerJoinAndSelect'] as const) 406 | }) 407 | ) 408 | } 409 | -------------------------------------------------------------------------------- /src/swagger/pagination-docs.spec.ts: -------------------------------------------------------------------------------- 1 | import { Get, Post, Type } from '@nestjs/common' 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' 3 | import { FilterOperator, FilterSuffix, PaginateConfig } from '../paginate' 4 | import { Test } from '@nestjs/testing' 5 | import { PaginatedSwaggerDocs } from './api-paginated-swagger-docs.decorator' 6 | import { ApiPaginationQuery } from './api-paginated-query.decorator' 7 | import { ApiOkPaginatedResponse } from './api-ok-paginated-response.decorator' 8 | import * as fs from 'node:fs' 9 | import * as path from 'node:path' 10 | 11 | const BASE_PAGINATION_CONFIG = { 12 | sortableColumns: ['id'], 13 | } satisfies PaginateConfig 14 | 15 | const FULL_CONFIG = { 16 | ...BASE_PAGINATION_CONFIG, 17 | defaultSortBy: [['id', 'DESC']], 18 | defaultLimit: 20, 19 | maxLimit: 100, 20 | filterableColumns: { 21 | id: true, 22 | name: [FilterOperator.EQ, FilterSuffix.NOT], 23 | }, 24 | searchableColumns: ['name'], 25 | select: ['id', 'name'], 26 | } satisfies PaginateConfig 27 | 28 | class TestDto { 29 | id: string 30 | name: string 31 | } 32 | 33 | // eslint-disable-next-line @typescript-eslint/ban-types 34 | async function getSwaggerDefinitionForEndpoint(entityType: Type, config: PaginateConfig) { 35 | class TestController { 36 | @PaginatedSwaggerDocs(entityType, config) 37 | @Get('/test') 38 | public test(): void { 39 | // 40 | } 41 | 42 | @ApiPaginationQuery(config) 43 | @ApiOkPaginatedResponse(entityType, config) 44 | @Post('/test') 45 | public testPost(): void { 46 | // 47 | } 48 | } 49 | 50 | const fakeAppModule = await Test.createTestingModule({ 51 | controllers: [TestController], 52 | }).compile() 53 | const fakeApp = fakeAppModule.createNestApplication() 54 | 55 | return SwaggerModule.createDocument(fakeApp, new DocumentBuilder().build()) 56 | } 57 | 58 | describe('PaginatedEndpoint decorator', () => { 59 | it('post and get definition should be the same', async () => { 60 | const openApiDefinition = await getSwaggerDefinitionForEndpoint(TestDto, BASE_PAGINATION_CONFIG) 61 | 62 | expect(openApiDefinition.paths['/test'].get.parameters).toStrictEqual( 63 | openApiDefinition.paths['/test'].post.parameters 64 | ) 65 | }) 66 | 67 | it('should annotate endpoint with OpenApi documentation with limited config', async () => { 68 | const openApiDefinition = await getSwaggerDefinitionForEndpoint(TestDto, BASE_PAGINATION_CONFIG) 69 | 70 | const params = openApiDefinition.paths['/test'].get.parameters 71 | expect(params).toStrictEqual([ 72 | { 73 | name: 'page', 74 | required: false, 75 | in: 'query', 76 | description: 77 | 'Page number to retrieve. If you provide invalid value the default page number will applied\n\n**Example:** 1\n\n\n**Default Value:** 1\n\n', 78 | schema: { 79 | type: 'number', 80 | }, 81 | }, 82 | { 83 | name: 'limit', 84 | required: false, 85 | in: 'query', 86 | description: 87 | 'Number of records per page.\n\n\n**Example:** 20\n\n\n\n**Default Value:** 20\n\n\n\n**Max Value:** 100\n\n\nIf provided value is greater than max value, max value will be applied.\n', 88 | schema: { 89 | type: 'number', 90 | }, 91 | }, 92 | { 93 | name: 'sortBy', 94 | required: false, 95 | in: 'query', 96 | description: 97 | 'Parameter to sort by.\nTo sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting\n\n**Format:** {fieldName}:{DIRECTION}\n\n\n**Example:** sortBy=id:DESC\n\n\n**Default Value:** No default sorting specified, the result order is not guaranteed if not provided\n\n**Available Fields**\n- id\n', 98 | schema: { 99 | type: 'array', 100 | items: { 101 | type: 'string', 102 | enum: ['id:ASC', 'id:DESC'], 103 | }, 104 | }, 105 | }, 106 | ]) 107 | expect(openApiDefinition.paths['/test'].get.responses).toEqual({ 108 | '200': { 109 | description: '', 110 | content: { 111 | 'application/json': { 112 | schema: { 113 | allOf: [ 114 | { 115 | $ref: '#/components/schemas/PaginatedDocumented', 116 | }, 117 | { 118 | properties: { 119 | data: { 120 | type: 'array', 121 | items: { 122 | $ref: '#/components/schemas/TestDto', 123 | }, 124 | }, 125 | meta: { 126 | properties: { 127 | select: { 128 | type: 'array', 129 | items: { 130 | type: 'string', 131 | }, 132 | }, 133 | filter: { 134 | type: 'object', 135 | properties: {}, 136 | }, 137 | }, 138 | }, 139 | }, 140 | }, 141 | ], 142 | }, 143 | }, 144 | }, 145 | }, 146 | }) 147 | }) 148 | 149 | it('should annotate endpoint with OpenApi documentation with full config', async () => { 150 | const openApiDefinition = await getSwaggerDefinitionForEndpoint(TestDto, FULL_CONFIG) 151 | 152 | const params = openApiDefinition.paths['/test'].get.parameters 153 | expect(params).toStrictEqual([ 154 | { 155 | name: 'page', 156 | required: false, 157 | in: 'query', 158 | description: 159 | 'Page number to retrieve. If you provide invalid value the default page number will applied\n\n**Example:** 1\n\n\n**Default Value:** 1\n\n', 160 | schema: { 161 | type: 'number', 162 | }, 163 | }, 164 | { 165 | name: 'limit', 166 | required: false, 167 | in: 'query', 168 | description: 169 | 'Number of records per page.\n\n\n**Example:** 20\n\n\n\n**Default Value:** 20\n\n\n\n**Max Value:** 100\n\n\nIf provided value is greater than max value, max value will be applied.\n', 170 | schema: { 171 | type: 'number', 172 | }, 173 | }, 174 | { 175 | name: 'filter.id', 176 | required: false, 177 | in: 'query', 178 | description: 179 | 'Filter by id query param.\n\n**Format:** filter.id={$not}:OPERATION:VALUE\n\n\n\n**Example:** filter.id=$btw:John Doe&filter.id=$contains:John Doe\n\n**Available Operations**\n- $eq\n\n- $gt\n\n- $gte\n\n- $in\n\n- $null\n\n- $lt\n\n- $lte\n\n- $btw\n\n- $ilike\n\n- $sw\n\n- $contains\n\n- $not\n\n- $and\n\n- $or', 180 | schema: { 181 | type: 'array', 182 | items: { 183 | type: 'string', 184 | }, 185 | }, 186 | }, 187 | { 188 | name: 'filter.name', 189 | required: false, 190 | in: 'query', 191 | description: 192 | 'Filter by name query param.\n\n**Format:** filter.name={$not}:OPERATION:VALUE\n\n\n\n**Example:** filter.name=$eq:John Doe\n\n**Available Operations**\n- $eq\n\n- $not\n\n- $and\n\n- $or', 193 | schema: { 194 | type: 'array', 195 | items: { 196 | type: 'string', 197 | }, 198 | }, 199 | }, 200 | { 201 | name: 'sortBy', 202 | required: false, 203 | in: 'query', 204 | description: 205 | 'Parameter to sort by.\nTo sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting\n\n**Format:** {fieldName}:{DIRECTION}\n\n\n**Example:** sortBy=id:DESC\n\n\n**Default Value:** id:DESC\n\n**Available Fields**\n- id\n', 206 | schema: { 207 | type: 'array', 208 | items: { 209 | type: 'string', 210 | enum: ['id:ASC', 'id:DESC'], 211 | }, 212 | }, 213 | }, 214 | { 215 | name: 'search', 216 | required: false, 217 | in: 'query', 218 | description: 219 | 'Search term to filter result values\n\n**Example:** John\n\n\n**Default Value:** No default value\n\n', 220 | schema: { 221 | type: 'string', 222 | }, 223 | }, 224 | { 225 | name: 'searchBy', 226 | required: false, 227 | in: 'query', 228 | description: 229 | 'List of fields to search by term to filter result values\n\n**Example:** name\n\n\n**Default Value:** By default all fields mentioned below will be used to search by term\n\n**Available Fields**\n- name\n', 230 | schema: { 231 | type: 'array', 232 | items: { 233 | type: 'string', 234 | }, 235 | }, 236 | }, 237 | { 238 | name: 'select', 239 | required: false, 240 | in: 'query', 241 | description: 242 | 'List of fields to select.\n\n**Example:** id,name\n\n\n**Default Value:** By default all fields returns. If you want to select only some fields, provide them in query param\n\n', 243 | schema: { 244 | type: 'string', 245 | }, 246 | }, 247 | ]) 248 | expect(openApiDefinition.paths['/test'].get.responses).toEqual({ 249 | '200': { 250 | description: '', 251 | content: { 252 | 'application/json': { 253 | schema: { 254 | allOf: [ 255 | { 256 | $ref: '#/components/schemas/PaginatedDocumented', 257 | }, 258 | { 259 | properties: { 260 | data: { 261 | type: 'array', 262 | items: { 263 | $ref: '#/components/schemas/TestDto', 264 | }, 265 | }, 266 | meta: { 267 | properties: { 268 | select: { 269 | type: 'array', 270 | items: { 271 | type: 'string', 272 | enum: ['id', 'name'], 273 | }, 274 | }, 275 | filter: { 276 | type: 'object', 277 | properties: { 278 | id: { 279 | oneOf: [ 280 | { 281 | type: 'string', 282 | }, 283 | { 284 | type: 'array', 285 | items: { 286 | type: 'string', 287 | }, 288 | }, 289 | ], 290 | }, 291 | name: { 292 | oneOf: [ 293 | { 294 | type: 'string', 295 | }, 296 | { 297 | type: 'array', 298 | items: { 299 | type: 'string', 300 | }, 301 | }, 302 | ], 303 | }, 304 | }, 305 | }, 306 | }, 307 | }, 308 | }, 309 | }, 310 | ], 311 | }, 312 | }, 313 | }, 314 | }, 315 | }) 316 | }) 317 | 318 | it('should match a base config, snapshot test for all config', async () => { 319 | const openApiDefinition = await getSwaggerDefinitionForEndpoint(TestDto, FULL_CONFIG) 320 | const fullOpenApiDefinition = JSON.parse( 321 | fs.readFileSync(path.join(__dirname, 'resources/full-openapi-definition.json')).toString('utf-8') 322 | ) 323 | 324 | expect(openApiDefinition).toStrictEqual(fullOpenApiDefinition) 325 | }) 326 | }) 327 | -------------------------------------------------------------------------------- /src/swagger/resources/full-openapi-definition.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "paths": { 4 | "/test": { 5 | "get": { 6 | "operationId": "TestController_test", 7 | "parameters": [ 8 | { 9 | "name": "page", 10 | "required": false, 11 | "in": "query", 12 | "description": "Page number to retrieve. If you provide invalid value the default page number will applied\n\n**Example:** 1\n\n\n**Default Value:** 1\n\n", 13 | "schema": { 14 | "type": "number" 15 | } 16 | }, 17 | { 18 | "name": "limit", 19 | "required": false, 20 | "in": "query", 21 | "description": "Number of records per page.\n\n\n**Example:** 20\n\n\n\n**Default Value:** 20\n\n\n\n**Max Value:** 100\n\n\nIf provided value is greater than max value, max value will be applied.\n", 22 | "schema": { 23 | "type": "number" 24 | } 25 | }, 26 | { 27 | "name": "filter.id", 28 | "required": false, 29 | "in": "query", 30 | "description": "Filter by id query param.\n\n**Format:** filter.id={$not}:OPERATION:VALUE\n\n\n\n**Example:** filter.id=$btw:John Doe&filter.id=$contains:John Doe\n\n**Available Operations**\n- $eq\n\n- $gt\n\n- $gte\n\n- $in\n\n- $null\n\n- $lt\n\n- $lte\n\n- $btw\n\n- $ilike\n\n- $sw\n\n- $contains\n\n- $not\n\n- $and\n\n- $or", 31 | "schema": { 32 | "type": "array", 33 | "items": { 34 | "type": "string" 35 | } 36 | } 37 | }, 38 | { 39 | "name": "filter.name", 40 | "required": false, 41 | "in": "query", 42 | "description": "Filter by name query param.\n\n**Format:** filter.name={$not}:OPERATION:VALUE\n\n\n\n**Example:** filter.name=$eq:John Doe\n\n**Available Operations**\n- $eq\n\n- $not\n\n- $and\n\n- $or", 43 | "schema": { 44 | "type": "array", 45 | "items": { 46 | "type": "string" 47 | } 48 | } 49 | }, 50 | { 51 | "name": "sortBy", 52 | "required": false, 53 | "in": "query", 54 | "description": "Parameter to sort by.\nTo sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting\n\n**Format:** {fieldName}:{DIRECTION}\n\n\n**Example:** sortBy=id:DESC\n\n\n**Default Value:** id:DESC\n\n**Available Fields**\n- id\n", 55 | "schema": { 56 | "type": "array", 57 | "items": { 58 | "type": "string", 59 | "enum": [ 60 | "id:ASC", 61 | "id:DESC" 62 | ] 63 | } 64 | } 65 | }, 66 | { 67 | "name": "search", 68 | "required": false, 69 | "in": "query", 70 | "description": "Search term to filter result values\n\n**Example:** John\n\n\n**Default Value:** No default value\n\n", 71 | "schema": { 72 | "type": "string" 73 | } 74 | }, 75 | { 76 | "name": "searchBy", 77 | "required": false, 78 | "in": "query", 79 | "description": "List of fields to search by term to filter result values\n\n**Example:** name\n\n\n**Default Value:** By default all fields mentioned below will be used to search by term\n\n**Available Fields**\n- name\n", 80 | "schema": { 81 | "type": "array", 82 | "items": { 83 | "type": "string" 84 | } 85 | } 86 | }, 87 | { 88 | "name": "select", 89 | "required": false, 90 | "in": "query", 91 | "description": "List of fields to select.\n\n**Example:** id,name\n\n\n**Default Value:** By default all fields returns. If you want to select only some fields, provide them in query param\n\n", 92 | "schema": { 93 | "type": "string" 94 | } 95 | } 96 | ], 97 | "responses": { 98 | "200": { 99 | "description": "", 100 | "content": { 101 | "application/json": { 102 | "schema": { 103 | "allOf": [ 104 | { 105 | "$ref": "#/components/schemas/PaginatedDocumented" 106 | }, 107 | { 108 | "properties": { 109 | "data": { 110 | "type": "array", 111 | "items": { 112 | "$ref": "#/components/schemas/TestDto" 113 | } 114 | }, 115 | "meta": { 116 | "properties": { 117 | "select": { 118 | "type": "array", 119 | "items": { 120 | "type": "string", 121 | "enum": [ 122 | "id", 123 | "name" 124 | ] 125 | } 126 | }, 127 | "filter": { 128 | "type": "object", 129 | "properties": { 130 | "id": { 131 | "oneOf": [ 132 | { 133 | "type": "string" 134 | }, 135 | { 136 | "type": "array", 137 | "items": { 138 | "type": "string" 139 | } 140 | } 141 | ] 142 | }, 143 | "name": { 144 | "oneOf": [ 145 | { 146 | "type": "string" 147 | }, 148 | { 149 | "type": "array", 150 | "items": { 151 | "type": "string" 152 | } 153 | } 154 | ] 155 | } 156 | } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | ] 163 | } 164 | } 165 | } 166 | } 167 | }, 168 | "tags": [ 169 | "Test" 170 | ] 171 | }, 172 | "post": { 173 | "operationId": "TestController_testPost", 174 | "parameters": [ 175 | { 176 | "name": "page", 177 | "required": false, 178 | "in": "query", 179 | "description": "Page number to retrieve. If you provide invalid value the default page number will applied\n\n**Example:** 1\n\n\n**Default Value:** 1\n\n", 180 | "schema": { 181 | "type": "number" 182 | } 183 | }, 184 | { 185 | "name": "limit", 186 | "required": false, 187 | "in": "query", 188 | "description": "Number of records per page.\n\n\n**Example:** 20\n\n\n\n**Default Value:** 20\n\n\n\n**Max Value:** 100\n\n\nIf provided value is greater than max value, max value will be applied.\n", 189 | "schema": { 190 | "type": "number" 191 | } 192 | }, 193 | { 194 | "name": "filter.id", 195 | "required": false, 196 | "in": "query", 197 | "description": "Filter by id query param.\n\n**Format:** filter.id={$not}:OPERATION:VALUE\n\n\n\n**Example:** filter.id=$btw:John Doe&filter.id=$contains:John Doe\n\n**Available Operations**\n- $eq\n\n- $gt\n\n- $gte\n\n- $in\n\n- $null\n\n- $lt\n\n- $lte\n\n- $btw\n\n- $ilike\n\n- $sw\n\n- $contains\n\n- $not\n\n- $and\n\n- $or", 198 | "schema": { 199 | "type": "array", 200 | "items": { 201 | "type": "string" 202 | } 203 | } 204 | }, 205 | { 206 | "name": "filter.name", 207 | "required": false, 208 | "in": "query", 209 | "description": "Filter by name query param.\n\n**Format:** filter.name={$not}:OPERATION:VALUE\n\n\n\n**Example:** filter.name=$eq:John Doe\n\n**Available Operations**\n- $eq\n\n- $not\n\n- $and\n\n- $or", 210 | "schema": { 211 | "type": "array", 212 | "items": { 213 | "type": "string" 214 | } 215 | } 216 | }, 217 | { 218 | "name": "sortBy", 219 | "required": false, 220 | "in": "query", 221 | "description": "Parameter to sort by.\nTo sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting\n\n**Format:** {fieldName}:{DIRECTION}\n\n\n**Example:** sortBy=id:DESC\n\n\n**Default Value:** id:DESC\n\n**Available Fields**\n- id\n", 222 | "schema": { 223 | "type": "array", 224 | "items": { 225 | "type": "string", 226 | "enum": [ 227 | "id:ASC", 228 | "id:DESC" 229 | ] 230 | } 231 | } 232 | }, 233 | { 234 | "name": "search", 235 | "required": false, 236 | "in": "query", 237 | "description": "Search term to filter result values\n\n**Example:** John\n\n\n**Default Value:** No default value\n\n", 238 | "schema": { 239 | "type": "string" 240 | } 241 | }, 242 | { 243 | "name": "searchBy", 244 | "required": false, 245 | "in": "query", 246 | "description": "List of fields to search by term to filter result values\n\n**Example:** name\n\n\n**Default Value:** By default all fields mentioned below will be used to search by term\n\n**Available Fields**\n- name\n", 247 | "schema": { 248 | "type": "array", 249 | "items": { 250 | "type": "string" 251 | } 252 | } 253 | }, 254 | { 255 | "name": "select", 256 | "required": false, 257 | "in": "query", 258 | "description": "List of fields to select.\n\n**Example:** id,name\n\n\n**Default Value:** By default all fields returns. If you want to select only some fields, provide them in query param\n\n", 259 | "schema": { 260 | "type": "string" 261 | } 262 | } 263 | ], 264 | "responses": { 265 | "200": { 266 | "description": "", 267 | "content": { 268 | "application/json": { 269 | "schema": { 270 | "allOf": [ 271 | { 272 | "$ref": "#/components/schemas/PaginatedDocumented" 273 | }, 274 | { 275 | "properties": { 276 | "data": { 277 | "type": "array", 278 | "items": { 279 | "$ref": "#/components/schemas/TestDto" 280 | } 281 | }, 282 | "meta": { 283 | "properties": { 284 | "select": { 285 | "type": "array", 286 | "items": { 287 | "type": "string", 288 | "enum": [ 289 | "id", 290 | "name" 291 | ] 292 | } 293 | }, 294 | "filter": { 295 | "type": "object", 296 | "properties": { 297 | "id": { 298 | "oneOf": [ 299 | { 300 | "type": "string" 301 | }, 302 | { 303 | "type": "array", 304 | "items": { 305 | "type": "string" 306 | } 307 | } 308 | ] 309 | }, 310 | "name": { 311 | "oneOf": [ 312 | { 313 | "type": "string" 314 | }, 315 | { 316 | "type": "array", 317 | "items": { 318 | "type": "string" 319 | } 320 | } 321 | ] 322 | } 323 | } 324 | } 325 | } 326 | } 327 | } 328 | } 329 | ] 330 | } 331 | } 332 | } 333 | } 334 | }, 335 | "tags": [ 336 | "Test" 337 | ] 338 | } 339 | } 340 | }, 341 | "info": { 342 | "title": "", 343 | "description": "", 344 | "version": "1.0.0", 345 | "contact": {} 346 | }, 347 | "tags": [], 348 | "servers": [], 349 | "components": { 350 | "schemas": { 351 | "PaginatedMetaDocumented": { 352 | "type": "object", 353 | "properties": { 354 | "itemsPerPage": { 355 | "type": "number", 356 | "title": "Number of items per page" 357 | }, 358 | "totalItems": { 359 | "type": "number", 360 | "title": "Total number of items" 361 | }, 362 | "currentPage": { 363 | "type": "number", 364 | "title": "Current requested page" 365 | }, 366 | "totalPages": { 367 | "type": "number", 368 | "title": "Total number of pages" 369 | }, 370 | "sortBy": { 371 | "type": "array", 372 | "title": "Sorting by columns", 373 | "items": { 374 | "type": "array", 375 | "items": { 376 | "oneOf": [ 377 | { 378 | "type": "string" 379 | }, 380 | { 381 | "type": "string", 382 | "enum": [ 383 | "ASC", 384 | "DESC" 385 | ] 386 | } 387 | ] 388 | } 389 | } 390 | }, 391 | "searchBy": { 392 | "title": "Search by fields", 393 | "type": "array", 394 | "items": { 395 | "type": "string" 396 | } 397 | }, 398 | "search": { 399 | "type": "string", 400 | "title": "Search term" 401 | }, 402 | "select": { 403 | "title": "List of selected fields", 404 | "type": "array", 405 | "items": { 406 | "type": "string" 407 | } 408 | }, 409 | "filter": { 410 | "type": "object", 411 | "title": "Filters that applied to the query", 412 | "required": [], 413 | "additionalProperties": false 414 | } 415 | }, 416 | "required": [ 417 | "itemsPerPage", 418 | "totalItems", 419 | "currentPage", 420 | "totalPages" 421 | ] 422 | }, 423 | "PaginatedLinksDocumented": { 424 | "type": "object", 425 | "properties": { 426 | "first": { 427 | "type": "string", 428 | "title": "Link to first page" 429 | }, 430 | "previous": { 431 | "type": "string", 432 | "title": "Link to previous page" 433 | }, 434 | "current": { 435 | "type": "string", 436 | "title": "Link to current page" 437 | }, 438 | "next": { 439 | "type": "string", 440 | "title": "Link to next page" 441 | }, 442 | "last": { 443 | "type": "string", 444 | "title": "Link to last page" 445 | } 446 | } 447 | }, 448 | "PaginatedDocumented": { 449 | "type": "object", 450 | "properties": { 451 | "data": { 452 | "title": "Array of entities", 453 | "additionalProperties": false, 454 | "type": "array", 455 | "items": { 456 | "type": "object" 457 | } 458 | }, 459 | "meta": { 460 | "title": "Pagination Metadata", 461 | "allOf": [ 462 | { 463 | "$ref": "#/components/schemas/PaginatedMetaDocumented" 464 | } 465 | ] 466 | }, 467 | "links": { 468 | "title": "Links to pages", 469 | "allOf": [ 470 | { 471 | "$ref": "#/components/schemas/PaginatedLinksDocumented" 472 | } 473 | ] 474 | } 475 | }, 476 | "required": [ 477 | "data", 478 | "meta", 479 | "links" 480 | ] 481 | }, 482 | "TestDto": { 483 | "type": "object", 484 | "properties": {} 485 | } 486 | } 487 | } 488 | } 489 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nest.js Paginate 2 | 3 | ![Main CI](https://github.com/ppetzold/nestjs-paginate/workflows/Main%20CI/badge.svg) 4 | [![npm](https://img.shields.io/npm/v/nestjs-paginate.svg)](https://www.npmjs.com/package/nestjs-paginate) 5 | [![downloads](https://img.shields.io/npm/dt/nestjs-paginate.svg)](https://www.npmjs.com/package/nestjs-paginate) 6 | [![codecov](https://codecov.io/gh/ppetzold/nestjs-paginate/branch/master/graph/badge.svg)](https://codecov.io/gh/ppetzold/nestjs-paginate) 7 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 8 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 9 | ![GitHub](https://img.shields.io/github/license/ppetzold/nestjs-paginate) 10 | 11 | Pagination and filtering helper method for TypeORM repositories or query builders using [Nest.js](https://nestjs.com/) framework. 12 | 13 | - Pagination conforms to [JSON:API](https://jsonapi.org/) 14 | - Sort by multiple columns 15 | - Search across columns 16 | - Select columns 17 | - Filter using operators (`$eq`, `$not`, `$null`, `$in`, `$gt`, `$gte`, `$lt`, `$lte`, `$btw`, `$ilike`, `$sw`, `$contains`) 18 | - Include relations and nested relations 19 | - Virtual column support 20 | - Cursor-based pagination 21 | 22 | ## Installation 23 | 24 | ``` 25 | npm install nestjs-paginate 26 | ``` 27 | 28 | ## Usage 29 | 30 | ### Global configurations 31 | 32 | You can configure the global settings for all paginated routes by updating the default global configuration 33 | using below method. Ideally, you need to do it as soon as possible in your application main bootstrap method, 34 | as it affects all paginated routes, and swagger generation logic. 35 | 36 | ```typescript 37 | import { updateGlobalConfig } from 'nestjs-paginate' 38 | 39 | updateGlobalConfig({ 40 | // this is default configuration 41 | defaultOrigin: undefined, 42 | defaultLimit: 20, 43 | defaultMaxLimit: 100, 44 | }); 45 | ``` 46 | 47 | 48 | 49 | ### Example 50 | 51 | The following code exposes a route that can be utilized like so: 52 | 53 | #### Endpoint 54 | 55 | ```url 56 | http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC&search=i&filter.age=$gte:3&select=id,name,color,age&withDeleted=true 57 | ``` 58 | 59 | #### Result 60 | 61 | ```json 62 | { 63 | "data": [ 64 | { 65 | "id": 4, 66 | "name": "George", 67 | "color": "white", 68 | "age": 3 69 | }, 70 | { 71 | "id": 5, 72 | "name": "Leche", 73 | "color": "white", 74 | "age": 6 75 | }, 76 | { 77 | "id": 2, 78 | "name": "Garfield", 79 | "color": "ginger", 80 | "age": 4 81 | }, 82 | { 83 | "id": 1, 84 | "name": "Milo", 85 | "color": "brown", 86 | "age": 5 87 | }, 88 | { 89 | "id": 3, 90 | "name": "Kitty", 91 | "color": "black", 92 | "age": 3 93 | } 94 | ], 95 | "meta": { 96 | "itemsPerPage": 5, 97 | "totalItems": 12, 98 | "currentPage": 2, 99 | "totalPages": 3, 100 | "sortBy": [["color", "DESC"]], 101 | "search": "i", 102 | "filter": { 103 | "age": "$gte:3" 104 | } 105 | }, 106 | "links": { 107 | "first": "http://localhost:3000/cats?limit=5&page=1&sortBy=color:DESC&search=i&filter.age=$gte:3", 108 | "previous": "http://localhost:3000/cats?limit=5&page=1&sortBy=color:DESC&search=i&filter.age=$gte:3", 109 | "current": "http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC&search=i&filter.age=$gte:3", 110 | "next": "http://localhost:3000/cats?limit=5&page=3&sortBy=color:DESC&search=i&filter.age=$gte:3", 111 | "last": "http://localhost:3000/cats?limit=5&page=3&sortBy=color:DESC&search=i&filter.age=$gte:3" 112 | } 113 | } 114 | ``` 115 | 116 | ### Example (Cursor-based Pagination) 117 | 118 | The following code exposes a route using cursor-based pagination: 119 | 120 | #### Endpoint 121 | 122 | ```url 123 | http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=V998328469600000 124 | ``` 125 | 126 | #### Result 127 | 128 | ```json 129 | { 130 | "data": [ 131 | { 132 | "id": 3, 133 | "name": "Shadow", 134 | "lastVetVisit": "2022-12-21T10:00:00.000Z" 135 | }, 136 | { 137 | "id": 4, 138 | "name": "Luna", 139 | "lastVetVisit": "2022-12-22T10:00:00.000Z" 140 | }, 141 | { 142 | "id": 5, 143 | "name": "Pepper", 144 | "lastVetVisit": "2022-12-23T10:00:00.000Z" 145 | }, 146 | { 147 | "id": 6, 148 | "name": "Simba", 149 | "lastVetVisit": "2022-12-24T10:00:00.000Z" 150 | }, 151 | { 152 | "id": 7, 153 | "name": "Tiger", 154 | "lastVetVisit": "2022-12-25T10:00:00.000Z" 155 | } 156 | ], 157 | "meta": { 158 | "itemsPerPage": 5, 159 | "cursor": "V998328469600000" 160 | }, 161 | "links": { 162 | "previous": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:DESC&cursor=V001671616800000", 163 | "current": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=V998328469600000", 164 | "next": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=V998328037600000" 165 | } 166 | } 167 | ``` 168 | 169 | #### Code 170 | 171 | ```ts 172 | import { Controller, Injectable, Get } from '@nestjs/common' 173 | import { InjectRepository } from '@nestjs/typeorm' 174 | import { FilterOperator, FilterSuffix, Paginate, PaginateQuery, paginate, Paginated } from 'nestjs-paginate' 175 | import { Repository, Entity, PrimaryGeneratedColumn, Column } from 'typeorm' 176 | 177 | @Entity() 178 | export class CatEntity { 179 | @PrimaryGeneratedColumn() 180 | id: number 181 | 182 | @Column('text') 183 | name: string 184 | 185 | @Column('text') 186 | color: string 187 | 188 | @Column('int') 189 | age: number 190 | 191 | @Column({ nullable: true }) 192 | lastVetVisit: Date | null 193 | 194 | @CreateDateColumn() 195 | createdAt: string 196 | } 197 | 198 | @Injectable() 199 | export class CatsService { 200 | constructor( 201 | @InjectRepository(CatEntity) 202 | private readonly catsRepository: Repository 203 | ) {} 204 | 205 | public findAll(query: PaginateQuery): Promise> { 206 | return paginate(query, this.catsRepository, { 207 | sortableColumns: ['id', 'name', 'color', 'age'], 208 | nullSort: 'last', 209 | defaultSortBy: [['id', 'DESC']], 210 | searchableColumns: ['name', 'color', 'age'], 211 | select: ['id', 'name', 'color', 'age', 'lastVetVisit'], 212 | filterableColumns: { 213 | name: [FilterOperator.EQ, FilterSuffix.NOT], 214 | age: true, 215 | }, 216 | }) 217 | } 218 | } 219 | 220 | @Controller('cats') 221 | export class CatsController { 222 | constructor(private readonly catsService: CatsService) {} 223 | 224 | @Get() 225 | public findAll(@Paginate() query: PaginateQuery): Promise> { 226 | return this.catsService.findAll(query) 227 | } 228 | } 229 | ``` 230 | 231 | ### Config 232 | 233 | ````ts 234 | const paginateConfig: PaginateConfig { 235 | /** 236 | * Required: true (must have a minimum of one column) 237 | * Type: (keyof CatEntity)[] 238 | * Description: These are the columns that are valid to be sorted by. 239 | */ 240 | sortableColumns: ['id', 'name', 'color'], 241 | 242 | /** 243 | * Required: false 244 | * Type: 'first' | 'last' 245 | * Description: Define whether to put null values at the beginning 246 | * or end of the result set. 247 | */ 248 | nullSort: 'last', 249 | 250 | /** 251 | * Required: false 252 | * Type: [keyof CatEntity, 'ASC' | 'DESC'][] 253 | * Default: [[sortableColumns[0], 'ASC]] 254 | * Description: The order to display the sorted entities. 255 | */ 256 | defaultSortBy: [['name', 'DESC']], 257 | 258 | /** 259 | * Required: false 260 | * Type: (keyof CatEntity)[] 261 | * Description: These columns will be searched through when using the search query 262 | * param. Limit search scope further by using `searchBy` query param. 263 | */ 264 | searchableColumns: ['name', 'color'], 265 | 266 | /** 267 | * Required: false 268 | * Type: (keyof CatEntity)[] 269 | * Default: None 270 | * Description: TypeORM partial selection. Limit selection further by using `select` query param. 271 | * https://typeorm.io/select-query-builder#partial-selection 272 | * Note: if you do not contain the primary key in the select array, primary key will be added automatically. 273 | * 274 | * Wildcard support: 275 | * - Use '*' to select all columns from the main entity. 276 | * - Use 'relation.*' to select all columns from a relation. 277 | * - Use 'relation.subrelation.*' to select all columns from nested relations. 278 | * 279 | * Examples: 280 | * select: ['*'] - Selects all columns from main entity 281 | * select: ['id', 'name', 'toys.*'] - Selects id, name from main entity and all columns from toys relation 282 | * select: ['*', 'toys.*'] - Selects all columns from both main entity and toys relation 283 | */ 284 | select: ['id', 'name', 'color'], 285 | 286 | /** 287 | * Required: false 288 | * Type: number 289 | * Default: 100 290 | * Description: The maximum amount of entities to return per page. 291 | * Set it to -1, in conjunction with limit=-1 on query param, to disable pagination. 292 | */ 293 | maxLimit: 20, 294 | 295 | /** 296 | * Required: false 297 | * Type: number 298 | * Default: 20 299 | */ 300 | defaultLimit: 50, 301 | 302 | /** 303 | * Required: false 304 | * Type: TypeORM find options 305 | * Default: None 306 | * https://typeorm.io/#/find-optionsfind-options.md 307 | */ 308 | where: { color: 'ginger' }, 309 | 310 | /** 311 | * Required: false 312 | * Type: { [key in CatEntity]?: FilterOperator[] } - Operators based on TypeORM find operators 313 | * Default: None 314 | * https://typeorm.io/#/find-options/advanced-options 315 | */ 316 | filterableColumns: { age: [FilterOperator.EQ, FilterOperator.IN] }, 317 | 318 | /** 319 | * Required: false 320 | * Type: RelationColumn 321 | * Description: Indicates what relations of entity should be loaded. 322 | */ 323 | relations: [], 324 | 325 | /** 326 | * Required: false 327 | * Type: boolean 328 | * Default: false 329 | * Description: Load eager relations using TypeORM's eager property. 330 | * Only works if `relations` is not defined. 331 | */ 332 | loadEagerRelations: true, 333 | 334 | /** 335 | * Required: false 336 | * Type: boolean 337 | * Description: Disables the global condition of "non-deleted" for the entity with delete date columns. 338 | * https://typeorm.io/select-query-builder#querying-deleted-rows 339 | */ 340 | withDeleted: false, 341 | 342 | /** 343 | * Required: false 344 | * Type: boolean 345 | * Description: Allows to specify withDeleted in query params to retrieve soft deleted records, convinient when you have archive functionality and some toggle to show or hide them. If not enabled explicitly the withDeleted query param will be ignored. 346 | */ 347 | allowWithDeletedInQuery: false, 348 | 349 | /** 350 | * Required: false 351 | * Type: string 352 | * Description: Allow user to choose between limit/offset and take/skip, or cursor-based pagination. 353 | * Default: PaginationType.TAKE_AND_SKIP 354 | * Options: PaginationType.LIMIT_AND_OFFSET, PaginationType.TAKE_AND_SKIP, PaginationType.CURSOR 355 | * 356 | * However, using limit/offset can cause problems with relations. 357 | */ 358 | paginationType: PaginationType.LIMIT_AND_OFFSET, 359 | 360 | /** 361 | * Required: false 362 | * Type: boolean 363 | * Default: false 364 | * Description: Generate relative paths in the resource links. 365 | */ 366 | relativePath: true, 367 | 368 | /** 369 | * Required: false 370 | * Type: string 371 | * Description: Overrides the origin of absolute resource links if set. 372 | */ 373 | origin: 'http://cats.example', 374 | 375 | /** 376 | * Required: false 377 | * Type: boolean 378 | * Default: false 379 | * Description: Prevent `searchBy` query param from limiting search scope further. Search will depend upon `searchableColumns` config option only 380 | */ 381 | ignoreSearchByInQueryParam: true, 382 | 383 | /** 384 | * Required: false 385 | * Type: boolean 386 | * Default: false 387 | * Description: Prevent `select` query param from limiting selection further. Partial selection will depend upon `select` config option only 388 | */ 389 | ignoreSelectInQueryParam: true, 390 | 391 | /** 392 | * Required: false 393 | * Type: 'leftJoinAndSelect' | 'innerJoinAndSelect' 394 | * Default: 'leftJoinAndSelect' 395 | * Description: Relationships will be joined with either LEFT JOIN or INNER JOIN, and their columns selected. Can be specified per column with `joinMethods` configuration. 396 | */ 397 | defaultJoinMethod: 'leftJoinAndSelect', 398 | 399 | /** 400 | * Required: false 401 | * Type: MappedColumns 402 | * Default: false 403 | * Description: Overrides the join method per relationship. 404 | */ 405 | joinMethods: {age: 'innerJoinAndSelect', size: 'leftJoinAndSelect'}, 406 | 407 | /** 408 | * Required: false 409 | * Type: boolean 410 | * Default: false 411 | * Description: Enable multi-word search behavior. When true, each word in the search query 412 | * will be treated as a separate search term, allowing for more flexible matching. 413 | */ 414 | multiWordSearch: false, 415 | 416 | /** 417 | * Required: false 418 | * Type: (qb: SelectQueryBuilder) => SelectQueryBuilder 419 | * Default: undefined 420 | * Description: Callback that lets you override the COUNT query executed by 421 | * paginate(). The function receives a **clone** of the original QueryBuilder, 422 | * so it already contains every WHERE clause and parameter parsed by 423 | * nestjs-paginate. 424 | * 425 | * Typical use-case: remove expensive LEFT JOINs or build a lighter DISTINCT 426 | * count when getManyAndCount() becomes a bottleneck. 427 | * 428 | * Example: 429 | * ```ts 430 | * buildCountQuery: qb => { 431 | * qb.expressionMap.joinAttributes = []; // drop all joins 432 | * qb.select('p.id').distinct(true); // keep DISTINCT on primary key 433 | * return qb; // paginate() will call .getCount() 434 | * } 435 | * ``` 436 | */ 437 | buildCountQuery: (qb: SelectQueryBuilder) => SelectQueryBuilder, 438 | } 439 | ```` 440 | 441 | ## Usage with Query Builder 442 | 443 | You can paginate custom queries by passing on the query builder: 444 | 445 | ### Example 446 | 447 | ```typescript 448 | const queryBuilder = repo 449 | .createQueryBuilder('cats') 450 | .leftJoinAndSelect('cats.owner', 'owner') 451 | .where('cats.owner = :ownerId', { ownerId }) 452 | 453 | const result = await paginate(query, queryBuilder, config) 454 | ``` 455 | 456 | ## Usage with Relations 457 | 458 | Similar as with repositories, you can utilize `relations` as a simplified left-join form: 459 | 460 | ### Example 461 | 462 | #### Endpoint 463 | 464 | ```url 465 | http://localhost:3000/cats?filter.toys.name=$in:Mouse,String 466 | ``` 467 | 468 | #### Code 469 | 470 | ```typescript 471 | const config: PaginateConfig = { 472 | relations: ['toys'], 473 | sortableColumns: ['id', 'name', 'toys.name'], 474 | filterableColumns: { 475 | 'toys.name': [FilterOperator.IN], 476 | }, 477 | } 478 | 479 | const result = await paginate(query, catRepo, config) 480 | ``` 481 | 482 | **Note:** Embedded columns on relations have to be wrapped with brackets: 483 | 484 | ```typescript 485 | const config: PaginateConfig = { 486 | sortableColumns: ['id', 'name', 'toys.(size.height)', 'toys.(size.width)'], 487 | searchableColumns: ['name'], 488 | relations: ['toys'], 489 | } 490 | ``` 491 | 492 | ## Usage with Nested Relations 493 | 494 | Similar as with relations, you can specify nested relations for sorting, filtering and searching: 495 | 496 | ### Example 497 | 498 | #### Endpoint 499 | 500 | ```url 501 | http://localhost:3000/cats?filter.home.pillows.color=pink 502 | ``` 503 | 504 | #### Code 505 | 506 | ```typescript 507 | const config: PaginateConfig = { 508 | relations: { home: { pillows: true } }, 509 | sortableColumns: ['id', 'name', 'home.pillows.color'], 510 | searchableColumns: ['name', 'home.pillows.color'], 511 | filterableColumns: { 512 | 'home.pillows.color': [FilterOperator.EQ], 513 | }, 514 | } 515 | 516 | const result = await paginate(query, catRepo, config) 517 | ``` 518 | 519 | ## Usage with Eager Loading 520 | 521 | Eager loading should work with TypeORM's eager property out of the box: 522 | 523 | ### Example 524 | 525 | #### Code 526 | 527 | ```typescript 528 | @Entity() 529 | export class CatEntity { 530 | // ... 531 | 532 | @OneToMany(() => CatToyEntity, (catToy) => catToy.cat, { 533 | eager: true, 534 | }) 535 | toys: CatToyEntity[] 536 | } 537 | 538 | const config: PaginateConfig = { 539 | loadEagerRelations: true, 540 | sortableColumns: ['id', 'name', 'toys.name'], 541 | filterableColumns: { 542 | 'toys.name': [FilterOperator.IN], 543 | }, 544 | } 545 | 546 | const result = await paginate(query, catRepo, config) 547 | ``` 548 | 549 | ## Filters 550 | 551 | Filter operators must be whitelisted per column in `PaginateConfig`. 552 | 553 | ### Examples 554 | 555 | #### Code 556 | 557 | ```typescript 558 | const config: PaginateConfig = { 559 | // ... 560 | filterableColumns: { 561 | // Enable individual operators on a column 562 | id: [FilterOperator.EQ, FilterSuffix.NOT], 563 | 564 | // Enable all operators on a column 565 | age: true, 566 | }, 567 | } 568 | ``` 569 | 570 | `?filter.name=$eq:Milo` is equivalent with `?filter.name=Milo` 571 | 572 | `?filter.age=$btw:4,6` where column `age` is between `4` and `6` 573 | 574 | `?filter.id=$not:$in:2,5,7` where column `id` is **not** `2`, `5` or `7` 575 | 576 | `?filter.summary=$not:$ilike:term` where column `summary` does **not** contain `term` 577 | 578 | `?filter.summary=$sw:term` where column `summary` starts with `term` 579 | 580 | `?filter.seenAt=$null` where column `seenAt` is `NULL` 581 | 582 | `?filter.seenAt=$not:$null` where column `seenAt` is **not** `NULL` 583 | 584 | `?filter.createdAt=$btw:2022-02-02,2022-02-10` where column `createdAt` is between the dates `2022-02-02` and `2022-02-10` 585 | 586 | `?filter.createdAt=$lt:2022-12-20T10:00:00.000Z` where column `createdAt` is before iso date `2022-12-20T10:00:00.000Z` 587 | 588 | `?filter.roles=$contains:moderator` where column `roles` is an array and contains the value `moderator` 589 | 590 | `?filter.roles=$contains:moderator,admin` where column `roles` is an array and contains the values `moderator` and `admin` 591 | 592 | ## Jsonb Filters 593 | 594 | You can filter on jsonb columns by using the dot notation. Json columns is limited to `$eq` operators only. 595 | 596 | `?filter.metadata.enabled=$eq:true` where column `metadata` is jsonb and contains an object with the key `enabled`. 597 | 598 | ## Multi Filters 599 | 600 | Multi filters are filters that can be applied to a single column with a comparator. 601 | 602 | ### Examples 603 | 604 | `?filter.createdAt=$gt:2022-02-02&filter.createdAt=$lt:2022-02-10` where column `createdAt` is after `2022-02-02` **and** before `2022-02-10` 605 | 606 | `?filter.roles=$contains:moderator&filter.roles=$or:$contains:admin` where column `roles` is an array and contains `moderator` **or** `admin` 607 | 608 | `?filter.id=$gt:3&filter.id=$and:$lt:5&filter.id=$or:$eq:7` where column `id` is greater than `3` **and** less than `5` **or** equal to `7` 609 | 610 | **Note:** The `$and` comparators are not required. The above example is equivalent to: 611 | 612 | `?filter.id=$gt:3&filter.id=$lt:5&filter.id=$or:$eq:7` 613 | 614 | **Note:** The first comparator on the the first filter is ignored because the filters are grouped by the column name and chained with an `$and` to other filters. 615 | 616 | `...&filter.id=5&filter.id=$or:7&filter.name=Milo&...` 617 | 618 | is resolved to: 619 | 620 | `WHERE ... AND (id = 5 OR id = 7) AND name = 'Milo' AND ...` 621 | 622 | ## Cursor-based Pagination 623 | 624 | - `paginationType: PaginationType.CURSOR` 625 | - Cursor format: 626 | - Numbers: `[prefix1][integer:11 digits][prefix2][decimal:4 digits]` (e.g., `Y00000000001V2500` for -1.25 in ASC). 627 | - Dates: `[prefix][value:15 digits]` (e.g., `V001671444000000` for a timestamp in DESC). 628 | - Prefixes: 629 | - `null`: `A` (lowest priority, last in results). 630 | - ASC: 631 | - positive-int: `V` (greater than or equal to 1), `X` (less than 1) 632 | - positive-decimal: `V` (not zero), `X` (zero) 633 | - zero-int: `X` 634 | - zero-decimal: `X` 635 | - negative-int: `Y` 636 | - negative-decimal: `V` 637 | - DESC: 638 | - positive-int: `V` 639 | - positive-decimal: `V` 640 | - zero-int: `N` 641 | - zero-decimal: `X` 642 | - negative-int: `M` (less than or equal to -1), `N` (greater than -1) 643 | - negative-decimal: `V` (not zero), `X` (zero) 644 | - Logic: 645 | - Numbers: Split into integer (11 digits) and decimal (4 digits) parts, with separate prefixes. Supports negative values, with sorting adjusted per direction. 646 | - Dates: Single prefix with 15-digit timestamp padded with zeros. 647 | - ASC: Negative → Zero → Positive → Null. 648 | - DESC: Positive → Zero → Negative → Null. 649 | - Notes: 650 | - Multiple columns: `sortBy` can include multiple columns to create and sort by the cursor (e.g., `sortBy=age:ASC&sortBy=createdAt:DESC`), but at least one column must be unique to ensure consistent ordering. 651 | - Supported columns: Cursor sorting is available for numeric and date-related columns (string columns are not supported). 652 | - Decimal support: Numeric columns can include decimals, limited to 11 digits for the integer part and 4 digits for the decimal part. 653 | 654 | ## Swagger 655 | 656 | You can use two default decorators @ApiOkResponsePaginated and @ApiPagination to generate swagger documentation for your endpoints 657 | 658 | `@ApiOkPaginatedResponse` is for response body, return http[](https://) status is 200 659 | 660 | `@ApiPaginationQuery` is for query params 661 | 662 | ```typescript 663 | @Get() 664 | @ApiOkPaginatedResponse( 665 | UserDto, 666 | USER_PAGINATION_CONFIG, 667 | ) 668 | @ApiPaginationQuery(USER_PAGINATION_CONFIG) 669 | async findAll( 670 | @Paginate() 671 | query: PaginateQuery, 672 | ): Promise> { 673 | 674 | } 675 | ``` 676 | 677 | There is also some syntax sugar for this, and you can use only one decorator `@PaginatedSwaggerDocs` for both response body and query params 678 | 679 | ```typescript 680 | @Get() 681 | @PaginatedSwaggerDocs(UserDto, USER_PAGINATION_CONFIG) 682 | async findAll( 683 | @Paginate() 684 | query: PaginateQuery, 685 | ): Promise> { 686 | 687 | } 688 | ``` 689 | 690 | It is also possible to customize a swagger UI completely or partially, by following the default implementation and creating your own version of PaginatedSwaggerDocs decorator 691 | 692 | Let's say you want some custom appearance for SortBy, you need to create a decorator for it 693 | 694 | ```typescript 695 | export function CustomSortBy(paginationConfig: PaginateConfig) { 696 | return ApiQuery({ 697 | name: 'sortBy', 698 | isArray: true, 699 | description: `My custom sort by description`, 700 | required: false, 701 | type: 'string', 702 | }) 703 | } 704 | ``` 705 | 706 | Now you can create your version of the whole docs decorator and use it 707 | 708 | ```typescript 709 | 710 | const CustomApiPaginationQuery = (paginationConfig: PaginateConfig) => { 711 | return applyDecorators( 712 | ...[ 713 | Page(), 714 | Limit(paginationConfig), 715 | Where(paginationConfig), 716 | CustomSortBy(paginationConfig), 717 | Search(paginationConfig), 718 | SearchBy(paginationConfig), 719 | Select(paginationConfig), 720 | ].filter((v): v is MethodDecorator => v !== undefined) 721 | ) 722 | } 723 | 724 | function CustomPaginatedSwaggerDocs>(dto: DTO, paginatedConfig: PaginateConfig) { 725 | return applyDecorators(ApiOkPaginatedResponse(dto, paginatedConfig), CustomApiPaginationQuery(paginatedConfig)) 726 | } 727 | 728 | ``` 729 | 730 | You can use CustomPaginatedSwaggerDocs instead of default PaginatedSwaggerDocs 731 | 732 | 733 | 734 | ## Troubleshooting 735 | 736 | The package does not report error reasons in the response bodies. They are instead 737 | reported as `debug` level [logging](https://docs.nestjs.com/techniques/logger#logger). 738 | 739 | Common errors include missing `sortableColumns` or `filterableColumns` (the latter only affects filtering). 740 | -------------------------------------------------------------------------------- /src/paginate.ts: -------------------------------------------------------------------------------- 1 | import { Logger, ServiceUnavailableException } from '@nestjs/common' 2 | import { mapKeys } from 'lodash' 3 | import { stringify } from 'querystring' 4 | import { 5 | Brackets, 6 | EntityMetadata, 7 | FindOptionsUtils, 8 | FindOptionsWhere, 9 | ObjectLiteral, 10 | Repository, 11 | SelectQueryBuilder, 12 | } from 'typeorm' 13 | import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause' 14 | import { PaginateQuery } from './decorator' 15 | import { addFilter, FilterOperator, FilterSuffix } from './filter' 16 | import { 17 | checkIsEmbedded, 18 | checkIsRelation, 19 | Column, 20 | createRelationSchema, 21 | extractVirtualProperty, 22 | fixColumnAlias, 23 | getMissingPrimaryKeyColumns, 24 | getPaddedExpr, 25 | getPropertiesByColumnName, 26 | getQueryUrlComponents, 27 | isDateColumnType, 28 | isEntityKey, 29 | isFindOperator, 30 | isISODate, 31 | isNil, 32 | isNotNil, 33 | isRepository, 34 | JoinMethod, 35 | MappedColumns, 36 | mergeRelationSchema, 37 | Order, 38 | positiveNumberOrDefault, 39 | quoteVirtualColumn, 40 | RelationSchema, 41 | RelationSchemaInput, 42 | SortBy, 43 | } from './helper' 44 | import globalConfig from './global-config' 45 | 46 | const logger: Logger = new Logger('nestjs-paginate') 47 | 48 | export { FilterOperator, FilterSuffix } 49 | 50 | export class Paginated { 51 | data: T[] 52 | meta: { 53 | itemsPerPage: number 54 | totalItems?: number 55 | currentPage?: number 56 | totalPages?: number 57 | sortBy: SortBy 58 | searchBy: Column[] 59 | search: string 60 | select: string[] 61 | filter?: { 62 | [column: string]: string | string[] 63 | } 64 | cursor?: string 65 | } 66 | links: { 67 | first?: string 68 | previous?: string 69 | current: string 70 | next?: string 71 | last?: string 72 | } 73 | } 74 | 75 | export enum PaginationType { 76 | LIMIT_AND_OFFSET = 'limit', 77 | TAKE_AND_SKIP = 'take', 78 | CURSOR = 'cursor', 79 | } 80 | 81 | // We use (string & {}) to maintain autocomplete while allowing any string 82 | // see https://github.com/microsoft/TypeScript/issues/29729 83 | export interface PaginateConfig { 84 | relations?: RelationSchemaInput 85 | sortableColumns: Column[] 86 | nullSort?: 'first' | 'last' 87 | searchableColumns?: Column[] 88 | // eslint-disable-next-line @typescript-eslint/ban-types 89 | select?: (Column | (string & {}))[] 90 | maxLimit?: number 91 | defaultSortBy?: SortBy 92 | defaultLimit?: number 93 | where?: FindOptionsWhere | FindOptionsWhere[] 94 | filterableColumns?: Partial> 95 | loadEagerRelations?: boolean 96 | withDeleted?: boolean 97 | allowWithDeletedInQuery?: boolean 98 | paginationType?: PaginationType 99 | relativePath?: boolean 100 | origin?: string 101 | ignoreSearchByInQueryParam?: boolean 102 | ignoreSelectInQueryParam?: boolean 103 | multiWordSearch?: boolean 104 | defaultJoinMethod?: JoinMethod 105 | joinMethods?: Partial> 106 | buildCountQuery?: (qb: SelectQueryBuilder) => SelectQueryBuilder 107 | } 108 | 109 | export enum PaginationLimit { 110 | NO_PAGINATION = -1, 111 | COUNTER_ONLY = 0, 112 | } 113 | 114 | function generateWhereStatement( 115 | queryBuilder: SelectQueryBuilder, 116 | obj: FindOptionsWhere | FindOptionsWhere[] 117 | ) { 118 | const toTransform = Array.isArray(obj) ? obj : [obj] 119 | return toTransform.map((item) => flattenWhereAndTransform(queryBuilder, item).join(' AND ')).join(' OR ') 120 | } 121 | 122 | function flattenWhereAndTransform( 123 | queryBuilder: SelectQueryBuilder, 124 | obj: FindOptionsWhere, 125 | separator = '.', 126 | parentKey = '' 127 | ) { 128 | return Object.entries(obj).flatMap(([key, value]) => { 129 | if (obj.hasOwnProperty(key)) { 130 | const joinedKey = parentKey ? `${parentKey}${separator}${key}` : key 131 | 132 | if (typeof value === 'object' && value !== null && !isFindOperator(value)) { 133 | return flattenWhereAndTransform(queryBuilder, value as FindOptionsWhere, separator, joinedKey) 134 | } else { 135 | const property = getPropertiesByColumnName(joinedKey) 136 | const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(queryBuilder, property) 137 | const isRelation = checkIsRelation(queryBuilder, property.propertyPath) 138 | const isEmbedded = checkIsEmbedded(queryBuilder, property.propertyPath) 139 | const alias = fixColumnAlias( 140 | property, 141 | queryBuilder.alias, 142 | isRelation, 143 | isVirtualProperty, 144 | isEmbedded, 145 | virtualQuery 146 | ) 147 | const whereClause = queryBuilder['createWhereConditionExpression']( 148 | queryBuilder['getWherePredicateCondition'](alias, value) 149 | ) 150 | 151 | const allJoinedTables = queryBuilder.expressionMap.joinAttributes.reduce( 152 | (acc, attr) => { 153 | acc[attr.alias.name] = true 154 | return acc 155 | }, 156 | {} as Record 157 | ) 158 | 159 | const allTablesInPath = property.column.split('.').slice(0, -1) 160 | const tablesToJoin = allTablesInPath.map((table, idx) => { 161 | if (idx === 0) { 162 | return table 163 | } 164 | return [...allTablesInPath.slice(0, idx), table].join('.') 165 | }) 166 | 167 | tablesToJoin.forEach((table) => { 168 | const pathSplit = table.split('.') 169 | const fullPath = 170 | pathSplit.length === 1 171 | ? '' 172 | : `_${pathSplit 173 | .slice(0, -1) 174 | .map((p) => p + '_rel') 175 | .join('_')}` 176 | const tableName = pathSplit[pathSplit.length - 1] 177 | const tableAliasWithProperty = `${queryBuilder.alias}${fullPath}.${tableName}` 178 | const joinTableAlias = `${queryBuilder.alias}${fullPath}_${tableName}_rel` 179 | 180 | const baseTableAlias = allJoinedTables[joinTableAlias] 181 | 182 | if (baseTableAlias) { 183 | return 184 | } else { 185 | queryBuilder.leftJoin(tableAliasWithProperty, joinTableAlias) 186 | } 187 | }) 188 | 189 | return whereClause 190 | } 191 | } 192 | }) 193 | } 194 | 195 | function fixCursorValue(value: any): any { 196 | if (isISODate(value)) { 197 | return new Date(value) 198 | } 199 | return value 200 | } 201 | 202 | export async function paginate( 203 | query: PaginateQuery, 204 | repo: Repository | SelectQueryBuilder, 205 | config: PaginateConfig 206 | ): Promise> { 207 | const dbType = (isRepository(repo) ? repo.manager : repo).connection.options.type 208 | const isMySqlOrMariaDb = ['mysql', 'mariadb'].includes(dbType) 209 | const metadata = isRepository(repo) ? repo.metadata : repo.expressionMap.mainAlias.metadata 210 | 211 | const page = positiveNumberOrDefault(query.page, 1, 1) 212 | 213 | const defaultLimit = config.defaultLimit || globalConfig.defaultLimit 214 | const maxLimit = config.maxLimit || globalConfig.defaultMaxLimit 215 | 216 | const isPaginated = !( 217 | query.limit === PaginationLimit.COUNTER_ONLY || 218 | (query.limit === PaginationLimit.NO_PAGINATION && maxLimit === PaginationLimit.NO_PAGINATION) 219 | ) 220 | 221 | const limit = 222 | query.limit === PaginationLimit.COUNTER_ONLY 223 | ? PaginationLimit.COUNTER_ONLY 224 | : isPaginated === true 225 | ? maxLimit === PaginationLimit.NO_PAGINATION 226 | ? query.limit ?? defaultLimit 227 | : query.limit === PaginationLimit.NO_PAGINATION 228 | ? defaultLimit 229 | : Math.min(query.limit ?? defaultLimit, maxLimit) 230 | : defaultLimit 231 | 232 | const generateNullCursor = (): string => { 233 | return 'A' + '0'.repeat(15) // null values ​​should be looked up last, so use the smallest prefix 234 | } 235 | 236 | const generateDateCursor = (value: number, direction: 'ASC' | 'DESC'): string => { 237 | if (direction === 'ASC' && value === 0) { 238 | return 'X' + '0'.repeat(15) 239 | } 240 | 241 | const finalValue = direction === 'ASC' ? Math.pow(10, 15) - value : value 242 | 243 | return 'V' + String(finalValue).padStart(15, '0') 244 | } 245 | 246 | const generateNumberCursor = (value: number, direction: 'ASC' | 'DESC'): string => { 247 | const integerLength = 11 248 | const decimalLength = 4 // sorting is not possible if the decimal point exceeds 4 digits 249 | const maxIntegerDigit = Math.pow(10, integerLength) 250 | const fixedScale = Math.pow(10, decimalLength) 251 | const absValue = Math.abs(value) 252 | const scaledValue = Math.round(absValue * fixedScale) 253 | const integerPart = Math.floor(scaledValue / fixedScale) 254 | const decimalPart = scaledValue % fixedScale 255 | 256 | let integerPrefix: string 257 | let decimalPrefix: string 258 | let finalInteger: number 259 | let finalDecimal: number 260 | 261 | if (direction === 'ASC') { 262 | if (value < 0) { 263 | integerPrefix = 'Y' 264 | decimalPrefix = 'V' 265 | finalInteger = integerPart 266 | finalDecimal = decimalPart 267 | } else if (value === 0) { 268 | integerPrefix = 'X' 269 | decimalPrefix = 'X' 270 | finalInteger = 0 271 | finalDecimal = 0 272 | } else { 273 | integerPrefix = integerPart === 0 ? 'X' : 'V' // X > V 274 | decimalPrefix = decimalPart === 0 ? 'X' : 'V' // X > V 275 | finalInteger = integerPart === 0 ? 0 : maxIntegerDigit - integerPart 276 | finalDecimal = decimalPart === 0 ? 0 : fixedScale - decimalPart 277 | } 278 | } else { 279 | // DESC 280 | if (value < 0) { 281 | integerPrefix = integerPart === 0 ? 'N' : 'M' // N > M 282 | decimalPrefix = decimalPart === 0 ? 'X' : 'V' // X > V 283 | finalInteger = integerPart === 0 ? 0 : maxIntegerDigit - integerPart 284 | finalDecimal = decimalPart === 0 ? 0 : fixedScale - decimalPart 285 | } else if (value === 0) { 286 | integerPrefix = 'N' 287 | decimalPrefix = 'X' 288 | finalInteger = 0 289 | finalDecimal = 0 290 | } else { 291 | integerPrefix = 'V' 292 | decimalPrefix = 'V' 293 | finalInteger = integerPart 294 | finalDecimal = decimalPart 295 | } 296 | } 297 | 298 | return ( 299 | integerPrefix + 300 | String(finalInteger).padStart(integerLength, '0') + 301 | decimalPrefix + 302 | String(finalDecimal).padStart(decimalLength, '0') 303 | ) 304 | } 305 | 306 | const generateCursor = (item: T, sortBy: SortBy, linkType: 'previous' | 'next' = 'next'): string => { 307 | return sortBy 308 | .map(([column, direction]) => { 309 | const columnProperties = getPropertiesByColumnName(String(column)) 310 | 311 | let propertyPath = [] 312 | if (columnProperties.isNested) { 313 | if (columnProperties.propertyPath) { 314 | propertyPath.push(columnProperties.propertyPath) 315 | } 316 | propertyPath = propertyPath.concat(columnProperties.propertyName.split('.')) 317 | } else if (columnProperties.propertyPath) { 318 | propertyPath = [columnProperties.propertyPath, columnProperties.propertyName] 319 | } else { 320 | propertyPath = [columnProperties.propertyName] 321 | } 322 | 323 | // Extract value from nested object 324 | let value = item 325 | for (let i = 0; i < propertyPath.length; i++) { 326 | const key = propertyPath[i] 327 | 328 | if (value === null || value === undefined) { 329 | value = null 330 | break 331 | } 332 | 333 | // Handle case where value is an array 334 | if (Array.isArray(value[key])) { 335 | const arrayValues = value[key] 336 | .map((item: any) => { 337 | let nestedValue = item 338 | for (let j = i + 1; j < propertyPath.length; j++) { 339 | if (nestedValue === null || nestedValue === undefined) { 340 | return null 341 | } 342 | 343 | // Handle embedded properties 344 | if (propertyPath[j].includes('.')) { 345 | const nestedProperties = propertyPath[j].split('.') 346 | for (const nestedProperty of nestedProperties) { 347 | nestedValue = nestedValue[nestedProperty] 348 | } 349 | } else { 350 | nestedValue = nestedValue[propertyPath[j]] 351 | } 352 | } 353 | return nestedValue 354 | }) 355 | .filter((v: any) => v !== null && v !== undefined) 356 | 357 | if (arrayValues.length === 0) { 358 | value = null 359 | } else { 360 | // Select min or max value based on sort direction and linkType (XOR) 361 | value = ( 362 | (direction === 'ASC') !== (linkType === 'previous') 363 | ? Math.min(...arrayValues) 364 | : Math.max(...arrayValues) 365 | ) as any 366 | } 367 | break 368 | } else { 369 | value = value[key] 370 | } 371 | } 372 | 373 | value = fixCursorValue(value) 374 | 375 | // Find column metadata 376 | let columnMeta = null 377 | if (propertyPath.length === 1) { 378 | // For regular column 379 | columnMeta = metadata.columns.find((col) => col.propertyName === columnProperties.propertyName) 380 | } else { 381 | // For relation column 382 | let currentMetadata = metadata 383 | let currentPath = '' 384 | 385 | // Traverse the relation path except for the last part 386 | for (let i = 0; i < propertyPath.length - 1; i++) { 387 | const relationName = propertyPath[i] 388 | currentPath = currentPath ? `${currentPath}.${relationName}` : relationName 389 | const relation = currentMetadata.findRelationWithPropertyPath(relationName) 390 | 391 | if (relation) { 392 | currentMetadata = relation.inverseEntityMetadata 393 | } else { 394 | break 395 | } 396 | } 397 | 398 | // Find column by the last property name 399 | const propertyName = propertyPath[propertyPath.length - 1] 400 | columnMeta = currentMetadata.columns.find((col) => col.propertyName === propertyName) 401 | } 402 | 403 | const isDateColumn = columnMeta && isDateColumnType(columnMeta.type) 404 | 405 | if (value === null || value === undefined) { 406 | return generateNullCursor() 407 | } 408 | 409 | if (isDateColumn) { 410 | return generateDateCursor(value.getTime(), direction) 411 | } else { 412 | const numericValue = Number(value) 413 | return generateNumberCursor(numericValue, direction) 414 | } 415 | }) 416 | .join('') 417 | } 418 | 419 | const getDateColumnExpression = (alias: string, dbType: string): string => { 420 | switch (dbType) { 421 | case 'mysql': 422 | case 'mariadb': 423 | return `UNIX_TIMESTAMP(${alias}) * 1000` 424 | case 'postgres': 425 | return `EXTRACT(EPOCH FROM ${alias}) * 1000` 426 | case 'sqlite': 427 | return `(STRFTIME('%s', ${alias}) + (STRFTIME('%f', ${alias}) - STRFTIME('%S', ${alias}))) * 1000` 428 | default: 429 | return alias 430 | } 431 | } 432 | 433 | const logAndThrowException = (msg: string) => { 434 | logger.debug(msg) 435 | throw new ServiceUnavailableException(msg) 436 | } 437 | 438 | if (config.sortableColumns.length < 1) { 439 | logAndThrowException("Missing required 'sortableColumns' config.") 440 | } 441 | 442 | const sortBy = [] as SortBy 443 | 444 | if (query.sortBy) { 445 | for (const order of query.sortBy) { 446 | if (isEntityKey(config.sortableColumns, order[0]) && ['ASC', 'DESC'].includes(order[1])) { 447 | sortBy.push(order as Order) 448 | } 449 | } 450 | } 451 | 452 | if (!sortBy.length) { 453 | sortBy.push(...(config.defaultSortBy || [[config.sortableColumns[0], 'ASC']])) 454 | } 455 | 456 | const searchBy: Column[] = [] 457 | 458 | let [items, totalItems]: [T[], number] = [[], 0] 459 | 460 | const queryBuilder = isRepository(repo) ? repo.createQueryBuilder('__root') : repo 461 | 462 | if (isRepository(repo) && !config.relations && config.loadEagerRelations === true) { 463 | if (!config.relations) { 464 | FindOptionsUtils.joinEagerRelations(queryBuilder, queryBuilder.alias, repo.metadata) 465 | } 466 | } 467 | 468 | if (isPaginated) { 469 | config.paginationType = config.paginationType || PaginationType.TAKE_AND_SKIP 470 | 471 | // Allow user to choose between limit/offset and take/skip, or cursor-based pagination. 472 | // However, using limit/offset can cause problems when joining one-to-many etc. 473 | if (config.paginationType === PaginationType.LIMIT_AND_OFFSET) { 474 | queryBuilder.limit(limit).offset((page - 1) * limit) 475 | } else if (config.paginationType === PaginationType.TAKE_AND_SKIP) { 476 | queryBuilder.take(limit).skip((page - 1) * limit) 477 | } else if (config.paginationType === PaginationType.CURSOR) { 478 | queryBuilder.take(limit) 479 | const padLength = 15 480 | const integerLength = 11 481 | const decimalLength = 4 482 | const fixedScale = Math.pow(10, 4) 483 | const maxIntegerDigit = Math.pow(10, 11) 484 | 485 | const concat = (parts: string[]): string => 486 | isMySqlOrMariaDb ? `CONCAT(${parts.join(', ')})` : parts.join(' || ') 487 | 488 | const generateNullCursorExpr = (): string => { 489 | const zeroPaddedExpr = getPaddedExpr('0', padLength, dbType) 490 | const prefix = 'A' 491 | 492 | return isMySqlOrMariaDb ? `CONCAT('${prefix}', ${zeroPaddedExpr})` : `'${prefix}' || ${zeroPaddedExpr}` 493 | } 494 | 495 | const generateDateCursorExpr = (columnExpr: string, direction: 'ASC' | 'DESC'): string => { 496 | const safeExpr = `COALESCE(${columnExpr}, 0)` 497 | const sqlExpr = direction === 'ASC' ? `POW(10, ${padLength}) - ${safeExpr}` : safeExpr 498 | 499 | const paddedExpr = getPaddedExpr(sqlExpr, padLength, dbType) 500 | const zeroPaddedExpr = getPaddedExpr('0', padLength, dbType) 501 | 502 | const prefixNull = "'A'" 503 | const prefixValue = "'V'" 504 | const prefixZero = "'X'" 505 | 506 | if (direction === 'ASC') { 507 | return `CASE 508 | WHEN ${columnExpr} IS NULL THEN ${concat([prefixNull, zeroPaddedExpr])} 509 | WHEN ${columnExpr} = 0 THEN ${concat([prefixZero, zeroPaddedExpr])} 510 | ELSE ${concat([prefixValue, paddedExpr])} 511 | END` 512 | } else { 513 | return `CASE 514 | WHEN ${columnExpr} IS NULL THEN ${concat([prefixNull, zeroPaddedExpr])} 515 | ELSE ${concat([prefixValue, paddedExpr])} 516 | END` 517 | } 518 | } 519 | 520 | const generateNumberCursorExpr = (columnExpr: string, direction: 'ASC' | 'DESC'): string => { 521 | const safeExpr = `COALESCE(${columnExpr}, 0)` 522 | const absSafeExpr = `ABS(${safeExpr})` 523 | const scaledExpr = `ROUND(${absSafeExpr} * ${fixedScale}, 0)` 524 | const intExpr = `FLOOR(${scaledExpr} / ${fixedScale})` 525 | const decExpr = `(${scaledExpr} % ${fixedScale})` 526 | const reversedIntExpr = `(${maxIntegerDigit} - ${intExpr})` 527 | const reversedDecExpr = `(${fixedScale} - ${decExpr})` 528 | 529 | const paddedIntExpr = getPaddedExpr(intExpr, integerLength, dbType) 530 | const paddedDecExpr = getPaddedExpr(decExpr, decimalLength, dbType) 531 | const reversedIntPaddedExpr = getPaddedExpr(reversedIntExpr, integerLength, dbType) 532 | const reversedDecPaddedExpr = getPaddedExpr(reversedDecExpr, decimalLength, dbType) 533 | const zeroPaddedIntExpr = getPaddedExpr('0', integerLength, dbType) 534 | const zeroPaddedDecExpr = getPaddedExpr('0', decimalLength, dbType) 535 | 536 | if (direction === 'ASC') { 537 | return `CASE 538 | WHEN ${columnExpr} IS NULL THEN ${generateNullCursorExpr()} 539 | WHEN ${columnExpr} < 0 THEN ${concat(["'Y'", paddedIntExpr, "'V'", paddedDecExpr])} 540 | WHEN ${columnExpr} = 0 THEN ${concat(["'X'", zeroPaddedIntExpr, "'X'", zeroPaddedDecExpr])} 541 | WHEN ${columnExpr} > 0 AND ${intExpr} = 0 AND ${decExpr} > 0 THEN ${concat([ 542 | "'X'", 543 | zeroPaddedIntExpr, 544 | "'V'", 545 | reversedDecPaddedExpr, 546 | ])} 547 | WHEN ${columnExpr} > 0 AND ${intExpr} > 0 AND ${decExpr} = 0 THEN ${concat([ 548 | "'V'", 549 | reversedIntPaddedExpr, 550 | "'X'", 551 | zeroPaddedDecExpr, 552 | ])} 553 | WHEN ${columnExpr} > 0 AND ${intExpr} > 0 AND ${decExpr} > 0 THEN ${concat([ 554 | "'V'", 555 | reversedIntPaddedExpr, 556 | "'V'", 557 | reversedDecPaddedExpr, 558 | ])} 559 | END` 560 | } else { 561 | return `CASE 562 | WHEN ${columnExpr} IS NULL THEN ${generateNullCursorExpr()} 563 | WHEN ${columnExpr} < 0 AND ${intExpr} > 0 AND ${decExpr} > 0 THEN ${concat([ 564 | "'M'", 565 | reversedIntPaddedExpr, 566 | "'V'", 567 | reversedDecPaddedExpr, 568 | ])} 569 | WHEN ${columnExpr} < 0 AND ${intExpr} > 0 AND ${decExpr} = 0 THEN ${concat([ 570 | "'M'", 571 | reversedIntPaddedExpr, 572 | "'X'", 573 | zeroPaddedDecExpr, 574 | ])} 575 | WHEN ${columnExpr} < 0 AND ${intExpr} = 0 AND ${decExpr} > 0 THEN ${concat([ 576 | "'N'", 577 | zeroPaddedIntExpr, 578 | "'V'", 579 | reversedDecPaddedExpr, 580 | ])} 581 | WHEN ${columnExpr} = 0 THEN ${concat(["'N'", zeroPaddedIntExpr, "'X'", zeroPaddedDecExpr])} 582 | WHEN ${columnExpr} > 0 THEN ${concat(["'V'", paddedIntExpr, "'V'", paddedDecExpr])} 583 | END` 584 | } 585 | } 586 | 587 | const cursorExpressions = sortBy.map(([column, direction]) => { 588 | const columnProperties = getPropertiesByColumnName(column) 589 | const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty( 590 | queryBuilder, 591 | columnProperties 592 | ) 593 | const isRelation = checkIsRelation(queryBuilder, columnProperties.propertyPath) 594 | const isEmbedded = checkIsEmbedded(queryBuilder, columnProperties.propertyPath) 595 | const alias = fixColumnAlias( 596 | columnProperties, 597 | queryBuilder.alias, 598 | isRelation, 599 | isVirtualProperty, 600 | isEmbedded, 601 | virtualQuery 602 | ) 603 | 604 | // Find column metadata to determine type for proper cursor handling 605 | let columnMeta = metadata.columns.find((col) => col.propertyName === columnProperties.propertyName) 606 | 607 | // If it's a relation column, we need to find the target column metadata 608 | if (isRelation) { 609 | // Find the relation by path and get the target entity metadata 610 | const relationPath = columnProperties.column.split('.') 611 | // The base entity is the starting point 612 | let currentMetadata = metadata 613 | 614 | // Traverse the relation path to find the target metadata 615 | for (let i = 0; i < relationPath.length - 1; i++) { 616 | const relationName = relationPath[i] 617 | const relation = currentMetadata.findRelationWithPropertyPath(relationName) 618 | 619 | if (relation) { 620 | // Update the metadata to the target entity metadata for the next iteration 621 | currentMetadata = relation.inverseEntityMetadata 622 | } else { 623 | break 624 | } 625 | } 626 | 627 | // Now get the property from the target entity 628 | const propertyName = relationPath[relationPath.length - 1] 629 | columnMeta = currentMetadata.columns.find((col) => col.propertyName === propertyName) 630 | } 631 | 632 | // Determine whether it's a date column 633 | const isDateColumn = columnMeta && isDateColumnType(columnMeta.type) 634 | const columnExpr = isDateColumn ? getDateColumnExpression(alias, dbType) : alias 635 | 636 | return isDateColumn 637 | ? generateDateCursorExpr(columnExpr, direction) 638 | : generateNumberCursorExpr(columnExpr, direction) 639 | }) 640 | 641 | const cursorExpression = 642 | cursorExpressions.length > 1 643 | ? isMySqlOrMariaDb 644 | ? `CONCAT(${cursorExpressions.join(', ')})` 645 | : cursorExpressions.join(' || ') 646 | : cursorExpressions[0] 647 | queryBuilder.addSelect(cursorExpression, 'cursor') 648 | 649 | if (query.cursor) { 650 | queryBuilder.andWhere(`${cursorExpression} < :cursor`, { cursor: query.cursor }) 651 | } 652 | 653 | isMySqlOrMariaDb ? queryBuilder.orderBy('`cursor`', 'DESC') : queryBuilder.orderBy('cursor', 'DESC') // since cursor is a reserved word in mysql, wrap it in backticks to recognize it as an alias 654 | } 655 | } 656 | 657 | if (config.withDeleted || (config.allowWithDeletedInQuery && query.withDeleted)) { 658 | queryBuilder.withDeleted() 659 | } 660 | 661 | let filterJoinMethods = {} 662 | if (query.filter) { 663 | filterJoinMethods = addFilter(queryBuilder, query, config.filterableColumns) 664 | } 665 | const joinMethods = { ...filterJoinMethods, ...config.joinMethods } 666 | 667 | // Add the relations specified by the config, or used in the currently 668 | // filtered filterable columns. 669 | if (config.relations || Object.keys(filterJoinMethods).length) { 670 | const relationsSchema = mergeRelationSchema( 671 | createRelationSchema(config.relations), 672 | createRelationSchema(Object.keys(joinMethods)) 673 | ) 674 | addRelationsFromSchema(queryBuilder, relationsSchema, config, joinMethods) 675 | } 676 | 677 | if (config.paginationType !== PaginationType.CURSOR) { 678 | let nullSort: string | undefined 679 | if (config.nullSort) { 680 | if (isMySqlOrMariaDb) { 681 | nullSort = config.nullSort === 'last' ? 'IS NULL' : 'IS NOT NULL' 682 | } else { 683 | nullSort = config.nullSort === 'last' ? 'NULLS LAST' : 'NULLS FIRST' 684 | } 685 | } 686 | 687 | for (const order of sortBy) { 688 | const columnProperties = getPropertiesByColumnName(order[0]) 689 | const { isVirtualProperty } = extractVirtualProperty(queryBuilder, columnProperties) 690 | const isRelation = checkIsRelation(queryBuilder, columnProperties.propertyPath) 691 | const isEmbedded = checkIsEmbedded(queryBuilder, columnProperties.propertyPath) 692 | let alias = fixColumnAlias(columnProperties, queryBuilder.alias, isRelation, isVirtualProperty, isEmbedded) 693 | 694 | if (isVirtualProperty) { 695 | alias = quoteVirtualColumn(alias, isMySqlOrMariaDb) 696 | } 697 | 698 | if (isMySqlOrMariaDb) { 699 | if (nullSort) { 700 | const selectionAliasName = `${alias.replace(/\./g, '_')}IsNull` 701 | queryBuilder.addSelect(`${alias} ${nullSort}`, selectionAliasName) 702 | queryBuilder.addOrderBy(selectionAliasName) 703 | } 704 | queryBuilder.addOrderBy(alias, order[1]) 705 | } else { 706 | queryBuilder.addOrderBy(alias, order[1], nullSort as 'NULLS FIRST' | 'NULLS LAST' | undefined) 707 | } 708 | } 709 | } 710 | 711 | /** 712 | * Expands select parameters containing wildcards (*) into actual column lists 713 | * 714 | * @returns Array of expanded column names 715 | */ 716 | const expandWildcardSelect = (selectParams: string[], queryBuilder: SelectQueryBuilder): string[] => { 717 | const expandedParams: string[] = [] 718 | 719 | const mainAlias = queryBuilder.expressionMap.mainAlias 720 | const mainMetadata = mainAlias.metadata 721 | 722 | /** 723 | * Internal function to expand wildcards 724 | * 725 | * @returns Array of expanded column names 726 | */ 727 | const _expandWidcard = (entityPath: string, metadata: EntityMetadata): string[] => { 728 | const expanded: string[] = [] 729 | 730 | // Add all columns from the relation entity 731 | expanded.push( 732 | ...metadata.columns 733 | .filter( 734 | (col) => 735 | !metadata.embeddeds 736 | .map((embedded) => embedded.columns.map((embeddedCol) => embeddedCol.propertyName)) 737 | .flat() 738 | .includes(col.propertyName) 739 | ) 740 | .map((col) => (entityPath ? `${entityPath}.${col.propertyName}` : col.propertyName)) 741 | ) 742 | 743 | // Add columns from embedded entities in the relation 744 | metadata.embeddeds.forEach((embedded) => { 745 | expanded.push( 746 | ...embedded.columns.map((col) => `${entityPath}.(${embedded.propertyName}.${col.propertyName})`) 747 | ) 748 | }) 749 | 750 | return expanded 751 | } 752 | 753 | for (const param of selectParams) { 754 | if (param === '*') { 755 | expandedParams.push(..._expandWidcard('', mainMetadata)) 756 | } else if (param.endsWith('.*')) { 757 | // Handle relation entity wildcards (e.g. 'user.*', 'user.profile.*') 758 | const parts = param.slice(0, -2).split('.') 759 | let currentPath = '' 760 | let currentMetadata = mainMetadata 761 | 762 | for (let i = 0; i < parts.length; i++) { 763 | const part = parts[i] 764 | currentPath = currentPath ? `${currentPath}.${part}` : part 765 | const relation = currentMetadata.findRelationWithPropertyPath(part) 766 | 767 | if (relation) { 768 | currentMetadata = relation.inverseEntityMetadata 769 | if (i === parts.length - 1) { 770 | // Expand wildcard at the last part 771 | expandedParams.push(..._expandWidcard(currentPath, currentMetadata)) 772 | } 773 | } else { 774 | break 775 | } 776 | } 777 | } else { 778 | // Add regular columns as is 779 | expandedParams.push(param) 780 | } 781 | } 782 | 783 | // Remove duplicates while preserving order 784 | return [...new Set(expandedParams)] 785 | } 786 | 787 | const selectParams = (() => { 788 | // Expand wildcards in config.select if it exists 789 | const expandedConfigSelect = config.select ? expandWildcardSelect(config.select, queryBuilder) : undefined 790 | 791 | // Expand wildcards in query.select if it exists 792 | const expandedQuerySelect = query.select ? expandWildcardSelect(query.select, queryBuilder) : undefined 793 | 794 | // Filter config.select with expanded query.select if both exist and ignoreSelectInQueryParam is false 795 | if (expandedConfigSelect && expandedQuerySelect && !config.ignoreSelectInQueryParam) { 796 | return expandedConfigSelect.filter((column) => expandedQuerySelect.includes(column)) 797 | } 798 | 799 | return expandedConfigSelect 800 | })() 801 | 802 | if (selectParams?.length > 0) { 803 | let cols: string[] = selectParams.reduce((cols, currentCol) => { 804 | const columnProperties = getPropertiesByColumnName(currentCol) 805 | const isRelation = checkIsRelation(queryBuilder, columnProperties.propertyPath) 806 | cols.push(fixColumnAlias(columnProperties, queryBuilder.alias, isRelation)) 807 | return cols 808 | }, []) 809 | 810 | const missingPrimaryKeys = getMissingPrimaryKeyColumns(queryBuilder, cols) 811 | if (missingPrimaryKeys.length > 0) { 812 | cols = cols.concat(missingPrimaryKeys) 813 | } 814 | 815 | queryBuilder.select(cols) 816 | } 817 | 818 | if (config.where && isRepository(repo)) { 819 | const baseWhereStr = generateWhereStatement(queryBuilder, config.where) 820 | queryBuilder.andWhere(`(${baseWhereStr})`) 821 | } 822 | 823 | if (config.searchableColumns) { 824 | if (query.searchBy && !config.ignoreSearchByInQueryParam) { 825 | for (const column of query.searchBy) { 826 | if (isEntityKey(config.searchableColumns, column)) { 827 | searchBy.push(column) 828 | } 829 | } 830 | } else { 831 | searchBy.push(...config.searchableColumns) 832 | } 833 | } 834 | 835 | if (query.search && searchBy.length) { 836 | queryBuilder.andWhere( 837 | new Brackets((qb: SelectQueryBuilder) => { 838 | // Explicitly handle the default case - multiWordSearch defaults to false 839 | const useMultiWordSearch = config.multiWordSearch ?? false 840 | if (!useMultiWordSearch) { 841 | // Strict search mode (default behavior) 842 | for (const column of searchBy) { 843 | const property = getPropertiesByColumnName(column) 844 | const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, property) 845 | const isRelation = checkIsRelation(qb, property.propertyPath) 846 | const isEmbedded = checkIsEmbedded(qb, property.propertyPath) 847 | const alias = fixColumnAlias( 848 | property, 849 | qb.alias, 850 | isRelation, 851 | isVirtualProperty, 852 | isEmbedded, 853 | virtualQuery 854 | ) 855 | 856 | const condition: WherePredicateOperator = { 857 | operator: 'ilike', 858 | parameters: [alias, `:${property.column}`], 859 | } 860 | 861 | if (['postgres', 'cockroachdb'].includes(queryBuilder.connection.options.type)) { 862 | condition.parameters[0] = `CAST(${condition.parameters[0]} AS text)` 863 | } 864 | 865 | qb.orWhere(qb['createWhereConditionExpression'](condition), { 866 | [property.column]: `%${query.search}%`, 867 | }) 868 | } 869 | } else { 870 | // Multi-word search mode 871 | const searchWords = query.search.split(' ').filter((word) => word.length > 0) 872 | searchWords.forEach((searchWord, index) => { 873 | qb.andWhere( 874 | new Brackets((subQb: SelectQueryBuilder) => { 875 | for (const column of searchBy) { 876 | const property = getPropertiesByColumnName(column) 877 | const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty( 878 | subQb, 879 | property 880 | ) 881 | const isRelation = checkIsRelation(subQb, property.propertyPath) 882 | const isEmbedded = checkIsEmbedded(subQb, property.propertyPath) 883 | const alias = fixColumnAlias( 884 | property, 885 | subQb.alias, 886 | isRelation, 887 | isVirtualProperty, 888 | isEmbedded, 889 | virtualQuery 890 | ) 891 | 892 | const condition: WherePredicateOperator = { 893 | operator: 'ilike', 894 | parameters: [alias, `:${property.column}_${index}`], 895 | } 896 | 897 | if (['postgres', 'cockroachdb'].includes(queryBuilder.connection.options.type)) { 898 | condition.parameters[0] = `CAST(${condition.parameters[0]} AS text)` 899 | } 900 | 901 | subQb.orWhere(subQb['createWhereConditionExpression'](condition), { 902 | [`${property.column}_${index}`]: `%${searchWord}%`, 903 | }) 904 | } 905 | }) 906 | ) 907 | }) 908 | } 909 | }) 910 | ) 911 | } 912 | 913 | if (query.limit === PaginationLimit.COUNTER_ONLY) { 914 | totalItems = await queryBuilder.getCount() 915 | } else if (isPaginated && config.paginationType !== PaginationType.CURSOR) { 916 | if (config.buildCountQuery) { 917 | items = await queryBuilder.getMany() 918 | totalItems = await config.buildCountQuery(queryBuilder.clone()).getCount() 919 | } else { 920 | ;[items, totalItems] = await queryBuilder.getManyAndCount() 921 | } 922 | } else { 923 | items = await queryBuilder.getMany() 924 | } 925 | 926 | const sortByQuery = sortBy.map((order) => `&sortBy=${order.join(':')}`).join('') 927 | const searchQuery = query.search ? `&search=${query.search}` : '' 928 | 929 | const searchByQuery = 930 | query.searchBy && searchBy.length && !config.ignoreSearchByInQueryParam 931 | ? searchBy.map((column) => `&searchBy=${column}`).join('') 932 | : '' 933 | 934 | // Only expose select in meta data if query select differs from config select 935 | const isQuerySelected = selectParams?.length !== config.select?.length 936 | const selectQuery = isQuerySelected ? `&select=${selectParams.join(',')}` : '' 937 | 938 | const filterQuery = query.filter 939 | ? '&' + 940 | stringify( 941 | mapKeys(query.filter, (_param, name) => 'filter.' + name), 942 | '&', 943 | '=', 944 | { encodeURIComponent: (str) => str } 945 | ) 946 | : '' 947 | 948 | const options = `&limit=${limit}${sortByQuery}${searchQuery}${searchByQuery}${selectQuery}${filterQuery}` 949 | 950 | let path: string = null 951 | if (query.path !== null) { 952 | // `query.path` does not exist in RPC/WS requests and is set to null then. 953 | const { queryOrigin, queryPath } = getQueryUrlComponents(query.path) 954 | if (config.relativePath) { 955 | path = queryPath 956 | } else if (isNotNil(config.origin)) { 957 | path = config.origin + queryPath 958 | } else { 959 | path = (isNil(globalConfig.defaultOrigin) ? queryOrigin : globalConfig.defaultOrigin) + queryPath 960 | } 961 | } 962 | 963 | const buildLink = (p: number): string => path + '?page=' + p + options 964 | 965 | const reversedSortBy = sortBy.map(([col, dir]) => [col, dir === 'ASC' ? 'DESC' : 'ASC'] as Order) 966 | 967 | const buildLinkForCursor = (cursor: string | undefined, isReversed: boolean = false): string => { 968 | let adjustedOptions = options 969 | 970 | if (isReversed && sortBy.length > 0) { 971 | adjustedOptions = `&limit=${limit}${reversedSortBy 972 | .map((order) => `&sortBy=${order.join(':')}`) 973 | .join('')}${searchQuery}${searchByQuery}${selectQuery}${filterQuery}` 974 | } 975 | 976 | return path + adjustedOptions.replace(/^./, '?') + (cursor ? `&cursor=${cursor}` : '') 977 | } 978 | 979 | const itemsPerPage = limit === PaginationLimit.COUNTER_ONLY ? totalItems : isPaginated ? limit : items.length 980 | const totalItemsForMeta = limit === PaginationLimit.COUNTER_ONLY || isPaginated ? totalItems : items.length 981 | const totalPages = isPaginated ? Math.ceil(totalItems / limit) : 1 982 | 983 | const results: Paginated = { 984 | data: items, 985 | meta: { 986 | itemsPerPage: config.paginationType === PaginationType.CURSOR ? items.length : itemsPerPage, 987 | totalItems: config.paginationType === PaginationType.CURSOR ? undefined : totalItemsForMeta, 988 | currentPage: config.paginationType === PaginationType.CURSOR ? undefined : page, 989 | totalPages: config.paginationType === PaginationType.CURSOR ? undefined : totalPages, 990 | sortBy, 991 | search: query.search, 992 | searchBy: query.search ? searchBy : undefined, 993 | select: isQuerySelected ? selectParams : undefined, 994 | filter: query.filter, 995 | cursor: config.paginationType === PaginationType.CURSOR ? query.cursor : undefined, 996 | }, 997 | // If there is no `path`, don't build links. 998 | links: 999 | path !== null 1000 | ? config.paginationType === PaginationType.CURSOR 1001 | ? { 1002 | previous: items.length 1003 | ? buildLinkForCursor(generateCursor(items[0], reversedSortBy, 'previous'), true) 1004 | : undefined, 1005 | current: buildLinkForCursor(query.cursor), 1006 | next: items.length 1007 | ? buildLinkForCursor(generateCursor(items[items.length - 1], sortBy)) 1008 | : undefined, 1009 | } 1010 | : { 1011 | first: page == 1 ? undefined : buildLink(1), 1012 | previous: page - 1 < 1 ? undefined : buildLink(page - 1), 1013 | current: buildLink(page), 1014 | next: page + 1 > totalPages ? undefined : buildLink(page + 1), 1015 | last: page == totalPages || !totalItems ? undefined : buildLink(totalPages), 1016 | } 1017 | : ({} as Paginated['links']), 1018 | } 1019 | 1020 | return Object.assign(new Paginated(), results) 1021 | } 1022 | 1023 | export function addRelationsFromSchema( 1024 | queryBuilder: SelectQueryBuilder, 1025 | schema: RelationSchema, 1026 | config: PaginateConfig, 1027 | joinMethods: Partial> 1028 | ): void { 1029 | const defaultJoinMethod = config.defaultJoinMethod ?? 'leftJoinAndSelect' 1030 | 1031 | const createQueryBuilderRelations = ( 1032 | prefix: string, 1033 | relations: RelationSchema, 1034 | alias?: string, 1035 | parentRelation?: string 1036 | ) => { 1037 | Object.keys(relations).forEach((relationName) => { 1038 | const joinMethod = 1039 | joinMethods[parentRelation ? `${parentRelation}.${relationName}` : relationName] ?? defaultJoinMethod 1040 | queryBuilder[joinMethod](`${alias ?? prefix}.${relationName}`, `${alias ?? prefix}_${relationName}_rel`) 1041 | 1042 | // Check whether this is a non-terminal node with a relation schema to load 1043 | const relationSchema = relations[relationName] 1044 | if ( 1045 | typeof relationSchema === 'object' && 1046 | relationSchema !== null && 1047 | Object.keys(relationSchema).length > 0 1048 | ) { 1049 | createQueryBuilderRelations( 1050 | relationName, 1051 | relationSchema, 1052 | `${alias ?? prefix}_${relationName}_rel`, 1053 | parentRelation ? `${parentRelation}.${relationName}` : relationName 1054 | ) 1055 | } 1056 | }) 1057 | } 1058 | createQueryBuilderRelations(queryBuilder.alias, schema) 1059 | } 1060 | --------------------------------------------------------------------------------