Nest.js TypeORM ๋ฆฌํŒฉํ„ฐ๋ง (QueryBuilder)

@beygeeยท July 18, 2021 ยท 15 min read

๐Ÿšง ์˜ค๋ž˜๋œ ํฌ์ŠคํŠธ๋ผ ํ˜„์žฌ TypeORM๊ณผ ๋ฒ„์ „ ํ˜ธํ™˜์ด ๋งž์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฐœ๋…์ ์ธ ๊ด€์ ์—์„œ ๋ด์ฃผ์‹œ๋ฉด ๊ฐ์‚ฌํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

์ด๋ฒˆ์—๋Š” TypeORM์—์„œ ์ž์ฃผ ์ด์šฉํ•˜๋Š” QueryBuilder์˜ ์ฝ”๋“œ๋Ÿ‰์„ ์ค„์ด๋Š” ๋ฒ•์— ๋Œ€ํ•ด ์‚ดํŽด๋ณด๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

QueryBuilder ์žฌ์‚ฌ์šฉ์„ฑ ๋†’์ด๊ธฐ

์„œ๋น„์Šค๋ฅผ ๋งŒ๋“ค๋ฉด์„œ ์กฐํšŒ๊ฐ€ ํ•„์š”ํ•œ ์ฟผ๋ฆฌ๋Š” ๋ชจ๋‘ TypeORM์˜ QueryBuilder ํŒจํ„ด์„ ์ด์šฉํ•˜์—ฌ ๋งŒ๋“ค๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. QueryBuilder๊ฐ€ TypeORM์˜ findOne๊ณผ ๊ฐ™์€ ํ•จ์ˆ˜๋ณด๋‹ค ๋” ์„ธ๋ฐ€ํ•˜๊ฒŒ(์กฐ์ธ๋œ ํ…Œ์ด๋ธ” where, order, having, group๋“ฑ) ์ฟผ๋ฆฌ๋ฅผ ์กฐ์ •ํ•  ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

// user.service.ts
import { Injectable } from "@nestjs/common"
import { UserRepository } from "./user.repository"

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}

  public async findOneByUserId(userId: number) {
    return this.userRepository.findOneByUserId(userId)
  }

  public async findOneByEmail(email: string) {
    return this.userRepository.findOneByEmail(email)
  }

  public async findOneByNickname(nickname: string) {
    return this.userRepository.findOneByNickname(nickname)
  }
}
// user.repository.ts
import { AbstractRepository, EntityRepository } from "typeorm"
import { User } from "./user.entity"

@EntityRepository(User)
export class UserRepository extends AbstractRepository<User> {
  public async findOneByUserId(userId: number) {
    const qb = this.repository
      .createQueryBuilder("User")
      .leftJoinAndSelect("User.userAuth", "userAuth")
      .leftJoinAndSelect("User.userProfile", "userProfile")

    qb.andWhere("User.id = :id", { id: userId })

    return qb.getOne()
  }

  public async findOneByEmail(email: string) {
    const qb = this.repository
      .createQueryBuilder("User")
      .leftJoinAndSelect("User.userAuth", "userAuth")
      .leftJoinAndSelect("User.userProfile", "userProfile")

    qb.andWhere("User.email = :email", { email })

    return qb.getOne()
  }

  public async findOneByNickname(nickname: string) {
    const qb = this.repository
      .createQueryBuilder("User")
      .leftJoinAndSelect("User.userAuth", "userAuth")
      .leftJoinAndSelect("User.userProfile", "userProfile")

    qb.andWhere("User.nickname = :nickname", { nickname })

    return qb.getOne()
  }
}

