diff --git a/README.md b/README.md index 5055515..1af1b45 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Pagination and filtering helper method for TypeORM repositories or query builder - Pagination conforms to [JSON:API](https://jsonapi.org/) - Sort by multiple columns -- Filter by multiple columns using operators +- Filter using operators +- Search across columns ## Installation @@ -28,7 +29,7 @@ The following code exposes a route that can be utilized like so: #### Endpoint ```url -http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC +http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC&search=i ``` #### Result @@ -58,7 +59,7 @@ http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC }, { "id": 3, - "name": "Shadow", + "name": "Kitty", "color": "black" } ], @@ -70,11 +71,11 @@ http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC "sortBy": [["color", "DESC"]] }, "links": { - "first": "http://localhost:3000/cats?limit=5&page=1&sortBy=color:DESC", - "previous": "http://localhost:3000/cats?limit=5&page=1&sortBy=color:DESC", - "current": "http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC", - "next": "http://localhost:3000/cats?limit=5&page=3&sortBy=color:DESC", - "last": "http://localhost:3000/cats?limit=5&page=3&sortBy=color:DESC" + "first": "http://localhost:3000/cats?limit=5&page=1&sortBy=color:DESC&search=i", + "previous": "http://localhost:3000/cats?limit=5&page=1&sortBy=color:DESC&search=i", + "current": "http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC&search=i", + "next": "http://localhost:3000/cats?limit=5&page=3&sortBy=color:DESC&search=i", + "last": "http://localhost:3000/cats?limit=5&page=3&sortBy=color:DESC&search=i" } } ``` @@ -136,6 +137,13 @@ const paginateConfig: PaginateConfig { */ sortableColumns: ['id', 'name', 'color'], + /** + * Required: false + * Type: (keyof CatEntity)[] + * Description: These columns will be searched through when using the search query param. + */ + sortableColumns: ['name', 'color'], + /** * Required: false * Type: number diff --git a/src/decorator.spec.ts b/src/decorator.spec.ts index fdeb60c..d87e8d0 100644 --- a/src/decorator.spec.ts +++ b/src/decorator.spec.ts @@ -3,6 +3,7 @@ import { HttpArgumentsHost, CustomParamFactory, ExecutionContext } from '@nestjs import { Request } from 'express' import { Paginate, PaginateQuery } from './decorator' +// eslint-disable-next-line @typescript-eslint/ban-types function getParamDecoratorFactory(decorator: Function): CustomParamFactory { class Test { public test(@decorator() _value: T): void { @@ -41,6 +42,7 @@ describe('Decorator', () => { page: undefined, limit: undefined, sortBy: undefined, + search: undefined, path: 'http://localhost/items', }) }) @@ -50,6 +52,7 @@ describe('Decorator', () => { page: '1', limit: '20', sortBy: ['id:ASC', 'createdAt:DESC'], + search: 'white', }) const result: PaginateQuery = decoratorfactory(null, context) @@ -61,6 +64,7 @@ describe('Decorator', () => { ['id', 'ASC'], ['createdAt', 'DESC'], ], + search: 'white', path: 'http://localhost/items', }) }) diff --git a/src/decorator.ts b/src/decorator.ts index 6927d3f..af000ae 100644 --- a/src/decorator.ts +++ b/src/decorator.ts @@ -5,6 +5,7 @@ export interface PaginateQuery { page?: number limit?: number sortBy?: [string, string][] + search?: string path: string } @@ -14,14 +15,14 @@ export const Paginate = createParamDecorator( const { query } = request const path = request.protocol + '://' + request.get('host') + request.baseUrl + request.path - let sortBy: [string, string][] = [] + const sortBy: [string, string][] = [] if (query.sortBy) { const params = !Array.isArray(query.sortBy) ? [query.sortBy] : query.sortBy - if (params.some((param) => typeof param === 'string')) { - for (const param of params as string[]) { + for (const param of params as string[]) { + if (typeof param === 'string') { const items = param.split(':') if (items.length === 2) { - sortBy.push([items[0], items[1]]) + sortBy.push(items as [string, string]) } } } @@ -30,7 +31,8 @@ export const Paginate = createParamDecorator( return { page: query.page ? parseInt(query.page.toString(), 10) : undefined, limit: query.limit ? parseInt(query.limit.toString(), 10) : undefined, - sortBy: sortBy.length > 0 ? sortBy : undefined, + sortBy: sortBy.length ? sortBy : undefined, + search: query.page ? query.search.toString() : undefined, path, } } diff --git a/src/paginate.spec.ts b/src/paginate.spec.ts index 60c9246..32ba539 100644 --- a/src/paginate.spec.ts +++ b/src/paginate.spec.ts @@ -1,4 +1,4 @@ -import { createConnection, Repository } from 'typeorm' +import { createConnection, Repository, Column } from 'typeorm' import { Paginated, paginate, PaginateConfig } from './paginate' import { PaginateQuery } from './decorator' import { Entity, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm' @@ -9,12 +9,19 @@ export class CatEntity { @PrimaryGeneratedColumn() id: number + @Column() + name: string + + @Column() + color: string + @CreateDateColumn() createdAt: string } describe('paginate', () => { let repo: Repository + let cats: CatEntity[] beforeAll(async () => { const connection = await createConnection({ @@ -25,39 +32,45 @@ describe('paginate', () => { entities: [CatEntity], }) repo = connection.getRepository(CatEntity) - await repo.save([repo.create(), repo.create(), repo.create(), repo.create(), repo.create()]) + cats = await repo.save([ + repo.create({ name: 'Milo', color: 'brown' }), + repo.create({ name: 'Garfield', color: 'ginger' }), + repo.create({ name: 'Shadow', color: 'black' }), + repo.create({ name: 'George', color: 'white' }), + repo.create({ name: 'Leche', color: 'white' }), + ]) }) it('should return an instance of Paginated', async () => { const config: PaginateConfig = { sortableColumns: ['id'], - defaultSortBy: [['createdAt', 'DESC']], // Should fall back to id + defaultSortBy: [['createdAt', 'DESC']], defaultLimit: 1, } const query: PaginateQuery = { path: '', - page: 30, // will fallback to last available page - limit: 2, - sortBy: [['id', 'ASC']], } - const results = await paginate(query, repo, config) + const result = await paginate(query, repo, config) - expect(results).toBeInstanceOf(Paginated) + expect(result).toBeInstanceOf(Paginated) + expect(result.data).toStrictEqual(cats.slice(0, 1)) }) - it('should default to index 0 of sortableColumns, when no other are given', async () => { + it('should default to page 1, if negative page is given', async () => { const config: PaginateConfig = { sortableColumns: ['id'], + defaultLimit: 1, } const query: PaginateQuery = { path: '', - page: 0, + page: -1, } - const results = await paginate(query, repo, config) + const result = await paginate(query, repo, config) - expect(results).toBeInstanceOf(Paginated) + expect(result.meta.currentPage).toBe(1) + expect(result.data).toStrictEqual(cats.slice(0, 1)) }) it('should return correct links', async () => { @@ -82,35 +95,54 @@ describe('paginate', () => { it('should default to defaultSortBy if query sortBy does not exist', async () => { const config: PaginateConfig = { sortableColumns: ['id', 'createdAt'], - defaultSortBy: [['createdAt', 'DESC']], + defaultSortBy: [['id', 'DESC']], } const query: PaginateQuery = { path: '', } - const results = await paginate(query, repo, config) + const result = await paginate(query, repo, config) - expect(results.meta.sortBy).toStrictEqual([['createdAt', 'DESC']]) + expect(result.meta.sortBy).toStrictEqual([['id', 'DESC']]) + expect(result.data).toStrictEqual(cats.slice(0).reverse()) }) - it('should accept multiple columns to sort', async () => { + it('should sort result by multiple columns', async () => { const config: PaginateConfig = { - sortableColumns: ['id', 'createdAt'], + sortableColumns: ['name', 'color'], } const query: PaginateQuery = { path: '', sortBy: [ - ['createdAt', 'DESC'], - ['id', 'ASC'], + ['color', 'DESC'], + ['name', 'ASC'], ], } - const { meta } = await paginate(query, repo, config) + const result = await paginate(query, repo, config) - expect(meta.sortBy).toStrictEqual([ - ['createdAt', 'DESC'], - ['id', 'ASC'], + expect(result.meta.sortBy).toStrictEqual([ + ['color', 'DESC'], + ['name', 'ASC'], ]) + expect(result.data).toStrictEqual([cats[3], cats[4], cats[1], cats[0], cats[2]]) + }) + + it('should return result based on search term', async () => { + const config: PaginateConfig = { + sortableColumns: ['id', 'name', 'color'], + searchableColumns: ['name', 'color'], + } + const query: PaginateQuery = { + path: '', + search: 'i', + } + + const result = await paginate(query, repo, config) + + expect(result.meta.search).toStrictEqual('i') + expect(result.data).toStrictEqual([cats[0], cats[1], cats[3], cats[4]]) + expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=i') }) it('should throw an error when no sortableColumns', async () => { diff --git a/src/paginate.ts b/src/paginate.ts index 88d1a0a..1cdd5cc 100644 --- a/src/paginate.ts +++ b/src/paginate.ts @@ -1,4 +1,4 @@ -import { Repository, FindConditions, SelectQueryBuilder } from 'typeorm' +import { Repository, FindConditions, SelectQueryBuilder, Like, ObjectLiteral } from 'typeorm' import { PaginateQuery } from './decorator' import { ServiceUnavailableException } from '@nestjs/common' @@ -14,6 +14,7 @@ export class Paginated { currentPage: number totalPages: number sortBy: SortBy + search: string } links: { first?: string @@ -26,6 +27,7 @@ export class Paginated { export interface PaginateConfig { sortableColumns: Column[] + searchableColumns?: Column[] maxLimit?: number defaultSortBy?: SortBy defaultLimit?: number @@ -40,6 +42,8 @@ export async function paginate( ): Promise> { let page = query.page || 1 const limit = query.limit || config.defaultLimit || 20 + const sortBy = [] as SortBy + const search = query.search const path = query.path function isEntityKey(sortableColumns: Column[], column: string): column is Column { @@ -49,7 +53,6 @@ export async function paginate( const { sortableColumns } = config if (config.sortableColumns.length < 1) throw new ServiceUnavailableException() - let sortBy: SortBy = [] if (query.sortBy) { for (const order of query.sortBy) { if (isEntityKey(sortableColumns, order[0]) && ['ASC', 'DESC'].includes(order[1])) { @@ -58,39 +61,47 @@ export async function paginate( } } if (!sortBy.length) { - sortBy = sortBy.concat(config.defaultSortBy || [[sortableColumns[0], 'ASC']]) + sortBy.push(...(config.defaultSortBy || [[sortableColumns[0], 'ASC']])) } + if (page < 1) page = 1 + let [items, totalItems]: [T[], number] = [[], 0] + let queryBuilder: SelectQueryBuilder + if (repo instanceof Repository) { - const query = repo + queryBuilder = repo .createQueryBuilder('e') .take(limit) .skip((page - 1) * limit) for (const order of sortBy) { - query.addOrderBy('e.' + order[0], order[1]) + queryBuilder.addOrderBy('e.' + order[0], order[1]) } - - ;[items, totalItems] = await query.where(config.where || {}).getManyAndCount() } else { - const query = repo.take(limit).skip((page - 1) * limit) + queryBuilder = repo.take(limit).skip((page - 1) * limit) for (const order of sortBy) { - query.addOrderBy(repo.alias + '.' + order[0], order[1]) + queryBuilder.addOrderBy(repo.alias + '.' + order[0], order[1]) } - - ;[items, totalItems] = await query.getManyAndCount() } + const where: ObjectLiteral[] = [] + if (search && config.searchableColumns) { + for (const column of config.searchableColumns) { + where.push({ [column]: Like(`%${search}%`), ...config.where }) + } + } + + ;[items, totalItems] = await queryBuilder.where(where.length ? where : config.where || {}).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 options = `&limit=${limit}${sortBy.map((order) => `&sortBy=${order.join(':')}`).join('')}${ + search ? `&search=${search}` : '' + }` const buildLink = (p: number): string => path + '?page=' + p + options @@ -102,6 +113,7 @@ export async function paginate( currentPage: page, totalPages: totalPages, sortBy, + search, }, links: { first: page == 1 ? undefined : buildLink(1),