From 4641ce216e98b0236e492ecc995daee3b9e72290 Mon Sep 17 00:00:00 2001 From: "jiho.park" Date: Thu, 8 Feb 2024 18:21:12 +0900 Subject: [PATCH] feat: support embedded entities --- spec/base/base.controller.recover.spec.ts | 4 ++ .../embedded-entites.spec.ts | 56 +++++++++++++++++++ .../embedded-entities.module.ts | 16 ++++++ spec/embedded-entities/employee.controller.ts | 14 +++++ spec/embedded-entities/employee.entity.ts | 23 ++++++++ spec/embedded-entities/employee.service.ts | 13 +++++ spec/embedded-entities/name.ts | 14 +++++ spec/embedded-entities/user.controller.ts | 14 +++++ spec/embedded-entities/user.entity.ts | 27 +++++++++ spec/embedded-entities/user.service.ts | 13 +++++ spec/logging/logging.spec.ts | 6 +- src/lib/crud.service.spec.ts | 3 + src/lib/crud.service.ts | 11 +++- .../read-many-request.interceptor.ts | 9 +-- .../read-one-request.interceptor.ts | 13 +---- .../interceptor/search-request.interceptor.ts | 10 +--- src/lib/interface/request.interface.ts | 3 +- src/lib/request/read-many.request.ts | 36 +++++++++++- 18 files changed, 251 insertions(+), 34 deletions(-) create mode 100644 spec/embedded-entities/embedded-entites.spec.ts create mode 100644 spec/embedded-entities/embedded-entities.module.ts create mode 100644 spec/embedded-entities/employee.controller.ts create mode 100644 spec/embedded-entities/employee.entity.ts create mode 100644 spec/embedded-entities/employee.service.ts create mode 100644 spec/embedded-entities/name.ts create mode 100644 spec/embedded-entities/user.controller.ts create mode 100644 spec/embedded-entities/user.entity.ts create mode 100644 spec/embedded-entities/user.service.ts diff --git a/spec/base/base.controller.recover.spec.ts b/spec/base/base.controller.recover.spec.ts index e65b249..3821c5f 100644 --- a/spec/base/base.controller.recover.spec.ts +++ b/spec/base/base.controller.recover.spec.ts @@ -55,5 +55,9 @@ describe('BaseController', () => { .post(`/base/${Number('a')}/recover`) .expect(HttpStatus.NOT_FOUND); }); + + it('should be throw not found exception', async () => { + await request(app.getHttpServer()).post('/base/0/recover').expect(HttpStatus.NOT_FOUND); + }); }); }); diff --git a/spec/embedded-entities/embedded-entites.spec.ts b/spec/embedded-entities/embedded-entites.spec.ts new file mode 100644 index 0000000..2a72c43 --- /dev/null +++ b/spec/embedded-entities/embedded-entites.spec.ts @@ -0,0 +1,56 @@ +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; + +import { EmbeddedEntitiesModule } from './embedded-entities.module'; +import { EmployeeEntity } from './employee.entity'; +import { UserEntity } from './user.entity'; +import { TestHelper } from '../test.helper'; + +describe('Embedded-entities', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [EmbeddedEntitiesModule, TestHelper.getTypeOrmPgsqlModule([UserEntity, EmployeeEntity])], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('should be provided url', async () => { + const routerPathList = TestHelper.getRoutePath(app.getHttpServer()); + expect(routerPathList.get).toEqual(expect.arrayContaining(['/user/:id', '/user', '/employee/:id', '/employee'])); + }); + + it('should be used embedded entity', async () => { + const { body: created } = await request(app.getHttpServer()) + .post('/user') + .send({ isActive: true, name: { first: 'firstUserName', last: 'lastUserName' } }) + .expect(HttpStatus.CREATED); + + const { body: readOne } = await request(app.getHttpServer()).get(`/user/${created.id}`).expect(HttpStatus.OK); + expect(readOne).toEqual({ + id: created.id, + isActive: true, + name: { first: 'firstUserName', last: 'lastUserName' }, + }); + + const { body: readMany } = await request(app.getHttpServer()).get('/user').expect(HttpStatus.OK); + expect(readMany.data[0].name).toBeDefined(); + + const { body: updated } = await request(app.getHttpServer()) + .patch(`/user/${created.id}`) + .send({ isActive: false, name: { first: 'updatedFirstUserName', last: 'updatedLastUserName' } }) + .expect(HttpStatus.OK); + expect(updated).toEqual({ + id: created.id, + isActive: false, + name: { first: 'updatedFirstUserName', last: 'updatedLastUserName' }, + }); + }); +}); diff --git a/spec/embedded-entities/embedded-entities.module.ts b/spec/embedded-entities/embedded-entities.module.ts new file mode 100644 index 0000000..6257671 --- /dev/null +++ b/spec/embedded-entities/embedded-entities.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { EmployeeController } from './employee.controller'; +import { EmployeeEntity } from './employee.entity'; +import { EmployeeService } from './employee.service'; +import { UserController } from './user.controller'; +import { UserEntity } from './user.entity'; +import { UserService } from './user.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserEntity, EmployeeEntity])], + controllers: [UserController, EmployeeController], + providers: [UserService, EmployeeService], +}) +export class EmbeddedEntitiesModule {} diff --git a/spec/embedded-entities/employee.controller.ts b/spec/embedded-entities/employee.controller.ts new file mode 100644 index 0000000..a4c9804 --- /dev/null +++ b/spec/embedded-entities/employee.controller.ts @@ -0,0 +1,14 @@ +import { Controller } from '@nestjs/common'; + +import { EmployeeEntity } from './employee.entity'; +import { EmployeeService } from './employee.service'; +import { Crud } from '../../src/lib/crud.decorator'; +import { CrudController } from '../../src/lib/interface'; + +@Crud({ + entity: EmployeeEntity, +}) +@Controller('employee') +export class EmployeeController implements CrudController { + constructor(public readonly crudService: EmployeeService) {} +} diff --git a/spec/embedded-entities/employee.entity.ts b/spec/embedded-entities/employee.entity.ts new file mode 100644 index 0000000..df223f3 --- /dev/null +++ b/spec/embedded-entities/employee.entity.ts @@ -0,0 +1,23 @@ +import { Type } from 'class-transformer'; +import { IsNumber, IsOptional } from 'class-validator'; +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +import { Name } from './name'; +import { GROUP } from '../../src/lib/interface'; + +@Entity() +export class EmployeeEntity { + @PrimaryGeneratedColumn() + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { groups: [GROUP.PARAMS] }) + id: string; + + @Column(() => Name) + @Type(() => Name) + name: Name; + + @Column() + @IsNumber({ allowNaN: false, allowInfinity: false }, { always: true }) + @IsOptional({ always: true }) + salary: number; +} diff --git a/spec/embedded-entities/employee.service.ts b/spec/embedded-entities/employee.service.ts new file mode 100644 index 0000000..40c493f --- /dev/null +++ b/spec/embedded-entities/employee.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { EmployeeEntity } from './employee.entity'; +import { CrudService } from '../../src/lib/crud.service'; + +@Injectable() +export class EmployeeService extends CrudService { + constructor(@InjectRepository(EmployeeEntity) repository: Repository) { + super(repository); + } +} diff --git a/spec/embedded-entities/name.ts b/spec/embedded-entities/name.ts new file mode 100644 index 0000000..993e2b8 --- /dev/null +++ b/spec/embedded-entities/name.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsString } from 'class-validator'; +import { Column } from 'typeorm'; + +export class Name { + @Column({ nullable: true, default: 'first' }) + @IsString({ always: true }) + @IsOptional({ always: true }) + first: string; + + @Column({ nullable: true, default: 'last' }) + @IsString({ always: true }) + @IsOptional({ always: true }) + last: string; +} diff --git a/spec/embedded-entities/user.controller.ts b/spec/embedded-entities/user.controller.ts new file mode 100644 index 0000000..321ebae --- /dev/null +++ b/spec/embedded-entities/user.controller.ts @@ -0,0 +1,14 @@ +import { Controller } from '@nestjs/common'; + +import { UserEntity } from './user.entity'; +import { UserService } from './user.service'; +import { Crud } from '../../src/lib/crud.decorator'; +import { CrudController } from '../../src/lib/interface'; + +@Crud({ + entity: UserEntity, +}) +@Controller('user') +export class UserController implements CrudController { + constructor(public readonly crudService: UserService) {} +} diff --git a/spec/embedded-entities/user.entity.ts b/spec/embedded-entities/user.entity.ts new file mode 100644 index 0000000..4af5a87 --- /dev/null +++ b/spec/embedded-entities/user.entity.ts @@ -0,0 +1,27 @@ +import { Type } from 'class-transformer'; +import { IsBoolean, IsNumber, IsObject, IsOptional, ValidateNested } from 'class-validator'; +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +import { Name } from './name'; +import { GROUP } from '../../src/lib/interface'; + +@Entity() +export class UserEntity { + @PrimaryGeneratedColumn() + @Type(() => Number) + @IsNumber({ allowNaN: false, allowInfinity: false }, { groups: [GROUP.PARAMS] }) + id: string; + + @Column(() => Name) + @Type(() => Name) + @IsOptional({ always: true }) + @IsObject({ always: true }) + @ValidateNested({ always: true }) + name: Name; + + @Column() + @Type(() => Boolean) + @IsBoolean({ always: true }) + @IsOptional({ always: true }) + isActive: boolean; +} diff --git a/spec/embedded-entities/user.service.ts b/spec/embedded-entities/user.service.ts new file mode 100644 index 0000000..b6cc551 --- /dev/null +++ b/spec/embedded-entities/user.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { UserEntity } from './user.entity'; +import { CrudService } from '../../src/lib/crud.service'; + +@Injectable() +export class UserService extends CrudService { + constructor(@InjectRepository(UserEntity) repository: Repository) { + super(repository); + } +} diff --git a/spec/logging/logging.spec.ts b/spec/logging/logging.spec.ts index b4ed6a1..3bea058 100644 --- a/spec/logging/logging.spec.ts +++ b/spec/logging/logging.spec.ts @@ -84,10 +84,11 @@ describe('Logging', () => { where: {}, take: 20, order: { col1: 'DESC' }, - select: { col1: true, col2: true, deletedAt: true }, withDeleted: false, relations: [], }, + _selectColumnSet: {}, + _excludeColumnSet: {}, _pagination: { _isNext: false, type: 'cursor', _where: btoa('{}') }, _sort: 'DESC', }), @@ -101,8 +102,9 @@ describe('Logging', () => { params: { col1: '1', }, - fields: { col1: true, col2: true, deletedAt: true }, relations: [], + excludedColumns: undefined, + selectColumns: undefined, softDeleted: expect.any(Boolean), }, 'CRUD GET /base/1', diff --git a/src/lib/crud.service.spec.ts b/src/lib/crud.service.spec.ts index 1890a06..f13ad23 100644 --- a/src/lib/crud.service.spec.ts +++ b/src/lib/crud.service.spec.ts @@ -9,6 +9,7 @@ describe('CrudService', () => { const mockRepository = { metadata: { primaryColumns: [{ propertyName: 'id' }], + columns: [{ databaseName: 'id' }, { databaseName: 'name1' }], }, findOne: jest.fn(), }; @@ -37,6 +38,7 @@ describe('CrudService', () => { const mockRepository = { metadata: { primaryColumns: [], + columns: [{ databaseName: 'id' }, { databaseName: 'name1' }], }, }; const crudService = new CrudService(mockRepository as unknown as Repository); @@ -61,6 +63,7 @@ describe('CrudService', () => { const mockRepository = { metadata: { primaryColumns: [{ propertyName: 'id' }], + columns: [{ databaseName: 'id' }, { databaseName: 'name1' }], }, find: jest.fn(), }; diff --git a/src/lib/crud.service.ts b/src/lib/crud.service.ts index cf9d17c..8eec5a2 100644 --- a/src/lib/crud.service.ts +++ b/src/lib/crud.service.ts @@ -1,6 +1,6 @@ import { ConflictException, Logger, NotFoundException } from '@nestjs/common'; import _ from 'lodash'; -import { DeepPartial, FindOptionsSelect, FindOptionsWhere, Repository } from 'typeorm'; +import { DeepPartial, FindOptionsWhere, Repository } from 'typeorm'; import { CrudReadOneRequest, @@ -20,14 +20,19 @@ const SUPPORTED_REPLICATION_TYPES = new Set(['mysql', 'mariadb', 'postgres', 'au export class CrudService { private primaryKey: string[]; + private columnNames: string[]; private usableQueryRunner = false; constructor(public readonly repository: Repository) { this.usableQueryRunner = SUPPORTED_REPLICATION_TYPES.has(this.repository.metadata.connection?.options.type); this.primaryKey = this.repository.metadata.primaryColumns?.map((columnMetadata) => columnMetadata.propertyName) ?? []; + this.columnNames = this.repository.metadata.columns.map((column) => + column.embeddedMetadata ? column.propertyPath : column.databaseName, + ); } readonly reservedReadMany = async (crudReadManyRequest: CrudReadManyRequest): Promise> => { + crudReadManyRequest.excludedColumns(this.columnNames); try { const { entities, total } = await (async () => { const findEntities = this.repository.find({ ...crudReadManyRequest.findOptions }); @@ -56,7 +61,9 @@ export class CrudService { readonly reservedReadOne = async (crudReadOneRequest: CrudReadOneRequest): Promise => { return this.repository .findOne({ - select: crudReadOneRequest.fields as unknown as FindOptionsSelect, + select: (crudReadOneRequest.selectColumns ?? this.columnNames).filter( + (columnName) => !crudReadOneRequest.excludedColumns?.includes(columnName), + ), where: crudReadOneRequest.params as FindOptionsWhere, withDeleted: crudReadOneRequest.softDeleted, relations: crudReadOneRequest.relations, diff --git a/src/lib/interceptor/read-many-request.interceptor.ts b/src/lib/interceptor/read-many-request.interceptor.ts index 10724c4..eee56f1 100644 --- a/src/lib/interceptor/read-many-request.interceptor.ts +++ b/src/lib/interceptor/read-many-request.interceptor.ts @@ -46,14 +46,7 @@ export function ReadManyRequestInterceptor(crudOptions: CrudOptions, factoryOpti const crudReadManyRequest: CrudReadManyRequest = new CrudReadManyRequest() .setPrimaryKey(factoryOption.primaryKeys ?? []) - .setSelect( - factoryOption.columns?.reduce((acc, { name }) => { - if (readManyOptions.exclude?.includes(name)) { - return acc; - } - return { ...acc, [name]: true }; - }, {}), - ) + .setExcludeColumn(readManyOptions.exclude) .setPagination(pagination) .setWithDeleted( _.isBoolean(customReadManyRequestOptions?.softDeleted) diff --git a/src/lib/interceptor/read-one-request.interceptor.ts b/src/lib/interceptor/read-one-request.interceptor.ts index 573b640..f163164 100644 --- a/src/lib/interceptor/read-one-request.interceptor.ts +++ b/src/lib/interceptor/read-one-request.interceptor.ts @@ -32,19 +32,10 @@ export function ReadOneRequestInterceptor(crudOptions: CrudOptions, factoryOptio : readOneOptions.softDelete ?? (CRUD_POLICY[method].default.softDeleted as boolean); const params = await this.checkParams(crudOptions.entity, req.params, factoryOption.columns); - const crudReadOneRequest: CrudReadOneRequest = { params, - fields: ( - this.getFields(customReadOneRequestOptions?.fields, fieldsByRequest) ?? - factoryOption.columns?.map((column) => column.name) ?? - [] - ).reduce((acc, name) => { - if (readOneOptions.exclude?.includes(name)) { - return acc; - } - return { ...acc, [name]: true }; - }, {}), + selectColumns: this.getFields(customReadOneRequestOptions?.fields, fieldsByRequest), + excludedColumns: readOneOptions.exclude, softDeleted, relations: this.getRelations(customReadOneRequestOptions), }; diff --git a/src/lib/interceptor/search-request.interceptor.ts b/src/lib/interceptor/search-request.interceptor.ts index 1a93ad2..22443df 100644 --- a/src/lib/interceptor/search-request.interceptor.ts +++ b/src/lib/interceptor/search-request.interceptor.ts @@ -70,14 +70,8 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption const crudReadManyRequest: CrudReadManyRequest = new CrudReadManyRequest() .setPrimaryKey(primaryKeys) .setPagination(pagination) - .setSelect( - (requestSearchDto.select ?? factoryOption.columns?.map((column) => column.name) ?? []).reduce((acc, name) => { - if (searchOptions.exclude?.includes(name as string)) { - return acc; - } - return { ...acc, [name]: true }; - }, {}), - ) + .setSelectColumn(requestSearchDto.select) + .setExcludeColumn(searchOptions.exclude) .setWhere(where) .setTake(requestSearchDto.take ?? CRUD_POLICY[method].default.numberOfTake) .setOrder(requestSearchDto.order as FindOptionsOrder, CRUD_POLICY[method].default.sort) diff --git a/src/lib/interface/request.interface.ts b/src/lib/interface/request.interface.ts index 26a507a..494fae4 100644 --- a/src/lib/interface/request.interface.ts +++ b/src/lib/interface/request.interface.ts @@ -15,7 +15,8 @@ export interface CrudReadRequestBase extends CrudRequestBase { } export interface CrudReadOneRequest extends CrudReadRequestBase { - fields?: Partial>; + selectColumns?: string[]; + excludedColumns?: string[]; params: Partial>; } diff --git a/src/lib/request/read-many.request.ts b/src/lib/request/read-many.request.ts index c62891a..fb0e4da 100644 --- a/src/lib/request/read-many.request.ts +++ b/src/lib/request/read-many.request.ts @@ -20,13 +20,17 @@ export class CrudReadManyRequest { private _sort: Sort; private _pagination: PaginationRequest; private _deserialize: (crudReadManyRequest: CrudReadManyRequest) => Where; + private _selectColumnSet: Set = new Set(); + private _excludeColumnSet: Set = new Set(); get primaryKeys() { return this._primaryKeys; } + get findOptions() { return this._findOptions; } + get pagination() { return this._pagination; } @@ -35,6 +39,19 @@ export class CrudReadManyRequest { return this._sort; } + excludedColumns(columns: string[]): this { + this._findOptions.select = columns.filter((column) => { + if (this._excludeColumnSet.has(column)) { + return false; + } + if (this._selectColumnSet.size === 0) { + return true; + } + return this._selectColumnSet.has(column); + }) as unknown as FindOptionsSelect; + return this; + } + setPagination(pagination: PaginationRequest): this { this._pagination = pagination; return this; @@ -50,8 +67,23 @@ export class CrudReadManyRequest { return this; } - setSelect(select: FindOptionsSelect | undefined): this { - this._findOptions.select = select; + setSelectColumn(columns: Array | undefined): this { + if (!columns || columns.length === 0) { + return this; + } + for (const column of columns) { + this._selectColumnSet.add(column); + } + return this; + } + + setExcludeColumn(columns: string[] | undefined): this { + if (!columns || columns.length === 0) { + return this; + } + for (const column of columns) { + this._excludeColumnSet.add(column); + } return this; }