feat: search across columns

This commit is contained in:
ppetzold 2020-06-28 19:34:00 +02:00
parent e11a785f81
commit e326de652c
5 changed files with 109 additions and 51 deletions

View File

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

View File

@ -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',
})
})

View File

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

View File

@ -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 () => {

View File

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