diff --git a/src/paginate.ts b/src/paginate.ts index e7375dd..788864d 100644 --- a/src/paginate.ts +++ b/src/paginate.ts @@ -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 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 = [D] extends [never] + ? never + : T extends Record + ? { + [K in keyof T]-?: K extends string + ? T[K] extends Date + ? `${K}` + : T[K] extends Array + ? `${K}` | Join> + : `${K}` | Join> + : never + }[keyof T] + : '' + +type RelationColumn = Extract< + Column, + { + [K in Column]: K extends `${infer R}.${string}` ? R : never + }[Column] +> -type Column = Extract type Order = [Column, 'ASC' | 'DESC'] type SortBy = Order[] @@ -47,6 +71,7 @@ export class Paginated { } export interface PaginateConfig { + relations?: RelationColumn[] sortableColumns: Column[] searchableColumns?: Column[] maxLimit?: number @@ -207,15 +232,21 @@ export async function paginate( .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( } 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) => { + 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) => { + 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() diff --git a/src/decorator.spec.ts b/src/test/decorator.spec.ts similarity index 98% rename from src/decorator.spec.ts rename to src/test/decorator.spec.ts index 7e6ace4..e425037 100644 --- a/src/decorator.spec.ts +++ b/src/test/decorator.spec.ts @@ -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(decorator: Function): CustomParamFactory { diff --git a/src/test/entity/cat-home.entity.ts b/src/test/entity/cat-home.entity.ts new file mode 100644 index 0000000..933326e --- /dev/null +++ b/src/test/entity/cat-home.entity.ts @@ -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 +} diff --git a/src/test/entity/cat-toy.entity.ts b/src/test/entity/cat-toy.entity.ts new file mode 100644 index 0000000..011ff33 --- /dev/null +++ b/src/test/entity/cat-toy.entity.ts @@ -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 +} diff --git a/src/test/entity/cat.entity.ts b/src/test/entity/cat.entity.ts new file mode 100644 index 0000000..4def491 --- /dev/null +++ b/src/test/entity/cat.entity.ts @@ -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 +} diff --git a/src/paginate.spec.ts b/src/test/paginate.spec.ts similarity index 60% rename from src/paginate.spec.ts rename to src/test/paginate.spec.ts index e3320fe..df4be66 100644 --- a/src/paginate.spec.ts +++ b/src/test/paginate.spec.ts @@ -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 + let catRepo: Repository + let catToyRepo: Repository + let catHomeRepo: Repository 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(query, repo, config) + const result = await paginate(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(query, queryBuilder, config) @@ -117,7 +118,7 @@ describe('paginate', () => { page: -1, } - const result = await paginate(query, repo, config) + const result = await paginate(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(query, repo, config) + const result = await paginate(query, catRepo, config) expect(result.data).toStrictEqual(cats.slice(0, 2)) }) @@ -150,7 +151,7 @@ describe('paginate', () => { limit: 2, } - const { links } = await paginate(query, repo, config) + const { links } = await paginate(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(query, repo, config) + const { links } = await paginate(query, catRepo, config) expect(links.first).toBe(undefined) expect(links.previous).toBe(undefined) @@ -189,7 +190,7 @@ describe('paginate', () => { path: '', } - const result = await paginate(query, repo, config) + const result = await paginate(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(query, repo, config) + const result = await paginate(query, catRepo, config) expect(result.meta.sortBy).toStrictEqual([ ['color', 'DESC'], @@ -226,13 +227,129 @@ describe('paginate', () => { search: 'i', } - const result = await paginate(query, repo, config) + const result = await paginate(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 = { + relations: ['cat'], + sortableColumns: ['id', 'name'], + searchableColumns: ['name', 'cat.name'], + } + const query: PaginateQuery = { + path: '', + search: 'Milo', + } + + const result = await paginate(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 = { + relations: ['toys'], + sortableColumns: ['id', 'name'], + searchableColumns: ['name', 'toys.name'], + } + const query: PaginateQuery = { + path: '', + search: 'Mouse', + } + + const result = await paginate(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 = { + relations: ['cat'], + sortableColumns: ['id', 'name', 'cat.id'], + } + const query: PaginateQuery = { + path: '', + sortBy: [['cat.id', 'DESC']], + } + + const result = await paginate(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 = { + 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(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 = { + 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(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 = { + relations: ['cat'], + sortableColumns: ['id', 'name'], + searchableColumns: ['name', 'cat.name'], + } + const query: PaginateQuery = { + path: '', + search: 'Garfield', + } + + const result = await paginate(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 = { sortableColumns: ['id', 'name', 'color'], @@ -248,7 +365,7 @@ describe('paginate', () => { searchBy: ['color'], } - const result = await paginate(query, repo, config) + const result = await paginate(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(query, repo, config) + const result = await paginate(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 = { + relations: ['cat'], + sortableColumns: ['id', 'name'], + filterableColumns: { + 'cat.name': [FilterOperator.NOT], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + 'cat.name': '$not:Milo', + }, + } + + const result = await paginate(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 = { + relations: ['toys'], + sortableColumns: ['id', 'name'], + filterableColumns: { + 'toys.name': [FilterOperator.NOT], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + 'toys.name': '$not:Stuffed Mouse', + }, + } + + const result = await paginate(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 = { + relations: ['cat'], + sortableColumns: ['id', 'name'], + filterableColumns: { + 'cat.name': [FilterOperator.NOT], + }, + } + const query: PaginateQuery = { + path: '', + filter: { + 'cat.name': '$not:Garfield', + }, + } + + const result = await paginate(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 = { sortableColumns: ['id'], @@ -304,7 +504,7 @@ describe('paginate', () => { }, } - const result = await paginate(query, repo, config) + const result = await paginate(query, catRepo, config) expect(result.meta.filter).toStrictEqual({ name: '$not:Leche', @@ -329,7 +529,7 @@ describe('paginate', () => { }, } - const result = await paginate(query, repo, config) + const result = await paginate(query, catRepo, config) expect(result.meta.filter).toStrictEqual({ name: '$not:Leche', @@ -355,7 +555,7 @@ describe('paginate', () => { }, } - const result = await paginate(query, repo, config) + const result = await paginate(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(query, repo, config) + const result = await paginate(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(query, repo, config) + const result = await paginate(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(query, repo, config) + const result = await paginate(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(query, repo, config) + const result = await paginate(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(query, repo, config) + const result = await paginate(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(query, repo, config) + const result = await paginate(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(query, repo, config) + const result = await paginate(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(query, repo, config) + await paginate(query, catRepo, config) } catch (err) { expect(err).toBeInstanceOf(HttpException) }