ํ•˜์ง€๋งŒ ๋น„์ฆˆ๋‹ˆ์Šค ์กฐํšŒ ๋กœ์ง์ด ๋งŽ์•„์งˆ ์ˆ˜๋ก, QueryBuilder ํ•จ์ˆ˜๋ฅผ ๋ฐ˜๋ณตํ•ด์„œ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์•„์กŒ์Šต๋‹ˆ๋‹ค. ๋ฌธ์ œ๋Š” ํฌ๊ฒŒ 3๊ฐ€์ง€ ๊ฒฝ์šฐ์˜€์Šต๋‹ˆ๋‹ค.

  • ๋„ˆ๋ฌด ๋งŽ์ด ๋ฐ˜๋ณต.. ๋˜ ๋ฐ˜๋ณต
  • Or ์—ฐ์‚ฐ์€ ์–ด๋–ป๊ฒŒ ํ•˜์ง€?
  • FindOperator๋กœ ์—ฐ์‚ฐ์ž ๋ฒ”์œ„ ๋„“ํžˆ๊ธฐ

๋„ˆ๋ฌด ๋งŽ์ด ๋ฐ˜๋ณต.. ๋˜ ๋ฐ˜๋ณต

์œ„์˜ ์˜ˆ์ œ์™€ ๊ฐ™์ด Service์—์„œ ์กฐํšŒ์˜ ๋‹จ์œ„๊ฐ€ ๋งŽ์•„์งˆ ๋•Œ๋งˆ๋‹ค Repository์—๋„ ๊ฐ™์ด ๋Š˜์–ด๋‚˜๋Š” ๋ฌธ์ œ๊ฐ€ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์ด Repository์˜ ์กฐํšŒ ๋ฉ”์†Œ๋“œ๋ฅผ ๋ฌถ์–ด์ฃผ์–ด ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

// user.service.ts
import { Injectable } from "@nestjs/common"
import { UserRepository } from "./user.repository"

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}

  public async findOneByUserId(userId: number) {
    return this.userRepository.findOne({ id: userId })
  }

  public async findOneByEmail(email: string) {
    return this.userRepository.findOne({ email })
  }

  public async findOneByNickname(nickname: string) {
    return this.userRepository.findOne({ nickname })
  }
}
// user.repository.ts
import { AbstractRepository, EntityRepository } from "typeorm"
import { User } from "./user.entity"

export interface UserFindOneOptions {
  id?: number
  email?: string
  nickname?: string
}

@EntityRepository(User)
export class UserRepository extends AbstractRepository<User> {
  public async findOne({ id, email, nickname }: UserFindOneOptions = {}) {
    const qb = this.repository
      .createQueryBuilder("User")
      .leftJoinAndSelect("User.userAuth", "userAuth")
      .leftJoinAndSelect("User.userProfile", "userProfile")

    if (id) qb.andWhere("User.id = :id", { id })
    if (email) qb.andWhere("User.email = :email", { email })
    if (nickname) qb.andWhere("User.nickname = :nickname", { nickname })

    return qb.getOne()
  }
}

์—ฌ๊ธฐ์„œ ์ฃผ์˜ํ•ด์•ผํ•  ๊ฒƒ์ด ์žˆ์Šต๋‹ˆ๋‹ค. Service์—์„œ this.userRepository.findOne() ์™€ ๊ฐ™์ด ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋„˜๊ธฐ์ง€ ์•Š๊ณ  ํ˜ธ์ถœํ•˜๋ฉด, ํ•ด๋‹น ํ…Œ์ด๋ธ”์— ์กด์žฌํ•˜๋Š” ๊ฐ€์žฅ ์ฒซ๋ฒˆ์งธ ๊ฐ์ฒด๊ฐ€ ๊ฐ€์ ธ์™€์ง‘๋‹ˆ๋‹ค. Query๋กœ ๋ณ€ํ™˜ํ•˜๋ฉด SELECT * FROM USER LIMIT 1 ํ•˜๋Š” ๊ฒƒ๊ณผ ๋งˆ์ฐฌ๊ฐ€์ง€๋‹ˆ๊นŒ์š”! ๋”ฐ๋ผ์„œ ๋นˆ ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ๋„˜๊ธธ ๊ฒฝ์šฐ, null๊ฐ’์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ์˜ˆ์™ธ์ฒ˜๋ฆฌ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

