Add project

This commit is contained in:
ppetzold 2020-06-26 23:25:03 +02:00
parent e83619baaf
commit d7670f0a10
16 changed files with 8232 additions and 1 deletions

14
.editorconfig Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,5 @@
node_modules/
lib/
coverage/
.DS_Store
npm-debug.log

15
.prettierrc Normal file
View 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
View File

@ -0,0 +1,6 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}

22
LICENSE Normal file
View 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
View File

@ -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

File diff suppressed because it is too large Load Diff

88
package.json Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
export * from './decorator'
export * from './paginate'

110
src/paginate.spec.ts Normal file
View 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
View 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
View 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__/*"]
}