feat: search across columns
This commit is contained in:
parent
e11a785f81
commit
e326de652c
24
README.md
24
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<CatEntity> {
|
||||
*/
|
||||
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
|
||||
|
@ -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<T>(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',
|
||||
})
|
||||
})
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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<CatEntity>
|
||||
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<CatEntity> = {
|
||||
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<CatEntity>(query, repo, config)
|
||||
const result = await paginate<CatEntity>(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<CatEntity> = {
|
||||
sortableColumns: ['id'],
|
||||
defaultLimit: 1,
|
||||
}
|
||||
const query: PaginateQuery = {
|
||||
path: '',
|
||||
page: 0,
|
||||
page: -1,
|
||||
}
|
||||
|
||||
const results = await paginate<CatEntity>(query, repo, config)
|
||||
const result = await paginate<CatEntity>(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<CatEntity> = {
|
||||
sortableColumns: ['id', 'createdAt'],
|
||||
defaultSortBy: [['createdAt', 'DESC']],
|
||||
defaultSortBy: [['id', 'DESC']],
|
||||
}
|
||||
const query: PaginateQuery = {
|
||||
path: '',
|
||||
}
|
||||
|
||||
const results = await paginate<CatEntity>(query, repo, config)
|
||||
const result = await paginate<CatEntity>(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<CatEntity> = {
|
||||
sortableColumns: ['id', 'createdAt'],
|
||||
sortableColumns: ['name', 'color'],
|
||||
}
|
||||
const query: PaginateQuery = {
|
||||
path: '',
|
||||
sortBy: [
|
||||
['createdAt', 'DESC'],
|
||||
['id', 'ASC'],
|
||||
['color', 'DESC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
}
|
||||
|
||||
const { meta } = await paginate<CatEntity>(query, repo, config)
|
||||
const result = await paginate<CatEntity>(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<CatEntity> = {
|
||||
sortableColumns: ['id', 'name', 'color'],
|
||||
searchableColumns: ['name', 'color'],
|
||||
}
|
||||
const query: PaginateQuery = {
|
||||
path: '',
|
||||
search: 'i',
|
||||
}
|
||||
|
||||
const result = await paginate<CatEntity>(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 () => {
|
||||
|
@ -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<T> {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
sortBy: SortBy<T>
|
||||
search: string
|
||||
}
|
||||
links: {
|
||||
first?: string
|
||||
@ -26,6 +27,7 @@ export class Paginated<T> {
|
||||
|
||||
export interface PaginateConfig<T> {
|
||||
sortableColumns: Column<T>[]
|
||||
searchableColumns?: Column<T>[]
|
||||
maxLimit?: number
|
||||
defaultSortBy?: SortBy<T>
|
||||
defaultLimit?: number
|
||||
@ -40,6 +42,8 @@ export async function paginate<T>(
|
||||
): Promise<Paginated<T>> {
|
||||
let page = query.page || 1
|
||||
const limit = query.limit || config.defaultLimit || 20
|
||||
const sortBy = [] as SortBy<T>
|
||||
const search = query.search
|
||||
const path = query.path
|
||||
|
||||
function isEntityKey(sortableColumns: Column<T>[], column: string): column is Column<T> {
|
||||
@ -49,7 +53,6 @@ export async function paginate<T>(
|
||||
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])) {
|
||||
@ -58,39 +61,47 @@ export async function paginate<T>(
|
||||
}
|
||||
}
|
||||
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<T>
|
||||
|
||||
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<T>(
|
||||
currentPage: page,
|
||||
totalPages: totalPages,
|
||||
sortBy,
|
||||
search,
|
||||
},
|
||||
links: {
|
||||
first: page == 1 ? undefined : buildLink(1),
|
||||
|
Loading…
Reference in New Issue
Block a user