// user.repository.ts
import { pickBy, isNil, negate } from "lodash"

// ๊ฐ์ฒด์˜ null, undefined ๊ฐ’์ธ ํ‚ค๋“ค์„ ์ง€์›Œ์ค๋‹ˆ๋‹ค.
// ์ž์„ธํ•œ ์›๋ฆฌ๋Š” lodash๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.
export const removeNilFromObject = (object: object) => {
  return pickBy(object, negate(isNil))
}

@EntityRepository(User)
export class UserRepository extends AbstractRepository<User> {
  public async findOne(options: UserFindOneOptions = {}) {
    // ๋นˆ ๊ฐ์ฒด์ผ ๊ฒฝ์šฐ null์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.
    if (Object.keys(removeNilFromObject(options)).length === 0) return null
    const { id, email, nickname } = options

    // ...

    return qb.getOne()
  }
}

Or ์—ฐ์‚ฐ์€ ์–ด๋–ป๊ฒŒ ํ•˜์ง€?

๊ฐ™์€ ๋ฐฉ๋ฒ•์œผ๋กœ ๋งŒ๋“  findAll ํ•จ์ˆ˜๋ฅผ ๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

// user.repository.ts
import { AbstractRepository, EntityRepository } from "typeorm"
import { User } from "./user.entity"

export interface UserFindAllWhereOptions {
  id?: number
  email?: string
  nickname?: string
}

export interface UserFindAllOptions {
  where?: UserFindAllWhereOptions
  skip?: number
  take?: number
}

@EntityRepository(User)
export class UserRepository extends AbstractRepository<User> {
  // ...

  // ํ•œ๋ฒˆ์— ๋งŽ์€ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•˜๊ธฐ ์œ„ํ•ด skip, take๋กœ ํŽ˜์ด์ง€๋„ค์ดํŒ… ํ•ฉ๋‹ˆ๋‹ค.
  public async findAll(options: UserFindAllOptions = {}) {
    const { where, skip, take } = options

    const qb = this.repository
      .createQueryBuilder("User")
      .leftJoinAndSelect("User.userAuth", "userAuth")
      .leftJoinAndSelect("User.userProfile", "userProfile")

    if (where) {
      const { id, email, nickname } = where
      if (id) qb.andWhere("User.id = :id", { id })
      if (email) qb.andWhere("User.email = :email", { email })
      if (nickname) qb.andWhere("User.nickname = :nickname", { nickname })
    }

    qb.skip(skip ?? 0)
    qb.skip(take ?? 20)

    const [items, total] = await qb.getManyAndCount()

    return { items, total }
  }
}

ํ˜„์žฌ ๋‹จ๊ณ„์—์„œ findAll ํ•จ์ˆ˜๋Š” AND ์—ฐ์‚ฐ๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ๋‹‰๋„ค์ž„์ด โ€˜alfredโ€™ ์ด๊ฑฐ๋‚˜, id๊ฐ€ 5์ธ User๋ฅผ ๊ฐ€์ ธ์˜ค์ง€ ๋ชปํ•˜์ง€์š”. OR ์—ฐ์‚ฐ์ด ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•˜๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผํ• ๊นŒ์š”?

์—ฌ๊ธฐ์„œ๋ถ€ํ„ฐ ๋Œ€๋Œ€์ ์ธ ๋ฆฌํŒฉํ„ฐ๋ง์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค. where ์— ๊ฐ์ฒด๊ฐ€ ๋“ค์–ด๊ฐ€๋ฉด And์ด๊ณ , ๋ฐฐ์—ด์„ ๋„ฃ์œผ๋ฉด Or๋ผ๊ณ  ์ธ์‹ํ•˜๊ฒŒ ํ•˜๋ฉด ์–ด๋–จ๊นŒ์š” ?

ID๊ฐ€ 5์ด๊ณ , Nickname์ด 'alfred'์ธ ์œ ์ €
this.userRepository.findAll({ where: {id: 5, nickname: 'alfred'} })

