Nest + gql + typeORM(@nestjs/typeorm) (2) entity와 schema를 한 번에 작성하기
Nest 프레임워크가 등장하기 전 Express + graphql + typeORM 하에서는 다음과 같은 불편한 점이 있었습니다.
1. @Entity부분과 graphql 쿼리 부분을 따로 작성해야 했습니다. 때문에 수작업으로 일치시켜주는 작업 때문에 실수도 잦았으며 비슷한 코드를 2번 작업해야 했기 때문에 번거로웠습니다.
2. resolver를 작성하는 부분에 있어 내부 로직의 타이핑은 entity를 통해 해줘야 했고 resolver의 타이핑은 graphql-typescript를 통해 gql schema를 타입으로 변환한 후 해당 타입을 사용해야 했습니다. 정리하자면, Validation을 위한 타이핑에 두 소스가 사용되었으며 이는 종종 혼동을 주기로 하고, 별도의 변환 작업을 수동으로 해줘야만 했습니다.
type Message {
id: Int!
text: String!
chatId: Int!
chat: Chat!
user: User!
createdAt: String!
updatedAt: String
}
import { BaseEntity, Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";
import Chat from "./Chat";
import User from "./User";
@Entity()
class Message extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: "text" })
text: string;
@Column({ nullable: true })
chatId: number;
@ManyToOne((type) => Chat, (chat) => chat.messages)
chat: Chat;
@ManyToOne((type) => User, (user) => user.messages)
user: User;
@CreateDateColumn()
createdAt: string;
@UpdateDateColumn()
updatedAt: string;
}
export default Message;
import { Resolvers } from "../../../types/resolvers";
import privateResolver from "../../utils/privateResolver";
import { GetChatQueryArgs, GetChatResponse } from "../../../types/graph";
import User from "../../../entities/User";
import Chat from "../../../entities/Chat";
// import Ride from "../../../entities/Ride";
const resolvers: Resolvers = {
Query: {
GetChat: privateResolver(
// graphql-typescript를 통해 gql schema를 타이핑 변환 후 사용
async (_, args: GetChatQueryArgs, { req, context }): Promise<GetChatResponse> => {
// 내부 로직 타이핑은 Entity를 통해
const user: User = req.user;
... 중략
}
),
},
};
export default resolvers;
그러나 Nest 프레임워크에서는 이러한 반복적인 작업을 덜 수 있게 되었습니다.
아래와 같이 @ObjectType을 통해 gql schema를 정의함과 동시에 @Entity를 붙여 typeORM entity를 만들 수 있습니다.
하나의 class를 작성하고 그것을 gql schema와 typeORM으로 동시에 사용하게 되어 위에 지적한 문제점을 모두 해결할 수 있습니다.
import { Field, ObjectType } from '@nestjs/graphql';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@ObjectType() // nest
@Entity() // typeORM
export class Restaurant {
@Field(() => Number) // nest(gql)
@PrimaryGeneratedColumn() // typeORM
id: number;
@Field(() => String) // nest(gql)
@Column() // typeORM
name: string; // nest(typescript)
@Field(() => Boolean)
@Column()
isVegan: boolean;
@Field(() => String)
@Column()
address: string;
@Field(() => String)
@Column()
ownersName: string;
@Field(() => String)
@Column()
categoryName: string;
}
그런데 자세히 보시면, Active Record 방식이 아닌 Data Mapper 방식을 사용하신 것을 확인하실 수 있습니다.
(active record와 data mapper의 차이는 darrengwon.tistory.com/930?category=921419 를 참고)
일반적으로 Active Record가 훨씬 편하지만
1. nest에서는 data mapper 방식에 따른 다양한 repositoy 기능을 마련해두었고
2. 공식 문서에 따르면 data mapper가 대규모 어플리케이션에 적합하며
3. 테스트하기에 편리하기 때문에
여러모로 Data Mapper 방식을 사용하는 것이 좋습니다. 이 부분은 다음 포스트에서 다루겠습니다.
Optional Types and Column
아래와 같은 schema/entity는 별다른 설정을 하지 않았으므로 required입니다.
@Field(() => Boolean) // nest(gql)
@Column() // typeORM
isVegan: boolean;
조금 더 다양한 값을 주기 위해 @Field의 정의 부분으로 들어가봅시다.
결과적으로, FieldOptions에 들어갈 수 있는 부분들은 nullable, defaultValue, name, description, deprecationReason, complexity가 있군요.
export interface BaseTypeOptions {
nullable?: boolean | NullableList;
defaultValue?: any;
}
export interface FieldOptions extends BaseTypeOptions {
name?: string;
description?: string;
deprecationReason?: string;
complexity?: Complexity;
}
export declare function Field(returnTypeFunction?: ReturnTypeFunc, options?: FieldOptions): PropertyDecorator & MethodDecorator;
typeORM의 @Column 부분의 정의에도 들어가봅시다. 엄청 많습니다.
export interface ColumnOptions extends ColumnCommonOptions {
type?: ColumnType;
name?: string;
length?: string | number;
width?: number;
nullable?: boolean;
readonly?: boolean;
update?: boolean;
select?: boolean;
insert?: boolean;
default?: any;
... 이하 생략
}
위에서의 속성을 이용해 다음과 같이 default 값을 넣어줄 수 있습니다.
여기서, gql/typeorm/validation을 한 파일에 다 관리하므로 각 설정에 대한 것도 3부분을 다 해줘야 하는 것을 잊지맙시다.
@Field(() => Boolean, { defaultValue: true }) // nest(gql)
@Column({ default: true }) // typeORM
@IsOptional() // class-validation
@IsBoolean() // class-validation
isVegan: boolean;
TypeORM entity Listener와 메서드들
Nest를 사용하기 전 active record 방식으로 짰을 때는 Entity 내부에 그냥 메서드를 정의해서 사용했고 Listener도 다음과 같이 달아주면 됩니다. data mapper 에서도 그냥 달아주면 됩니다. 아래와 같이 말이죠.
혹시, Listener에 대해 잘 모르면 darrengwon.tistory.com/886?category=921419 여기를 참고합시다.
@InputType({ isAbstract: true })
@ObjectType()
@Entity()
export class User extends CoreEntity {
@Field(() => String) // gql
@Column() // typeorm
@IsEmail() // class-validatior
email: string;
@Field(() => String)
@Column()
@IsString()
password: string;
@Field(() => UserRole)
@Column({ type: 'enum', enum: UserRole })
@IsEnum(UserRole)
role: UserRole;
@BeforeInsert()
@BeforeUpdate()
async hashPassword(): Promise<void> {
try {
this.password = await bcrypt.hash(this.password, 10);
} catch (error) {
console.log(error);
throw new InternalServerErrorException();
}
}
async checkPassword(givenPassword: string): Promise<boolean> {
try {
const ok = await bcrypt.compare(givenPassword, this.password);
return ok;
} catch (error) {
console.log(error);
throw new InternalServerErrorException();
}
}
}
Auto-load entities
docs.nestjs.com/techniques/database#auto-load-entities
그리고 이렇게 작성한 Entity는 typeORM 사용해왔듯 `./**/*.entity{ .ts,.js}` 꼴과 같이 원하는 경로로 넣으면 됩니다. 아니면 그냥 Entity를 불러와 통으로 넣어도 되구요.
const connectionOptions: ConnectionOptions = {
... 중략
entities: ["./entities/**/*.*"],
};
however, that glob paths are not supported by webpack, so if you are building your application within a monorepo, you won't be able to use them. To address this issue, an alternative solution is provided. To automatically load entities, set the autoLoadEntities property of the configuration object (passed into the forRoot() method) to true, as shown below:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
TypeOrmModule.forRoot({
...
autoLoadEntities: true,
}),
],
})
export class AppModule {}