├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── docker-compose.yml ├── jest.setup.ts ├── package-lock.json ├── package.json ├── renovate.json ├── src ├── __tests__ │ ├── cat-hair.entity.ts │ ├── cat-home-pillow-brand.entity.ts │ ├── cat-home-pillow.entity.ts │ ├── cat-home.entity.ts │ ├── cat-toy.entity.ts │ ├── cat.entity.ts │ ├── column-option.ts │ ├── size.embed.ts │ ├── toy-shop-address.entity.ts │ └── toy-shop.entity.ts ├── decorator.spec.ts ├── decorator.ts ├── filter.ts ├── helper.ts ├── index.ts ├── paginate.spec.ts ├── paginate.ts └── swagger │ ├── api-ok-paginated-response.decorator.ts │ ├── api-paginated-query.decorator.ts │ ├── api-paginated-swagger-docs.decorator.ts │ ├── index.ts │ ├── paginated-swagger.type.ts │ └── pagination-docs.spec.ts └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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: [16.x, 18.x, 20.x] 16 | 17 | services: 18 | postgres: 19 | image: postgres:latest 20 | env: 21 | POSTGRES_USER: root 22 | POSTGRES_PASSWORD: pass 23 | POSTGRES_DB: test 24 | ports: 25 | - 5432:5432 26 | options: --health-cmd pg_isready 27 | --health-interval 10s 28 | --health-timeout 5s 29 | --health-retries 5 30 | 31 | mariadb: 32 | image: mariadb:latest 33 | env: 34 | MYSQL_ROOT_PASSWORD: pass 35 | MYSQL_DATABASE: test 36 | ports: 37 | - 3306:3306 38 | options: --health-cmd "mariadb-admin ping" 39 | --health-interval 10s 40 | --health-timeout 5s 41 | --health-retries 5 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | - name: Use Node.js ${{ matrix.node-version }} 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version: ${{ matrix.node-version }} 49 | - run: npm ci 50 | - run: npm run format:ci 51 | - run: npm run lint 52 | - run: npm run build 53 | 54 | # TODO: run postgres and sqlite in parallel 55 | - run: DB=postgres npm run test:cov 56 | - run: DB=mariadb npm run test:cov 57 | - run: DB=sqlite npm run test:cov 58 | - run: 'bash <(curl -s https://codecov.io/bash)' 59 | if: github.event_name == 'push' && matrix.node-version == '20.x' 60 | - name: Semantic Release 61 | if: github.event_name == 'push' && matrix.node-version == '20.x' 62 | uses: cycjimmy/semantic-release-action@v3 63 | env: 64 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 65 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 66 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | coverage/ 4 | .DS_Store 5 | npm-debug.log 6 | .history 7 | .idea/ 8 | .env 9 | test.sql -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": "explicit" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ### Example 31 | 32 | The following code exposes a route that can be utilized like so: 33 | 34 | #### Endpoint 35 | 36 | ```url 37 | http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC&search=i&filter.age=$gte:3&select=id,name,color,age 38 | ``` 39 | 40 | #### Result 41 | 42 | ```json 43 | { 44 | "data": [ 45 | { 46 | "id": 4, 47 | "name": "George", 48 | "color": "white", 49 | "age": 3 50 | }, 51 | { 52 | "id": 5, 53 | "name": "Leche", 54 | "color": "white", 55 | "age": 6 56 | }, 57 | { 58 | "id": 2, 59 | "name": "Garfield", 60 | "color": "ginger", 61 | "age": 4 62 | }, 63 | { 64 | "id": 1, 65 | "name": "Milo", 66 | "color": "brown", 67 | "age": 5 68 | }, 69 | { 70 | "id": 3, 71 | "name": "Kitty", 72 | "color": "black", 73 | "age": 3 74 | } 75 | ], 76 | "meta": { 77 | "itemsPerPage": 5, 78 | "totalItems": 12, 79 | "currentPage": 2, 80 | "totalPages": 3, 81 | "sortBy": [["color", "DESC"]], 82 | "search": "i", 83 | "filter": { 84 | "age": "$gte:3" 85 | } 86 | }, 87 | "links": { 88 | "first": "http://localhost:3000/cats?limit=5&page=1&sortBy=color:DESC&search=i&filter.age=$gte:3", 89 | "previous": "http://localhost:3000/cats?limit=5&page=1&sortBy=color:DESC&search=i&filter.age=$gte:3", 90 | "current": "http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC&search=i&filter.age=$gte:3", 91 | "next": "http://localhost:3000/cats?limit=5&page=3&sortBy=color:DESC&search=i&filter.age=$gte:3", 92 | "last": "http://localhost:3000/cats?limit=5&page=3&sortBy=color:DESC&search=i&filter.age=$gte:3" 93 | } 94 | } 95 | ``` 96 | 97 | ### Example (Cursor-based Pagination) 98 | 99 | The following code exposes a route using cursor-based pagination: 100 | 101 | #### Endpoint 102 | 103 | ```url 104 | http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=V998328469600000 105 | ``` 106 | 107 | #### Result 108 | 109 | ```json 110 | { 111 | "data": [ 112 | { 113 | "id": 3, 114 | "name": "Shadow", 115 | "lastVetVisit": "2022-12-21T10:00:00.000Z" 116 | }, 117 | { 118 | "id": 4, 119 | "name": "Luna", 120 | "lastVetVisit": "2022-12-22T10:00:00.000Z" 121 | }, 122 | { 123 | "id": 5, 124 | "name": "Pepper", 125 | "lastVetVisit": "2022-12-23T10:00:00.000Z" 126 | }, 127 | { 128 | "id": 6, 129 | "name": "Simba", 130 | "lastVetVisit": "2022-12-24T10:00:00.000Z" 131 | }, 132 | { 133 | "id": 7, 134 | "name": "Tiger", 135 | "lastVetVisit": "2022-12-25T10:00:00.000Z" 136 | } 137 | ], 138 | "meta": { 139 | "itemsPerPage": 5, 140 | "cursor": "V998328469600000" 141 | }, 142 | "links": { 143 | "previous": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:DESC&cursor=V001671616800000", 144 | "current": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=V998328469600000", 145 | "next": "http://localhost:3000/cats?limit=5&sortBy=lastVetVisit:ASC&cursor=V998328037600000" 146 | } 147 | } 148 | ``` 149 | 150 | #### Code 151 | 152 | ```ts 153 | import { Controller, Injectable, Get } from '@nestjs/common' 154 | import { InjectRepository } from '@nestjs/typeorm' 155 | import { FilterOperator, FilterSuffix, Paginate, PaginateQuery, paginate, Paginated } from 'nestjs-paginate' 156 | import { Repository, Entity, PrimaryGeneratedColumn, Column } from 'typeorm' 157 | 158 | @Entity() 159 | export class CatEntity { 160 | @PrimaryGeneratedColumn() 161 | id: number 162 | 163 | @Column('text') 164 | name: string 165 | 166 | @Column('text') 167 | color: string 168 | 169 | @Column('int') 170 | age: number 171 | 172 | @Column({ nullable: true }) 173 | lastVetVisit: Date | null 174 | 175 | @CreateDateColumn() 176 | createdAt: string 177 | } 178 | 179 | @Injectable() 180 | export class CatsService { 181 | constructor( 182 | @InjectRepository(CatEntity) 183 | private readonly catsRepository: Repository 184 | ) {} 185 | 186 | public findAll(query: PaginateQuery): Promise> { 187 | return paginate(query, this.catsRepository, { 188 | sortableColumns: ['id', 'name', 'color', 'age'], 189 | nullSort: 'last', 190 | defaultSortBy: [['id', 'DESC']], 191 | searchableColumns: ['name', 'color', 'age'], 192 | select: ['id', 'name', 'color', 'age', 'lastVetVisit'], 193 | filterableColumns: { 194 | name: [FilterOperator.EQ, FilterSuffix.NOT], 195 | age: true, 196 | }, 197 | }) 198 | } 199 | } 200 | 201 | @Controller('cats') 202 | export class CatsController { 203 | constructor(private readonly catsService: CatsService) {} 204 | 205 | @Get() 206 | public findAll(@Paginate() query: PaginateQuery): Promise> { 207 | return this.catsService.findAll(query) 208 | } 209 | } 210 | ``` 211 | 212 | ### Config 213 | 214 | ````ts 215 | const paginateConfig: PaginateConfig { 216 | /** 217 | * Required: true (must have a minimum of one column) 218 | * Type: (keyof CatEntity)[] 219 | * Description: These are the columns that are valid to be sorted by. 220 | */ 221 | sortableColumns: ['id', 'name', 'color'], 222 | 223 | /** 224 | * Required: false 225 | * Type: 'first' | 'last' 226 | * Description: Define whether to put null values at the beginning 227 | * or end of the result set. 228 | */ 229 | nullSort: 'last', 230 | 231 | /** 232 | * Required: false 233 | * Type: [keyof CatEntity, 'ASC' | 'DESC'][] 234 | * Default: [[sortableColumns[0], 'ASC]] 235 | * Description: The order to display the sorted entities. 236 | */ 237 | defaultSortBy: [['name', 'DESC']], 238 | 239 | /** 240 | * Required: false 241 | * Type: (keyof CatEntity)[] 242 | * Description: These columns will be searched through when using the search query 243 | * param. Limit search scope further by using `searchBy` query param. 244 | */ 245 | searchableColumns: ['name', 'color'], 246 | 247 | /** 248 | * Required: false 249 | * Type: (keyof CatEntity)[] 250 | * Default: None 251 | * Description: TypeORM partial selection. Limit selection further by using `select` query param. 252 | * https://typeorm.io/select-query-builder#partial-selection 253 | * Note: if you do not contain the primary key in the select array, primary key will be added automatically. 254 | * 255 | * Wildcard support: 256 | * - Use '*' to select all columns from the main entity. 257 | * - Use 'relation.*' to select all columns from a relation. 258 | * - Use 'relation.subrelation.*' to select all columns from nested relations. 259 | * 260 | * Examples: 261 | * select: ['*'] - Selects all columns from main entity 262 | * select: ['id', 'name', 'toys.*'] - Selects id, name from main entity and all columns from toys relation 263 | * select: ['*', 'toys.*'] - Selects all columns from both main entity and toys relation 264 | */ 265 | select: ['id', 'name', 'color'], 266 | 267 | /** 268 | * Required: false 269 | * Type: number 270 | * Default: 100 271 | * Description: The maximum amount of entities to return per page. 272 | * Set it to -1, in conjunction with limit=-1 on query param, to disable pagination. 273 | */ 274 | maxLimit: 20, 275 | 276 | /** 277 | * Required: false 278 | * Type: number 279 | * Default: 20 280 | */ 281 | defaultLimit: 50, 282 | 283 | /** 284 | * Required: false 285 | * Type: TypeORM find options 286 | * Default: None 287 | * https://typeorm.io/#/find-optionsfind-options.md 288 | */ 289 | where: { color: 'ginger' }, 290 | 291 | /** 292 | * Required: false 293 | * Type: { [key in CatEntity]?: FilterOperator[] } - Operators based on TypeORM find operators 294 | * Default: None 295 | * https://typeorm.io/#/find-options/advanced-options 296 | */ 297 | filterableColumns: { age: [FilterOperator.EQ, FilterOperator.IN] }, 298 | 299 | /** 300 | * Required: false 301 | * Type: RelationColumn 302 | * Description: Indicates what relations of entity should be loaded. 303 | */ 304 | relations: [], 305 | 306 | /** 307 | * Required: false 308 | * Type: boolean 309 | * Default: false 310 | * Description: Load eager relations using TypeORM's eager property. 311 | * Only works if `relations` is not defined. 312 | */ 313 | loadEagerRelations: true, 314 | 315 | /** 316 | * Required: false 317 | * Type: boolean 318 | * Description: Disables the global condition of "non-deleted" for the entity with delete date columns. 319 | * https://typeorm.io/select-query-builder#querying-deleted-rows 320 | */ 321 | withDeleted: false, 322 | 323 | /** 324 | * Required: false 325 | * Type: string 326 | * Description: Allow user to choose between limit/offset and take/skip, or cursor-based pagination. 327 | * Default: PaginationType.TAKE_AND_SKIP 328 | * Options: PaginationType.LIMIT_AND_OFFSET, PaginationType.TAKE_AND_SKIP, PaginationType.CURSOR 329 | * 330 | * However, using limit/offset can cause problems with relations. 331 | */ 332 | paginationType: PaginationType.LIMIT_AND_OFFSET, 333 | 334 | /** 335 | * Required: false 336 | * Type: boolean 337 | * Default: false 338 | * Description: Generate relative paths in the resource links. 339 | */ 340 | relativePath: true, 341 | 342 | /** 343 | * Required: false 344 | * Type: string 345 | * Description: Overrides the origin of absolute resource links if set. 346 | */ 347 | origin: 'http://cats.example', 348 | 349 | /** 350 | * Required: false 351 | * Type: boolean 352 | * Default: false 353 | * Description: Prevent `searchBy` query param from limiting search scope further. Search will depend upon `searchableColumns` config option only 354 | */ 355 | ignoreSearchByInQueryParam: true, 356 | 357 | /** 358 | * Required: false 359 | * Type: boolean 360 | * Default: false 361 | * Description: Prevent `select` query param from limiting selection further. Partial selection will depend upon `select` config option only 362 | */ 363 | ignoreSelectInQueryParam: true, 364 | 365 | /** 366 | * Required: false 367 | * Type: 'leftJoinAndSelect' | 'innerJoinAndSelect' 368 | * Default: 'leftJoinAndSelect' 369 | * Description: Relationships will be joined with either LEFT JOIN or INNER JOIN, and their columns selected. Can be specified per column with `joinMethods` configuration. 370 | */ 371 | defaultJoinMethod: 'leftJoinAndSelect', 372 | 373 | /** 374 | * Required: false 375 | * Type: MappedColumns 376 | * Default: false 377 | * Description: Overrides the join method per relationship. 378 | */ 379 | joinMethods: {age: 'innerJoinAndSelect', size: 'leftJoinAndSelect'}, 380 | 381 | /** 382 | * Required: false 383 | * Type: boolean 384 | * Default: false 385 | * Description: Enable multi-word search behavior. When true, each word in the search query 386 | * will be treated as a separate search term, allowing for more flexible matching. 387 | */ 388 | multiWordSearch: false, 389 | 390 | /** 391 | * Required: false 392 | * Type: (qb: SelectQueryBuilder) => SelectQueryBuilder 393 | * Default: undefined 394 | * Description: Callback that lets you override the COUNT query executed by 395 | * paginate(). The function receives a **clone** of the original QueryBuilder, 396 | * so it already contains every WHERE clause and parameter parsed by 397 | * nestjs-paginate. 398 | * 399 | * Typical use-case: remove expensive LEFT JOINs or build a lighter DISTINCT 400 | * count when getManyAndCount() becomes a bottleneck. 401 | * 402 | * Example: 403 | * ```ts 404 | * buildCountQuery: qb => { 405 | * qb.expressionMap.joinAttributes = []; // drop all joins 406 | * qb.select('p.id').distinct(true); // keep DISTINCT on primary key 407 | * return qb; // paginate() will call .getCount() 408 | * } 409 | * ``` 410 | */ 411 | buildCountQuery: (qb: SelectQueryBuilder) => SelectQueryBuilder, 412 | } 413 | ```` 414 | 415 | ## Usage with Query Builder 416 | 417 | You can paginate custom queries by passing on the query builder: 418 | 419 | ### Example 420 | 421 | ```typescript 422 | const queryBuilder = repo 423 | .createQueryBuilder('cats') 424 | .leftJoinAndSelect('cats.owner', 'owner') 425 | .where('cats.owner = :ownerId', { ownerId }) 426 | 427 | const result = await paginate(query, queryBuilder, config) 428 | ``` 429 | 430 | ## Usage with Relations 431 | 432 | Similar as with repositories, you can utilize `relations` as a simplified left-join form: 433 | 434 | ### Example 435 | 436 | #### Endpoint 437 | 438 | ```url 439 | http://localhost:3000/cats?filter.toys.name=$in:Mouse,String 440 | ``` 441 | 442 | #### Code 443 | 444 | ```typescript 445 | const config: PaginateConfig = { 446 | relations: ['toys'], 447 | sortableColumns: ['id', 'name', 'toys.name'], 448 | filterableColumns: { 449 | 'toys.name': [FilterOperator.IN], 450 | }, 451 | } 452 | 453 | const result = await paginate(query, catRepo, config) 454 | ``` 455 | 456 | **Note:** Embedded columns on relations have to be wrapped with brackets: 457 | 458 | ```typescript 459 | const config: PaginateConfig = { 460 | sortableColumns: ['id', 'name', 'toys.(size.height)', 'toys.(size.width)'], 461 | searchableColumns: ['name'], 462 | relations: ['toys'], 463 | } 464 | ``` 465 | 466 | ## Usage with Nested Relations 467 | 468 | Similar as with relations, you can specify nested relations for sorting, filtering and searching: 469 | 470 | ### Example 471 | 472 | #### Endpoint 473 | 474 | ```url 475 | http://localhost:3000/cats?filter.home.pillows.color=pink 476 | ``` 477 | 478 | #### Code 479 | 480 | ```typescript 481 | const config: PaginateConfig = { 482 | relations: { home: { pillows: true } }, 483 | sortableColumns: ['id', 'name', 'home.pillows.color'], 484 | searchableColumns: ['name', 'home.pillows.color'], 485 | filterableColumns: { 486 | 'home.pillows.color': [FilterOperator.EQ], 487 | }, 488 | } 489 | 490 | const result = await paginate(query, catRepo, config) 491 | ``` 492 | 493 | ## Usage with Eager Loading 494 | 495 | Eager loading should work with TypeORM's eager property out of the box: 496 | 497 | ### Example 498 | 499 | #### Code 500 | 501 | ```typescript 502 | @Entity() 503 | export class CatEntity { 504 | // ... 505 | 506 | @OneToMany(() => CatToyEntity, (catToy) => catToy.cat, { 507 | eager: true, 508 | }) 509 | toys: CatToyEntity[] 510 | } 511 | 512 | const config: PaginateConfig = { 513 | loadEagerRelations: true, 514 | sortableColumns: ['id', 'name', 'toys.name'], 515 | filterableColumns: { 516 | 'toys.name': [FilterOperator.IN], 517 | }, 518 | } 519 | 520 | const result = await paginate(query, catRepo, config) 521 | ``` 522 | 523 | ## Filters 524 | 525 | Filter operators must be whitelisted per column in `PaginateConfig`. 526 | 527 | ### Examples 528 | 529 | #### Code 530 | 531 | ```typescript 532 | const config: PaginateConfig = { 533 | // ... 534 | filterableColumns: { 535 | // Enable individual operators on a column 536 | id: [FilterOperator.EQ, FilterSuffix.NOT], 537 | 538 | // Enable all operators on a column 539 | age: true, 540 | }, 541 | } 542 | ``` 543 | 544 | `?filter.name=$eq:Milo` is equivalent with `?filter.name=Milo` 545 | 546 | `?filter.age=$btw:4,6` where column `age` is between `4` and `6` 547 | 548 | `?filter.id=$not:$in:2,5,7` where column `id` is **not** `2`, `5` or `7` 549 | 550 | `?filter.summary=$not:$ilike:term` where column `summary` does **not** contain `term` 551 | 552 | `?filter.summary=$sw:term` where column `summary` starts with `term` 553 | 554 | `?filter.seenAt=$null` where column `seenAt` is `NULL` 555 | 556 | `?filter.seenAt=$not:$null` where column `seenAt` is **not** `NULL` 557 | 558 | `?filter.createdAt=$btw:2022-02-02,2022-02-10` where column `createdAt` is between the dates `2022-02-02` and `2022-02-10` 559 | 560 | `?filter.createdAt=$lt:2022-12-20T10:00:00.000Z` where column `createdAt` is before iso date `2022-12-20T10:00:00.000Z` 561 | 562 | `?filter.roles=$contains:moderator` where column `roles` is an array and contains the value `moderator` 563 | 564 | `?filter.roles=$contains:moderator,admin` where column `roles` is an array and contains the values `moderator` and `admin` 565 | 566 | ## Jsonb Filters 567 | 568 | You can filter on jsonb columns by using the dot notation. Json columns is limited to `$eq` operators only. 569 | 570 | `?filter.metadata.enabled=$eq:true` where column `metadata` is jsonb and contains an object with the key `enabled`. 571 | 572 | ## Multi Filters 573 | 574 | Multi filters are filters that can be applied to a single column with a comparator. 575 | 576 | ### Examples 577 | 578 | `?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` 579 | 580 | `?filter.roles=$contains:moderator&filter.roles=$or:$contains:admin` where column `roles` is an array and contains `moderator` **or** `admin` 581 | 582 | `?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` 583 | 584 | **Note:** The `$and` comparators are not required. The above example is equivalent to: 585 | 586 | `?filter.id=$gt:3&filter.id=$lt:5&filter.id=$or:$eq:7` 587 | 588 | **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. 589 | 590 | `...&filter.id=5&filter.id=$or:7&filter.name=Milo&...` 591 | 592 | is resolved to: 593 | 594 | `WHERE ... AND (id = 5 OR id = 7) AND name = 'Milo' AND ...` 595 | 596 | ## Cursor-based Pagination 597 | 598 | - `paginationType: PaginationType.CURSOR` 599 | - Cursor format: 600 | - Numbers: `[prefix1][integer:11 digits][prefix2][decimal:4 digits]` (e.g., `Y00000000001V2500` for -1.25 in ASC). 601 | - Dates: `[prefix][value:15 digits]` (e.g., `V001671444000000` for a timestamp in DESC). 602 | - Prefixes: 603 | - `null`: `A` (lowest priority, last in results). 604 | - ASC: 605 | - positive-int: `V` (greater than or equal to 1), `X` (less than 1) 606 | - positive-decimal: `V` (not zero), `X` (zero) 607 | - zero-int: `X` 608 | - zero-decimal: `X` 609 | - negative-int: `Y` 610 | - negative-decimal: `V` 611 | - DESC: 612 | - positive-int: `V` 613 | - positive-decimal: `V` 614 | - zero-int: `N` 615 | - zero-decimal: `X` 616 | - negative-int: `M` (less than or equal to -1), `N` (greater than -1) 617 | - negative-decimal: `V` (not zero), `X` (zero) 618 | - Logic: 619 | - Numbers: Split into integer (11 digits) and decimal (4 digits) parts, with separate prefixes. Supports negative values, with sorting adjusted per direction. 620 | - Dates: Single prefix with 15-digit timestamp padded with zeros. 621 | - ASC: Negative → Zero → Positive → Null. 622 | - DESC: Positive → Zero → Negative → Null. 623 | - Notes: 624 | - 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. 625 | - Supported columns: Cursor sorting is available for numeric and date-related columns (string columns are not supported). 626 | - Decimal support: Numeric columns can include decimals, limited to 11 digits for the integer part and 4 digits for the decimal part. 627 | 628 | ## Swagger 629 | 630 | You can use two default decorators @ApiOkResponsePaginated and @ApiPagination to generate swagger documentation for your endpoints 631 | 632 | `@ApiOkPaginatedResponse` is for response body, return http[](https://) status is 200 633 | 634 | `@ApiPaginationQuery` is for query params 635 | 636 | ```typescript 637 | @Get() 638 | @ApiOkPaginatedResponse( 639 | UserDto, 640 | USER_PAGINATION_CONFIG, 641 | ) 642 | @ApiPaginationQuery(USER_PAGINATION_CONFIG) 643 | async findAll( 644 | @Paginate() 645 | query: PaginateQuery, 646 | ): Promise> { 647 | 648 | } 649 | ``` 650 | 651 | There is also some syntax sugar for this, and you can use only one decorator `@PaginatedSwaggerDocs` for both response body and query params 652 | 653 | ```typescript 654 | @Get() 655 | @PaginatedSwaggerDocs(UserDto, USER_PAGINATION_CONFIG) 656 | async findAll( 657 | @Paginate() 658 | query: PaginateQuery, 659 | ): Promise> { 660 | 661 | } 662 | ``` 663 | 664 | ## Troubleshooting 665 | 666 | The package does not report error reasons in the response bodies. They are instead 667 | reported as `debug` level [logging](https://docs.nestjs.com/techniques/logger#logger). 668 | 669 | Common errors include missing `sortableColumns` or `filterableColumns` (the latter only affects filtering). 670 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | 3 | dotenv.config({ path: '.env' }) 4 | -------------------------------------------------------------------------------- /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", 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 | "jest": "^29.7.0", 50 | "mysql2": "^3.14.0", 51 | "pg": "^8.14.0", 52 | "prettier": "^3.0.3", 53 | "reflect-metadata": "^0.2.2", 54 | "rxjs": "^7.8.2", 55 | "sqlite3": "^5.1.7", 56 | "ts-jest": "^29.2.6", 57 | "ts-node": "^10.9.2", 58 | "typeorm": "^0.3.17", 59 | "typescript": "^5.5.4" 60 | }, 61 | "dependencies": { 62 | "lodash": "^4.17.21" 63 | }, 64 | "peerDependencies": { 65 | "@nestjs/common": "^10.0.0 || ^11.0.0", 66 | "@nestjs/swagger": "^8.0.0 || ^11.0.0", 67 | "express": "^4.21.2 || ^5.0.0", 68 | "fastify": "^4.0.0 || ^5.0.0", 69 | "typeorm": "^0.3.17" 70 | }, 71 | "jest": { 72 | "moduleFileExtensions": [ 73 | "js", 74 | "json", 75 | "ts" 76 | ], 77 | "rootDir": "src", 78 | "testRegex": ".spec.ts$", 79 | "transform": { 80 | "^.+\\.(t|j)s$": "ts-jest" 81 | }, 82 | "coverageDirectory": "../coverage", 83 | "testEnvironment": "node", 84 | "setupFiles": [ 85 | "/../jest.setup.ts" 86 | ] 87 | }, 88 | "repository": { 89 | "type": "git", 90 | "url": "git+https://github.com/ppetzold/nestjs-paginate.git" 91 | }, 92 | "homepage": "https://github.com/ppetzold/nestjs-paginate#readme", 93 | "bugs": { 94 | "url": "https://github.com/ppetzold/nestjs-paginate/issues" 95 | }, 96 | "publishConfig": { 97 | "access": "public" 98 | }, 99 | "release": { 100 | "branches": [ 101 | "master" 102 | ] 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /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__/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-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 | -------------------------------------------------------------------------------- /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-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-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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__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/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 | path: 'http://localhost/items', 113 | }) 114 | }) 115 | 116 | it('should handle fastify undefined query fields', () => { 117 | const context = fastifyContextFactory({}) 118 | 119 | const result: PaginateQuery = decoratorfactory(null, context) 120 | 121 | expect(result).toStrictEqual({ 122 | page: undefined, 123 | limit: undefined, 124 | sortBy: undefined, 125 | search: undefined, 126 | searchBy: undefined, 127 | filter: undefined, 128 | select: undefined, 129 | cursor: undefined, 130 | path: 'http://localhost/items', 131 | }) 132 | }) 133 | 134 | it('should handle express defined query fields', () => { 135 | const context = expressContextFactory({ 136 | page: '1', 137 | limit: '20', 138 | sortBy: ['id:ASC', 'createdAt:DESC'], 139 | search: 'white', 140 | 'filter.name': '$not:$eq:Kitty', 141 | 'filter.createdAt': ['$gte:2020-01-01', '$lte:2020-12-31'], 142 | select: ['name', 'createdAt'], 143 | cursor: 'abc123', 144 | }) 145 | 146 | const result: PaginateQuery = decoratorfactory(null, context) 147 | 148 | expect(result).toStrictEqual({ 149 | page: 1, 150 | limit: 20, 151 | sortBy: [ 152 | ['id', 'ASC'], 153 | ['createdAt', 'DESC'], 154 | ], 155 | search: 'white', 156 | searchBy: undefined, 157 | select: ['name', 'createdAt'], 158 | path: 'http://localhost/items', 159 | filter: { 160 | name: '$not:$eq:Kitty', 161 | createdAt: ['$gte:2020-01-01', '$lte:2020-12-31'], 162 | }, 163 | cursor: 'abc123', 164 | }) 165 | }) 166 | 167 | it('should handle fastify defined query fields', () => { 168 | const context = fastifyContextFactory({ 169 | page: '1', 170 | limit: '20', 171 | sortBy: ['id:ASC', 'createdAt:DESC'], 172 | search: 'white', 173 | 'filter.name': '$not:$eq:Kitty', 174 | 'filter.createdAt': ['$gte:2020-01-01', '$lte:2020-12-31'], 175 | select: ['name', 'createdAt'], 176 | cursor: 'abc123', 177 | }) 178 | 179 | const result: PaginateQuery = decoratorfactory(null, context) 180 | 181 | expect(result).toStrictEqual({ 182 | page: 1, 183 | limit: 20, 184 | sortBy: [ 185 | ['id', 'ASC'], 186 | ['createdAt', 'DESC'], 187 | ], 188 | search: 'white', 189 | searchBy: undefined, 190 | path: 'http://localhost/items', 191 | filter: { 192 | name: '$not:$eq:Kitty', 193 | createdAt: ['$gte:2020-01-01', '$lte:2020-12-31'], 194 | }, 195 | select: ['name', 'createdAt'], 196 | cursor: 'abc123', 197 | }) 198 | }) 199 | }) 200 | -------------------------------------------------------------------------------- /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 | 6 | function isRecord(data: unknown): data is Record { 7 | return data !== null && typeof data === 'object' && !Array.isArray(data) 8 | } 9 | 10 | function isExpressRequest(request: unknown): request is ExpressRequest { 11 | return isRecord(request) && typeof request.get === 'function' 12 | } 13 | 14 | export interface PaginateQuery { 15 | page?: number 16 | limit?: number 17 | sortBy?: [string, string][] 18 | searchBy?: string[] 19 | search?: string 20 | filter?: { [column: string]: string | string[] } 21 | select?: string[] 22 | cursor?: string 23 | path: string 24 | } 25 | 26 | const singleSplit = (param: string, res: any[]) => res.push(param) 27 | 28 | const multipleSplit = (param: string, res: any[]) => { 29 | const items = param.split(':') 30 | if (items.length === 2) { 31 | res.push(items as [string, string]) 32 | } 33 | } 34 | 35 | const multipleAndCommaSplit = (param: string, res: any[]) => { 36 | const set = new Set(param.split(',')) 37 | set.forEach((item) => res.push(item)) 38 | } 39 | 40 | function parseParam(queryParam: unknown, parserLogic: (param: string, res: any[]) => void): T[] | undefined { 41 | const res = [] 42 | if (queryParam) { 43 | const params = !Array.isArray(queryParam) ? [queryParam] : queryParam 44 | for (const param of params) { 45 | if (isString(param)) { 46 | parserLogic(param, res) 47 | } 48 | } 49 | } 50 | return res.length ? res : undefined 51 | } 52 | 53 | export const Paginate = createParamDecorator((_data: unknown, ctx: ExecutionContext): PaginateQuery => { 54 | let path: string 55 | let query: Record 56 | 57 | switch (ctx.getType()) { 58 | case 'http': 59 | const request: ExpressRequest | FastifyRequest = ctx.switchToHttp().getRequest() 60 | query = request.query as Record 61 | 62 | // Determine if Express or Fastify to rebuild the original url and reduce down to protocol, host and base url 63 | let originalUrl: string 64 | if (isExpressRequest(request)) { 65 | originalUrl = request.protocol + '://' + request.get('host') + request.originalUrl 66 | } else { 67 | originalUrl = request.protocol + '://' + request.hostname + request.url 68 | } 69 | 70 | const urlParts = new URL(originalUrl) 71 | path = urlParts.protocol + '//' + urlParts.host + urlParts.pathname 72 | break 73 | case 'ws': 74 | query = ctx.switchToWs().getData() 75 | path = null 76 | break 77 | case 'rpc': 78 | query = ctx.switchToRpc().getData() 79 | path = null 80 | break 81 | } 82 | 83 | const searchBy = parseParam(query.searchBy, singleSplit) 84 | const sortBy = parseParam<[string, string]>(query.sortBy, multipleSplit) 85 | const select = parseParam(query.select, multipleAndCommaSplit) 86 | 87 | const filter = mapKeys( 88 | pickBy( 89 | query, 90 | (param, name) => 91 | name.includes('filter.') && 92 | (isString(param) || (Array.isArray(param) && (param as any[]).every((p) => isString(p)))) 93 | ) as Dictionary, 94 | (_param, name) => name.replace('filter.', '') 95 | ) 96 | 97 | return { 98 | page: query.page ? parseInt(query.page.toString(), 10) : undefined, 99 | limit: query.limit ? parseInt(query.limit.toString(), 10) : undefined, 100 | sortBy, 101 | search: query.search ? query.search.toString() : undefined, 102 | searchBy, 103 | filter: Object.keys(filter).length ? filter : undefined, 104 | select, 105 | cursor: query.cursor ? query.cursor.toString() : undefined, 106 | path, 107 | } 108 | }) 109 | -------------------------------------------------------------------------------- /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 || 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/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 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './decorator' 2 | export * from './paginate' 3 | export * from './swagger' 4 | -------------------------------------------------------------------------------- /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 | isRepository, 32 | JoinMethod, 33 | MappedColumns, 34 | mergeRelationSchema, 35 | Order, 36 | positiveNumberOrDefault, 37 | RelationSchema, 38 | RelationSchemaInput, 39 | SortBy, 40 | } from './helper' 41 | 42 | const logger: Logger = new Logger('nestjs-paginate') 43 | 44 | export { FilterOperator, FilterSuffix } 45 | 46 | export class Paginated { 47 | data: T[] 48 | meta: { 49 | itemsPerPage: number 50 | totalItems?: number 51 | currentPage?: number 52 | totalPages?: number 53 | sortBy: SortBy 54 | searchBy: Column[] 55 | search: string 56 | select: string[] 57 | filter?: { 58 | [column: string]: string | string[] 59 | } 60 | cursor?: string 61 | } 62 | links: { 63 | first?: string 64 | previous?: string 65 | current: string 66 | next?: string 67 | last?: string 68 | } 69 | } 70 | 71 | export enum PaginationType { 72 | LIMIT_AND_OFFSET = 'limit', 73 | TAKE_AND_SKIP = 'take', 74 | CURSOR = 'cursor', 75 | } 76 | 77 | // We use (string & {}) to maintain autocomplete while allowing any string 78 | // see https://github.com/microsoft/TypeScript/issues/29729 79 | export interface PaginateConfig { 80 | relations?: RelationSchemaInput 81 | sortableColumns: Column[] 82 | nullSort?: 'first' | 'last' 83 | searchableColumns?: Column[] 84 | // eslint-disable-next-line @typescript-eslint/ban-types 85 | select?: (Column | (string & {}))[] 86 | maxLimit?: number 87 | defaultSortBy?: SortBy 88 | defaultLimit?: number 89 | where?: FindOptionsWhere | FindOptionsWhere[] 90 | filterableColumns?: Partial> 91 | loadEagerRelations?: boolean 92 | withDeleted?: boolean 93 | paginationType?: PaginationType 94 | relativePath?: boolean 95 | origin?: string 96 | ignoreSearchByInQueryParam?: boolean 97 | ignoreSelectInQueryParam?: boolean 98 | multiWordSearch?: boolean 99 | defaultJoinMethod?: JoinMethod 100 | joinMethods?: Partial> 101 | buildCountQuery?: (qb: SelectQueryBuilder) => SelectQueryBuilder 102 | } 103 | 104 | export enum PaginationLimit { 105 | NO_PAGINATION = -1, 106 | COUNTER_ONLY = 0, 107 | DEFAULT_LIMIT = 20, 108 | DEFAULT_MAX_LIMIT = 100, 109 | } 110 | 111 | function generateWhereStatement( 112 | queryBuilder: SelectQueryBuilder, 113 | obj: FindOptionsWhere | FindOptionsWhere[] 114 | ) { 115 | const toTransform = Array.isArray(obj) ? obj : [obj] 116 | return toTransform.map((item) => flattenWhereAndTransform(queryBuilder, item).join(' AND ')).join(' OR ') 117 | } 118 | 119 | function flattenWhereAndTransform( 120 | queryBuilder: SelectQueryBuilder, 121 | obj: FindOptionsWhere, 122 | separator = '.', 123 | parentKey = '' 124 | ) { 125 | return Object.entries(obj).flatMap(([key, value]) => { 126 | if (obj.hasOwnProperty(key)) { 127 | const joinedKey = parentKey ? `${parentKey}${separator}${key}` : key 128 | 129 | if (typeof value === 'object' && value !== null && !isFindOperator(value)) { 130 | return flattenWhereAndTransform(queryBuilder, value as FindOptionsWhere, separator, joinedKey) 131 | } else { 132 | const property = getPropertiesByColumnName(joinedKey) 133 | const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(queryBuilder, property) 134 | const isRelation = checkIsRelation(queryBuilder, property.propertyPath) 135 | const isEmbedded = checkIsEmbedded(queryBuilder, property.propertyPath) 136 | const alias = fixColumnAlias( 137 | property, 138 | queryBuilder.alias, 139 | isRelation, 140 | isVirtualProperty, 141 | isEmbedded, 142 | virtualQuery 143 | ) 144 | const whereClause = queryBuilder['createWhereConditionExpression']( 145 | queryBuilder['getWherePredicateCondition'](alias, value) 146 | ) 147 | 148 | const allJoinedTables = queryBuilder.expressionMap.joinAttributes.reduce( 149 | (acc, attr) => { 150 | acc[attr.alias.name] = true 151 | return acc 152 | }, 153 | {} as Record 154 | ) 155 | 156 | const allTablesInPath = property.column.split('.').slice(0, -1) 157 | const tablesToJoin = allTablesInPath.map((table, idx) => { 158 | if (idx === 0) { 159 | return table 160 | } 161 | return [...allTablesInPath.slice(0, idx), table].join('.') 162 | }) 163 | 164 | tablesToJoin.forEach((table) => { 165 | const pathSplit = table.split('.') 166 | const fullPath = 167 | pathSplit.length === 1 168 | ? '' 169 | : `_${pathSplit 170 | .slice(0, -1) 171 | .map((p) => p + '_rel') 172 | .join('_')}` 173 | const tableName = pathSplit[pathSplit.length - 1] 174 | const tableAliasWithProperty = `${queryBuilder.alias}${fullPath}.${tableName}` 175 | const joinTableAlias = `${queryBuilder.alias}${fullPath}_${tableName}_rel` 176 | 177 | const baseTableAlias = allJoinedTables[joinTableAlias] 178 | 179 | if (baseTableAlias) { 180 | return 181 | } else { 182 | queryBuilder.leftJoin(tableAliasWithProperty, joinTableAlias) 183 | } 184 | }) 185 | 186 | return whereClause 187 | } 188 | } 189 | }) 190 | } 191 | 192 | function fixCursorValue(value: any): any { 193 | if (isISODate(value)) { 194 | return new Date(value) 195 | } 196 | return value 197 | } 198 | 199 | export async function paginate( 200 | query: PaginateQuery, 201 | repo: Repository | SelectQueryBuilder, 202 | config: PaginateConfig 203 | ): Promise> { 204 | const dbType = (isRepository(repo) ? repo.manager : repo).connection.options.type 205 | const isMySqlOrMariaDb = ['mysql', 'mariadb'].includes(dbType) 206 | const metadata = isRepository(repo) ? repo.metadata : repo.expressionMap.mainAlias.metadata 207 | 208 | const page = positiveNumberOrDefault(query.page, 1, 1) 209 | 210 | const defaultLimit = config.defaultLimit || PaginationLimit.DEFAULT_LIMIT 211 | const maxLimit = config.maxLimit || PaginationLimit.DEFAULT_MAX_LIMIT 212 | 213 | const isPaginated = !( 214 | query.limit === PaginationLimit.COUNTER_ONLY || 215 | (query.limit === PaginationLimit.NO_PAGINATION && maxLimit === PaginationLimit.NO_PAGINATION) 216 | ) 217 | 218 | const limit = 219 | query.limit === PaginationLimit.COUNTER_ONLY 220 | ? PaginationLimit.COUNTER_ONLY 221 | : isPaginated === true 222 | ? maxLimit === PaginationLimit.NO_PAGINATION 223 | ? query.limit ?? defaultLimit 224 | : query.limit === PaginationLimit.NO_PAGINATION 225 | ? defaultLimit 226 | : Math.min(query.limit ?? defaultLimit, maxLimit) 227 | : defaultLimit 228 | 229 | const generateNullCursor = (): string => { 230 | return 'A' + '0'.repeat(15) // null values ​​should be looked up last, so use the smallest prefix 231 | } 232 | 233 | const generateDateCursor = (value: number, direction: 'ASC' | 'DESC'): string => { 234 | if (direction === 'ASC' && value === 0) { 235 | return 'X' + '0'.repeat(15) 236 | } 237 | 238 | const finalValue = direction === 'ASC' ? Math.pow(10, 15) - value : value 239 | 240 | return 'V' + String(finalValue).padStart(15, '0') 241 | } 242 | 243 | const generateNumberCursor = (value: number, direction: 'ASC' | 'DESC'): string => { 244 | const integerLength = 11 245 | const decimalLength = 4 // sorting is not possible if the decimal point exceeds 4 digits 246 | const maxIntegerDigit = Math.pow(10, integerLength) 247 | const fixedScale = Math.pow(10, decimalLength) 248 | const absValue = Math.abs(value) 249 | const scaledValue = Math.round(absValue * fixedScale) 250 | const integerPart = Math.floor(scaledValue / fixedScale) 251 | const decimalPart = scaledValue % fixedScale 252 | 253 | let integerPrefix: string 254 | let decimalPrefix: string 255 | let finalInteger: number 256 | let finalDecimal: number 257 | 258 | if (direction === 'ASC') { 259 | if (value < 0) { 260 | integerPrefix = 'Y' 261 | decimalPrefix = 'V' 262 | finalInteger = integerPart 263 | finalDecimal = decimalPart 264 | } else if (value === 0) { 265 | integerPrefix = 'X' 266 | decimalPrefix = 'X' 267 | finalInteger = 0 268 | finalDecimal = 0 269 | } else { 270 | integerPrefix = integerPart === 0 ? 'X' : 'V' // X > V 271 | decimalPrefix = decimalPart === 0 ? 'X' : 'V' // X > V 272 | finalInteger = integerPart === 0 ? 0 : maxIntegerDigit - integerPart 273 | finalDecimal = decimalPart === 0 ? 0 : fixedScale - decimalPart 274 | } 275 | } else { 276 | // DESC 277 | if (value < 0) { 278 | integerPrefix = integerPart === 0 ? 'N' : 'M' // N > M 279 | decimalPrefix = decimalPart === 0 ? 'X' : 'V' // X > V 280 | finalInteger = integerPart === 0 ? 0 : maxIntegerDigit - integerPart 281 | finalDecimal = decimalPart === 0 ? 0 : fixedScale - decimalPart 282 | } else if (value === 0) { 283 | integerPrefix = 'N' 284 | decimalPrefix = 'X' 285 | finalInteger = 0 286 | finalDecimal = 0 287 | } else { 288 | integerPrefix = 'V' 289 | decimalPrefix = 'V' 290 | finalInteger = integerPart 291 | finalDecimal = decimalPart 292 | } 293 | } 294 | 295 | return ( 296 | integerPrefix + 297 | String(finalInteger).padStart(integerLength, '0') + 298 | decimalPrefix + 299 | String(finalDecimal).padStart(decimalLength, '0') 300 | ) 301 | } 302 | 303 | const generateCursor = (item: T, sortBy: SortBy, linkType: 'previous' | 'next' = 'next'): string => { 304 | return sortBy 305 | .map(([column, direction]) => { 306 | const columnProperties = getPropertiesByColumnName(String(column)) 307 | 308 | let propertyPath = [] 309 | if (columnProperties.isNested) { 310 | if (columnProperties.propertyPath) { 311 | propertyPath.push(columnProperties.propertyPath) 312 | } 313 | propertyPath = propertyPath.concat(columnProperties.propertyName.split('.')) 314 | } else if (columnProperties.propertyPath) { 315 | propertyPath = [columnProperties.propertyPath, columnProperties.propertyName] 316 | } else { 317 | propertyPath = [columnProperties.propertyName] 318 | } 319 | 320 | // Extract value from nested object 321 | let value = item 322 | for (let i = 0; i < propertyPath.length; i++) { 323 | const key = propertyPath[i] 324 | 325 | if (value === null || value === undefined) { 326 | value = null 327 | break 328 | } 329 | 330 | // Handle case where value is an array 331 | if (Array.isArray(value[key])) { 332 | const arrayValues = value[key] 333 | .map((item: any) => { 334 | let nestedValue = item 335 | for (let j = i + 1; j < propertyPath.length; j++) { 336 | if (nestedValue === null || nestedValue === undefined) { 337 | return null 338 | } 339 | 340 | // Handle embedded properties 341 | if (propertyPath[j].includes('.')) { 342 | const nestedProperties = propertyPath[j].split('.') 343 | for (const nestedProperty of nestedProperties) { 344 | nestedValue = nestedValue[nestedProperty] 345 | } 346 | } else { 347 | nestedValue = nestedValue[propertyPath[j]] 348 | } 349 | } 350 | return nestedValue 351 | }) 352 | .filter((v: any) => v !== null && v !== undefined) 353 | 354 | if (arrayValues.length === 0) { 355 | value = null 356 | } else { 357 | // Select min or max value based on sort direction and linkType (XOR) 358 | value = ( 359 | (direction === 'ASC') !== (linkType === 'previous') 360 | ? Math.min(...arrayValues) 361 | : Math.max(...arrayValues) 362 | ) as any 363 | } 364 | break 365 | } else { 366 | value = value[key] 367 | } 368 | } 369 | 370 | value = fixCursorValue(value) 371 | 372 | // Find column metadata 373 | let columnMeta = null 374 | if (propertyPath.length === 1) { 375 | // For regular column 376 | columnMeta = metadata.columns.find((col) => col.propertyName === columnProperties.propertyName) 377 | } else { 378 | // For relation column 379 | let currentMetadata = metadata 380 | let currentPath = '' 381 | 382 | // Traverse the relation path except for the last part 383 | for (let i = 0; i < propertyPath.length - 1; i++) { 384 | const relationName = propertyPath[i] 385 | currentPath = currentPath ? `${currentPath}.${relationName}` : relationName 386 | const relation = currentMetadata.findRelationWithPropertyPath(relationName) 387 | 388 | if (relation) { 389 | currentMetadata = relation.inverseEntityMetadata 390 | } else { 391 | break 392 | } 393 | } 394 | 395 | // Find column by the last property name 396 | const propertyName = propertyPath[propertyPath.length - 1] 397 | columnMeta = currentMetadata.columns.find((col) => col.propertyName === propertyName) 398 | } 399 | 400 | const isDateColumn = columnMeta && isDateColumnType(columnMeta.type) 401 | 402 | if (value === null || value === undefined) { 403 | return generateNullCursor() 404 | } 405 | 406 | if (isDateColumn) { 407 | return generateDateCursor(value.getTime(), direction) 408 | } else { 409 | const numericValue = Number(value) 410 | return generateNumberCursor(numericValue, direction) 411 | } 412 | }) 413 | .join('') 414 | } 415 | 416 | const getDateColumnExpression = (alias: string, dbType: string): string => { 417 | switch (dbType) { 418 | case 'mysql': 419 | case 'mariadb': 420 | return `UNIX_TIMESTAMP(${alias}) * 1000` 421 | case 'postgres': 422 | return `EXTRACT(EPOCH FROM ${alias}) * 1000` 423 | case 'sqlite': 424 | return `(STRFTIME('%s', ${alias}) + (STRFTIME('%f', ${alias}) - STRFTIME('%S', ${alias}))) * 1000` 425 | default: 426 | return alias 427 | } 428 | } 429 | 430 | const logAndThrowException = (msg: string) => { 431 | logger.debug(msg) 432 | throw new ServiceUnavailableException(msg) 433 | } 434 | 435 | if (config.sortableColumns.length < 1) { 436 | logAndThrowException("Missing required 'sortableColumns' config.") 437 | } 438 | 439 | const sortBy = [] as SortBy 440 | 441 | if (query.sortBy) { 442 | for (const order of query.sortBy) { 443 | if (isEntityKey(config.sortableColumns, order[0]) && ['ASC', 'DESC'].includes(order[1])) { 444 | sortBy.push(order as Order) 445 | } 446 | } 447 | } 448 | 449 | if (!sortBy.length) { 450 | sortBy.push(...(config.defaultSortBy || [[config.sortableColumns[0], 'ASC']])) 451 | } 452 | 453 | const searchBy: Column[] = [] 454 | 455 | let [items, totalItems]: [T[], number] = [[], 0] 456 | 457 | const queryBuilder = isRepository(repo) ? repo.createQueryBuilder('__root') : repo 458 | 459 | if (isRepository(repo) && !config.relations && config.loadEagerRelations === true) { 460 | if (!config.relations) { 461 | FindOptionsUtils.joinEagerRelations(queryBuilder, queryBuilder.alias, repo.metadata) 462 | } 463 | } 464 | 465 | if (isPaginated) { 466 | config.paginationType = config.paginationType || PaginationType.TAKE_AND_SKIP 467 | 468 | // Allow user to choose between limit/offset and take/skip, or cursor-based pagination. 469 | // However, using limit/offset can cause problems when joining one-to-many etc. 470 | if (config.paginationType === PaginationType.LIMIT_AND_OFFSET) { 471 | queryBuilder.limit(limit).offset((page - 1) * limit) 472 | } else if (config.paginationType === PaginationType.TAKE_AND_SKIP) { 473 | queryBuilder.take(limit).skip((page - 1) * limit) 474 | } else if (config.paginationType === PaginationType.CURSOR) { 475 | queryBuilder.take(limit) 476 | const padLength = 15 477 | const integerLength = 11 478 | const decimalLength = 4 479 | const fixedScale = Math.pow(10, 4) 480 | const maxIntegerDigit = Math.pow(10, 11) 481 | 482 | const concat = (parts: string[]): string => 483 | isMySqlOrMariaDb ? `CONCAT(${parts.join(', ')})` : parts.join(' || ') 484 | 485 | const generateNullCursorExpr = (): string => { 486 | const zeroPaddedExpr = getPaddedExpr('0', padLength, dbType) 487 | const prefix = 'A' 488 | 489 | return isMySqlOrMariaDb ? `CONCAT('${prefix}', ${zeroPaddedExpr})` : `'${prefix}' || ${zeroPaddedExpr}` 490 | } 491 | 492 | const generateDateCursorExpr = (columnExpr: string, direction: 'ASC' | 'DESC'): string => { 493 | const safeExpr = `COALESCE(${columnExpr}, 0)` 494 | const sqlExpr = direction === 'ASC' ? `POW(10, ${padLength}) - ${safeExpr}` : safeExpr 495 | 496 | const paddedExpr = getPaddedExpr(sqlExpr, padLength, dbType) 497 | const zeroPaddedExpr = getPaddedExpr('0', padLength, dbType) 498 | 499 | const prefixNull = "'A'" 500 | const prefixValue = "'V'" 501 | const prefixZero = "'X'" 502 | 503 | if (direction === 'ASC') { 504 | return `CASE 505 | WHEN ${columnExpr} IS NULL THEN ${concat([prefixNull, zeroPaddedExpr])} 506 | WHEN ${columnExpr} = 0 THEN ${concat([prefixZero, zeroPaddedExpr])} 507 | ELSE ${concat([prefixValue, paddedExpr])} 508 | END` 509 | } else { 510 | return `CASE 511 | WHEN ${columnExpr} IS NULL THEN ${concat([prefixNull, zeroPaddedExpr])} 512 | ELSE ${concat([prefixValue, paddedExpr])} 513 | END` 514 | } 515 | } 516 | 517 | const generateNumberCursorExpr = (columnExpr: string, direction: 'ASC' | 'DESC'): string => { 518 | const safeExpr = `COALESCE(${columnExpr}, 0)` 519 | const absSafeExpr = `ABS(${safeExpr})` 520 | const scaledExpr = `ROUND(${absSafeExpr} * ${fixedScale}, 0)` 521 | const intExpr = `FLOOR(${scaledExpr} / ${fixedScale})` 522 | const decExpr = `(${scaledExpr} % ${fixedScale})` 523 | const reversedIntExpr = `(${maxIntegerDigit} - ${intExpr})` 524 | const reversedDecExpr = `(${fixedScale} - ${decExpr})` 525 | 526 | const paddedIntExpr = getPaddedExpr(intExpr, integerLength, dbType) 527 | const paddedDecExpr = getPaddedExpr(decExpr, decimalLength, dbType) 528 | const reversedIntPaddedExpr = getPaddedExpr(reversedIntExpr, integerLength, dbType) 529 | const reversedDecPaddedExpr = getPaddedExpr(reversedDecExpr, decimalLength, dbType) 530 | const zeroPaddedIntExpr = getPaddedExpr('0', integerLength, dbType) 531 | const zeroPaddedDecExpr = getPaddedExpr('0', decimalLength, dbType) 532 | 533 | if (direction === 'ASC') { 534 | return `CASE 535 | WHEN ${columnExpr} IS NULL THEN ${generateNullCursorExpr()} 536 | WHEN ${columnExpr} < 0 THEN ${concat(["'Y'", paddedIntExpr, "'V'", paddedDecExpr])} 537 | WHEN ${columnExpr} = 0 THEN ${concat(["'X'", zeroPaddedIntExpr, "'X'", zeroPaddedDecExpr])} 538 | WHEN ${columnExpr} > 0 AND ${intExpr} = 0 AND ${decExpr} > 0 THEN ${concat([ 539 | "'X'", 540 | zeroPaddedIntExpr, 541 | "'V'", 542 | reversedDecPaddedExpr, 543 | ])} 544 | WHEN ${columnExpr} > 0 AND ${intExpr} > 0 AND ${decExpr} = 0 THEN ${concat([ 545 | "'V'", 546 | reversedIntPaddedExpr, 547 | "'X'", 548 | zeroPaddedDecExpr, 549 | ])} 550 | WHEN ${columnExpr} > 0 AND ${intExpr} > 0 AND ${decExpr} > 0 THEN ${concat([ 551 | "'V'", 552 | reversedIntPaddedExpr, 553 | "'V'", 554 | reversedDecPaddedExpr, 555 | ])} 556 | END` 557 | } else { 558 | return `CASE 559 | WHEN ${columnExpr} IS NULL THEN ${generateNullCursorExpr()} 560 | WHEN ${columnExpr} < 0 AND ${intExpr} > 0 AND ${decExpr} > 0 THEN ${concat([ 561 | "'M'", 562 | reversedIntPaddedExpr, 563 | "'V'", 564 | reversedDecPaddedExpr, 565 | ])} 566 | WHEN ${columnExpr} < 0 AND ${intExpr} > 0 AND ${decExpr} = 0 THEN ${concat([ 567 | "'M'", 568 | reversedIntPaddedExpr, 569 | "'X'", 570 | zeroPaddedDecExpr, 571 | ])} 572 | WHEN ${columnExpr} < 0 AND ${intExpr} = 0 AND ${decExpr} > 0 THEN ${concat([ 573 | "'N'", 574 | zeroPaddedIntExpr, 575 | "'V'", 576 | reversedDecPaddedExpr, 577 | ])} 578 | WHEN ${columnExpr} = 0 THEN ${concat(["'N'", zeroPaddedIntExpr, "'X'", zeroPaddedDecExpr])} 579 | WHEN ${columnExpr} > 0 THEN ${concat(["'V'", paddedIntExpr, "'V'", paddedDecExpr])} 580 | END` 581 | } 582 | } 583 | 584 | const cursorExpressions = sortBy.map(([column, direction]) => { 585 | const columnProperties = getPropertiesByColumnName(column) 586 | const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty( 587 | queryBuilder, 588 | columnProperties 589 | ) 590 | const isRelation = checkIsRelation(queryBuilder, columnProperties.propertyPath) 591 | const isEmbedded = checkIsEmbedded(queryBuilder, columnProperties.propertyPath) 592 | const alias = fixColumnAlias( 593 | columnProperties, 594 | queryBuilder.alias, 595 | isRelation, 596 | isVirtualProperty, 597 | isEmbedded, 598 | virtualQuery 599 | ) 600 | 601 | // Find column metadata to determine type for proper cursor handling 602 | let columnMeta = metadata.columns.find((col) => col.propertyName === columnProperties.propertyName) 603 | 604 | // If it's a relation column, we need to find the target column metadata 605 | if (isRelation) { 606 | // Find the relation by path and get the target entity metadata 607 | const relationPath = columnProperties.column.split('.') 608 | // The base entity is the starting point 609 | let currentMetadata = metadata 610 | 611 | // Traverse the relation path to find the target metadata 612 | for (let i = 0; i < relationPath.length - 1; i++) { 613 | const relationName = relationPath[i] 614 | const relation = currentMetadata.findRelationWithPropertyPath(relationName) 615 | 616 | if (relation) { 617 | // Update the metadata to the target entity metadata for the next iteration 618 | currentMetadata = relation.inverseEntityMetadata 619 | } else { 620 | break 621 | } 622 | } 623 | 624 | // Now get the property from the target entity 625 | const propertyName = relationPath[relationPath.length - 1] 626 | columnMeta = currentMetadata.columns.find((col) => col.propertyName === propertyName) 627 | } 628 | 629 | // Determine whether it's a date column 630 | const isDateColumn = columnMeta && isDateColumnType(columnMeta.type) 631 | const columnExpr = isDateColumn ? getDateColumnExpression(alias, dbType) : alias 632 | 633 | return isDateColumn 634 | ? generateDateCursorExpr(columnExpr, direction) 635 | : generateNumberCursorExpr(columnExpr, direction) 636 | }) 637 | 638 | const cursorExpression = 639 | cursorExpressions.length > 1 640 | ? isMySqlOrMariaDb 641 | ? `CONCAT(${cursorExpressions.join(', ')})` 642 | : cursorExpressions.join(' || ') 643 | : cursorExpressions[0] 644 | queryBuilder.addSelect(cursorExpression, 'cursor') 645 | 646 | if (query.cursor) { 647 | queryBuilder.andWhere(`${cursorExpression} < :cursor`, { cursor: query.cursor }) 648 | } 649 | 650 | 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 651 | } 652 | } 653 | 654 | if (config.withDeleted) { 655 | queryBuilder.withDeleted() 656 | } 657 | 658 | let filterJoinMethods = {} 659 | if (query.filter) { 660 | filterJoinMethods = addFilter(queryBuilder, query, config.filterableColumns) 661 | } 662 | const joinMethods = { ...filterJoinMethods, ...config.joinMethods } 663 | 664 | // Add the relations specified by the config, or used in the currently 665 | // filtered filterable columns. 666 | if (config.relations || Object.keys(filterJoinMethods).length) { 667 | const relationsSchema = mergeRelationSchema( 668 | createRelationSchema(config.relations), 669 | createRelationSchema(Object.keys(joinMethods)) 670 | ) 671 | addRelationsFromSchema(queryBuilder, relationsSchema, config, joinMethods) 672 | } 673 | 674 | if (config.paginationType !== PaginationType.CURSOR) { 675 | let nullSort: string | undefined 676 | if (config.nullSort) { 677 | if (isMySqlOrMariaDb) { 678 | nullSort = config.nullSort === 'last' ? 'IS NULL' : 'IS NOT NULL' 679 | } else { 680 | nullSort = config.nullSort === 'last' ? 'NULLS LAST' : 'NULLS FIRST' 681 | } 682 | } 683 | 684 | for (const order of sortBy) { 685 | const columnProperties = getPropertiesByColumnName(order[0]) 686 | const { isVirtualProperty } = extractVirtualProperty(queryBuilder, columnProperties) 687 | const isRelation = checkIsRelation(queryBuilder, columnProperties.propertyPath) 688 | const isEmbedded = checkIsEmbedded(queryBuilder, columnProperties.propertyPath) 689 | let alias = fixColumnAlias(columnProperties, queryBuilder.alias, isRelation, isVirtualProperty, isEmbedded) 690 | 691 | if (isMySqlOrMariaDb) { 692 | if (isVirtualProperty) { 693 | alias = `\`${alias}\`` 694 | } 695 | if (nullSort) { 696 | queryBuilder.addOrderBy(`${alias} ${nullSort}`) 697 | } 698 | queryBuilder.addOrderBy(alias, order[1]) 699 | } else { 700 | if (isVirtualProperty) { 701 | alias = `"${alias}"` 702 | } 703 | queryBuilder.addOrderBy(alias, order[1], nullSort as 'NULLS FIRST' | 'NULLS LAST' | undefined) 704 | } 705 | } 706 | } 707 | 708 | /** 709 | * Expands select parameters containing wildcards (*) into actual column lists 710 | * 711 | * @returns Array of expanded column names 712 | */ 713 | const expandWildcardSelect = (selectParams: string[], queryBuilder: SelectQueryBuilder): string[] => { 714 | const expandedParams: string[] = [] 715 | 716 | const mainAlias = queryBuilder.expressionMap.mainAlias 717 | const mainMetadata = mainAlias.metadata 718 | 719 | /** 720 | * Internal function to expand wildcards 721 | * 722 | * @returns Array of expanded column names 723 | */ 724 | const _expandWidcard = (entityPath: string, metadata: EntityMetadata): string[] => { 725 | const expanded: string[] = [] 726 | 727 | // Add all columns from the relation entity 728 | expanded.push( 729 | ...metadata.columns 730 | .filter( 731 | (col) => 732 | !metadata.embeddeds 733 | .map((embedded) => embedded.columns.map((embeddedCol) => embeddedCol.propertyName)) 734 | .flat() 735 | .includes(col.propertyName) 736 | ) 737 | .map((col) => (entityPath ? `${entityPath}.${col.propertyName}` : col.propertyName)) 738 | ) 739 | 740 | // Add columns from embedded entities in the relation 741 | metadata.embeddeds.forEach((embedded) => { 742 | expanded.push( 743 | ...embedded.columns.map((col) => `${entityPath}.(${embedded.propertyName}.${col.propertyName})`) 744 | ) 745 | }) 746 | 747 | return expanded 748 | } 749 | 750 | for (const param of selectParams) { 751 | if (param === '*') { 752 | expandedParams.push(..._expandWidcard('', mainMetadata)) 753 | } else if (param.endsWith('.*')) { 754 | // Handle relation entity wildcards (e.g. 'user.*', 'user.profile.*') 755 | const parts = param.slice(0, -2).split('.') 756 | let currentPath = '' 757 | let currentMetadata = mainMetadata 758 | 759 | for (let i = 0; i < parts.length; i++) { 760 | const part = parts[i] 761 | currentPath = currentPath ? `${currentPath}.${part}` : part 762 | const relation = currentMetadata.findRelationWithPropertyPath(part) 763 | 764 | if (relation) { 765 | currentMetadata = relation.inverseEntityMetadata 766 | if (i === parts.length - 1) { 767 | // Expand wildcard at the last part 768 | expandedParams.push(..._expandWidcard(currentPath, currentMetadata)) 769 | } 770 | } else { 771 | break 772 | } 773 | } 774 | } else { 775 | // Add regular columns as is 776 | expandedParams.push(param) 777 | } 778 | } 779 | 780 | // Remove duplicates while preserving order 781 | return [...new Set(expandedParams)] 782 | } 783 | 784 | const selectParams = (() => { 785 | // Expand wildcards in config.select if it exists 786 | const expandedConfigSelect = config.select ? expandWildcardSelect(config.select, queryBuilder) : undefined 787 | 788 | // Expand wildcards in query.select if it exists 789 | const expandedQuerySelect = query.select ? expandWildcardSelect(query.select, queryBuilder) : undefined 790 | 791 | // Filter config.select with expanded query.select if both exist and ignoreSelectInQueryParam is false 792 | if (expandedConfigSelect && expandedQuerySelect && !config.ignoreSelectInQueryParam) { 793 | return expandedConfigSelect.filter((column) => expandedQuerySelect.includes(column)) 794 | } 795 | 796 | return expandedConfigSelect 797 | })() 798 | 799 | if (selectParams?.length > 0) { 800 | let cols: string[] = selectParams.reduce((cols, currentCol) => { 801 | const columnProperties = getPropertiesByColumnName(currentCol) 802 | const isRelation = checkIsRelation(queryBuilder, columnProperties.propertyPath) 803 | cols.push(fixColumnAlias(columnProperties, queryBuilder.alias, isRelation)) 804 | return cols 805 | }, []) 806 | 807 | const missingPrimaryKeys = getMissingPrimaryKeyColumns(queryBuilder, cols) 808 | if (missingPrimaryKeys.length > 0) { 809 | cols = cols.concat(missingPrimaryKeys) 810 | } 811 | 812 | queryBuilder.select(cols) 813 | } 814 | 815 | if (config.where && isRepository(repo)) { 816 | const baseWhereStr = generateWhereStatement(queryBuilder, config.where) 817 | queryBuilder.andWhere(`(${baseWhereStr})`) 818 | } 819 | 820 | if (config.searchableColumns) { 821 | if (query.searchBy && !config.ignoreSearchByInQueryParam) { 822 | for (const column of query.searchBy) { 823 | if (isEntityKey(config.searchableColumns, column)) { 824 | searchBy.push(column) 825 | } 826 | } 827 | } else { 828 | searchBy.push(...config.searchableColumns) 829 | } 830 | } 831 | 832 | if (query.search && searchBy.length) { 833 | queryBuilder.andWhere( 834 | new Brackets((qb: SelectQueryBuilder) => { 835 | // Explicitly handle the default case - multiWordSearch defaults to false 836 | const useMultiWordSearch = config.multiWordSearch ?? false 837 | if (!useMultiWordSearch) { 838 | // Strict search mode (default behavior) 839 | for (const column of searchBy) { 840 | const property = getPropertiesByColumnName(column) 841 | const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty(qb, property) 842 | const isRelation = checkIsRelation(qb, property.propertyPath) 843 | const isEmbedded = checkIsEmbedded(qb, property.propertyPath) 844 | const alias = fixColumnAlias( 845 | property, 846 | qb.alias, 847 | isRelation, 848 | isVirtualProperty, 849 | isEmbedded, 850 | virtualQuery 851 | ) 852 | 853 | const condition: WherePredicateOperator = { 854 | operator: 'ilike', 855 | parameters: [alias, `:${property.column}`], 856 | } 857 | 858 | if (['postgres', 'cockroachdb'].includes(queryBuilder.connection.options.type)) { 859 | condition.parameters[0] = `CAST(${condition.parameters[0]} AS text)` 860 | } 861 | 862 | qb.orWhere(qb['createWhereConditionExpression'](condition), { 863 | [property.column]: `%${query.search}%`, 864 | }) 865 | } 866 | } else { 867 | // Multi-word search mode 868 | const searchWords = query.search.split(' ').filter((word) => word.length > 0) 869 | searchWords.forEach((searchWord, index) => { 870 | qb.andWhere( 871 | new Brackets((subQb: SelectQueryBuilder) => { 872 | for (const column of searchBy) { 873 | const property = getPropertiesByColumnName(column) 874 | const { isVirtualProperty, query: virtualQuery } = extractVirtualProperty( 875 | subQb, 876 | property 877 | ) 878 | const isRelation = checkIsRelation(subQb, property.propertyPath) 879 | const isEmbedded = checkIsEmbedded(subQb, property.propertyPath) 880 | const alias = fixColumnAlias( 881 | property, 882 | subQb.alias, 883 | isRelation, 884 | isVirtualProperty, 885 | isEmbedded, 886 | virtualQuery 887 | ) 888 | 889 | const condition: WherePredicateOperator = { 890 | operator: 'ilike', 891 | parameters: [alias, `:${property.column}_${index}`], 892 | } 893 | 894 | if (['postgres', 'cockroachdb'].includes(queryBuilder.connection.options.type)) { 895 | condition.parameters[0] = `CAST(${condition.parameters[0]} AS text)` 896 | } 897 | 898 | subQb.orWhere(subQb['createWhereConditionExpression'](condition), { 899 | [`${property.column}_${index}`]: `%${searchWord}%`, 900 | }) 901 | } 902 | }) 903 | ) 904 | }) 905 | } 906 | }) 907 | ) 908 | } 909 | 910 | if (query.limit === PaginationLimit.COUNTER_ONLY) { 911 | totalItems = await queryBuilder.getCount() 912 | } else if (isPaginated && config.paginationType !== PaginationType.CURSOR) { 913 | if (config.buildCountQuery) { 914 | items = await queryBuilder.getMany() 915 | totalItems = await config.buildCountQuery(queryBuilder.clone()).getCount() 916 | } else { 917 | ;[items, totalItems] = await queryBuilder.getManyAndCount() 918 | } 919 | } else { 920 | items = await queryBuilder.getMany() 921 | } 922 | 923 | const sortByQuery = sortBy.map((order) => `&sortBy=${order.join(':')}`).join('') 924 | const searchQuery = query.search ? `&search=${query.search}` : '' 925 | 926 | const searchByQuery = 927 | query.searchBy && searchBy.length && !config.ignoreSearchByInQueryParam 928 | ? searchBy.map((column) => `&searchBy=${column}`).join('') 929 | : '' 930 | 931 | // Only expose select in meta data if query select differs from config select 932 | const isQuerySelected = selectParams?.length !== config.select?.length 933 | const selectQuery = isQuerySelected ? `&select=${selectParams.join(',')}` : '' 934 | 935 | const filterQuery = query.filter 936 | ? '&' + 937 | stringify( 938 | mapKeys(query.filter, (_param, name) => 'filter.' + name), 939 | '&', 940 | '=', 941 | { encodeURIComponent: (str) => str } 942 | ) 943 | : '' 944 | 945 | const options = `&limit=${limit}${sortByQuery}${searchQuery}${searchByQuery}${selectQuery}${filterQuery}` 946 | 947 | let path: string = null 948 | if (query.path !== null) { 949 | // `query.path` does not exist in RPC/WS requests and is set to null then. 950 | const { queryOrigin, queryPath } = getQueryUrlComponents(query.path) 951 | if (config.relativePath) { 952 | path = queryPath 953 | } else if (config.origin) { 954 | path = config.origin + queryPath 955 | } else { 956 | path = queryOrigin + queryPath 957 | } 958 | } 959 | 960 | const buildLink = (p: number): string => path + '?page=' + p + options 961 | 962 | const reversedSortBy = sortBy.map(([col, dir]) => [col, dir === 'ASC' ? 'DESC' : 'ASC'] as Order) 963 | 964 | const buildLinkForCursor = (cursor: string | undefined, isReversed: boolean = false): string => { 965 | let adjustedOptions = options 966 | 967 | if (isReversed && sortBy.length > 0) { 968 | adjustedOptions = `&limit=${limit}${reversedSortBy 969 | .map((order) => `&sortBy=${order.join(':')}`) 970 | .join('')}${searchQuery}${searchByQuery}${selectQuery}${filterQuery}` 971 | } 972 | 973 | return path + adjustedOptions.replace(/^./, '?') + (cursor ? `&cursor=${cursor}` : '') 974 | } 975 | 976 | const itemsPerPage = limit === PaginationLimit.COUNTER_ONLY ? totalItems : isPaginated ? limit : items.length 977 | const totalItemsForMeta = limit === PaginationLimit.COUNTER_ONLY || isPaginated ? totalItems : items.length 978 | const totalPages = isPaginated ? Math.ceil(totalItems / limit) : 1 979 | 980 | const results: Paginated = { 981 | data: items, 982 | meta: { 983 | itemsPerPage: config.paginationType === PaginationType.CURSOR ? items.length : itemsPerPage, 984 | totalItems: config.paginationType === PaginationType.CURSOR ? undefined : totalItemsForMeta, 985 | currentPage: config.paginationType === PaginationType.CURSOR ? undefined : page, 986 | totalPages: config.paginationType === PaginationType.CURSOR ? undefined : totalPages, 987 | sortBy, 988 | search: query.search, 989 | searchBy: query.search ? searchBy : undefined, 990 | select: isQuerySelected ? selectParams : undefined, 991 | filter: query.filter, 992 | cursor: config.paginationType === PaginationType.CURSOR ? query.cursor : undefined, 993 | }, 994 | // If there is no `path`, don't build links. 995 | links: 996 | path !== null 997 | ? config.paginationType === PaginationType.CURSOR 998 | ? { 999 | previous: items.length 1000 | ? buildLinkForCursor(generateCursor(items[0], reversedSortBy, 'previous'), true) 1001 | : undefined, 1002 | current: buildLinkForCursor(query.cursor), 1003 | next: items.length 1004 | ? buildLinkForCursor(generateCursor(items[items.length - 1], sortBy)) 1005 | : undefined, 1006 | } 1007 | : { 1008 | first: page == 1 ? undefined : buildLink(1), 1009 | previous: page - 1 < 1 ? undefined : buildLink(page - 1), 1010 | current: buildLink(page), 1011 | next: page + 1 > totalPages ? undefined : buildLink(page + 1), 1012 | last: page == totalPages || !totalItems ? undefined : buildLink(totalPages), 1013 | } 1014 | : ({} as Paginated['links']), 1015 | } 1016 | 1017 | return Object.assign(new Paginated(), results) 1018 | } 1019 | 1020 | export function addRelationsFromSchema( 1021 | queryBuilder: SelectQueryBuilder, 1022 | schema: RelationSchema, 1023 | config: PaginateConfig, 1024 | joinMethods: Partial> 1025 | ): void { 1026 | const defaultJoinMethod = config.defaultJoinMethod ?? 'leftJoinAndSelect' 1027 | 1028 | const createQueryBuilderRelations = ( 1029 | prefix: string, 1030 | relations: RelationSchema, 1031 | alias?: string, 1032 | parentRelation?: string 1033 | ) => { 1034 | Object.keys(relations).forEach((relationName) => { 1035 | const joinMethod = 1036 | joinMethods[parentRelation ? `${parentRelation}.${relationName}` : relationName] ?? defaultJoinMethod 1037 | queryBuilder[joinMethod](`${alias ?? prefix}.${relationName}`, `${alias ?? prefix}_${relationName}_rel`) 1038 | 1039 | // Check whether this is a non-terminal node with a relation schema to load 1040 | const relationSchema = relations[relationName] 1041 | if ( 1042 | typeof relationSchema === 'object' && 1043 | relationSchema !== null && 1044 | Object.keys(relationSchema).length > 0 1045 | ) { 1046 | createQueryBuilderRelations( 1047 | relationName, 1048 | relationSchema, 1049 | `${alias ?? prefix}_${relationName}_rel`, 1050 | parentRelation ? `${parentRelation}.${relationName}` : relationName 1051 | ) 1052 | } 1053 | }) 1054 | } 1055 | createQueryBuilderRelations(queryBuilder.alias, schema) 1056 | } 1057 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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, PaginationLimit } from '../paginate' 5 | 6 | const DEFAULT_VALUE_KEY = 'Default Value' 7 | 8 | function p(key: string | 'Format' | 'Example' | 'Default Value' | 'Max Value', value: string) { 9 | return `