ID๊ฐ€ 5์ด๊ฑฐ๋‚˜, Nickname์ด 'alfred'์ธ ์œ ์ €
this.userRepository.findAll({ where: [{id: 5},{nickname: 'alfred'}] })

๋จผ์ € UserFindAllOptions ํƒ€์ž…์„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.

export interface UserFindAllWhereOptions {
  id?: number
  email?: string
  nickname?: string
}
export interface UserFindAllOptions {
  where?: UserFindAllWhereOptions | UserFindAllWhereOptions[]
  skip?: number
  take?: number
}

๊ทธ๋ฆฌ๊ณ  UserRepository findAll ํ•จ์ˆ˜๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.

// user.repository.ts
import { AbstractRepository, Brackets, EntityRepository } from "typeorm"
import { User } from "./user.entity"

export interface UserFindAllWhereOptions {
  id?: number
  email?: string
  nickname?: string
}

export interface UserFindAllOptions {
  where?: UserFindAllWhereOptions | UserFindAllWhereOptions[]

  skip?: number
  take?: number
}

@EntityRepository(User)
export class UserRepository extends AbstractRepository<User> {
  // ...

  public async findAll(options: UserFindAllOptions = {}) {
    const { where, skip, take } = options

    const qb = this.repository
      .createQueryBuilder("User")
      .leftJoinAndSelect("User.userAuth", "userAuth")
      .leftJoinAndSelect("User.userProfile", "userProfile")

    if (where) {
      // ๊ฐ€์žฅ ์ƒ์œ„์— AND ์—ฐ์‚ฐ์œผ๋กœ ๊ด„ํ˜ธ๋ฅผ ์”Œ์›Œ์ค˜์•ผํ•œ๋‹ค: ( (...) OR (...) OR (...) )
      if (Array.isArray(where)) {
        qb.andWhere(
          new Brackets(qb => {
            // where์ด ๋ฐฐ์—ด์ด๋ฉด OR ์—ฐ์‚ฐ: (...) OR (...) OR (...)
            where.forEach(wh => {
              qb.orWhere(
                // OR ์—ฐ์‚ฐ
                new Brackets(qb => {
                  // where์˜ ๋ฐฐ์—ด ํ•œ ์š”์†Œ๋งˆ๋‹ค ๊ด„ํ˜ธ๋ฅผ ์”Œ์šด๋‹ค
                  const { id, email, nickname } = wh
                  if (id) qb.andWhere(`User.id = ${id}`)
                  if (email) qb.andWhere(`User.email = "${email}"`)
                  if (nickname) qb.andWhere(`User.nickname = "${nickname}"`)
                })
              )
            })
          })
        )
      } else {
        const { id, email, nickname } = where
        if (id) qb.andWhere("User.id = :id", { id })
        if (email) qb.andWhere("User.email = :email", { email })
        if (nickname) qb.andWhere("User.nickname = :nickname", { nickname })
      }
    }

    qb.skip(skip ?? 0)
    qb.skip(take ?? 20)

    const [items, total] = await qb.getManyAndCount()

    return { items, total }
  }
}

์•„์ง ๋ถˆ์™„์ „ํ•œ ๋ถ€๋ถ„์ด ์กฐ๊ธˆ ์žˆ์ง€๋งŒ, OR ์—ฐ์‚ฐ์„ ํ•  ์ˆ˜ ์žˆ๋Š” ๋ผˆ๋Œ€๋Š” ์™„์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค! ํ•˜์ง€๋งŒ Repository ํด๋ž˜์Šค์˜ ๋ฉ์น˜๊ฐ€ ๋„ˆ๋ฌด ์ปค์ ธ๋ฒ„๋ ธ์Šต๋‹ˆ๋‹ค. ์ฟผ๋ฆฌ ๋นŒ๋”์˜ ๋กœ์ง ์—ฐ์‚ฐ์ด ๋งŽ์•„์กŒ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค. ์ด๋ฅผ QueryApplier ๋ผ๋Š” ํด๋ž˜์Šค๋ฅผ ๋”ฐ๋กœ ๋งŒ๋“ค์–ด ์ˆ˜ํ–‰ํ•˜๊ฒŒ ํ•˜๋ฉด ์–ด๋–จ๊นŒ์š”?

