Skip to content

Commit

Permalink
Merge pull request #449 from jiho-kr/feature/embedded-entities
Browse files Browse the repository at this point in the history
  • Loading branch information
jiho-kr authored Feb 11, 2024
2 parents 7a9174b + 4641ce2 commit ec5b8c7
Show file tree
Hide file tree
Showing 18 changed files with 251 additions and 34 deletions.
4 changes: 4 additions & 0 deletions spec/base/base.controller.recover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
56 changes: 56 additions & 0 deletions spec/embedded-entities/embedded-entites.spec.ts
Original file line number Diff line number Diff line change
@@ -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' },
});
});
});
16 changes: 16 additions & 0 deletions spec/embedded-entities/embedded-entities.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
14 changes: 14 additions & 0 deletions spec/embedded-entities/employee.controller.ts
Original file line number Diff line number Diff line change
@@ -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<EmployeeEntity> {
constructor(public readonly crudService: EmployeeService) {}
}
23 changes: 23 additions & 0 deletions spec/embedded-entities/employee.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions spec/embedded-entities/employee.service.ts
Original file line number Diff line number Diff line change
@@ -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<EmployeeEntity> {
constructor(@InjectRepository(EmployeeEntity) repository: Repository<EmployeeEntity>) {
super(repository);
}
}
14 changes: 14 additions & 0 deletions spec/embedded-entities/name.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 14 additions & 0 deletions spec/embedded-entities/user.controller.ts
Original file line number Diff line number Diff line change
@@ -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<UserEntity> {
constructor(public readonly crudService: UserService) {}
}
27 changes: 27 additions & 0 deletions spec/embedded-entities/user.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions spec/embedded-entities/user.service.ts
Original file line number Diff line number Diff line change
@@ -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<UserEntity> {
constructor(@InjectRepository(UserEntity) repository: Repository<UserEntity>) {
super(repository);
}
}
6 changes: 4 additions & 2 deletions spec/logging/logging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
Expand All @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions src/lib/crud.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe('CrudService', () => {
const mockRepository = {
metadata: {
primaryColumns: [{ propertyName: 'id' }],
columns: [{ databaseName: 'id' }, { databaseName: 'name1' }],
},
findOne: jest.fn(),
};
Expand Down Expand Up @@ -37,6 +38,7 @@ describe('CrudService', () => {
const mockRepository = {
metadata: {
primaryColumns: [],
columns: [{ databaseName: 'id' }, { databaseName: 'name1' }],
},
};
const crudService = new CrudService(mockRepository as unknown as Repository<EntityType>);
Expand All @@ -62,6 +64,7 @@ describe('CrudService', () => {
const mockRepository = {
metadata: {
primaryColumns: [{ propertyName: 'id' }],
columns: [{ databaseName: 'id' }, { databaseName: 'name1' }],
},
find: jest.fn(),
};
Expand Down
11 changes: 9 additions & 2 deletions src/lib/crud.service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -20,14 +20,19 @@ const SUPPORTED_REPLICATION_TYPES = new Set(['mysql', 'mariadb', 'postgres', 'au

export class CrudService<T extends EntityType> {
private primaryKey: string[];
private columnNames: string[];
private usableQueryRunner = false;

constructor(public readonly repository: Repository<T>) {
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<T>): Promise<PaginationResponse<T>> => {
crudReadManyRequest.excludedColumns(this.columnNames);
try {
const { entities, total } = await (async () => {
const findEntities = this.repository.find({ ...crudReadManyRequest.findOptions });
Expand Down Expand Up @@ -56,7 +61,9 @@ export class CrudService<T extends EntityType> {
readonly reservedReadOne = async (crudReadOneRequest: CrudReadOneRequest<T>): Promise<T> => {
return this.repository
.findOne({
select: crudReadOneRequest.fields as unknown as FindOptionsSelect<T>,
select: (crudReadOneRequest.selectColumns ?? this.columnNames).filter(
(columnName) => !crudReadOneRequest.excludedColumns?.includes(columnName),
),
where: crudReadOneRequest.params as FindOptionsWhere<T>,
withDeleted: crudReadOneRequest.softDeleted,
relations: crudReadOneRequest.relations,
Expand Down
9 changes: 1 addition & 8 deletions src/lib/interceptor/read-many-request.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,7 @@ export function ReadManyRequestInterceptor(crudOptions: CrudOptions, factoryOpti

const crudReadManyRequest: CrudReadManyRequest<typeof crudOptions.entity> = new CrudReadManyRequest<typeof crudOptions.entity>()
.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)
Expand Down
13 changes: 2 additions & 11 deletions src/lib/interceptor/read-one-request.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof crudOptions.entity> = {
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),
};
Expand Down
10 changes: 2 additions & 8 deletions src/lib/interceptor/search-request.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,8 @@ export function SearchRequestInterceptor(crudOptions: CrudOptions, factoryOption
const crudReadManyRequest: CrudReadManyRequest<typeof crudOptions.entity> = new CrudReadManyRequest<typeof crudOptions.entity>()
.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<typeof crudOptions.entity>, CRUD_POLICY[method].default.sort)
Expand Down
3 changes: 2 additions & 1 deletion src/lib/interface/request.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export interface CrudReadRequestBase extends CrudRequestBase {
}

export interface CrudReadOneRequest<T> extends CrudReadRequestBase {
fields?: Partial<Record<keyof T, unknown>>;
selectColumns?: string[];
excludedColumns?: string[];
params: Partial<Record<keyof T, unknown>>;
}

Expand Down
Loading

0 comments on commit ec5b8c7

Please sign in to comment.