Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support embedded entities #449

Merged
merged 1 commit into from
Feb 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -61,6 +63,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
Loading