๋จผ์ € ์ปค์Šคํ…€ ๋ ˆํฌ์ง€ํ† ๋ฆฌ ์ƒ์œ„ ์ถ”์ƒ ํด๋ž˜์Šค๋ฅผ ์ •์˜ํ•˜์—ฌ ๊ทธ ๊ณณ์—์„œ QueryApplier๋ฅผ ํ•ฉ์„ฑ์‹œํ‚ค๊ฒ ์Šต๋‹ˆ๋‹ค. AbstractEntityRepository ๋ผ๋Š” class๋ฅผ ์ƒˆ๋กœ ๋งŒ๋“ญ๋‹ˆ๋‹ค.

// entity.repository.ts
import {
  AbstractRepository,
  WhereExpression,
  Brackets,
  FindOperator,
} from "typeorm"

export abstract class AbstractEntityRepository<
  T
> extends AbstractRepository<T> {
  protected readonly queryApplier: EntityQueryApplier

  constructor() {
    super()
    this.queryApplier = new EntityQueryApplier()
  }
}

export interface BuildWhereOptionsFunction<T> {
  ({
    filterQuery,
    where,
  }: {
    filterQuery: (query: string) => void
    where: T
  }): void
}

export interface ApplyOptions<T> {
  qb: WhereExpression
  where?: T | T[]
  buildWhereOptions: BuildWhereOptionsFunction<T>
}

class EntityQueryApplier {
  public apply<T>({ qb, where, buildWhereOptions }: ApplyOptions<T>) {
    if (!where) return

    if (Array.isArray(where)) {
      qb.andWhere(
        new Brackets(qb => {
          where.forEach(wh => {
            qb.orWhere(
              new Brackets(qb => {
                this.applyBuildOptions({ qb, where: wh, buildWhereOptions })
              })
            )
          })
        })
      )
    } else {
      this.applyBuildOptions({ qb, where, buildWhereOptions })
    }
  }

  private applyBuildOptions<T>({
    qb,
    where,
    buildWhereOptions,
  }: {
    qb: WhereExpression
    where: T
    buildWhereOptions: BuildWhereOptionsFunction<T>
  }) {
    buildWhereOptions({
      where,
      filterQuery: (query: string) => {
        qb.andWhere(query)
      },
    })
  }
}

์œ„์™€ ๊ฐ™์ด queryBuilder์˜ where ๋กœ์ง์„ ๋”ฐ๋กœ ํด๋ž˜์Šค๋กœ ๋นผ๋ƒ…๋‹ˆ๋‹ค.

์œ„ ์ถ”์ƒ ๋ ˆํฌ์ง€ํ† ๋ฆฌ ํด๋ž˜์Šค๋ฅผ ์ด์šฉํ•˜์—ฌ UserRepository์˜ findAll์„ ๋ฆฌํŒฉํ„ฐ๋งํ•ด๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค.

// user.repository.ts
import { Brackets, EntityRepository } from "typeorm"
import { AbstractEntityRepository } from "./entity.repository.v2"
import { User } from "./user.entity"

export interface UserFindAllWhereOptions {
  id?: number
  email?: string
  nickname?: string
}

export interface UserFindAllOptions {
  where?: UserFindAllWhereOptions | UserFindAllWhereOptions[]

  skip?: number
  take?: number
}

@EntityRepository(User)
export class UserRepository extends AbstractEntityRepository<User> {
  // ...

