diff --git a/spec/embedded-entities/embedded-entites.spec.ts b/spec/embedded-entities/embedded-entites.spec.ts index 2a72c43..fabc1a6 100644 --- a/spec/embedded-entities/embedded-entites.spec.ts +++ b/spec/embedded-entities/embedded-entites.spec.ts @@ -4,6 +4,7 @@ import request from 'supertest'; import { EmbeddedEntitiesModule } from './embedded-entities.module'; import { EmployeeEntity } from './employee.entity'; +import { EmployeeService } from './employee.service'; import { UserEntity } from './user.entity'; import { TestHelper } from '../test.helper'; @@ -19,6 +20,9 @@ describe('Embedded-entities', () => { }); afterAll(async () => { + const repository = app.get(EmployeeService).repository; + await repository.query('DROP TABLE IF EXISTS user_entity'); + await repository.query('DROP TABLE IF EXISTS employee_entity'); await app?.close(); }); diff --git a/spec/naming-strategy/embedded-entites.spec.ts b/spec/naming-strategy/embedded-entites.spec.ts new file mode 100644 index 0000000..94c70ef --- /dev/null +++ b/spec/naming-strategy/embedded-entites.spec.ts @@ -0,0 +1,61 @@ +import { HttpStatus, INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; + +import { SnakeNamingStrategy } from './snake-naming.strategy'; +import { EmbeddedEntitiesModule } from '../embedded-entities/embedded-entities.module'; +import { EmployeeEntity } from '../embedded-entities/employee.entity'; +import { EmployeeService } from '../embedded-entities/employee.service'; +import { UserEntity } from '../embedded-entities/user.entity'; +import { TestHelper } from '../test.helper'; + +describe('Embedded-entities using NamingStrategy', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [EmbeddedEntitiesModule, TestHelper.getTypeOrmPgsqlModule([UserEntity, EmployeeEntity], new SnakeNamingStrategy())], + }).compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + const repository = app.get(EmployeeService).repository; + await repository.query('DROP TABLE IF EXISTS user_entity'); + await repository.query('DROP TABLE IF EXISTS employee_entity'); + 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/naming-strategy/naming-stategy.spec.ts b/spec/naming-strategy/naming-stategy.spec.ts new file mode 100644 index 0000000..e270903 --- /dev/null +++ b/spec/naming-strategy/naming-stategy.spec.ts @@ -0,0 +1,229 @@ +/* eslint-disable max-classes-per-file */ +import { Controller, HttpStatus, INestApplication, Injectable, Module } from '@nestjs/common'; +import { ApiHideProperty, ApiProperty } from '@nestjs/swagger'; +import { TestingModule, Test } from '@nestjs/testing'; +import { InjectRepository, TypeOrmModule } from '@nestjs/typeorm'; +import { Exclude, Type } from 'class-transformer'; +import { IsDateString, IsNumber, IsOptional, IsString } from 'class-validator'; +import request from 'supertest'; +import { + Entity, + TableInheritance, + PrimaryGeneratedColumn, + Column, + ChildEntity, + Repository, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, +} from 'typeorm'; + +import { SnakeNamingStrategy } from './snake-naming.strategy'; +import { Crud } from '../../src/lib/crud.decorator'; +import { CrudService } from '../../src/lib/crud.service'; +import { CrudController, GROUP, Method } from '../../src/lib/interface'; +import { TestHelper } from '../test.helper'; + +@Entity('content') +@TableInheritance({ column: { type: 'varchar', name: 'type' } }) +class ContentEntity { + @PrimaryGeneratedColumn() + @IsNumber({}, { groups: [GROUP.PARAMS] }) + @Type(() => Number) + id: number; + + @Column() + @IsString({ always: true }) + @IsOptional({ always: true }) + title: string; + + @Column() + @IsString({ always: true }) + @IsOptional({ always: true }) + description: string; + + @IsDateString(undefined, { + groups: [GROUP.READ_MANY, GROUP.READ_ONE], + }) + @IsOptional({ groups: [GROUP.READ_MANY, GROUP.READ_ONE] }) + @Type(() => Date) + @CreateDateColumn() + @ApiProperty({ type: Date, format: 'date-time' }) + declare readonly createdAt?: Date; + + @IsDateString(undefined, { + groups: [GROUP.READ_MANY, GROUP.READ_ONE], + }) + @IsOptional({ groups: [GROUP.READ_MANY, GROUP.READ_ONE] }) + @UpdateDateColumn() + @Type(() => Date) + @ApiProperty({ type: Date, format: 'date-time' }) + declare readonly updatedAt?: Date; + + @Exclude() + @IsOptional({ always: true }) + @DeleteDateColumn({ + type: 'timestamp', + nullable: true, + }) + @IsDateString(undefined, { + groups: [GROUP.READ_MANY, GROUP.READ_ONE, GROUP.SEARCH, GROUP.PARAMS], + }) + @Type(() => Date) + @ApiHideProperty() + declare readonly deletedAt?: Date; +} + +@ChildEntity({ name: 'photo' }) +class PhotoEntity extends ContentEntity { + @Column() + @IsString({ always: true }) + @IsOptional({ always: true }) + size: string; +} + +@Injectable() +class PhotoService extends CrudService { + constructor(@InjectRepository(PhotoEntity) repository: Repository) { + super(repository); + } +} + +@Crud({ + entity: PhotoEntity, + routes: { [Method.DELETE]: { softDelete: false } }, +}) +@Controller('photo') +class PhotoController implements CrudController { + constructor(public readonly crudService: PhotoService) {} +} + +@ChildEntity('question') +class QuestionEntity extends ContentEntity { + @Column() + @Type(() => Number) + @IsNumber({}, { always: true }) + @IsOptional({ always: true }) + answersCount: number; +} +@Injectable() +class QuestionService extends CrudService { + constructor(@InjectRepository(QuestionEntity) repository: Repository) { + super(repository); + } +} + +@Crud({ + entity: QuestionEntity, + routes: { [Method.DELETE]: { softDelete: false } }, +}) +@Controller('question') +class QuestionController implements CrudController { + constructor(public readonly crudService: QuestionService) {} +} + +@Module({ + imports: [TypeOrmModule.forFeature([ContentEntity, PhotoEntity, QuestionEntity])], + controllers: [PhotoController, QuestionController], + providers: [PhotoService, QuestionService], +}) +class ContentModule {} + +describe('NamingStrategy', () => { + let app: INestApplication; + let photoService: PhotoService; + let questionService: QuestionService; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + ContentModule, + TestHelper.getTypeOrmPgsqlModule([ContentEntity, PhotoEntity, QuestionEntity], new SnakeNamingStrategy()), + ], + }).compile(); + app = moduleFixture.createNestApplication(); + + photoService = moduleFixture.get(PhotoService); + questionService = moduleFixture.get(QuestionService); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + it('should be defined', () => { + expect(app).toBeDefined(); + }); + + it('should be fetch data', async () => { + await photoService.repository.delete({}); + const title = `Tester-${Date.now()}`; + const { body: created } = await request(app.getHttpServer()) + .post('/photo') + .send({ title, description: 'Photo Test', size: '100px' }) + .expect(HttpStatus.CREATED); + + const { body: readOne } = await request(app.getHttpServer()).get(`/photo/${created.id}`).expect(HttpStatus.OK); + expect(readOne.title).toEqual(title); + }); + + it('should be used PhotoEntity', async () => { + await photoService.repository.delete({}); + const title = `Tester-${Date.now()}`; + const { body: created } = await request(app.getHttpServer()) + .post('/photo') + .send({ title, description: 'Photo Test', size: '100px' }) + .expect(HttpStatus.CREATED); + expect(created.title).toEqual(title); + expect(created.id).toBeDefined(); + expect(created.size).toEqual('100px'); + + const { body: readOne } = await request(app.getHttpServer()).get(`/photo/${created.id}`).expect(HttpStatus.OK); + expect(readOne.title).toEqual(title); + + const { body: readMany } = await request(app.getHttpServer()).get('/photo').expect(HttpStatus.OK); + expect(readMany.data).toHaveLength(1); + expect(readMany.data[0].title).toEqual(title); + expect(readMany.metadata.total).toEqual(1); + + const { body: updated } = await request(app.getHttpServer()) + .patch(`/photo/${created.id}`) + .send({ title: 'updated' }) + .expect(HttpStatus.OK); + expect(updated.title).toEqual('updated'); + + await request(app.getHttpServer()).delete(`/photo/${created.id}`).expect(HttpStatus.OK); + await request(app.getHttpServer()).get(`/photo/${created.id}`).expect(HttpStatus.NOT_FOUND); + }); + + it('should be used QuestionEntity', async () => { + await questionService.repository.delete({}); + const title = `Tester-${Date.now()}`; + const { body: created } = await request(app.getHttpServer()) + .post('/question') + .send({ title, description: 'Question Test', answersCount: 10 }) + .expect(HttpStatus.CREATED); + expect(created.title).toEqual(title); + expect(created.id).toBeDefined(); + expect(created.size).not.toBeDefined(); + expect(created.answersCount).toEqual(10); + + const { body: readOne } = await request(app.getHttpServer()).get(`/question/${created.id}`).expect(HttpStatus.OK); + expect(readOne.title).toEqual(title); + + const { body: readMany } = await request(app.getHttpServer()).get('/question').expect(HttpStatus.OK); + expect(readMany.data).toHaveLength(1); + expect(readMany.data[0].title).toEqual(title); + expect(readMany.metadata.total).toEqual(1); + + const { body: updated } = await request(app.getHttpServer()) + .patch(`/question/${created.id}`) + .send({ title: 'updated' }) + .expect(HttpStatus.OK); + expect(updated.title).toEqual('updated'); + + await request(app.getHttpServer()).delete(`/question/${created.id}`).expect(HttpStatus.OK); + await request(app.getHttpServer()).get(`/question/${created.id}`).expect(HttpStatus.NOT_FOUND); + }); +}); diff --git a/spec/naming-strategy/snake-naming.strategy.ts b/spec/naming-strategy/snake-naming.strategy.ts new file mode 100644 index 0000000..d2195f9 --- /dev/null +++ b/spec/naming-strategy/snake-naming.strategy.ts @@ -0,0 +1,32 @@ +import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm'; +import { snakeCase } from 'typeorm/util/StringUtils'; + +export class SnakeNamingStrategy extends DefaultNamingStrategy implements NamingStrategyInterface { + tableName(className: string, customName: string): string { + return customName || snakeCase(className); + } + + columnName(propertyName: string, customName: string, embeddedPrefixes: string[]): string { + return snakeCase(embeddedPrefixes.join('_')) + (customName || snakeCase(propertyName)); + } + + relationName(propertyName: string): string { + return snakeCase(propertyName); + } + + joinColumnName(relationName: string, referencedColumnName: string): string { + return snakeCase(`${relationName}_${referencedColumnName}`); + } + + joinTableName(firstTableName: string, secondTableName: string, firstPropertyName: string): string { + return snakeCase(`${firstTableName}_${firstPropertyName.replace(/\./gi, '_')}_${secondTableName}`); + } + + joinTableColumnName(tableName: string, propertyName: string, columnName?: string): string { + return snakeCase(`${tableName}_${columnName ?? propertyName}`); + } + + classTableInheritanceParentColumnName(parentTableName: string, parentTableIdPropertyName: string): string { + return snakeCase(`${parentTableName}_${parentTableIdPropertyName}`); + } +} diff --git a/spec/test.helper.ts b/spec/test.helper.ts index 61037cd..4256cd4 100644 --- a/spec/test.helper.ts +++ b/spec/test.helper.ts @@ -30,6 +30,9 @@ export class TestHelper { const tables = [...metadata.tables]; while (tables.length > 0) { const table = tables.shift()!; + if (!table.name) { + return; + } const entity = table.target as typeof BaseEntity; await entity.query(`DROP TABLE ${table.name}`).catch((error) => { @@ -55,7 +58,7 @@ export class TestHelper { }); } - static getTypeOrmPgsqlModule(entities: TypeOrmModuleOptions['entities']) { + static getTypeOrmPgsqlModule(entities: TypeOrmModuleOptions['entities'], namingStrategy?: TypeOrmModuleOptions['namingStrategy']) { return TypeOrmModule.forRoot({ type: 'postgres', database: process.env.POSTGRESQL_DATABASE_NAME, @@ -65,6 +68,7 @@ export class TestHelper { synchronize: true, logging: true, logger: 'file', + namingStrategy, }); } diff --git a/src/lib/crud.service.ts b/src/lib/crud.service.ts index 55474d3..b3a06b8 100644 --- a/src/lib/crud.service.ts +++ b/src/lib/crud.service.ts @@ -26,9 +26,7 @@ export class CrudService { 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, - ); + this.columnNames = this.repository.metadata.columns.map((column) => column.propertyPath); } readonly reservedReadMany = async (crudReadManyRequest: CrudReadManyRequest): Promise> => {