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/) - Pagination conforms to [JSON:API](https://jsonapi.org/)
- Sort by multiple columns - Sort by multiple columns
- Filter by multiple columns using operators - Filter using operators
- Search across columns
## Installation ## Installation
@ -28,7 +29,7 @@ The following code exposes a route that can be utilized like so:
#### Endpoint #### Endpoint
```url ```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 #### Result
@ -58,7 +59,7 @@ http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC
}, },
{ {
"id": 3, "id": 3,
"name": "Shadow", "name": "Kitty",
"color": "black" "color": "black"
} }
], ],
@ -70,11 +71,11 @@ http://localhost:3000/cats?limit=5&page=2&sortBy=color:DESC
"sortBy": [["color", "DESC"]] "sortBy": [["color", "DESC"]]
}, },
"links": { "links": {
"first": "http://localhost:3000/cats?limit=5&page=1&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", "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", "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", "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" "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'], 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 * Required: false
* Type: number * Type: number

View File

@ -3,6 +3,7 @@ import { HttpArgumentsHost, CustomParamFactory, ExecutionContext } from '@nestjs
import { Request } from 'express' import { Request } from 'express'
import { Paginate, PaginateQuery } from './decorator' import { Paginate, PaginateQuery } from './decorator'
// eslint-disable-next-line @typescript-eslint/ban-types
function getParamDecoratorFactory<T>(decorator: Function): CustomParamFactory { function getParamDecoratorFactory<T>(decorator: Function): CustomParamFactory {
class Test { class Test {
public test(@decorator() _value: T): void { public test(@decorator() _value: T): void {
@ -41,6 +42,7 @@ describe('Decorator', () => {
page: undefined, page: undefined,
limit: undefined, limit: undefined,
sortBy: undefined, sortBy: undefined,
search: undefined,
path: 'http://localhost/items', path: 'http://localhost/items',
}) })
}) })
@ -50,6 +52,7 @@ describe('Decorator', () => {
page: '1', page: '1',
limit: '20', limit: '20',
sortBy: ['id:ASC', 'createdAt:DESC'], sortBy: ['id:ASC', 'createdAt:DESC'],
search: 'white',
}) })
const result: PaginateQuery = decoratorfactory(null, context) const result: PaginateQuery = decoratorfactory(null, context)
@ -61,6 +64,7 @@ describe('Decorator', () => {
['id', 'ASC'], ['id', 'ASC'],
['createdAt', 'DESC'], ['createdAt', 'DESC'],
], ],
search: 'white',
path: 'http://localhost/items', path: 'http://localhost/items',
}) })
}) })

View File