  public async findAll(options: UserFindAllOptions = {}) {
    const { where, skip, take } = options

    const qb = this.repository
      .createQueryBuilder("User")
      .leftJoinAndSelect("User.userAuth", "userAuth")
      .leftJoinAndSelect("User.userProfile", "userProfile")

    this.queryApplier.apply({
      qb,
      where,
      buildWhereOptions: ({ filterQuery, where }) => {
        const { id, email, nickname } = where

        filterQuery(`User.id = ${id}`)
        filterQuery(`User.email = "${email}"`) // ๋ฌธ์ž์—ด์€ ํฐ ๋”ฐ์˜ดํ‘œ๋กœ ๊ฐ์‹ธ์ค˜์•ผ ํ•ฉ๋‹ˆ๋‹ค.
        filterQuery(`User.nickname = "${nickname}"`)
      },
    })

    qb.skip(skip ?? 0)
    qb.skip(take ?? 20)

    const [items, total] = await qb.getManyAndCount()

    return { items, total }
  }
}

UserRepository๋Š” AbstractEntityRepository๋ฅผ ์ƒ์†๋ฐ›์•„ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ฟผ๋ฆฌ where ์กฐํšŒ ๋กœ์ง์„ QueryApplier๋กœ ์œ„์ž„ํ•ฉ๋‹ˆ๋‹ค.

์•ž์œผ๋กœ ์ปค์Šคํ…€ ๋ ˆํฌ์ง€ํ† ๋ฆฌ๋ฅผ ๋งŒ๋“ค๋•Œ๋งˆ๋‹ค OR ์กฐํšŒ๋ฅผ ํฌํ•จํ•œ ๋กœ์ง์„ ๊ฐ„๋‹จํ•˜๊ฒŒ ์ค„์ผ ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

FindOperator๋กœ ์—ฐ์‚ฐ์ž ๋ฒ”์œ„ ๋„“ํžˆ๊ธฐ

TypeORM Repository ๊ธฐ๋ณธ ๋‚ด์žฅ ํ•จ์ˆ˜์ธ find, findOne์„ ์ด์šฉํ•˜๋ฉด ์ปฌ๋Ÿผ ๊ฐ’์— ๋‹ค์Œ๊ณผ ๊ฐ™์€ FindOperator (In, LessThan, Like๋“ฑ)๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

this.userRepository.find({ where: { id: In([1,2,3,4,5]) } })

์šฐ๋ฆฌ๊ฐ€ ๋งŒ๋“  findOne, findAll์—๋„ ์ด๋Ÿฐ FindOperator๋ฅผ ์ ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด๋ณด๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

์‹ค์ œ๋กœ ์ฟผ๋ฆฌ๋ฅผ ์ ์šฉํ•˜๋Š” ๋ถ€๋ถ„์€ QueryApplier์˜ applyBuildOptions ํ•จ์ˆ˜ ๋‚ด๋ถ€์˜ filterQuery ์ž…๋‹ˆ๋‹ค. ๊ทธ ๊ณณ์— FindOperator ๊ฐ’์ด ๋“ค์–ด๊ฐˆ ์ˆ˜ ์žˆ๋„๋ก ์ˆ˜์ •ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค. ์ฝ”๋“œ๊ฐ€ ๋‹ค์†Œ ๊ธธ์ง€๋งŒ ์—ฌ์œ ๋ฅผ ๊ฐ–๊ณ  ์ฝ์–ด๋ณด์‹œ๊ธธ ๋ฐ”๋ž๋‹ˆ๋‹ค.

// entity.repository.ts
import { isNil } from "lodash"
import {
  AbstractRepository,
  WhereExpression,
  Brackets,
  FindOperator,
} from "typeorm"

export type EntityFindOperator<T> = T | FindOperator<T>

export abstract class AbstractEntityRepository<
  T
> extends AbstractRepository<T> {
  protected readonly queryApplier: EntityQueryApplier

  constructor() {
    super()
    this.queryApplier = new EntityQueryApplier()
  }
}

export interface BuildWhereOptionsFunction<T> {
  ({
    filterQuery,
    where,
  }: {
    filterQuery: (property: string, valueOrOperator: any) => void
    where: T
  }): void
}

