안녕하세요잇~
이번 시간에는 가상 컬럼에 대해 알아보겠습니다. 제목을 어떻게 해야 검색이 잘 될지 생각하다가 그냥 가상 컬럼이라는 제목으로 짓게 되었습니다.
왜? why?
이 포스팅을 작성하게 된것인지 저의 사례를 들려드리겠습니다.
사건의 발단은 바로 typeorm의 addSelect였습니다.
entity에는 정의하지 않았지만 createBuilder에서 addSelect로 Computed Column(계산된 열)을 추가해야 될 일이 있었습니다. 근데 여기서 getMany로 Mapping되면서 깔끔하게 받고 싶지만 앞서 말씀드린것처럼 계산된 열이기 때문에 entity에는 정의되지 않은 컬럼이기 때문에 값이 담기지 않았습니다.
이것을 해결하기 위해 getRawMany()로 받아서 사용하려 했지만 도무지 마음에 들지 않아(entity와 MAPPING이 안되서 쓰기 싫었습니다.)
구글링하다가 찾은 방법을 여러분들께 소개해드립니다.
우선 저는 ORM을 쓰는 큰 이유 중 하나는 entity 매핑입니다.
얼마나 편합니까. 알아서 객체타입으로 바꿔주는데 아주 사랑스럽습니다.(물론 어느정도 세팅은 필요하지만 말입니다 ㅎㅎ)
눈물의 똥꼬쇼를 하며 해결했던 코드 먼저 보여드리겠습니다.
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
@Entity()
export class Order {
@PrimaryGeneratedColumn()
id: number;
@Column()
userId: number;
}
const builder = this.UserRepository
.createQueryBuilder()
.addSelect('(SELECT COUNT(*) FROM Order o WHERE o.user_id = id', 'count');
const records = await builder.getRawAndEntities();
const responseDto = records.raw.map((rawRecord, i) => {
return new ResponseDto(Object.assign(records.entities[i], { count: rawRecord.count }));
});
위와 같은 방법으로 똥꼬쇼를 하며 아 결국 방법이 이거밖에 없는건가?
근데 너무 마음에 안드는데..? 구글링을 해보자 하고 찾은 방법은 바로바로!! 아래와 같습니다.
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@VirtualColumn()
count: number;
}
위와 같은 방법을 사용하기 위해선 decorator 파일을 하나 만들어 주셔야 합니다.
/src/common/database/decorator (저는 이와 같은 경로로 파일을 만들어서 사용합니다.)
import 'reflect-metadata';
export const VIRTUAL_COLUMN_KEY = Symbol('VIRTUAL_COLUMN_KEY');
export function VirtualColumn(name?: string): PropertyDecorator {
return (target, propertyKey) => {
const metaInfo = Reflect.getMetadata(VIRTUAL_COLUMN_KEY, target) || {};
metaInfo[propertyKey] = name ?? propertyKey;
Reflect.defineMetadata(VIRTUAL_COLUMN_KEY, metaInfo, target);
};
}
@VirtualColumn 데코를 만들었으니 끝일까요? 그랫으면 참 좋겠지만 아닙니다..
왜냐 위의 데코레이터는 선언되는 것 뿐입니다. 이제 선언은 되었으니 사용을 해야겠죠?
사용하기 위해선 polyfill파일도 만들어 주셔야합니다.
import { VIRTUAL_COLUMN_KEY } from '@root/common/database/decorators/virtual-column.decorator';
import { SelectQueryBuilder } from 'typeorm';
// declare module 'typeorm' {
// interface SelectQueryBuilder<Entity> {
// getMany(this: SelectQueryBuilder<Entity>): Promise<Entity[] | undefined>;
// }
// }
SelectQueryBuilder.prototype.getMany = async function () {
const { entities, raw } = await this.getRawAndEntities();
const items = entities.map((entitiy, index) => {
const metaInfo = Reflect.getMetadata(VIRTUAL_COLUMN_KEY, entitiy) ?? {};
const item = raw[index];
for (const [propertyKey, name] of Object.entries<string>(metaInfo)) {
entitiy[propertyKey] = item[name];
}
return entitiy;
});
return [...items];
};
typeorm의 SelectQueryBuilder의 prototype을 재정의해서 사용합니다.
그럼 이 polyfill파일은 어떻게 적용해야 할까요??
저의 경우에는 database module을 ./src/config/database 폴더안에 넣고 사용합니다.
config.module.ts
import * as Joi from 'joi';
import { Module } from '@nestjs/common';
import configuration from './configuration';
import { DatabaseConfigService } from './config.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import './polyfill';
/**
* Import and provide app configuration related classes.
*
* @module
*/
@Module({
imports: [
ConfigModule.forRoot({
load: [configuration],
validationSchema: Joi.object({
DB_HOST: Joi.string().default('localhost'),
DB_PORT: Joi.number().default(3306),
DB_USERNAME: Joi.string().exist(),
DB_PASSWORD: Joi.string().exist(),
DB_DATABASE: Joi.string().default('unknown'),
DB_MIGRATE: Joi.boolean(),
DB_RO_HOST: Joi.string(),
DB_RO_PORT: Joi.number(),
DB_RO_USERNAME: Joi.string(),
DB_RO_PASSWORD: Joi.string(),
DB_RO_DATABASE: Joi.string(),
DB_CONNECTION_LIMIT: Joi.number(),
}),
}),
],
providers: [ConfigService, DatabaseConfigService],
exports: [ConfigService, DatabaseConfigService],
})
export class DatabaseConfigModule {}
데이터베이스 모듈안에 polyfill을 적용하시면 이제 getMany를 사용하실때 entity에 정의해놓은 @VirtualColumn과 매핑되서 사용하실 수 있습니다. 아름답습니다.
똥꼬쇼 하던 코드가 아래와 같이 깔끔하게 변할 수 있습니다.
const builder = this.UserRepository
.createQueryBuilder()
.addSelect('(SELECT COUNT(*) FROM Order o WHERE o.user_id = id', 'count');
const records = await builder.getMany();
const responseDto = records.map((record) => new ResponseDto(record));
});
기존의 typeorm을 사용하던 것과 차이가 없이 사용하실 수 있습니다.
근데 저 declare module이 궁금해서 찾아보았습니다.
Ambient Module
이라는 것으로 typescript에서 설명한 것을 따르면
`
TypeScript로 작성되지 않은 라이브러리의 모양을 설명하려면 라이브러리가 노출하는 API를 선언해야 합니다.
구현을 정의하지 않는 선언을 "ambient"라고 부릅니다. 일반적으로 .d.ts파일에 정의되어 있습니다
Node.js에서 대부분의 작업은 하나 이상의 모듈을 로드하여 수행됩니다. .d.ts최상위 내보내기 선언을 사용 하여 자체 파일에 각 모듈을 정의할 수 있지만 하나의 큰 .d.ts파일로 작성하는 것이 더 편리합니다. 이를 위해 앰비언트 네임스페이스와 유사한 구성을 사용하지만 module나중에 가져올 때 사용할 수 있는 모듈의 인용된 이름과 키워드를 사용합니다.
`
결국 typescript로 작성되지 않은 라이브러리를 typescript로 선언된 것처럼 만드는 것이라고 생각이드는데 typeorm은 typescipt로 작성되서 그런지 declare를 뺴고도 잘 동작되었습니다.
저도 declare에 대한 이해가 부족하기 때문에 조금 더 공부해서 보충글을 나중에 적어놓겠습니다!
오늘도 봐주셔서 감사합니다. 도움이 되셨다면 하트 부탁드립니다.
근데 사견으로 typeorm 좀 불편한거 같아요.. 그렇게들 생각안하시나요? 불편하시다고 생각하시면 하트 부탁드립니다.
추가로 언젠가는 addSelectAndMap()이라는게 나와서 위와같은 똥꼬쇼를 안하게 되는 날이 올꺼라고 믿습니다. 왜냐 typeorm github에 저와 같은 사람들이 와서 issue를 남겨놓은것을 본적있습니다. typeorm 0.4떄는 믿어도 되는거지?? 제발~~
그리고 이름은 가상컬럼보단 Computed Column추천드립니다. Microsoft 피셜 Computed Column이에요~
Prisma 추천해요!