@ -5,6 +5,7 @@ export interface PaginateQuery {
page?: number page?: number
limit?: number limit?: number
sortBy?: [string, string][] sortBy?: [string, string][]
search?: string
path: string path: string
} }
@ -14,14 +15,14 @@ export const Paginate = createParamDecorator(
const { query } = request const { query } = request
const path = request.protocol + '://' + request.get('host') + request.baseUrl + request.path const path = request.protocol + '://' + request.get('host') + request.baseUrl + request.path
let sortBy: [string, string][] = [] const sortBy: [string, string][] = []
if (query.sortBy) { if (query.sortBy) {
const params = !Array.isArray(query.sortBy) ? [query.sortBy] : 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(':') const items = param.split(':')
if (items.length === 2) { 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 { return {
page: query.page ? parseInt(query.page.toString(), 10) : undefined, page: query.page ? parseInt(query.page.toString(), 10) : undefined,
limit: query.limit ? parseInt(query.limit.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, 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 { Paginated, paginate, PaginateConfig } from './paginate'
import { PaginateQuery } from './decorator' import { PaginateQuery } from './decorator'
import { Entity, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm' import { Entity, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'
@ -9,12 +9,19 @@ export class CatEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number id: number
@Column()
name: string
@Column()
color: string
@CreateDateColumn() @CreateDateColumn()
createdAt: string createdAt: string
} }
describe('paginate', () => { describe('paginate', () => {
let repo: Repository<CatEntity> let repo: Repository<CatEntity>
let cats: CatEntity[]
beforeAll(async () => { beforeAll(async () => {
const connection = await createConnection({ const connection = await createConnection({
@ -25,39 +32,45 @@ describe('paginate', () => {
entities: [CatEntity], entities: [CatEntity],
}) })
repo = connection.getRepository(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 () => { it('should return an instance of Paginated', async () => {
const config: PaginateConfig<CatEntity> = { const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'], sortableColumns: ['id'],
defaultSortBy: [['createdAt', 'DESC']], // Should fall back to id defaultSortBy: [['createdAt', 'DESC']],
defaultLimit: 1, defaultLimit: 1,
} }
const query: PaginateQuery = { const query: PaginateQuery = {
path: '', 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> = { const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'], sortableColumns: ['id'],
defaultLimit: 1,
} }
const query: PaginateQuery = { const query: PaginateQuery = {
path: '', 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 () => { it('should return correct links', async () => {
@ -82,35 +95,54 @@ describe('paginate', () => {
it('should default to defaultSortBy if query sortBy does not exist', async () => { it('should default to defaultSortBy if query sortBy does not exist', async () => {
const config: PaginateConfig<CatEntity> = { const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id', 'createdAt'], sortableColumns: ['id', 'createdAt'],
defaultSortBy: [['createdAt', 'DESC']], defaultSortBy: [['id', 'DESC']],
} }
const query: PaginateQuery = { const query: PaginateQuery = {
path: '', 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> = { const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id', 'createdAt'], sortableColumns: ['name', 'color'],
} }
const query: PaginateQuery = { const query: PaginateQuery = {
path: '', path: '',
sortBy: [ sortBy: [
['createdAt', 'DESC'], ['color', 'DESC'],
['id', 'ASC'], ['name', 'ASC'],
], ],
} }
const { meta } = await paginate<CatEntity>(query, repo, config) const result = await paginate<CatEntity>(query, repo, config)
expect(meta.sortBy).toStrictEqual([ expect(result.meta.sortBy).toStrictEqual([
['createdAt', 'DESC'], ['color', 'DESC'],
['id', 'ASC'], ['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 () => { 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 { PaginateQuery } from './decorator'
import { ServiceUnavailableException } from '@nestjs/common' import { ServiceUnavailableException } from '@nestjs/common'
@ -14,6 +14,7 @@ export class Paginated<T> {
currentPage: number currentPage: number
totalPages: number totalPages: number
sortBy: SortBy<T> sortBy: SortBy<T>
search: string
} }
links: { links: {
first?: string first?: string
@ -26,6 +27,7 @@ export class Paginated<T> {
export interface PaginateConfig<T> { export interface PaginateConfig<T> {
sortableColumns: Column<T>[] sortableColumns: Column<T>[]
searchableColumns?: Column<T>[]
maxLimit?: number maxLimit?: number
defaultSortBy?: SortBy<T> defaultSortBy?: SortBy<T>
defaultLimit?: number defaultLimit?: number
@ -40,6 +42,8 @@ export async function paginate<T>(
): Promise<Paginated<T>> { ): Promise<Paginated<T>> {
let page = query.page || 1 let page = query.page || 1
const limit = query.limit || config.defaultLimit || 20 const limit = query.limit || config.defaultLimit || 20
const sortBy = [] as SortBy<T>
const search = query.search
const path = query.path const path = query.path
function isEntityKey(sortableColumns: Column<T>[], column: string): column is Column<T> { function isEntityKey(sortableColumns: Column<T>[], column: string): column is Column<T> {
@ -49,7 +53,6 @@ export async function paginate<T>(
const { sortableColumns } = config const { sortableColumns } = config
if (config.sortableColumns.length < 1) throw new ServiceUnavailableException() if (config.sortableColumns.length < 1) throw new ServiceUnavailableException()
let sortBy: SortBy<T> = []
if (query.sortBy) { if (query.sortBy) {
for (const order of query.sortBy) { for (const order of query.sortBy) {
if (isEntityKey(sortableColumns, order[0]) && ['ASC', 'DESC'].includes(order[1])) { if (isEntityKey(sortableColumns, order[0]) && ['ASC', 'DESC'].includes(order[1])) {
@ -58,39 +61,47 @@ export async function paginate<T>(
} }
} }
if (!sortBy.length) { 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 [items, totalItems]: [T[], number] = [[], 0]
let queryBuilder: SelectQueryBuilder<T>
if (repo instanceof Repository) { if (repo instanceof Repository) {
const query = repo queryBuilder = repo
.createQueryBuilder('e') .createQueryBuilder('e')
.take(limit) .take(limit)
.skip((page - 1) * limit) .skip((page - 1) * limit)
for (const order of sortBy) { 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 { } else {
const query = repo.take(limit).skip((page - 1) * limit) queryBuilder = repo.take(limit).skip((page - 1) * limit)
for (const order of sortBy) { 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 let totalPages = totalItems / limit
if (totalItems % limit) totalPages = Math.ceil(totalPages) if (totalItems % limit) totalPages = Math.ceil(totalPages)
if (page > totalPages) page = totalPages const options = `&limit=${limit}${sortBy.map((order) => `&sortBy=${order.join(':')}`).join('')}${
if (page < 1) page = 1 search ? `&search=${search}` : ''
}`
const options = `&limit=${limit}${sortBy.map((order) => `&sortBy=${order.join(':')}`).join('')}`
const buildLink = (p: number): string => path + '?page=' + p + options const buildLink = (p: number): string => path + '?page=' + p + options
@ -102,6 +113,7 @@ export async function paginate<T>(
currentPage: page, currentPage: page,
totalPages: totalPages, totalPages: totalPages,
sortBy, sortBy,
search,
}, },
links: { links: {
first: page == 1 ? undefined : buildLink(1), first: page == 1 ? undefined : buildLink(1),