export interface ApplyOptions<T> {
  qb: WhereExpression
  where?: T | T[]
  buildWhereOptions: BuildWhereOptionsFunction<T>
}

class EntityQueryApplier {
  public apply<T>({ qb, where, buildWhereOptions }: ApplyOptions<T>) {
    if (!where) return

    if (Array.isArray(where)) {
      qb.andWhere(
        new Brackets(qb => {
          where.forEach(wh => {
            qb.orWhere(
              new Brackets(qb => {
                this.applyBuildOptions({ qb, where: wh, buildWhereOptions })
              })
            )
          })
        })
      )
    } else {
      this.applyBuildOptions({ qb, where, buildWhereOptions })
    }
  }

  private applyBuildOptions<T>({
    qb,
    where,
    buildWhereOptions,
  }: {
    qb: WhereExpression
    where: T
    buildWhereOptions: BuildWhereOptionsFunction<T>
  }) {
    buildWhereOptions({
      where,
      filterQuery: (property, valueOrOperator) => {
        if (isNil(valueOrOperator)) return
        qb.andWhere(
          this.computeFindOperatorExpression(property, valueOrOperator)
        )
      },
    })
  }

  /**
   * ์ปฌ๋Ÿผ๊ณผ ๋น„๊ต๊ฐ’์— ๋Œ€ํ•œ Raw Query๋ฅผ ๊ณ„์‚ฐํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•ด์ค€๋‹ค. Referenced by TypeORM.
   * @param property Column ์ด๋ฆ„
   * @param operator FindOperator ๋˜๋Š” ์ง์ ‘ ๊ฐ’(Literal์€ Equal์—ฐ์‚ฐ์„ ํ•œ๋‹ค.)
   * @returns
   */
  private computeFindOperatorExpression(
    property: string,
    operator: FindOperator<any> | any
  ) {
    const wrappedValue = (value: any) => {
      // ํฐ ๋”ฐ์˜ดํ‘œ ํŒŒ์‹ฑ
      if (typeof value === "string") return `"${value.replace(/"/g, '\\"')}"`
      else if (value instanceof Date) return `"${value.toISOString()}"`

      return value
    }

    if (!(operator instanceof FindOperator))
      return `${property} = ${wrappedValue(operator)}`

    switch (operator.type) {
      case "not":
        if (operator.child) {
          return `NOT(${this.computeFindOperatorExpression(
            property,
            operator.child
          )})`
        } else {
          return `${property} != ${wrappedValue(operator.value)}`
        }
      case "lessThan":
        return `${property} < ${wrappedValue(operator.value)}`
      case "lessThanOrEqual":
        return `${property} <= ${wrappedValue(operator.value)}`
      case "moreThan":
        return `${property} > ${wrappedValue(operator.value)}`
      case "moreThanOrEqual":
        return `${property} >= ${wrappedValue(operator.value)}`
      case "equal":
        return `${property} = ${wrappedValue(operator.value)}`
      case "like":
        return `${property} LIKE ${wrappedValue(operator.value)}`
      case "between":
        return `${property} BETWEEN ${wrappedValue(
          operator.value[0]
        )} AND ${wrappedValue(operator.value[1])}`
      case "in":
        if (operator.value.length === 0) {
          return "0=1"
        }
        return `${property} IN (${operator.value
          .map(v => wrappedValue(v))
          .join(", ")})`
      case "any":
        return `${property} = ANY(${wrappedValue(operator.value)})`
      case "isNull":
        return `${property} IS NULL`
    }

    throw new TypeError(
      `Unsupported FindOperator ${FindOperator.constructor.name}`
    )
  }
}

๋ฆฌํŒฉํ„ฐ๋ง๋œ AbstractEntityRepository๋ฅผ ํ† ๋Œ€๋กœ UserRepository๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.

// user.repository.ts
import { EntityRepository } from "typeorm"
import {
  AbstractEntityRepository,
  EntityFindOperator,
} from "./entity.repository"
import { User } from "./user.entity"

