feat: repository relations and nested search and filters and nested sort (#186)

This commit is contained in:
Sakura 2022-03-15 03:02:01 +08:00 committed by GitHub
parent 5b2fb73baf
commit 6e733f48e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 397 additions and 68 deletions

View File

@ -2,7 +2,6 @@ import {
Repository,
FindConditions,
SelectQueryBuilder,
ObjectLiteral,
FindOperator,
Equal,
MoreThan,
@ -20,8 +19,33 @@ import { PaginateQuery } from './decorator'
import { ServiceUnavailableException } from '@nestjs/common'
import { values, mapKeys } from 'lodash'
import { stringify } from 'querystring'
import { WherePredicateOperator } from 'typeorm/query-builder/WhereClause'
type Join<K, P> = K extends string ? (P extends string ? `${K}${'' extends P ? '' : '.'}${P}` : never) : never
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...0[]]
type Column<T, D extends number = 2> = [D] extends [never]
? never
: T extends Record<string, any>
? {
[K in keyof T]-?: K extends string
? T[K] extends Date
? `${K}`
: T[K] extends Array<infer U>
? `${K}` | Join<K, Column<U, Prev[D]>>
: `${K}` | Join<K, Column<T[K], Prev[D]>>
: never
}[keyof T]
: ''
type RelationColumn<T> = Extract<
Column<T>,
{
[K in Column<T>]: K extends `${infer R}.${string}` ? R : never
}[Column<T>]
>
type Column<T> = Extract<keyof T, string>
type Order<T> = [Column<T>, 'ASC' | 'DESC']
type SortBy<T> = Order<T>[]
@ -47,6 +71,7 @@ export class Paginated<T> {
}
export interface PaginateConfig<T> {
relations?: RelationColumn<T>[]
sortableColumns: Column<T>[]
searchableColumns?: Column<T>[]
maxLimit?: number
@ -207,15 +232,21 @@ export async function paginate<T>(
.createQueryBuilder('e')
.take(limit)
.skip((page - 1) * limit)
for (const order of sortBy) {
queryBuilder.addOrderBy('e.' + order[0], order[1])
}
} else {
queryBuilder = repo.take(limit).skip((page - 1) * limit)
}
for (const order of sortBy) {
queryBuilder.addOrderBy(repo.alias + '.' + order[0], order[1])
if (config.relations?.length) {
config.relations.forEach((relation) => {
queryBuilder.leftJoinAndSelect(`${queryBuilder.alias}.${relation}`, `${queryBuilder.alias}_${relation}`)
})
}
for (const order of sortBy) {
if (order[0].split('.').length > 1) {
queryBuilder.addOrderBy(`${queryBuilder.alias}_${order[0]}`, order[1])
} else {
queryBuilder.addOrderBy(`${queryBuilder.alias}.${order[0]}`, order[1])
}
}
@ -224,16 +255,51 @@ export async function paginate<T>(
}
if (query.search && searchBy.length) {
const search: ObjectLiteral[] = []
for (const column of searchBy) {
search.push({ [column]: ILike(`%${query.search}%`) })
}
queryBuilder.andWhere(new Brackets((qb) => qb.andWhere(search)))
queryBuilder.andWhere(
new Brackets((qb: SelectQueryBuilder<T>) => {
for (const column of searchBy) {
const propertyPath = (column as string).split('.')
if (propertyPath.length > 1) {
const condition: WherePredicateOperator = {
operator: 'ilike',
parameters: [`${qb.alias}_${column}`, `:${column}`],
}
qb.orWhere(qb['createWhereConditionExpression'](condition), {
[column]: `%${query.search}%`,
})
} else {
qb.orWhere({
[column]: ILike(`%${query.search}%`),
})
}
}
})
)
}
if (query.filter) {
const filter = parseFilter(query, config)
queryBuilder.andWhere(new Brackets((qb) => qb.andWhere(filter)))
queryBuilder.andWhere(
new Brackets((qb: SelectQueryBuilder<T>) => {
for (const column in filter) {
const propertyPath = (column as string).split('.')
if (propertyPath.length > 1) {
const condition = qb['getWherePredicateCondition'](
column,
filter[column]
) as WherePredicateOperator
condition.parameters = [`${qb.alias}_${column}`, `:${column}`]
qb.andWhere(qb['createWhereConditionExpression'](condition), {
[column]: filter[column].value,
})
} else {
qb.andWhere({
[column]: filter[column],
})
}
}
})
)
}
;[items, totalItems] = await queryBuilder.getManyAndCount()

View File

@ -2,7 +2,7 @@ import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'
import { HttpArgumentsHost, CustomParamFactory, ExecutionContext } from '@nestjs/common/interfaces'
import { Request as ExpressRequest } from 'express'
import { FastifyRequest } from 'fastify'
import { Paginate, PaginateQuery } from './decorator'
import { Paginate, PaginateQuery } from '../index'
// eslint-disable-next-line @typescript-eslint/ban-types
function getParamDecoratorFactory<T>(decorator: Function): CustomParamFactory {

View File

@ -0,0 +1,17 @@
import { Column, CreateDateColumn, Entity, OneToOne, PrimaryGeneratedColumn } from 'typeorm'
import { CatEntity } from './cat.entity'
@Entity()
export class CatHomeEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@OneToOne(() => CatEntity, (cat) => cat.home)
cat: CatEntity
@CreateDateColumn()
createdAt: string
}

View File

@ -0,0 +1,18 @@
import { Column, CreateDateColumn, Entity, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'
import { CatEntity } from './cat.entity'
@Entity()
export class CatToyEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@ManyToOne(() => CatEntity, (cat) => cat.toys)
@JoinColumn()
cat: CatEntity
@CreateDateColumn()
createdAt: string
}

View File

@ -0,0 +1,28 @@
import { Column, CreateDateColumn, Entity, JoinColumn, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm'
import { CatToyEntity } from './cat-toy.entity'
import { CatHomeEntity } from './cat-home.entity'
@Entity()
export class CatEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@Column()
color: string
@Column({ nullable: true })
age: number | null
@OneToMany(() => CatToyEntity, (catToy) => catToy.cat)
toys: CatToyEntity[]
@OneToOne(() => CatHomeEntity, (catHome) => catHome.cat, { nullable: true })
@JoinColumn()
home: CatHomeEntity
@CreateDateColumn()
createdAt: string
}

View File

@ -1,4 +1,4 @@
import { createConnection, Repository, Column, In, Connection } from 'typeorm'
import { createConnection, Repository, In, Connection } from 'typeorm'
import {
Paginated,
paginate,
@ -7,33 +7,22 @@ import {
isOperator,
getFilterTokens,
OperatorSymbolToFunction,
} from './paginate'
import { PaginateQuery } from './decorator'
import { Entity, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'
} from '../index'
import { PaginateQuery } from '../index'
import { HttpException } from '@nestjs/common'
@Entity()
export class CatEntity {
@PrimaryGeneratedColumn()
id: number
@Column()
name: string
@Column()
color: string
@Column({ nullable: true })
age: number | null
@CreateDateColumn()
createdAt: string
}
import { CatEntity } from './entity/cat.entity'
import { CatToyEntity } from './entity/cat-toy.entity'
import { CatHomeEntity } from './entity/cat-home.entity'
import { clone } from 'lodash'
describe('paginate', () => {
let connection: Connection
let repo: Repository<CatEntity>
let catRepo: Repository<CatEntity>
let catToyRepo: Repository<CatToyEntity>
let catHomeRepo: Repository<CatHomeEntity>
let cats: CatEntity[]
let catToys: CatToyEntity[]
let catHomes: CatHomeEntity[]
beforeAll(async () => {
connection = await createConnection({
@ -41,15 +30,27 @@ describe('paginate', () => {
database: ':memory:',
synchronize: true,
logging: false,
entities: [CatEntity],
entities: [CatEntity, CatToyEntity, CatHomeEntity],
})
repo = connection.getRepository(CatEntity)
cats = await repo.save([
repo.create({ name: 'Milo', color: 'brown', age: 6 }),
repo.create({ name: 'Garfield', color: 'ginger', age: 5 }),
repo.create({ name: 'Shadow', color: 'black', age: 4 }),
repo.create({ name: 'George', color: 'white', age: 3 }),
repo.create({ name: 'Leche', color: 'white', age: null }),
catRepo = connection.getRepository(CatEntity)
catToyRepo = connection.getRepository(CatToyEntity)
catHomeRepo = connection.getRepository(CatHomeEntity)
cats = await catRepo.save([
catRepo.create({ name: 'Milo', color: 'brown', age: 6 }),
catRepo.create({ name: 'Garfield', color: 'ginger', age: 5 }),
catRepo.create({ name: 'Shadow', color: 'black', age: 4 }),
catRepo.create({ name: 'George', color: 'white', age: 3 }),
catRepo.create({ name: 'Leche', color: 'white', age: null }),
])
catToys = await catToyRepo.save([
catToyRepo.create({ name: 'Fuzzy Thing', cat: cats[0] }),
catToyRepo.create({ name: 'Stuffed Mouse', cat: cats[0] }),
catToyRepo.create({ name: 'Mouse', cat: cats[0] }),
catToyRepo.create({ name: 'String', cat: cats[1] }),
])
catHomes = await catHomeRepo.save([
catHomeRepo.create({ name: 'Box', cat: cats[0] }),
catHomeRepo.create({ name: 'House', cat: cats[1] }),
])
})
@ -63,7 +64,7 @@ describe('paginate', () => {
path: '',
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result).toBeInstanceOf(Paginated)
expect(result.data).toStrictEqual(cats.slice(0, 1))
@ -79,7 +80,7 @@ describe('paginate', () => {
path: '',
}
const queryBuilder = await repo.createQueryBuilder('cats')
const queryBuilder = await catRepo.createQueryBuilder('cats')
const result = await paginate<CatEntity>(query, queryBuilder, config)
@ -117,7 +118,7 @@ describe('paginate', () => {
page: -1,
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.currentPage).toBe(1)
expect(result.data).toStrictEqual(cats.slice(0, 1))
@ -135,7 +136,7 @@ describe('paginate', () => {
limit: 20,
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data).toStrictEqual(cats.slice(0, 2))
})
@ -150,7 +151,7 @@ describe('paginate', () => {
limit: 2,
}
const { links } = await paginate<CatEntity>(query, repo, config)
const { links } = await paginate<CatEntity>(query, catRepo, config)
expect(links.first).toBe('?page=1&limit=2&sortBy=id:ASC')
expect(links.previous).toBe('?page=1&limit=2&sortBy=id:ASC')
@ -171,7 +172,7 @@ describe('paginate', () => {
search: 'Pluto',
}
const { links } = await paginate<CatEntity>(query, repo, config)
const { links } = await paginate<CatEntity>(query, catRepo, config)
expect(links.first).toBe(undefined)
expect(links.previous).toBe(undefined)
@ -189,7 +190,7 @@ describe('paginate', () => {
path: '',
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.sortBy).toStrictEqual([['id', 'DESC']])
expect(result.data).toStrictEqual(cats.slice(0).reverse())
@ -207,7 +208,7 @@ describe('paginate', () => {
],
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.sortBy).toStrictEqual([
['color', 'DESC'],
@ -226,13 +227,129 @@ describe('paginate', () => {
search: 'i',
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, 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 return result based on search term on many-to-one relation', async () => {
const config: PaginateConfig<CatToyEntity> = {
relations: ['cat'],
sortableColumns: ['id', 'name'],
searchableColumns: ['name', 'cat.name'],
}
const query: PaginateQuery = {
path: '',
search: 'Milo',
}
const result = await paginate<CatToyEntity>(query, catToyRepo, config)
expect(result.meta.search).toStrictEqual('Milo')
expect(result.data).toStrictEqual([catToys[0], catToys[1], catToys[2]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=Milo')
})
it('should return result based on search term on one-to-many relation', async () => {
const config: PaginateConfig<CatEntity> = {
relations: ['toys'],
sortableColumns: ['id', 'name'],
searchableColumns: ['name', 'toys.name'],
}
const query: PaginateQuery = {
path: '',
search: 'Mouse',
}
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.search).toStrictEqual('Mouse')
const toy = clone(catToys[1])
delete toy.cat
const toy2 = clone(catToys[2])
delete toy2.cat
expect(result.data).toStrictEqual([Object.assign(clone(cats[0]), { toys: [toy, toy2] })])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=Mouse')
})
it('should return result based on search term on one-to-one relation', async () => {
const config: PaginateConfig<CatHomeEntity> = {
relations: ['cat'],
sortableColumns: ['id', 'name', 'cat.id'],
}
const query: PaginateQuery = {
path: '',
sortBy: [['cat.id', 'DESC']],
}
const result = await paginate<CatHomeEntity>(query, catHomeRepo, config)
expect(result.meta.sortBy).toStrictEqual([['cat.id', 'DESC']])
expect(result.data).toStrictEqual([catHomes[0], catHomes[1]].sort((a, b) => b.cat.id - a.cat.id))
expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.id:DESC')
})
it('should return result based on sort and search on many-to-one relation', async () => {
const config: PaginateConfig<CatToyEntity> = {
relations: ['cat'],
sortableColumns: ['id', 'name', 'cat.id'],
searchableColumns: ['name', 'cat.name'],
}
const query: PaginateQuery = {
path: '',
sortBy: [['cat.id', 'DESC']],
search: 'Milo',
}
const result = await paginate<CatToyEntity>(query, catToyRepo, config)
expect(result.meta.search).toStrictEqual('Milo')
expect(result.data).toStrictEqual([catToys[0], catToys[1], catToys[2]].sort((a, b) => b.cat.id - a.cat.id))
expect(result.links.current).toBe('?page=1&limit=20&sortBy=cat.id:DESC&search=Milo')
})
it('should return result based on sort on one-to-many relation', async () => {
const config: PaginateConfig<CatEntity> = {
relations: ['toys'],
sortableColumns: ['id', 'name', 'toys.id'],
searchableColumns: ['name', 'toys.name'],
}
const query: PaginateQuery = {
path: '',
sortBy: [['toys.id', 'DESC']],
search: 'Mouse',
}
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.search).toStrictEqual('Mouse')
const toy1 = clone(catToys[1])
delete toy1.cat
const toy2 = clone(catToys[2])
delete toy2.cat
expect(result.data).toStrictEqual([Object.assign(clone(cats[0]), { toys: [toy2, toy1] })])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=toys.id:DESC&search=Mouse')
})
it('should return result based on sort on one-to-one relation', async () => {
const config: PaginateConfig<CatHomeEntity> = {
relations: ['cat'],
sortableColumns: ['id', 'name'],
searchableColumns: ['name', 'cat.name'],
}
const query: PaginateQuery = {
path: '',
search: 'Garfield',
}
const result = await paginate<CatHomeEntity>(query, catHomeRepo, config)
expect(result.meta.search).toStrictEqual('Garfield')
expect(result.data).toStrictEqual([catHomes[1]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&search=Garfield')
})
it('should return result based on search term and searchBy columns', async () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id', 'name', 'color'],
@ -248,7 +365,7 @@ describe('paginate', () => {
searchBy: ['color'],
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.search).toStrictEqual(searchTerm)
expect(result.meta.searchBy).toStrictEqual(['color'])
@ -273,7 +390,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.filter).toStrictEqual({
name: '$not:Leche',
@ -282,6 +399,89 @@ describe('paginate', () => {
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.name=$not:Leche')
})
it('should return result based on filter on many-to-one relation', async () => {
const config: PaginateConfig<CatToyEntity> = {
relations: ['cat'],
sortableColumns: ['id', 'name'],
filterableColumns: {
'cat.name': [FilterOperator.NOT],
},
}
const query: PaginateQuery = {
path: '',
filter: {
'cat.name': '$not:Milo',
},
}
const result = await paginate<CatToyEntity>(query, catToyRepo, config)
expect(result.meta.filter).toStrictEqual({
'cat.name': '$not:Milo',
})
expect(result.data).toStrictEqual([catToys[3]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.name=$not:Milo')
})
it('should return result based on filter on one-to-many relation', async () => {
const config: PaginateConfig<CatEntity> = {
relations: ['toys'],
sortableColumns: ['id', 'name'],
filterableColumns: {
'toys.name': [FilterOperator.NOT],
},
}
const query: PaginateQuery = {
path: '',
filter: {
'toys.name': '$not:Stuffed Mouse',
},
}
const result = await paginate<CatEntity>(query, catRepo, config)
const cat1 = clone(cats[0])
const cat2 = clone(cats[1])
const catToys1 = clone(catToys[0])
const catToys2 = clone(catToys[2])
const catToys3 = clone(catToys[3])
delete catToys1.cat
delete catToys2.cat
delete catToys3.cat
cat1.toys = [catToys1, catToys2]
cat2.toys = [catToys3]
expect(result.meta.filter).toStrictEqual({
'toys.name': '$not:Stuffed Mouse',
})
expect(result.data).toStrictEqual([cat1, cat2])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.toys.name=$not:Stuffed Mouse')
})
it('should return result based on where config and filter on one-to-one relation', async () => {
const config: PaginateConfig<CatHomeEntity> = {
relations: ['cat'],
sortableColumns: ['id', 'name'],
filterableColumns: {
'cat.name': [FilterOperator.NOT],
},
}
const query: PaginateQuery = {
path: '',
filter: {
'cat.name': '$not:Garfield',
},
}
const result = await paginate<CatHomeEntity>(query, catHomeRepo, config)
expect(result.meta.filter).toStrictEqual({
'cat.name': '$not:Garfield',
})
expect(result.data).toStrictEqual([catHomes[0]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.cat.name=$not:Garfield')
})
it('should return result based on where array and filter', async () => {
const config: PaginateConfig<CatEntity> = {
sortableColumns: ['id'],
@ -304,7 +504,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.filter).toStrictEqual({
name: '$not:Leche',
@ -329,7 +529,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.filter).toStrictEqual({
name: '$not:Leche',
@ -355,7 +555,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.meta.search).toStrictEqual('white')
expect(result.meta.filter).toStrictEqual({ id: '$not:$in:1,2,5' })
@ -380,7 +580,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data).toStrictEqual([cats[2], cats[3]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.id=$not:$in:1,2,5')
@ -400,7 +600,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data).toStrictEqual([cats[0], cats[1], cats[2]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.age=$gte:4')
@ -420,7 +620,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data).toStrictEqual([cats[1], cats[2]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.age=$btw:4,5')
@ -440,7 +640,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data).toStrictEqual([cats[4]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.age=$null')
@ -460,7 +660,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data).toStrictEqual([cats[0], cats[1], cats[2], cats[3]])
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.age=$not:$null')
@ -480,7 +680,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data).toStrictEqual(cats)
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.age=$not:$null')
@ -500,7 +700,7 @@ describe('paginate', () => {
},
}
const result = await paginate<CatEntity>(query, repo, config)
const result = await paginate<CatEntity>(query, catRepo, config)
expect(result.data).toStrictEqual(cats)
expect(result.links.current).toBe('?page=1&limit=20&sortBy=id:ASC&filter.age=$not:$null')
@ -515,7 +715,7 @@ describe('paginate', () => {
}
try {
await paginate<CatEntity>(query, repo, config)
await paginate<CatEntity>(query, catRepo, config)
} catch (err) {
expect(err).toBeInstanceOf(HttpException)
}