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/)
|
- 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
|
||||||
|
@ -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',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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),
|
||||||
|
Loading…
Reference in New Issue
Block a user