export interface UserFindAllWhereOptions {
  id?: EntityFindOperator<number> // export type EntityFindOperator<T> = T | FindOperator<T>
  email?: EntityFindOperator<string>
  nickname?: EntityFindOperator<string>
}

export interface UserFindAllOptions {
  where?: UserFindAllWhereOptions | UserFindAllWhereOptions[]

  skip?: number
  take?: number
}

@EntityRepository(User)
export class UserRepository extends AbstractEntityRepository<User> {
  // ...
  public async findAll(options: UserFindAllOptions = {}) {
    const { where, skip, take } = options

    const qb = this.repository
      .createQueryBuilder("User")
      .leftJoinAndSelect("User.userAuth", "userAuth")
      .leftJoinAndSelect("User.userProfile", "userProfile")

    this.queryApplier.apply({
      qb,
      where,
      buildWhereOptions: ({ filterQuery, where }) => {
        const { id, email, nickname } = where

        filterQuery("User.id", id) // id๋Š” FindOperator์ผ์ˆ˜๋„, ๋ฆฌํ„ฐ๋Ÿด ๊ฐ’์ผ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค!
        filterQuery("User.email", email)
        filterQuery("User.nickname", nickname)
      },
    })

    qb.skip(skip ?? 0)
    qb.skip(take ?? 20)

    const [items, total] = await qb.getManyAndCount()

    return { items, total }
  }
}

์ด์ œ ๋‹ค ๋๋‚ฌ์Šต๋‹ˆ๋‹ค! ๊ณ ์ƒ ๋งŽ์œผ์…จ์Šต๋‹ˆ๋‹ค. ์ด์ œ UserService์—์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์ด Repository ์ ‘๊ทผ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.

// user.service.ts
import { Injectable } from "@nestjs/common"
import { In, LessThan } from "typeorm"
import { UserRepository } from "./user.repository"

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}

  // ...

  public async findAllTest() {
    // ID๊ฐ€ 5๋ณด๋‹ค ์ž‘๊ฑฐ๋‚˜, ๋‹‰๋„ค์ž„์ด alfred, beygee์ธ User๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค!
    return this.userRepository.findAll({
      where: [{ id: LessThan(5) }, { nickname: In(["alfred", "beygee"]) }],
    })
  }
}

์ดํ† ๋ก Service์—์„œ๋Š” Repository where ์กฐ๊ฑด๋ฌธ์„ ๊ฐ์ฒด ํ˜•ํƒœ๋กœ ๋‚ ๋ฆฌ๊ณ , Repo์—์„œ๋Š” ๊ทธ ์กฐ๊ฑด์„ QueryBuilder๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ž‘์—…ํ•˜๋Š” ๋กœ์ง์„ ๊ตฌํ˜„ํ•ด๋ณด์•˜์Šต๋‹ˆ๋‹ค.

์•„์ง ์—ฌ๊ธฐ์ €๊ธฐ ๊ฐœ์„ ํ•ด์•ผํ•  ์‚ฌํ•ญ๋“ค์ด ๋ณด์ด๊ณ , ๊ฐˆ ๊ธธ์ด ํ•œ์ฐธ ๋‚จ์€ ๊ฒƒ ๊ฐ™์Šต๋‹ˆ๋‹ค. ๋” ์ข‹์€ ๊ตฌํ˜„์‚ฌํ•ญ์ด ์žˆ๋‹ค๋ฉด ๊ณต์œ ํ•ด์ฃผ์‹œ๋ฉด ๊ฐ์‚ฌํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

@beygee
๋ฏธ์…˜ ๋‹ฌ์„ฑ์„ ์œ„ํ•ด ์‹คํ—˜์ ์ธ ๋„์ „๋ถ€ํ„ฐ ์•ˆ์ •์ ์ธ ์„ค๊ณ„๊นŒ์ง€ ๊ตฌํ˜„ํ•˜๋Š” ๊ฒƒ์„ ์ฆ๊ฒจํ•ฉ๋‹ˆ๋‹ค.