Skip to content

Commit

Permalink
Merge pull request #470 from jiho-kr/issue/463
Browse files Browse the repository at this point in the history
fix: column wrong defined when use  namingStrategy
  • Loading branch information
jiho-kr authored Mar 4, 2024
2 parents 60a11f8 + 466a801 commit f943472
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 4 deletions.
4 changes: 4 additions & 0 deletions spec/embedded-entities/embedded-entites.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -19,6 +20,9 @@ describe('Embedded-entities', () => {
});

afterAll(async () => {
const repository = app.get<EmployeeService>(EmployeeService).repository;
await repository.query('DROP TABLE IF EXISTS user_entity');
await repository.query('DROP TABLE IF EXISTS employee_entity');
await app?.close();
});

Expand Down
61 changes: 61 additions & 0 deletions spec/naming-strategy/embedded-entites.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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' },
});
});
});
229 changes: 229 additions & 0 deletions spec/naming-strategy/naming-stategy.spec.ts
Original file line number Diff line number Diff line change
@@ -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<PhotoEntity> {
constructor(@InjectRepository(PhotoEntity) repository: Repository<PhotoEntity>) {
super(repository);
}
}

@Crud({
entity: PhotoEntity,
routes: { [Method.DELETE]: { softDelete: false } },
})
@Controller('photo')
class PhotoController implements CrudController<PhotoEntity> {
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<QuestionEntity> {
constructor(@InjectRepository(QuestionEntity) repository: Repository<QuestionEntity>) {
super(repository);
}
}

@Crud({
entity: QuestionEntity,
routes: { [Method.DELETE]: { softDelete: false } },
})
@Controller('question')
class QuestionController implements CrudController<QuestionEntity> {
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>(PhotoService);
questionService = moduleFixture.get<QuestionService>(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);
});
});
32 changes: 32 additions & 0 deletions spec/naming-strategy/snake-naming.strategy.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
6 changes: 5 additions & 1 deletion spec/test.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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,
Expand All @@ -65,6 +68,7 @@ export class TestHelper {
synchronize: true,
logging: true,
logger: 'file',
namingStrategy,
});
}

Expand Down
4 changes: 1 addition & 3 deletions src/lib/crud.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ export class CrudService<T extends EntityType> {
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,
);
this.columnNames = this.repository.metadata.columns.map((column) => column.propertyPath);
}

readonly reservedReadMany = async (crudReadManyRequest: CrudReadManyRequest<T>): Promise<PaginationResponse<T>> => {
Expand Down

0 comments on commit f943472

Please sign in to comment.