Add project
This commit is contained in:
parent
e83619baaf
commit
d7670f0a10
14
.editorconfig
Normal file
14
.editorconfig
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
[*]
|
||||||
|
insert_final_newline = true
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{json,js,yml,md}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
24
.eslintrc.json
Normal file
24
.eslintrc.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"project": "tsconfig.json",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": ["@typescript-eslint/eslint-plugin"],
|
||||||
|
"extends": [
|
||||||
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"prettier",
|
||||||
|
"prettier/@typescript-eslint"
|
||||||
|
],
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"jest": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/interface-name-prefix": "off",
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off"
|
||||||
|
}
|
||||||
|
}
|
26
.github/workflows/main.yml
vendored
Normal file
26
.github/workflows/main.yml
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
name: Main CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [10.x, 12.x, 14.x]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build
|
||||||
|
- run: npm run test:cov
|
||||||
|
- run: bash <(curl -s https://codecov.io/bash)
|
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
lib/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log
|
15
.prettierrc
Normal file
15
.prettierrc
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 4,
|
||||||
|
"printWidth": 120,
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "{*.json,*.md,.prettierrc,.*.yml}",
|
||||||
|
"options": {
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": true
|
||||||
|
}
|
||||||
|
}
|
22
LICENSE
Normal file
22
LICENSE
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020 Philipp Petzold (https://github.com/ppetzold)
|
||||||
|
Copyright (c) 2020 jyutzio (https://github.com/jyutzio)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
170
README.md
170
README.md
@ -1 +1,169 @@
|
|||||||
# nestjs-paginate
|
# Nest.js Paginate
|
||||||
|
|
||||||
|
![Version](https://img.shields.io/github/package-json/v/ppetzold/nestjs-paginate)
|
||||||
|
![Main CI](https://github.com/ppetzold/nestjs-paginate/workflows/Main%20CI/badge.svg)
|
||||||
|
[![Coverage](https://img.shields.io/codecov/c/github/ppetzold/nestjs-paginate/master.svg)](https://codecov.io/gh/ppetzold/nestjs-paginate)
|
||||||
|
[![GitHub license](https://img.shields.io/github/license/ppetzold/nestjs-paginate.svg)](https://github.com/ppetzold/nestjs-paginate/blob/master/LICENSE)
|
||||||
|
[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier)
|
||||||
|
[![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)
|
||||||
|
|
||||||
|
Pagination and filtering helper method for TypeORM repositories or query builders using [Nest.js](https://nestjs.com/) framework.
|
||||||
|
|
||||||
|
- Pagination conforms to [JSON:API](https://jsonapi.org/)
|
||||||
|
- Sort by multiple columns
|
||||||
|
- Filter by multiple columns using operators
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install nestjs-paginate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Example example
|
||||||
|
|
||||||
|
The following code exposes a route that can be utilized like so:
|
||||||
|
|
||||||
|
#### Endpoint
|
||||||
|
|
||||||
|
```url
|
||||||
|
http://localhost:3000/cats?limit=10&page=2&sortBy=createdAt:DESC&sortBy=color:ASC
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Result
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "George",
|
||||||
|
"color": "white"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "Leche",
|
||||||
|
"color": "white"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "Garfield",
|
||||||
|
"color": "ginger"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Milo",
|
||||||
|
"color": "brown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "Shadow",
|
||||||
|
"color": "black"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {
|
||||||
|
"itemsPerPage": 2,
|
||||||
|
"totalItems": 5,
|
||||||
|
"currentPage": 2,
|
||||||
|
"totalPages": 3,
|
||||||
|
"sortBy": [["color", "DESC"]]
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"first": "http://localhost:3000/cats?limit=2&page=1&sortBy=color:DESC",
|
||||||
|
"previous": "http://localhost:3000/cats?&limit=2&page=1&sortBy=color:DESC",
|
||||||
|
"next": "http://localhost:3000/cats?&limit=2&page=3&sortBy=color:DESC",
|
||||||
|
"last": "http://localhost:3000/cats?&limit=2&page=3&sortBy=color:DESC"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Code
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Controller, Injectable, Get } from '@nestjs/common'
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm'
|
||||||
|
import { Repository, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
|
||||||
|
import { Paginate, PaginateQuery, paginate, Paginated } from 'nestjs-paginate'
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class CatEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
name: string
|
||||||
|
|
||||||
|
@Column('text')
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CatsService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CatEntity)
|
||||||
|
private readonly catsRepository: Repository<CatEntity>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public findAll(query: PaginateQuery): Promise<Paginated<CatEntity>> {
|
||||||
|
return paginate(query, this.catsRepository, {
|
||||||
|
sortableColumns: ['name', 'color'],
|
||||||
|
defaultOrderby: [['color', 'DESC']],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('cats')
|
||||||
|
export class CatsController {
|
||||||
|
constructor(private readonly catsService: CatsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
public findAll(@Paginate() query: PaginateQuery): Promise<Paginated<CatEntity>> {
|
||||||
|
return this.catsService.findAll(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const paginateConfig: PaginateConfig<CatEntity> {
|
||||||
|
/**
|
||||||
|
* Required: true (must have a minimum of one column)
|
||||||
|
* Type: keyof CatEntity
|
||||||
|
* Description: These are the columns that are valid to be sorted by.
|
||||||
|
*/
|
||||||
|
sortableColumns: ['id', 'name', 'color'],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required: false
|
||||||
|
* Type: number
|
||||||
|
* Default: 100
|
||||||
|
* Description: The maximum amount of entities to return per page.
|
||||||
|
*/
|
||||||
|
maxLimit: 20,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required: false
|
||||||
|
* Type: [string, 'ASC' | 'DESC'][]
|
||||||
|
* Default: [sortableColumns[0], 'ASC]]
|
||||||
|
* Description: The order to display the sorted entities.
|
||||||
|
*/
|
||||||
|
defaultSortBy: [['name', 'DESC']],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required: false
|
||||||
|
* Type: number
|
||||||
|
* Default: 20
|
||||||
|
*/
|
||||||
|
defaultLimit: 50,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required: false
|
||||||
|
* Type: TypeORM find options
|
||||||
|
* Default: None
|
||||||
|
* https://typeorm.io/#/find-optionsfind-options.md
|
||||||
|
*/
|
||||||
|
where: { color: 'ginger' }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
7507
package-lock.json
generated
Normal file
7507
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
88
package.json
Normal file
88
package.json
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"name": "nestjs-paginate",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"author": "Philipp Petzold <ppetzold@protonmail.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "lib/index.js",
|
||||||
|
"typings": "lib/index.d.ts",
|
||||||
|
"files": [
|
||||||
|
"lib/**/*"
|
||||||
|
],
|
||||||
|
"description": "Pagination and filtering helper method for TypeORM repostiories or query builders using Nest.js framework.",
|
||||||
|
"keywords": [
|
||||||
|
"nestjs",
|
||||||
|
"typeorm",
|
||||||
|
"express",
|
||||||
|
"pagination",
|
||||||
|
"paginate",
|
||||||
|
"filtration",
|
||||||
|
"filter"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"prebuild": "rimraf lib",
|
||||||
|
"build": "tsc",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
|
"lint": "eslint \"src/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"rxjs": "^6.5.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/common": "^7.2.0",
|
||||||
|
"@types/express": "^4.17.6",
|
||||||
|
"@types/jest": "^26.0.3",
|
||||||
|
"@types/node": "^14.0.14",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^3.4.0",
|
||||||
|
"@typescript-eslint/parser": "^3.4.0",
|
||||||
|
"eslint": "^7.3.1",
|
||||||
|
"eslint-config-prettier": "^6.11.0",
|
||||||
|
"eslint-plugin-prettier": "^3.1.4",
|
||||||
|
"jest": "^26.1.0",
|
||||||
|
"prettier": "^2.0.5",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"sqlite3": "^4.2.0",
|
||||||
|
"ts-jest": "^26.1.1",
|
||||||
|
"ts-node": "^8.10.2",
|
||||||
|
"typeorm": "^0.2.25",
|
||||||
|
"typescript": "^3.9.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^7.2.0",
|
||||||
|
"express": "^4.17.1",
|
||||||
|
"typeorm": "^0.2.25"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/ppetzold/nestjs-paginate.git"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/ppetzold/nestjs-paginate#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/ppetzold/nestjs-paginate/issues"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"branches": [
|
||||||
|
"master"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
67
src/decorator.spec.ts
Normal file
67
src/decorator.spec.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'
|
||||||
|
import { HttpArgumentsHost, CustomParamFactory, ExecutionContext } from '@nestjs/common/interfaces'
|
||||||
|
import { Request } from 'express'
|
||||||
|
import { Paginate, PaginateQuery } from './decorator'
|
||||||
|
|
||||||
|
function getParamDecoratorFactory<T>(decorator: Function): CustomParamFactory {
|
||||||
|
class Test {
|
||||||
|
public test(@decorator() _value: T): void {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, Test, 'test')
|
||||||
|
return args[Object.keys(args)[0]].factory
|
||||||
|
}
|
||||||
|
const decoratorfactory = getParamDecoratorFactory<PaginateQuery>(Paginate)
|
||||||
|
|
||||||
|
function contextFactory(query: Request['query']): Partial<ExecutionContext> {
|
||||||
|
const mockContext: Partial<ExecutionContext> = {
|
||||||
|
switchToHttp: (): HttpArgumentsHost =>
|
||||||
|
Object({
|
||||||
|
getRequest: (): Partial<Request> =>
|
||||||
|
Object({
|
||||||
|
protocol: 'http',
|
||||||
|
get: () => 'localhost',
|
||||||
|
baseUrl: '/items',
|
||||||
|
path: '/all',
|
||||||
|
query: query,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
return mockContext
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Decorator', () => {
|
||||||
|
it('should handle undefined query fields', () => {
|
||||||
|
const context = contextFactory({})
|
||||||
|
|
||||||
|
const result: PaginateQuery = decoratorfactory(null, context)
|
||||||
|
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
page: undefined,
|
||||||
|
limit: undefined,
|
||||||
|
sortBy: undefined,
|
||||||
|
path: 'http://localhost/items/all',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle defined query fields', () => {
|
||||||
|
const context = contextFactory({
|
||||||
|
page: '1',
|
||||||
|
limit: '20',
|
||||||
|
sortBy: ['id:ASC', 'createdAt:DESC'],
|
||||||
|
})
|
||||||
|
|
||||||
|
const result: PaginateQuery = decoratorfactory(null, context)
|
||||||
|
|
||||||
|
expect(result).toStrictEqual({
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
sortBy: [
|
||||||
|
['id', 'ASC'],
|
||||||
|
['createdAt', 'DESC'],
|
||||||
|
],
|
||||||
|
path: 'http://localhost/items/all',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
44
src/decorator.ts
Normal file
44
src/decorator.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
|
||||||
|
import { Request } from 'express'
|
||||||
|
|
||||||
|
export interface PaginateQuery {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
sortBy?: [string, string][]
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Paginate = createParamDecorator(
|
||||||
|
(_data: unknown, ctx: ExecutionContext): PaginateQuery => {
|
||||||
|
const request: Request = ctx.switchToHttp().getRequest()
|
||||||
|
const { query } = request
|
||||||
|
const path = request.protocol + '://' + request.get('host') + request.baseUrl + request.path
|
||||||
|
|
||||||
|
function readParamAsArray(param: unknown): string[] {
|
||||||
|
const result = typeof param === 'string' ? [param] : param
|
||||||
|
if (Array.isArray(result) && result.every((value) => typeof value === 'string')) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortBy: [string, string][] | undefined = undefined
|
||||||
|
if (query.sortBy) {
|
||||||
|
const params = readParamAsArray(query.sortBy)
|
||||||
|
for (const param of params) {
|
||||||
|
const items = param.split(':')
|
||||||
|
if (items.length === 2) {
|
||||||
|
if (!sortBy) sortBy = []
|
||||||
|
sortBy.push(items as [string, string])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
page: query.page ? parseInt(query.page.toString(), 10) : undefined,
|
||||||
|
limit: query.limit ? parseInt(query.limit.toString(), 10) : undefined,
|
||||||
|
sortBy,
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
2
src/index.ts
Normal file
2
src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './decorator'
|
||||||
|
export * from './paginate'
|
110
src/paginate.spec.ts
Normal file
110
src/paginate.spec.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { createConnection, Repository } from 'typeorm'
|
||||||
|
import { Paginated, paginate, PaginateConfig } from './paginate'
|
||||||
|
import { PaginateQuery } from './decorator'
|
||||||
|
import { Entity, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'
|
||||||
|
import { HttpException } from '@nestjs/common'
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class CatEntity {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
id: number
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('paginate', () => {
|
||||||
|
let repo: Repository<CatEntity>
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const connection = await createConnection({
|
||||||
|
type: 'sqlite',
|
||||||
|
database: ':memory:',
|
||||||
|
synchronize: true,
|
||||||
|
logging: false,
|
||||||
|
entities: [CatEntity],
|
||||||
|
})
|
||||||
|
repo = connection.getRepository(CatEntity)
|
||||||
|
await repo.save([repo.create(), repo.create(), repo.create(), repo.create(), repo.create()])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return an instance of Paginated', async () => {
|
||||||
|
const config: PaginateConfig<CatEntity> = {
|
||||||
|
sortableColumns: ['id'],
|
||||||
|
defaultSortBy: [['createdAt', 'DESC']], // Should fall back to id
|
||||||
|
defaultLimit: 1,
|
||||||
|
}
|
||||||
|
const query: PaginateQuery = {
|
||||||
|
path: '',
|
||||||
|
page: 30, // will fallback to last available page
|
||||||
|
limit: 2,
|
||||||
|
sortBy: [['id', 'ASC']],
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await paginate<CatEntity>(query, repo, config)
|
||||||
|
|
||||||
|
expect(results).toBeInstanceOf(Paginated)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should default to index 0 of sortableColumns, when no other are given', async () => {
|
||||||
|
const config: PaginateConfig<CatEntity> = {
|
||||||
|
sortableColumns: ['id'],
|
||||||
|
}
|
||||||
|
const query: PaginateQuery = {
|
||||||
|
path: '',
|
||||||
|
page: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await paginate<CatEntity>(query, repo, config)
|
||||||
|
|
||||||
|
expect(results).toBeInstanceOf(Paginated)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return correct links', async () => {
|
||||||
|
const config: PaginateConfig<CatEntity> = {
|
||||||
|
sortableColumns: ['id'],
|
||||||
|
}
|
||||||
|
const query: PaginateQuery = {
|
||||||
|
path: '',
|
||||||
|
page: 2,
|
||||||
|
limit: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { links } = await paginate<CatEntity>(query, repo, config)
|
||||||
|
|
||||||
|
expect(links.first).toBe('?page=1&limit=2&sortBy=id:ASC')
|
||||||
|
expect(links.previous).toBe('?page=1&limit=2&sortBy=id:ASC')
|
||||||
|
expect(links.current).toBe('?page=2&limit=2&sortBy=id:ASC')
|
||||||
|
expect(links.next).toBe('?page=3&limit=2&sortBy=id:ASC')
|
||||||
|
expect(links.last).toBe('?page=3&limit=2&sortBy=id:ASC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should default to defaultOrderby if query sortBy does not exist', async () => {
|
||||||
|
const config: PaginateConfig<CatEntity> = {
|
||||||
|
sortableColumns: ['id', 'createdAt'],
|
||||||
|
defaultSortBy: [['createdAt', 'DESC']],
|
||||||
|
}
|
||||||
|
const query: PaginateQuery = {
|
||||||
|
path: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await paginate<CatEntity>(query, repo, config)
|
||||||
|
|
||||||
|
expect(results.meta.sortBy).toStrictEqual([['createdAt', 'DESC']])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error when no sortableColumns', async () => {
|
||||||
|
const config: PaginateConfig<CatEntity> = {
|
||||||
|
sortableColumns: [],
|
||||||
|
}
|
||||||
|
const query: PaginateQuery = {
|
||||||
|
path: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await paginate<CatEntity>(query, repo, config)
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(HttpException)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
116
src/paginate.ts
Normal file
116
src/paginate.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { Repository, FindConditions, SelectQueryBuilder } from 'typeorm'
|
||||||
|
import { PaginateQuery } from './decorator'
|
||||||
|
import { ServiceUnavailableException } from '@nestjs/common'
|
||||||
|
|
||||||
|
type Column<T> = Extract<keyof T, string>
|
||||||
|
type Order<T> = [Column<T>, 'ASC' | 'DESC']
|
||||||
|
type SortBy<T> = Order<T>[]
|
||||||
|
|
||||||
|
export class Paginated<T> {
|
||||||
|
data: T[]
|
||||||
|
meta: {
|
||||||
|
itemsPerPage: number
|
||||||
|
totalItems: number
|
||||||
|
currentPage: number
|
||||||
|
totalPages: number
|
||||||
|
sortBy: SortBy<T>
|
||||||
|
}
|
||||||
|
links: {
|
||||||
|
first?: string
|
||||||
|
previous?: string
|
||||||
|
current: string
|
||||||
|
next?: string
|
||||||
|
last?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginateConfig<T> {
|
||||||
|
sortableColumns: Column<T>[]
|
||||||
|
maxLimit?: number
|
||||||
|
defaultSortBy?: SortBy<T>
|
||||||
|
defaultLimit?: number
|
||||||
|
where?: FindConditions<T>
|
||||||
|
queryBuilder?: SelectQueryBuilder<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function paginate<T>(
|
||||||
|
query: PaginateQuery,
|
||||||
|
repo: Repository<T> | SelectQueryBuilder<T>,
|
||||||
|
config: PaginateConfig<T>
|
||||||
|
): Promise<Paginated<T>> {
|
||||||
|
let page = query.page || 1
|
||||||
|
const limit = query.limit || config.defaultLimit || 20
|
||||||
|
const path = query.path
|
||||||
|
|
||||||
|
function isEntityKey(sortableColumns: Column<T>[], column: string): column is Column<T> {
|
||||||
|
return !!sortableColumns.find((c) => c === column)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sortableColumns } = config
|
||||||
|
if (config.sortableColumns.length < 1) throw new ServiceUnavailableException()
|
||||||
|
|
||||||
|
let sortBy: SortBy<T> = []
|
||||||
|
if (query.sortBy) {
|
||||||
|
for (const order of query.sortBy) {
|
||||||
|
if (isEntityKey(sortableColumns, order[0]) && ['ASC', 'DESC'].includes(order[1])) {
|
||||||
|
sortBy.push(order as Order<T>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sortBy.length) {
|
||||||
|
sortBy = sortBy.concat(config.defaultSortBy || [[sortableColumns[0], 'ASC']])
|
||||||
|
}
|
||||||
|
|
||||||
|
let [items, totalItems]: [T[], number] = [[], 0]
|
||||||
|
|
||||||
|
if (repo instanceof Repository) {
|
||||||
|
const query = repo
|
||||||
|
.createQueryBuilder('e')
|
||||||
|
.take(limit)
|
||||||
|
.skip((page - 1) * limit)
|
||||||
|
|
||||||
|
for (const order of sortBy) {
|
||||||
|
query.addOrderBy('e.' + order[0], order[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
;[items, totalItems] = await query.where(config.where || {}).getManyAndCount()
|
||||||
|
} else {
|
||||||
|
const query = repo.take(limit).skip((page - 1) * limit)
|
||||||
|
|
||||||
|
for (const order of sortBy) {
|
||||||
|
query.addOrderBy(repo.alias + '.' + order[0], order[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
;[items, totalItems] = await query.getManyAndCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalPages = totalItems / limit
|
||||||
|
if (totalItems % limit) totalPages = Math.ceil(totalPages)
|
||||||
|
|
||||||
|
if (page > totalPages) page = totalPages
|
||||||
|
if (page < 1) page = 1
|
||||||
|
|
||||||
|
const options = `&limit=${limit}${sortBy.map((order) => `&sortBy=${order.join(':')}`).join('')}`
|
||||||
|
|
||||||
|
const buildLink = (p: number): string => path + '?page=' + p + options
|
||||||
|
|
||||||
|
const results: Paginated<T> = {
|
||||||
|
data: items,
|
||||||
|
meta: {
|
||||||
|
itemsPerPage: limit,
|
||||||
|
totalItems,
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: totalPages,
|
||||||
|
sortBy,
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
first: page == 1 ? undefined : buildLink(1),
|
||||||
|
previous: page - 1 < 1 ? undefined : buildLink(page - 1),
|
||||||
|
current: buildLink(page),
|
||||||
|
next: page + 1 > totalPages ? undefined : buildLink(page + 1),
|
||||||
|
last: page == totalPages ? undefined : buildLink(totalPages),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.assign(new Paginated<T>(), results)
|
||||||
|
}
|
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "es2017",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./lib",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"typeRoots": ["node_modules/@types"],
|
||||||
|
"incremental": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/__test__/*"]
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user