10 | ${key}: ${value} 11 |

` 12 | } 13 | 14 | function li(key: string | 'Available Fields', values: string[]) { 15 | return `

${key}

    ${values.map((v) => `
  • ${v}
  • `).join('\n')}
` 16 | } 17 | 18 | export function SortBy(paginationConfig: PaginateConfig) { 19 | const defaultSortMessage = paginationConfig.defaultSortBy 20 | ? paginationConfig.defaultSortBy.map(([col, order]) => `${col}:${order}`).join(',') 21 | : 'No default sorting specified, the result order is not guaranteed' 22 | 23 | const sortBy = paginationConfig.sortableColumns.reduce((prev, curr) => { 24 | return [...prev, `${curr}:ASC`, `${curr}:DESC`] 25 | }, []) 26 | 27 | return ApiQuery({ 28 | name: 'sortBy', 29 | isArray: true, 30 | enum: sortBy, 31 | description: `Parameter to sort by. 32 |

To sort by multiple fields, just provide query param multiple types. The order in url defines an order of sorting

33 | ${p('Format', 'fieldName:DIRECTION')} 34 | ${p('Example', 'sortBy=id:DESC&sortBy=createdAt:ASC')} 35 | ${p('Default Value', defaultSortMessage)} 36 | ${li('Available Fields', paginationConfig.sortableColumns)} 37 | `, 38 | required: false, 39 | type: 'string', 40 | }) 41 | } 42 | 43 | function Limit(paginationConfig: PaginateConfig) { 44 | return ApiQuery({ 45 | name: 'limit', 46 | description: `Number of records per page. 47 | ${p('Example', '20')} 48 | ${p(DEFAULT_VALUE_KEY, paginationConfig?.defaultLimit?.toString() || PaginationLimit.DEFAULT_LIMIT.toString())} 49 | ${p('Max Value', paginationConfig.maxLimit?.toString() || PaginationLimit.DEFAULT_MAX_LIMIT.toString())} 50 | 51 | If provided value is greater than max value, max value will be applied. 52 | `, 53 | required: false, 54 | type: 'number', 55 | }) 56 | } 57 | 58 | function Select(paginationConfig: PaginateConfig) { 59 | if (!paginationConfig.select) { 60 | return 61 | } 62 | 63 | return ApiQuery({ 64 | name: 'select', 65 | description: `List of fields to select. 66 | ${p('Example', paginationConfig.select.slice(0, Math.min(5, paginationConfig.select.length)).join(','))} 67 | ${p( 68 | DEFAULT_VALUE_KEY, 69 | 'By default all fields returns. If you want to select only some fields, provide them in query param' 70 | )} 71 | `, 72 | required: false, 73 | type: 'string', 74 | }) 75 | } 76 | 77 | function Where(paginationConfig: PaginateConfig) { 78 | if (!paginationConfig.filterableColumns) return 79 | 80 | const allColumnsDecorators = Object.entries(paginationConfig.filterableColumns) 81 | .map(([fieldName, filterOperations]) => { 82 | const operations = 83 | filterOperations === true || filterOperations === undefined 84 | ? [ 85 | ...Object.values(FilterComparator), 86 | ...Object.values(FilterSuffix), 87 | ...Object.values(FilterOperator), 88 | ] 89 | : filterOperations.map((fo) => fo.toString()) 90 | 91 | return ApiQuery({ 92 | name: `filter.${fieldName}`, 93 | description: `Filter by ${fieldName} query param. 94 | ${p('Format', `filter.${fieldName}={$not}:OPERATION:VALUE`)} 95 | ${p('Example', `filter.${fieldName}=$not:$like:John Doe&filter.${fieldName}=like:John`)} 96 | ${li('Available Operations', operations)}`, 97 | required: false, 98 | type: 'string', 99 | isArray: true, 100 | }) 101 | }) 102 | .filter((v) => v !== undefined) 103 | 104 | return applyDecorators(...allColumnsDecorators) 105 | } 106 | 107 | function Page() { 108 | return ApiQuery({ 109 | name: 'page', 110 | description: `Page number to retrieve.If you provide invalid value the default page number will applied 111 | ${p('Example', '1')} 112 | ${p(DEFAULT_VALUE_KEY, '1')} 113 | `, 114 | required: false, 115 | type: 'number', 116 | }) 117 | } 118 | 119 | function Search(paginateConfig: PaginateConfig) { 120 | if (!paginateConfig.searchableColumns) return 121 | 122 | return ApiQuery({ 123 | name: 'search', 124 | description: `Search term to filter result values 125 | ${p('Example', 'John')} 126 | ${p(DEFAULT_VALUE_KEY, 'No default value')} 127 | `, 128 | required: false, 129 | type: 'string', 130 | }) 131 | } 132 | 133 | function SearchBy(paginateConfig: PaginateConfig) { 134 | if (!paginateConfig.searchableColumns) return 135 | 136 | return ApiQuery({ 137 | name: 'searchBy', 138 | description: `List of fields to search by term to filter result values 139 | ${p( 140 | 'Example', 141 | paginateConfig.searchableColumns.slice(0, Math.min(5, paginateConfig.searchableColumns.length)).join(',') 142 | )} 143 | ${p(DEFAULT_VALUE_KEY, 'By default all fields mentioned below will be used to search by term')} 144 | ${li('Available Fields', paginateConfig.searchableColumns)} 145 | `, 146 | required: false, 147 | isArray: true, 148 | type: 'string', 149 | }) 150 | } 151 | 152 | export const ApiPaginationQuery = (paginationConfig: PaginateConfig) => { 153 | return applyDecorators( 154 | ...[ 155 | Page(), 156 | Limit(paginationConfig), 157 | Where(paginationConfig), 158 | SortBy(paginationConfig), 159 | Search(paginationConfig), 160 | SearchBy(paginationConfig), 161 | Select(paginationConfig), 162 | ].filter((v): v is MethodDecorator => v !== undefined) 163 | ) 164 | } 165 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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: true, 118 | isArray: false, 119 | type: 'object', 120 | additionalProperties: false, 121 | }) 122 | filter?: { 123 | [p: string]: string | string[] 124 | } 125 | } 126 | 127 | export class PaginatedDocumented extends Paginated { 128 | @ApiProperty({ 129 | isArray: true, 130 | selfRequired: true, 131 | title: 'Array of entities', 132 | type: 'object', 133 | additionalProperties: false, 134 | }) 135 | override data!: T[] 136 | 137 | @ApiProperty({ 138 | title: 'Pagination Metadata', 139 | required: true, 140 | }) 141 | override meta!: PaginatedMetaDocumented 142 | 143 | @ApiProperty({ 144 | title: 'Links to pages', 145 | required: true, 146 | }) 147 | override links!: PaginatedLinksDocumented 148 | } 149 | -------------------------------------------------------------------------------- /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 | 9 | const BASE_PAGINATION_CONFIG = { 10 | sortableColumns: ['id'], 11 | } satisfies PaginateConfig 12 | 13 | const FULL_CONFIG = { 14 | ...BASE_PAGINATION_CONFIG, 15 | defaultSortBy: [['id', 'DESC']], 16 | defaultLimit: 20, 17 | maxLimit: 100, 18 | filterableColumns: { 19 | id: true, 20 | name: [FilterOperator.EQ, FilterSuffix.NOT], 21 | }, 22 | searchableColumns: ['name'], 23 | select: ['id', 'name'], 24 | } satisfies PaginateConfig 25 | 26 | class TestDto { 27 | id: string 28 | name: string 29 | } 30 | 31 | // eslint-disable-next-line @typescript-eslint/ban-types 32 | async function getSwaggerDefinitionForEndpoint(entityType: Type, config: PaginateConfig) { 33 | class TestController { 34 | @PaginatedSwaggerDocs(entityType, config) 35 | @Get('/test') 36 | public test(): void { 37 | // 38 | } 39 | 40 | @ApiPaginationQuery(config) 41 | @ApiOkPaginatedResponse(entityType, config) 42 | @Post('/test') 43 | public testPost(): void { 44 | // 45 | } 46 | } 47 | 48 | const fakeAppModule = await Test.createTestingModule({ 49 | controllers: [TestController], 50 | }).compile() 51 | const fakeApp = fakeAppModule.createNestApplication() 52 | 53 | return SwaggerModule.createDocument(fakeApp, new DocumentBuilder().build()) 54 | } 55 | 56 | describe('PaginatedEndpoint decorator', () => { 57 | it('post and get definition should be the same', async () => { 58 | const openApiDefinition = await getSwaggerDefinitionForEndpoint(TestDto, BASE_PAGINATION_CONFIG) 59 | 60 | expect(openApiDefinition.paths['/test'].get.parameters).toStrictEqual( 61 | openApiDefinition.paths['/test'].post.parameters 62 | ) 63 | }) 64 | 65 | it('should annotate endpoint with OpenApi documentation with limited config', async () => { 66 | const openApiDefinition = await getSwaggerDefinitionForEndpoint(TestDto, BASE_PAGINATION_CONFIG) 67 | 68 | const params = openApiDefinition.paths['/test'].get.parameters 69 | expect(params).toStrictEqual([ 70 | { 71 | name: 'page', 72 | required: false, 73 | in: 'query', 74 | description: 75 | '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 ', 76 | schema: { 77 | type: 'number', 78 | }, 79 | }, 80 | { 81 | name: 'limit', 82 | required: false, 83 | in: 'query', 84 | description: 85 | 'Number of records per page.\n

\n Example: 20\n

\n

\n Default Value: 20\n

\n

\n Max Value: 100\n

\n\n If provided value is greater than max value, max value will be applied.\n ', 86 | schema: { 87 | type: 'number', 88 | }, 89 | }, 90 | { 91 | name: 'sortBy', 92 | required: false, 93 | in: 'query', 94 | description: 95 | 'Parameter to sort by.\n

To 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&sortBy=createdAt:ASC\n

\n

\n Default Value: No default sorting specified, the result order is not guaranteed\n

\n

Available Fields

  • id
\n ', 96 | schema: { 97 | type: 'array', 98 | items: { 99 | type: 'string', 100 | enum: ['id:ASC', 'id:DESC'], 101 | }, 102 | }, 103 | }, 104 | ]) 105 | expect(openApiDefinition.paths['/test'].get.responses).toEqual({ 106 | '200': { 107 | description: '', 108 | content: { 109 | 'application/json': { 110 | schema: { 111 | allOf: [ 112 | { 113 | $ref: '#/components/schemas/PaginatedDocumented', 114 | }, 115 | { 116 | properties: { 117 | data: { 118 | type: 'array', 119 | items: { 120 | $ref: '#/components/schemas/TestDto', 121 | }, 122 | }, 123 | meta: { 124 | properties: { 125 | select: { 126 | type: 'array', 127 | items: { 128 | type: 'string', 129 | }, 130 | }, 131 | filter: { 132 | type: 'object', 133 | properties: {}, 134 | }, 135 | }, 136 | }, 137 | }, 138 | }, 139 | ], 140 | }, 141 | }, 142 | }, 143 | }, 144 | }) 145 | }) 146 | 147 | it('should annotate endpoint with OpenApi documentation with full config', async () => { 148 | const openApiDefinition = await getSwaggerDefinitionForEndpoint(TestDto, FULL_CONFIG) 149 | 150 | const params = openApiDefinition.paths['/test'].get.parameters 151 | expect(params).toStrictEqual([ 152 | { 153 | name: 'page', 154 | required: false, 155 | in: 'query', 156 | description: 157 | '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 ', 158 | schema: { 159 | type: 'number', 160 | }, 161 | }, 162 | { 163 | name: 'limit', 164 | required: false, 165 | in: 'query', 166 | description: 167 | 'Number of records per page.\n

\n Example: 20\n

\n

\n Default Value: 20\n

\n

\n Max Value: 100\n

\n\n If provided value is greater than max value, max value will be applied.\n ', 168 | schema: { 169 | type: 'number', 170 | }, 171 | }, 172 | { 173 | name: 'filter.id', 174 | required: false, 175 | in: 'query', 176 | description: 177 | 'Filter by id query param.\n

\n Format: filter.id={$not}:OPERATION:VALUE\n

\n

\n Example: filter.id=$not:$like:John Doe&filter.id=like:John\n

\n

Available Operations

  • $and
  • \n
  • $or
  • \n
  • $not
  • \n
  • $eq
  • \n
  • $gt
  • \n
  • $gte
  • \n
  • $in
  • \n
  • $null
  • \n
  • $lt
  • \n
  • $lte
  • \n
  • $btw
  • \n
  • $ilike
  • \n
  • $sw
  • \n
  • $contains
', 178 | schema: { 179 | type: 'array', 180 | items: { 181 | type: 'string', 182 | }, 183 | }, 184 | }, 185 | { 186 | name: 'filter.name', 187 | required: false, 188 | in: 'query', 189 | description: 190 | 'Filter by name query param.\n

\n Format: filter.name={$not}:OPERATION:VALUE\n

\n

\n Example: filter.name=$not:$like:John Doe&filter.name=like:John\n

\n

Available Operations

  • $eq
  • \n
  • $not
', 191 | schema: { 192 | type: 'array', 193 | items: { 194 | type: 'string', 195 | }, 196 | }, 197 | }, 198 | { 199 | name: 'sortBy', 200 | required: false, 201 | in: 'query', 202 | description: 203 | 'Parameter to sort by.\n

To 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&sortBy=createdAt:ASC\n

\n

\n Default Value: id:DESC\n

\n

Available Fields

  • id
\n ', 204 | schema: { 205 | type: 'array', 206 | items: { 207 | type: 'string', 208 | enum: ['id:ASC', 'id:DESC'], 209 | }, 210 | }, 211 | }, 212 | { 213 | name: 'search', 214 | required: false, 215 | in: 'query', 216 | description: 217 | 'Search term to filter result values\n

\n Example: John\n

\n

\n Default Value: No default value\n

\n ', 218 | schema: { 219 | type: 'string', 220 | }, 221 | }, 222 | { 223 | name: 'searchBy', 224 | required: false, 225 | in: 'query', 226 | description: 227 | '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

  • name
\n ', 228 | schema: { 229 | type: 'array', 230 | items: { 231 | type: 'string', 232 | }, 233 | }, 234 | }, 235 | { 236 | name: 'select', 237 | required: false, 238 | in: 'query', 239 | description: 240 | '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 ', 241 | schema: { 242 | type: 'string', 243 | }, 244 | }, 245 | ]) 246 | expect(openApiDefinition.paths['/test'].get.responses).toEqual({ 247 | '200': { 248 | description: '', 249 | content: { 250 | 'application/json': { 251 | schema: { 252 | allOf: [ 253 | { 254 | $ref: '#/components/schemas/PaginatedDocumented', 255 | }, 256 | { 257 | properties: { 258 | data: { 259 | type: 'array', 260 | items: { 261 | $ref: '#/components/schemas/TestDto', 262 | }, 263 | }, 264 | meta: { 265 | properties: { 266 | select: { 267 | type: 'array', 268 | items: { 269 | type: 'string', 270 | enum: ['id', 'name'], 271 | }, 272 | }, 273 | filter: { 274 | type: 'object', 275 | properties: { 276 | id: { 277 | oneOf: [ 278 | { 279 | type: 'string', 280 | }, 281 | { 282 | type: 'array', 283 | items: { 284 | type: 'string', 285 | }, 286 | }, 287 | ], 288 | }, 289 | name: { 290 | oneOf: [ 291 | { 292 | type: 'string', 293 | }, 294 | { 295 | type: 'array', 296 | items: { 297 | type: 'string', 298 | }, 299 | }, 300 | ], 301 | }, 302 | }, 303 | }, 304 | }, 305 | }, 306 | }, 307 | }, 308 | ], 309 | }, 310 | }, 311 | }, 312 | }, 313 | }) 314 | }) 315 | }) 316 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------