From 19db83590a227eef8bdf47c50b1526da6d4219b8 Mon Sep 17 00:00:00 2001 From: Youngho Kim Date: Sun, 26 May 2024 19:50:20 +0900 Subject: [PATCH] refactor: improve validation for search route and refactor test code (#548) --- src/lib/crud.service.ts | 38 +- .../search-request.interceptor.spec.ts | 491 ++++++++++-------- .../interceptor/search-request.interceptor.ts | 67 +-- .../provider/typeorm-query-builder.helper.ts | 123 ++--- 4 files changed, 392 insertions(+), 327 deletions(-) diff --git a/src/lib/crud.service.ts b/src/lib/crud.service.ts index b3a06b8..266c121 100644 --- a/src/lib/crud.service.ts +++ b/src/lib/crud.service.ts @@ -31,29 +31,23 @@ export class CrudService { readonly reservedReadMany = async (crudReadManyRequest: CrudReadManyRequest): Promise> => { crudReadManyRequest.excludedColumns(this.columnNames); - try { - const { entities, total } = await (async () => { - const findEntities = this.repository.find({ ...crudReadManyRequest.findOptions }); + const { entities, total } = await (async () => { + const findEntities = this.repository.find({ ...crudReadManyRequest.findOptions }); - if (crudReadManyRequest.pagination.isNext) { - const entities = await findEntities; - return { entities, total: crudReadManyRequest.pagination.nextTotal() }; - } - const [entities, total] = await Promise.all([ - findEntities, - this.repository.count({ - where: crudReadManyRequest.findOptions.where, - withDeleted: crudReadManyRequest.findOptions.withDeleted, - }), - ]); - return { entities, total }; - })(); - return crudReadManyRequest.toResponse(entities, total); - } catch (error) { - Logger.error(JSON.stringify(crudReadManyRequest)); - Logger.error(error); - throw error; - } + if (crudReadManyRequest.pagination.isNext) { + const entities = await findEntities; + return { entities, total: crudReadManyRequest.pagination.nextTotal() }; + } + const [entities, total] = await Promise.all([ + findEntities, + this.repository.count({ + where: crudReadManyRequest.findOptions.where, + withDeleted: crudReadManyRequest.findOptions.withDeleted, + }), + ]); + return { entities, total }; + })(); + return crudReadManyRequest.toResponse(entities, total); }; readonly reservedReadOne = async (crudReadOneRequest: CrudReadOneRequest): Promise => { diff --git a/src/lib/interceptor/search-request.interceptor.spec.ts b/src/lib/interceptor/search-request.interceptor.spec.ts index 7322a9e..3968bcf 100644 --- a/src/lib/interceptor/search-request.interceptor.spec.ts +++ b/src/lib/interceptor/search-request.interceptor.spec.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { NestInterceptor, UnprocessableEntityException } from '@nestjs/common'; +import { UnprocessableEntityException } from '@nestjs/common'; import { IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; import { BaseEntity } from 'typeorm'; -import { CustomSearchRequestOptions } from './custom-request.interceptor'; import { SearchRequestInterceptor } from './search-request.interceptor'; import { Sort } from '../interface'; import { CrudLogger } from '../provider/crud-logger'; @@ -23,10 +22,17 @@ describe('SearchRequestInterceptor', () => { col3: number; } - let interceptor: any; + let interceptor: InstanceType>; beforeAll(() => { const Interceptor = SearchRequestInterceptor( - { entity: TestEntity }, + { + entity: TestEntity, + routes: { + search: { + limitOfTake: 100_000, + }, + }, + }, { columns: [ { name: 'col1', type: 'string', isPrimary: false }, @@ -41,276 +47,343 @@ describe('SearchRequestInterceptor', () => { interceptor = new Interceptor(); }); - describe('validateBody', () => { - it('should throw when body is not an object', async () => { - const invalidBodyList = [null, undefined, 'string', 0, 1, true, false]; - for (const body of invalidBodyList) { - await expect(interceptor.validateBody(body)).rejects.toThrow(UnprocessableEntityException); - } + describe('body', () => { + describe('should throw when body is not an object', () => { + test.each([null, undefined, 'string', []])('body(%p)', async (body) => { + await expect(interceptor.validateBody(body)).rejects.toThrow(new UnprocessableEntityException('body should be object')); + }); }); }); describe('body.select', () => { - it('should validate select has key of entity', async () => { - const invalidSelectList = [{ select: ['col0'] }, { select: 'col1' }]; - for (const select of invalidSelectList) { - await expect(interceptor.validateBody(select)).rejects.toThrow(UnprocessableEntityException); - } - + it('return search request dto when input is valid', async () => { expect(await interceptor.validateBody({ select: ['col1', 'col2'] })).toEqual({ select: ['col1', 'col2'], take: 20, withDeleted: false, }); }); + + describe('throw when body.select is not array type', () => { + test.each([undefined, null, 'col1', {}])('select(%p)', async (select) => { + await expect(interceptor.validateBody({ select })).rejects.toThrow( + new UnprocessableEntityException('select must be array type'), + ); + }); + }); + + it('throw when select key is not included in entity fields', async () => { + await expect(interceptor.validateBody({ select: ['col0'] })).rejects.toThrow( + new UnprocessableEntityException('select key col0 is not included in entity fields'), + ); + }); }); describe('body.where', () => { - it('should validate entity column name', async () => { - await expect( - interceptor.validateBody({ - where: [{ unknown: { operator: '=', operand: 7 }, col3: { operator: '>', operand: 3 } }], - }), - ).rejects.toThrow(UnprocessableEntityException); - }); + describe('return search request dto when input is valid', () => { + it('BETWEEN', async () => { + expect( + await interceptor.validateBody({ + where: [{ col3: { operator: 'BETWEEN', operand: [0, 5] } }], + }), + ).toEqual({ + where: [{ col3: { operator: 'BETWEEN', operand: [0, 5] } }], + take: 20, + withDeleted: false, + }); + }); + it('IN', async () => { + expect( + await interceptor.validateBody({ + where: [{ col3: { operator: 'IN', operand: [0, 1, 2] } }], + }), + ).toEqual({ + where: [{ col3: { operator: 'IN', operand: [0, 1, 2] } }], + take: 20, + withDeleted: false, + }); + }); - it('should validate not option', async () => { - expect( - await interceptor.validateBody({ + it('NULL', async () => { + expect(await interceptor.validateBody({ where: [{ col3: { operator: 'NULL' } }] })).toEqual({ + where: [{ col3: { operator: 'NULL' } }], + take: 20, + withDeleted: false, + }); + }); + + it('Union Operators("=", "!=", ">", ">=", "<", "<=", "LIKE", "ILIKE")', async () => { + expect( + await interceptor.validateBody({ + where: [{ col2: { operator: '=', operand: 7 }, col3: { operator: '>', operand: 3 } }], + }), + ).toEqual({ where: [{ col2: { operator: '=', operand: 7 }, col3: { operator: '>', operand: 3 } }], - }), - ).toEqual({ - where: [{ col2: { operator: '=', operand: 7 }, col3: { operator: '>', operand: 3 } }], - take: 20, - withDeleted: false, + take: 20, + withDeleted: false, + }); + }); + + it('not option', async () => { + expect( + await interceptor.validateBody({ + where: [{ col2: { operator: '=', operand: 7, not: false }, col3: { operator: '>', operand: 3, not: true } }], + }), + ).toEqual({ + take: 20, + where: [{ col2: { not: false, operand: 7, operator: '=' }, col3: { not: true, operand: 3, operator: '>' } }], + withDeleted: false, + }); + }); + }); + + describe('throw when body.where is not array type', () => { + test.each([{}, null, 'unknown'])('body.where(%p)', async (where) => { + await expect(interceptor.validateBody({ where })).rejects.toThrow( + new UnprocessableEntityException('where must be array type'), + ); + }); + }); + + describe('throw when item of body.where is not object', () => { + test.each([null, undefined, 'unknown'])('body.where([%p])', async (filter) => { + await expect(interceptor.validateBody({ where: [filter] })).rejects.toThrow( + new UnprocessableEntityException('items of where must be object type'), + ); }); + }); + it('throw when body.where filter key is not included in entity fields', async () => { await expect( interceptor.validateBody({ - where: [{ col2: { operator: '=', operand: 7, not: 1 }, col3: { operator: '>', operand: 3 } }], + where: [{ unknown: { operator: '=', operand: 7 }, col3: { operator: '>', operand: 3 } }], }), - ).rejects.toThrow(UnprocessableEntityException); + ).rejects.toThrow(new UnprocessableEntityException("where key unknown is not included in entity's fields")); + }); + it('throw when body.where filter value is not object type', async () => { await expect( interceptor.validateBody({ - where: [{ col2: { operator: '=', operand: 7 }, col3: { operator: '>', operand: 3, not: 'true' } }], - }), - ).rejects.toThrow(UnprocessableEntityException); - - expect( - await interceptor.validateBody({ - where: [{ col2: { operator: '=', operand: 7, not: false }, col3: { operator: '>', operand: 3, not: true } }], + where: [{ col1: 1 }], }), - ).toEqual({ - take: 20, - where: [{ col2: { not: false, operand: 7, operator: '=' }, col3: { not: true, operand: 3, operator: '>' } }], - withDeleted: false, - }); + ).rejects.toThrow(new UnprocessableEntityException('where.col1 is not object type')); }); - it('should throw when query filter is not an array', async () => { - await expect(interceptor.validateBody({ where: {} })).rejects.toThrow(UnprocessableEntityException); - await expect(interceptor.validateBody({ where: null })).rejects.toThrow(UnprocessableEntityException); - await expect(interceptor.validateBody({ where: 'unknown' })).rejects.toThrow(UnprocessableEntityException); + it('throw when body.where filter not define operator', async () => { + await expect( + interceptor.validateBody({ + where: [{ col1: { operand: 1 } }], + }), + ).rejects.toThrow(new UnprocessableEntityException('where.col1 not have operator')); }); - it('should throw when invalid query filter is given', async () => { - await expect(interceptor.validateBody({ where: [null] })).rejects.toThrow(UnprocessableEntityException); - await expect(interceptor.validateBody({ where: [undefined] })).rejects.toThrow(UnprocessableEntityException); - await expect(interceptor.validateBody({ where: ['unknown'] })).rejects.toThrow(UnprocessableEntityException); + describe('throw when not option is non-boolean type', () => { + test.each([1, 'true'])('not(%p)', async (not) => { + await expect( + interceptor.validateBody({ + where: [{ col2: { operator: '=', operand: 7, not }, col3: { operator: '>', operand: 3 } }], + }), + ).rejects.toThrow(new UnprocessableEntityException("where.col2 has 'not' value of non-boolean type")); + }); }); - it('should validate Union Operators("=", "!=", ">", ">=", "<", "<=", "LIKE", "ILIKE")', async () => { - const invalidWhereList = [{ where: [{ col1: 1 }] }, { where: [{ abc: 1 }] }, { where: [{ col1: 1, col3: 3 }] }]; - - for (const where of invalidWhereList) { - await expect(interceptor.validateBody(where)).rejects.toThrow(UnprocessableEntityException); - } - - expect( - await interceptor.validateBody({ - where: [{ col2: { operator: '=', operand: 7 }, col3: { operator: '>', operand: 3 } }], - }), - ).toEqual({ - where: [{ col2: { operator: '=', operand: 7 }, col3: { operator: '>', operand: 3 } }], - take: 20, - withDeleted: false, + describe('throw when operand for BETWEEN is not array of two numbers', () => { + test.each([undefined, 0, [1, 2, 3], ['1', '2'], [1, '2']])('operand(%p)', async (operand) => { + await expect(interceptor.validateBody({ where: [{ col3: { operator: 'BETWEEN', operand } }] })).rejects.toThrow( + new UnprocessableEntityException('operand for BETWEEN should be array of two numbers, but where.col3 not satisfy it'), + ); }); }); - it('should validate BETWEEN operation', async () => { - const invalidWhereList = [ - { where: [{ col3: { operator: 'BETWEEN' } }] }, - { where: [{ col3: { operator: 'BETWEEN', operand: 0 } }] }, - { where: [{ col3: { operator: 'BETWEEN', operand: [1, '2'] } }] }, - { where: [{ col3: { operator: 'BETWEEN', operand: [1, 2, 3] } }] }, - ]; - - for (const where of invalidWhereList) { - await expect(interceptor.validateBody(where)).rejects.toThrow(UnprocessableEntityException); - } - - expect( - await interceptor.validateBody({ - where: [{ col3: { operator: 'BETWEEN', operand: [0, 5] } }], - }), - ).toEqual({ - where: [{ col3: { operator: 'BETWEEN', operand: [0, 5] } }], - take: 20, - withDeleted: false, + describe('throw when operand for IN is not array consisting of same type items', () => { + test.each([undefined, 0, [], [0, '1', 2], [0, 1, null]])('operand(%p)', async (operand) => { + await expect(interceptor.validateBody({ where: [{ col3: { operator: 'IN', operand } }] })).rejects.toThrow( + new UnprocessableEntityException( + 'operand for IN should be array consisting of same type items, but where.col3 not satisfy it', + ), + ); }); }); - it('should validate IN operation', async () => { - const invalidWhereList = [ - { where: [{ col3: { operator: 'IN' } }] }, - { where: [{ col3: { operator: 'IN', operand: 0 } }] }, - { where: [{ col3: { operator: 'IN', operand: [] } }] }, - { where: [{ col3: { operator: 'IN', operand: [0, '1', 2] } }] }, - { where: [{ col3: { operator: 'IN', operand: [0, 1, null] } }] }, - ]; - - for (const where of invalidWhereList) { - await expect(interceptor.validateBody(where)).rejects.toThrow(UnprocessableEntityException); - } - - expect( - await interceptor.validateBody({ - where: [{ col3: { operator: 'IN', operand: [0, 1, 2] } }], - }), - ).toEqual({ - where: [{ col3: { operator: 'IN', operand: [0, 1, 2] } }], - take: 20, - withDeleted: false, + describe('throw when operand for NULL is defined', () => { + test.each([null, 0, '123'])('operand(%p)', async (operand) => { + await expect(interceptor.validateBody({ where: [{ col3: { operator: 'NULL', operand } }] })).rejects.toThrow( + new UnprocessableEntityException('operand for NULL should not be defined, but where.col3 not satisfy it'), + ); }); }); - it('should support defined operator only', async () => { + it('throw when not supported operator is given', async () => { await expect( interceptor.validateBody({ where: [{ col3: { operator: 'NOT IN', operand: [0, 1, 2] } }], }), - ).rejects.toThrow(UnprocessableEntityException); + ).rejects.toThrow(new UnprocessableEntityException('operator NOT IN for where.col3 is not supported')); }); - it('should validate NULL operation', async () => { - const invalidWhereList = [ - { where: [{ col3: { operator: 'NULL', operand: 0 } }] }, - { where: [{ col3: { operator: 'NULL', operand: null } }] }, - { where: [{ col3: { operator: 'NULL', operand: '123' } }] }, - ]; - - for (const where of invalidWhereList) { - await expect(interceptor.validateBody(where)).rejects.toThrow(UnprocessableEntityException); - } - - expect(await interceptor.validateBody({ where: [{ col3: { operator: 'NULL' } }] })).toEqual({ - where: [{ col3: { operator: 'NULL' } }], - take: 20, - withDeleted: false, + describe('throw when operand type not equal entity field type', () => { + test.each([ + ['col1', 'BETWEEN', [1, 2]], + ['col1', '>', 123], + ['col2', 'IN', ['1']], + ])('key(%s), operator(%s), operand(%p)', async (key, operator, operand) => { + await expect(interceptor.validateBody({ where: [{ [key]: { operator, operand } }] })).rejects.toThrow( + UnprocessableEntityException, + ); }); }); - - it('should throw when operand type not equals entity field type', async () => { - const invalidWhereList = [ - { where: [{ col1: { operator: 'BETWEEN', operand: [1, 2] } }] }, - { where: [{ col1: { operator: 'IN', operand: [1] } }] }, - { where: [{ col2: { operator: 'IN', operand: ['1'] } }] }, - { where: [{ col1: { operator: '>', operand: 123 } }] }, - ]; - - for (const where of invalidWhereList) { - await expect(interceptor.validateBody(where)).rejects.toThrow(UnprocessableEntityException); - } - }); }); describe('body.order', () => { - it('should validate order ', async () => { - const invalidOrderList = [{ order: { col0: Sort.ASC } }, { order: { col1: 'unknown order' } }, { order: 'unknown' }]; - for (const order of invalidOrderList) { - await expect(interceptor.validateBody(order)).rejects.toThrow(UnprocessableEntityException); - } - + it('return search request dto when input is valid', async () => { expect(await interceptor.validateBody({ order: { col2: Sort.ASC, col3: Sort.DESC } })).toEqual({ order: { col2: Sort.ASC, col3: Sort.DESC }, take: 20, withDeleted: false, }); }); + + describe('throw when body.order is not object type', () => { + test.each([undefined, null, 'unknown', []])('order(%p)', async (order) => { + await expect(interceptor.validateBody({ order })).rejects.toThrow( + new UnprocessableEntityException('order must be object type'), + ); + }); + }); + + it('throw when order key is not included in entity fields', async () => { + await expect(interceptor.validateBody({ order: { col0: Sort.ASC } })).rejects.toThrow( + new UnprocessableEntityException("order key col0 is not included in entity's fields"), + ); + }); + + it('throw when not supported sort type is given', async () => { + await expect(interceptor.validateBody({ order: { col1: 'unknown' } })).rejects.toThrow( + new UnprocessableEntityException('order type unknown is not supported'), + ); + }); }); describe('body.withDeleted', () => { - it('should validate withDeleted', async () => { - const invalidList = [{ withDeleted: 1 }, { withDeleted: null }, { withDeleted: 'unknown' }]; - for (const withDeleted of invalidList) { - await expect(interceptor.validateBody(withDeleted)).rejects.toThrow(UnprocessableEntityException); - } - - expect(await interceptor.validateBody({ withDeleted: true })).toEqual({ withDeleted: true, take: 20 }); - expect(await interceptor.validateBody({ withDeleted: false })).toEqual({ withDeleted: false, take: 20 }); + describe('return search request dto when input is valid', () => { + test.each([true, false])('withDeleted(%p)', async (withDeleted) => { + expect(await interceptor.validateBody({ withDeleted })).toEqual({ withDeleted, take: 20 }); + }); + }); + + describe('throw when withDeleted is not boolean type', () => { + test.each([undefined, null, 'true', 0])('withDeleted(%p)', async (withDeleted) => { + await expect(interceptor.validateBody({ withDeleted })).rejects.toThrow( + new UnprocessableEntityException('withDeleted must be boolean type'), + ); + }); }); }); describe('body.take', () => { - it('should validate take', async () => { - const invalidList = [{ take: 0 }, { take: -10 }, { take: null }, { take: 'unknown' }]; - for (const take of invalidList) { - await expect(interceptor.validateBody(take)).rejects.toThrow(UnprocessableEntityException); - } - - expect(await interceptor.validateBody({ take: 20 })).toEqual({ take: 20, withDeleted: false }); - expect(await interceptor.validateBody({ take: 100_000 })).toEqual({ take: 100_000, withDeleted: false }); + describe('return search request dto when input is valid', () => { + test.each([1, 20, 100_000])('take(%p)', async (take) => { + expect(await interceptor.validateBody({ take })).toEqual({ take, withDeleted: false }); + }); + }); + + describe('throw when take is not positive number', () => { + test.each([null, [], 'unknown', -10, 0])('take(%p)', async (take) => { + await expect(interceptor.validateBody({ take })).rejects.toThrow( + new UnprocessableEntityException('take must be positive number'), + ); + }); + }); + + it('throw when take exceeds limit of take', async () => { + await expect(interceptor.validateBody({ take: 100_001 })).rejects.toThrow( + new UnprocessableEntityException('take must be less than 100000'), + ); }); }); - it('should be get relation values per each condition', () => { - type InterceptorType = NestInterceptor & { - getRelations: (customSearchRequestOptions: CustomSearchRequestOptions) => string[]; - }; - const Interceptor = SearchRequestInterceptor( - { entity: {} as typeof BaseEntity }, - { relations: [], logger: new CrudLogger(), primaryKeys: [] }, - ); - const interceptor = new Interceptor() as InterceptorType; - expect(interceptor.getRelations({ relations: [] })).toEqual([]); - expect(interceptor.getRelations({ relations: ['table'] })).toEqual(['table']); - expect(interceptor.getRelations({})).toEqual([]); - - const InterceptorWithoutSearchRoute = SearchRequestInterceptor( - { entity: {} as typeof BaseEntity, routes: { readOne: { relations: false } } }, - { relations: [], logger: new CrudLogger(), primaryKeys: [] }, - ); - const interceptorWithoutSearchRoute = new InterceptorWithoutSearchRoute() as InterceptorType; - expect(interceptorWithoutSearchRoute.getRelations({ relations: [] })).toEqual([]); - expect(interceptorWithoutSearchRoute.getRelations({ relations: ['table'] })).toEqual(['table']); - expect(interceptorWithoutSearchRoute.getRelations({})).toEqual([]); - - const InterceptorWithoutRelations = SearchRequestInterceptor( - { entity: {} as typeof BaseEntity, routes: { search: {} } }, - { relations: [], logger: new CrudLogger(), primaryKeys: [] }, - ); - const interceptorWithoutRelations = new InterceptorWithoutRelations() as InterceptorType; - expect(interceptorWithoutRelations.getRelations({ relations: [] })).toEqual([]); - expect(interceptorWithoutRelations.getRelations({ relations: ['table'] })).toEqual(['table']); - expect(interceptorWithoutRelations.getRelations({})).toEqual([]); - - const InterceptorWithOptions = SearchRequestInterceptor( - { entity: {} as typeof BaseEntity, routes: { search: { relations: ['option'] } } }, - { relations: [], logger: new CrudLogger(), primaryKeys: [] }, - ); - const interceptorWithOptions = new InterceptorWithOptions() as InterceptorType; - expect(interceptorWithOptions.getRelations({ relations: [] })).toEqual([]); - expect(interceptorWithOptions.getRelations({ relations: ['table'] })).toEqual(['table']); - expect(interceptorWithOptions.getRelations({})).toEqual(['option']); - - const InterceptorWithFalseOptions = SearchRequestInterceptor( - { entity: {} as typeof BaseEntity, routes: { search: { relations: false } } }, - { relations: [], logger: new CrudLogger(), primaryKeys: [] }, - ); - const interceptorWithFalseOptions = new InterceptorWithFalseOptions() as InterceptorType; - expect(interceptorWithFalseOptions.getRelations({ relations: [] })).toEqual([]); - expect(interceptorWithFalseOptions.getRelations({ relations: ['table'] })).toEqual(['table']); - expect(interceptorWithFalseOptions.getRelations({})).toEqual([]); + describe('getRelations', () => { + describe('return relations in custom options when it is array type', () => { + let interceptor: InstanceType>; + beforeAll(() => { + const Interceptor = SearchRequestInterceptor( + { entity: {} as typeof BaseEntity, routes: { search: { relations: ['route'] } } }, + { relations: ['factory'], logger: new CrudLogger(), primaryKeys: [] }, + ); + interceptor = new Interceptor(); + }); + + it('customOptions.relations(["table"])', () => { + expect(interceptor.getRelations({ relations: ['custom'] })).toEqual(['custom']); + }); + + test.each([undefined, null, 'custom', {}])('customOptions.relations(%p)', (relations) => { + expect(interceptor.getRelations({ relations: relations as string[] })).not.toEqual(relations); + }); + }); + + describe('return relations in factory options when relations in search route option is not defined', () => { + it('route option is not defined', () => { + const Interceptor = SearchRequestInterceptor( + { entity: {} as typeof BaseEntity }, + { relations: ['factory'], logger: new CrudLogger(), primaryKeys: [] }, + ); + const interceptor = new Interceptor(); + + expect(interceptor.getRelations({})).toEqual(['factory']); + }); + + it('search route option is not defined', () => { + const Interceptor = SearchRequestInterceptor( + { entity: {} as typeof BaseEntity, routes: {} }, + { relations: ['factory'], logger: new CrudLogger(), primaryKeys: [] }, + ); + const interceptor = new Interceptor(); + + expect(interceptor.getRelations({})).toEqual(['factory']); + }); + + it('relations is not defined in search route option', () => { + const Interceptor = SearchRequestInterceptor( + { entity: {} as typeof BaseEntity, routes: { search: {} } }, + { relations: ['factory'], logger: new CrudLogger(), primaryKeys: [] }, + ); + const interceptor = new Interceptor(); + + expect(interceptor.getRelations({})).toEqual(['factory']); + }); + }); + + it('return empty array when relations in search route is false', () => { + const Interceptor = SearchRequestInterceptor( + { entity: {} as typeof BaseEntity, routes: { search: { relations: false } } }, + { relations: ['factory'], logger: new CrudLogger(), primaryKeys: [] }, + ); + const interceptor = new Interceptor(); + + expect(interceptor.getRelations({})).toEqual([]); + }); + + describe('return relations in search route option when it is array type', () => { + it('routes.search.relations(["route"])', () => { + const Interceptor = SearchRequestInterceptor( + { entity: {} as typeof BaseEntity, routes: { search: { relations: ['route'] } } }, + { relations: ['factory'], logger: new CrudLogger(), primaryKeys: [] }, + ); + const interceptor = new Interceptor(); + + expect(interceptor.getRelations({})).toEqual(['route']); + }); + + test.each([undefined, null, 'route', {}])('routes.search.relations(%p)', (routeRelations) => { + const Interceptor = SearchRequestInterceptor( + { entity: {} as typeof BaseEntity, routes: { search: { relations: routeRelations as string[] } } }, + { relations: ['factory'], logger: new CrudLogger(), primaryKeys: [] }, + ); + interceptor = new Interceptor(); + + expect(interceptor.getRelations({})).not.toEqual(routeRelations); + }); + }); }); }); diff --git a/src/lib/interceptor/search-request.interceptor.ts b/src/lib/interceptor/search-request.interceptor.ts index 8d4075c..8d5eb43 100644 --- a/src/lib/interceptor/search-request.interceptor.ts +++ b/src/lib/interceptor/search-request.interceptor.ts @@ -1,4 +1,4 @@ -import { CallHandler, ExecutionContext, mixin, NestInterceptor, Type, UnprocessableEntityException } from '@nestjs/common'; +import { CallHandler, ExecutionContext, mixin, NestInterceptor, UnprocessableEntityException } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { Request } from 'express'; @@ -18,8 +18,8 @@ import { PaginationHelper, TypeOrmQueryBuilderHelper } from '../provider'; import { CrudReadManyRequest } from '../request'; const method = Method.SEARCH; -export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption: FactoryOption): Type { - class MixinInterceptor extends RequestAbstractInterceptor implements NestInterceptor { +export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption: FactoryOption) { + class MixinSearchRequestInterceptor extends RequestAbstractInterceptor implements NestInterceptor { constructor() { super(factoryOption.logger); } @@ -89,7 +89,7 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption } async validateBody(body: unknown): Promise> { - const isObject = body !== null && typeof body === 'object'; + const isObject = body !== null && typeof body === 'object' && !Array.isArray(body); if (!isObject) { throw new UnprocessableEntityException('body should be object'); } @@ -133,29 +133,32 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption } const differenceKeys = _.difference(select, factoryOption.columns?.map((column) => column.name) ?? []); if (differenceKeys.length > 0) { - throw new UnprocessableEntityException(`${differenceKeys.toLocaleString()} is unknown`); + throw new UnprocessableEntityException(`select key ${differenceKeys.toLocaleString()} is not included in entity fields`); } } async validateQueryFilterList(value: unknown): Promise { if (!Array.isArray(value)) { - throw new UnprocessableEntityException('incorrect query format'); + throw new UnprocessableEntityException('where must be array type'); } for (const queryFilter of value) { const query: Record = {}; if (typeof queryFilter !== 'object' || queryFilter == null) { - throw new UnprocessableEntityException('incorrect queryFilter format'); + throw new UnprocessableEntityException('items of where must be object type'); } for (const [key, operation] of Object.entries(queryFilter)) { - if (typeof operation !== 'object' || operation == null || !('operator' in operation)) { - throw new UnprocessableEntityException('operator is required'); - } if (!factoryOption.columns?.some((column) => column.name === key)) { - throw new UnprocessableEntityException(`${key} is unknown key`); + throw new UnprocessableEntityException(`where key ${key} is not included in entity's fields`); + } + if (typeof operation !== 'object' || operation == null) { + throw new UnprocessableEntityException(`where.${key} is not object type`); + } + if (!('operator' in operation)) { + throw new UnprocessableEntityException(`where.${key} not have operator`); } if ('not' in operation && typeof operation.not !== 'boolean') { - throw new UnprocessableEntityException('Type `not` should be Boolean type'); + throw new UnprocessableEntityException(`where.${key} has 'not' value of non-boolean type`); } switch (operation.operator) { case operatorBetween: @@ -164,10 +167,13 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption 'operand' in operation && Array.isArray(operation.operand) && operation.operand.length === 2 && - typeof operation.operand[0] === typeof operation.operand[1] + typeof operation.operand[0] === 'number' && + typeof operation.operand[1] === 'number' ) ) { - throw new UnprocessableEntityException(`${operation.operator} allows only array length of 2`); + throw new UnprocessableEntityException( + `operand for ${operatorBetween} should be array of two numbers, but where.${key} not satisfy it`, + ); } query[key] = operation.operand[0]; break; @@ -181,19 +187,21 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption ) ) { throw new UnprocessableEntityException( - `${operation.operator} allows only array contains item in identical type`, + `operand for ${operatorIn} should be array consisting of same type items, but where.${key} not satisfy it`, ); } query[key] = operation.operand[0]; break; case operatorNull: if ('operand' in operation) { - throw new UnprocessableEntityException(); + throw new UnprocessableEntityException( + `operand for ${operatorNull} should not be defined, but where.${key} not satisfy it`, + ); } break; default: if (!('operand' in operation && operatorList.includes(operation.operator as OperatorUnion))) { - throw new UnprocessableEntityException(`${operation.operator} is not support operation type`); + throw new UnprocessableEntityException(`operator ${operation.operator} for where.${key} is not supported`); } query[key] = operation.operand; } @@ -219,17 +227,17 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption } validateOrder(order: RequestSearchDto['order']): void { - if (typeof order !== 'object') { + if (typeof order !== 'object' || order === null || Array.isArray(order)) { throw new UnprocessableEntityException('order must be object type'); } const sortOptions = Object.values(Sort); for (const [key, sort] of Object.entries(order)) { if (!factoryOption.columns?.some((column) => column.name === key)) { - throw new UnprocessableEntityException(`${key} is unknown key`); + throw new UnprocessableEntityException(`order key ${key} is not included in entity's fields`); } if (!sortOptions.includes(sort as Sort)) { - throw new UnprocessableEntityException(`${sort} is unknown Order Type`); + throw new UnprocessableEntityException(`order type ${sort} is not supported`); } } } @@ -241,12 +249,9 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption } validateTake(take: RequestSearchDto['take'], limitOfTake: number | undefined): number | undefined { - if (take == null) { - throw new UnprocessableEntityException('take must be positive number type'); - } const takeNumber = Number(take); - if (!Number.isInteger(takeNumber) || takeNumber < 1) { - throw new UnprocessableEntityException('take must be positive number type'); + if (take == null || Array.isArray(take) || !Number.isInteger(takeNumber) || takeNumber < 1) { + throw new UnprocessableEntityException('take must be positive number'); } if (limitOfTake !== undefined && takeNumber > limitOfTake) { throw new UnprocessableEntityException(`take must be less than ${limitOfTake}`); @@ -258,11 +263,15 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption if (Array.isArray(customSearchRequestOptions?.relations)) { return customSearchRequestOptions.relations; } - if (crudOptions.routes?.[method]?.relations === false) { + const routeOptions = crudOptions.routes?.[method]; + if (!routeOptions) { + return factoryOption.relations; + } + if (routeOptions.relations === false) { return []; } - if (crudOptions.routes?.[method] && Array.isArray(crudOptions.routes[method].relations)) { - return crudOptions.routes[method].relations; + if (Array.isArray(routeOptions.relations)) { + return routeOptions.relations; } return factoryOption.relations; } @@ -307,5 +316,5 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption } } - return mixin(MixinInterceptor); + return mixin(MixinSearchRequestInterceptor); } diff --git a/src/lib/provider/typeorm-query-builder.helper.ts b/src/lib/provider/typeorm-query-builder.helper.ts index 8e9cda1..95197af 100644 --- a/src/lib/provider/typeorm-query-builder.helper.ts +++ b/src/lib/provider/typeorm-query-builder.helper.ts @@ -1,4 +1,3 @@ -import { UnprocessableEntityException } from '@nestjs/common'; import { Not, MoreThan, @@ -34,75 +33,65 @@ export class TypeOrmQueryBuilderHelper { } const findOptionsWhere: Record = {}; for (const [field, operation] of Object.entries(filter)) { - if (typeof operation === 'object' && operation !== null) { - if ('operator' in operation) { - if (operation.operator === operatorNull) { - findOptionsWhere[field] = IsNull(); - } + if (operation.operator === operatorNull) { + findOptionsWhere[field] = IsNull(); + } - if ('operand' in operation) { - const paramName = getParameterName(); - const { operator, operand } = operation; - switch (operator) { - case '=': - findOptionsWhere[field] = operand; - break; - case '!=': - findOptionsWhere[field] = Not(operand); - break; - case '>': - findOptionsWhere[field] = MoreThan(operand); - break; - case '>=': - findOptionsWhere[field] = MoreThanOrEqual(operand); - break; - case '<': - findOptionsWhere[field] = LessThan(operand); - break; - case '<=': - findOptionsWhere[field] = LessThanOrEqual(operand); - break; - case 'LIKE': - findOptionsWhere[field] = Like(operand); - break; - case 'ILIKE': - findOptionsWhere[field] = ILike(operand); - break; - case '?': - findOptionsWhere[field] = Raw((alias) => `${alias} ? :${paramName}`, { - [paramName]: operand, - }); - break; - case '@>': - findOptionsWhere[field] = Raw((alias) => `${alias} @> :${paramName}`, { - [paramName]: operand, - }); - break; - case 'JSON_CONTAINS': - findOptionsWhere[field] = Raw((alias) => `JSON_CONTAINS (${alias}, :${paramName})`, { - [paramName]: operand, - }); - break; - case operatorBetween: - if (!Array.isArray(operand) || operand.length !== 2) { - throw new UnprocessableEntityException(`Invalid operand for operator ${operatorBetween}`); - } - const [min, max] = operand; - findOptionsWhere[field] = Between(min, max); - break; - case operatorIn: - if (!Array.isArray(operand)) { - throw new UnprocessableEntityException(`Invalid operand for operator ${operatorBetween}`); - } - findOptionsWhere[field] = In(operand); - break; - } - } + if ('operand' in operation) { + const paramName = getParameterName(); + const { operator, operand } = operation; + switch (operator) { + case '=': + findOptionsWhere[field] = operand; + break; + case '!=': + findOptionsWhere[field] = Not(operand); + break; + case '>': + findOptionsWhere[field] = MoreThan(operand); + break; + case '>=': + findOptionsWhere[field] = MoreThanOrEqual(operand); + break; + case '<': + findOptionsWhere[field] = LessThan(operand); + break; + case '<=': + findOptionsWhere[field] = LessThanOrEqual(operand); + break; + case 'LIKE': + findOptionsWhere[field] = Like(operand); + break; + case 'ILIKE': + findOptionsWhere[field] = ILike(operand); + break; + case '?': + findOptionsWhere[field] = Raw((alias) => `${alias} ? :${paramName}`, { + [paramName]: operand, + }); + break; + case '@>': + findOptionsWhere[field] = Raw((alias) => `${alias} @> :${paramName}`, { + [paramName]: operand, + }); + break; + case 'JSON_CONTAINS': + findOptionsWhere[field] = Raw((alias) => `JSON_CONTAINS (${alias}, :${paramName})`, { + [paramName]: operand, + }); + break; + case operatorBetween: + const [min, max] = operand; + findOptionsWhere[field] = Between(min, max); + break; + case operatorIn: + findOptionsWhere[field] = In(operand); + break; } + } - if (findOptionsWhere[field] && 'not' in operation && operation.not) { - findOptionsWhere[field] = Not(findOptionsWhere[field]); - } + if (findOptionsWhere[field] && 'not' in operation && operation.not) { + findOptionsWhere[field] = Not(findOptionsWhere[field]); } }