Nest + gql + typeORM(@nestjs/typeorm) (7) typeORM relationsihp
docs.nestjs.com/techniques/database#relations
typeORM의 relationship에 대한 제 포스팅이 있으니 relationship에 대한 설명 자체는 생략하도록하겠습니다.
하나의 Ride가 하나의 Chat을 가지고 있는 OneToOne 관계를 Nest없이 순수하게 구현했을 때는 다음과 같다.
@OneToOne(() => 연결할 entity, 연결된 entity와 관계를 맺는 컬럼, {설정}) 순서로 적어주었다.
// Ride entity
@OneToOne((type) => Chat, (chat) => chat.ride, { nullable: true })
@JoinColumn()
chat: Chat;
// Chat entity
@OneToOne((type) => Ride, (ride) => ride.chat)
ride: Ride;
Nest에서 User와 Verification의 OneToOne 관계를 구현해보자. verification을 통해 user로 접근해야하니 @JoinColumn은 Verification 쪽에 붙여주자.
@InputType({ isAbstract: true })
@ObjectType()
@Entity()
export class Verification extends CoreEntity {
... 생략
@OneToOne(() => User)
@JoinColumn()
user: User;
}
@InputType({ isAbstract: true })
@ObjectType()
@Entity()
export class User extends CoreEntity {
... 생략
@Field(() => Boolean)
@Column({ default: false })
verified: boolean;
일반 typeORM에서 사용한 것처럼 realtions을 통해 join을 구사할 수 있다.
const verification = await this.verification.findOne({ code }, { relations: ['user'] });
join까지 필요 없고 fk만 가져오고 싶다면 아래 포스트를 참고하자. typeORM의 장점 중 하나다.
darrengwon.tistory.com/929?category=921419
삭제 동작을 고려해두자
나중에 프로덕션 때 항상 문제가 되는 건데 삭제 동작을 고려하지 않아서 에러가 자주 나곤한다.
Restaurant와 Category 두 entity가 존재하며, 1개의 Category에 여러 Restaurant가 속하는 1대 다 관계를 형성하고 있다고 가정하자.
상식적으로, 카테고리를 지운다고 해서 카테고리 내에 있는 모든 레스토랑의 목록을 삭제하는 것보다, 카테고리는 지워지더라도 레스토랑은 orphan이 되더라도 남아 있는 것이 맞다.
따라서 Restaurant 엔티티에서 category 필드를 nullable로 만들어주고, 삭제시 set null 되게 설정하면 된다.
@InputType({ isAbstract: true })
@ObjectType()
@Entity()
export class Restaurant extends CoreEntity {
// 카테고리가 지워지면 레스토랑도 지워지면 안됨. 차라리 orphan이 되는 것이 맞음. 따라서 카테고리 삭제시 null로 처리
// 이를 위해 category가 nullable할 수 있도록 하자.
@Field(() => Category, { nullable: true })
@ManyToOne(() => Category, (category) => category.restaurants, { nullable: true, onDelete: 'SET NULL' })
category: Category;
}
@InputType({ isAbstract: true })
@ObjectType()
@Entity()
export class Category extends CoreEntity {
@Field(() => [Restaurant])
@OneToMany(() => Restaurant, (restaurant) => restaurant.category)
restaurants: Restaurant[];
}
onDelete의 액션은 "RESTRICT" | "CASCADE" | "SET NULL" | "DEFAULT" | "NO ACTION"; 이 존재한다.
RESTRICT는 지워지는 것을 막는 것
CASCADE는 같이 지워지는 것
SET NULL은 지워지면 null로 설정하는 것(해당 컬럼이 nullable하도록 별도 설정 필요)
* tip) hash를 hash하는 것을 막자
user에 대해 무언가를 업데이트, 삽입할 때마다 password를 해쉬화하는 코드는 아래와 같다.
그런데 password가 아닌 다른 컬럼을 수정하여 저장한다면 이미 hash된 password를 또 hash하게 되어 정상적인 패스워드를 입력해도 로그인을 할 수 없게 된다.
비밀번호가 바뀔 때만(혹은 새로 만들어질 때만) hash를 진행해야 한다.
@InputType({ isAbstract: true })
@ObjectType()
@Entity()
export class User extends CoreEntity {
... 중략
@Field(() => String)
@Column({ type: 'text' })
@IsString()
password: string;
@BeforeInsert()
@BeforeUpdate()
async hashPassword(): Promise<void> {
try {
this.password = await bcrypt.hash(this.password, 10);
} catch (error) {
console.log(error);
throw new InternalServerErrorException();
}
}
}
우선 select를 false로 두자. (select의 기본값은 true이다)
Indicates if column is always selected by QueryBuilder and find operations. default is true
이렇게 두면 다른 곳에서 불러도 password는 불러오지 않게 된다.
@Field(() => String)
@Column({ type: 'text', select: false })
@IsString()
password: string;
select가 false가 되었으므로 기본적으로 아래 같이 user를 불러오면 password를 출력되지 않습니다.
const user = await this.users.findOne({ email });
password가 필요한 다른 로직에서는 옵션에서 select를 이용해 직접 가져와야 합니다.
const user = await this.users.findOne({ email }, { select: ['password'] });
더 주의할 점은, select를 사용하는 이상 발생하는 side effect입니다. 가져올 column을 명시했다면, 다른 값들은 암시적으로 불러와지는 것이 아니기 때문에 explicit하게 다 적어줘야 한다는 겁니다. 아... labourous...
const user = await this.users.findOne({ email }, { select: ['id', 'password'] });
그리고 해당 password가 있을 때만 hash화를 진행하도록합시다. 이렇게 되면 double hashing되는 문제는 해결됩니다.
그러나 이 외의 user 정보를 불러오는 로직에 select를 다 넣어줘야겠죠.
@BeforeInsert()
@BeforeUpdate()
async hashPassword(): Promise<void> {
if (this.password) {
try {
this.password = await bcrypt.hash(this.password, 10);
} catch (error) {
console.log(error);
throw new InternalServerErrorException();
}
}
}
findOne Options
select, where, loadRelationIds, relations 정도는 사용하였는데, 사실 TypeORM을 사용하려면 모두 잘 사용할 줄 알아야 한다. 트랜잭션, 락, 캐쉬 등...
export interface FindOneOptions<Entity = any> {
// Specifies what columns should be retrieved.
select?: (keyof Entity)[];
// Simple condition that should be applied to match entities.
where?: FindConditions<Entity>[] | FindConditions<Entity> | ObjectLiteral | string;
// Indicates what relations of entity should be loaded (simplified left join form).
relations?: string[];
// Specifies what relations should be loaded.
join?: JoinOptions;
// Order, in which entities should be ordered.
order?: { [P in keyof Entity]?: "ASC" | "DESC" | 1 | -1; };
// Enables or disables query result caching.
cache?: boolean | number | { id: any; milliseconds: number; };
// Indicates what locking mode should be used.
lock?: {
mode: "optimistic";
version: number | Date;
} | {
mode: "pessimistic_read" | "pessimistic_write" | "dirty_read" | "pessimistic_partial_write" | "pessimistic_write_or_fail";
};
// Indicates if soft-deleted rows should be included in entity result.
withDeleted?: boolean;
// If sets to true then loads all relation ids of the entity and maps them into relation values (not relation objects).
// If array of strings is given then loads only relation ids of the given properties.
loadRelationIds?: boolean | {
relations?: string[];
disableMixedMap?: boolean;
};
// Indicates if eager relations should be loaded or not. By default they are loaded when find methods are used.
loadEagerRelations?: boolean;
// If this is set to true, SELECT query in a `find` method will be executed in a transaction.
transaction?: boolean;
}