Typeorm Migration - Enum

@ZungTa · 2022-06-29 수요일 · 5 min read

문제

Typeorm + Postgresql 을 사용하던 중 enum 타입으로 적용해놨던 컬럼에서 enum 타입의 구성을 바꾸는 과정을 진행했다.

기존 데이터를 보존하며 migration이 되게 진행해야 했다.

그냥 문자열 컬럼이었다면 간단했겠지만 enum 타입이라 많이 까다로웠다.

typeorm의 migration:genrate로 생성된 코드로는 제약 조건 오류가 발생하므로 직접 쿼리를 만들어줘야 한다.

enum 변경의 before, after는 다음과 같다.

before

export enum ProductDealState {
  requested = "requested",
  seller_response = "seller_response",
  complete = "complete",
  complete_requested = "complete_requested",
  breakdown = "breakdown",}

after

export enum ProductDealState {
  requested = "requested",
  seller_response = "seller_response",
  complete = "complete",
  complete_requested = "complete_requested",
  deal_broken = "deal_broken",}

breakdown을 deal_broken으로 변경해주는 작업이다.

우선 typeorm migration:generate로 코드를 생성하면 이렇게 나온다.

import { MigrationInterface, QueryRunner } from "typeorm"

export class mig1656467386655 implements MigrationInterface {
  name = "mig1656467386655"

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TYPE "public"."product_deal_history_state_enum" RENAME TO "product_deal_history_state_enum_old"`
    )
    await queryRunner.query(
      `CREATE TYPE "public"."product_deal_history_state_enum" AS ENUM('requested', 'seller_response', 'complete', 'complete_requested', 'deal_broken')`
    )
    await queryRunner.query(
      `ALTER TABLE "product_deal_history" ALTER COLUMN "state" DROP DEFAULT`
    )
    await queryRunner.query(
      `ALTER TABLE "product_deal_history" ALTER COLUMN "state" TYPE "public"."product_deal_history_state_enum" USING "state"::"text"::"public"."product_deal_history_state_enum"`
    )
    await queryRunner.query(
      `ALTER TABLE "product_deal_history" ALTER COLUMN "state" SET DEFAULT 'requested'`
    )
    await queryRunner.query(
      `DROP TYPE "public"."product_deal_history_state_enum_old"`
    )
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `CREATE TYPE "public"."product_deal_history_state_enum_old" AS ENUM('requested', 'seller_response', 'complete', 'complete_requested', 'breakdown')`
    )
    await queryRunner.query(
      `ALTER TABLE "product_deal_history" ALTER COLUMN "state" DROP DEFAULT`
    )
    await queryRunner.query(
      `ALTER TABLE "product_deal_history" ALTER COLUMN "state" TYPE "public"."product_deal_history_state_enum_old" USING "state"::"text"::"public"."product_deal_history_state_enum_old"`
    )
    await queryRunner.query(
      `ALTER TABLE "product_deal_history" ALTER COLUMN "state" SET DEFAULT 'requested'`
    )
    await queryRunner.query(
      `DROP TYPE "public"."product_deal_history_state_enum"`
    )
    await queryRunner.query(
      `ALTER TYPE "public"."product_deal_history_state_enum_old" RENAME TO "product_deal_history_state_enum"`
    )
  }
}

이 코드의 경우 product_deal_history 테이블에 state컬럼이 breakdown값으로 되어있는 row가 없다면 에러없이 수행된다.

breakdown 값으로 되어있는 row가 있다면 에러가 발생한다.

error: invalid input value for enum product_deal_history_state_enum: "breakdown"

그렇다면 어떻게 해야할까?

개발 DB라면 데이터를 다 지우고 새로 적용하면 될 일이지만 운영 DB는 그렇게 할 수 없다.

내가 생각한 방법은 2가지가 있다.

  1. product_deal_history 테이블을 product_deal_history_old 테이블로 변경한 뒤 새로 테이블을 만들어서 old 테이블을 참조 및 비교하여 적절한 값을 새로운 테이블에 넣어준다.
  2. state 컬럼을 state_old 로 변경한 뒤 새로 컬럼을 만들어서 기존 컬럼의 값을 참조 및 비교하여 적절한 값을 새로운 컬럼에 업데이트 해준다.

2번으로 진행해봤다.

기존 타입과 기존 컬럼을 old로 두고 새로운 타입과 컬럼을 만들어서 update 문으로 데이터를 넣어 주었다.

그 부분의 쿼리는 이렇다.

update "public"."product_deal_history" dest set state = src.state_old from "public"."product_deal_history" src where dest.id = src.id and src.state_old != 'breakdown'

breakdown이 아닌 값들은 고스란히 유지하면 되니까 이렇게 해줬는데 타입이 맞지 않다고 에러가 뜬다.

error: column "state" is of type product_deal_history_state_enum but expression is of type product_deal_history_state_enum_old

겹치는 문자열이라도 old 타입의 컬럼 값 그대로 새로운 컬럼에 넣는 것은 안되는 것 같다.

그래서 old 타입에 deal_broken도 추가해서 값 복사를 한 다음에 새로운 타입으로 변경해보려고 했다.

(alter type 명령어에 add value는 있는데 drop value는 없어서 새로운 타입을 만들어줘야 함.)

ALTER TYPE "product_deal_history_state_enum_old" ADD VALUE 'deal_broken'

이것도 에러가 발생했다.

unsafe use of new value "deal_broken" of enum type product_deal_history_state_enum_old

알아보니 트랜잭션 중에 타입에 새로운 값을 추가할 순 있지만 동일한 트랜잭션 내에서 그 값을 쓸수는 없었다.

https://stackoverflow.com/questions/65130629/new-enum-values-must-be-committed-before-they-can-be-used/65131233#65131233

해결

old 타입과 old 컬럼을 두고 새로운 컬럼은 old 타입으로 일단 지정한다.

같은 값으로 옮겨야 할 값들을 update 해준다.

breakdown → deal_broken으로 바뀌어야 하는 값은 따로 조건을 주어서 update를 한 후 type을 새로운 타입으로 변경 시켜준다.

이 과정에서 not null 설정도 에러가 뜨는 부분이 있어서 처음에는 not null 설정을 하지 않고 값 복사가 끝난 뒤에 not null 설정을 해주었다.

대략적인 순서와 코드는 이렇게 되었다.

import { MigrationInterface, QueryRunner } from "typeorm"

export class mig1656414845072 implements MigrationInterface {
  name = "mig1656414845072"

  /**
   * enum 타입은 migration 과정이 복잡함.\
   * 기존 타입을 old로 바꿔주고 new 타입을 생성한다.\
   * 기존 컬럼을 old로 바꿔주고 new 컬럼을 아직은 old 타입으로 생성한다.\
   * new 칼럼으로 바꾸지 않을 부분만 똑같이 데이터를 넣어준다.\
   * new 컬럼에 아까 하지 못한 not null 설정과 default 설정을 해준다.\
   * new 컬럼에 old 컬럼 값과 비교하여 바뀔 부분만 새로운 값을 넣어준다.\
   * old 컬럼을 제거한다.\
   * old 타입을 제거한다.
   */
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TYPE "public"."product_deal_history_state_enum" RENAME TO "product_deal_history_state_enum_old"`
    )
    await queryRunner.query(
      `CREATE TYPE "public"."product_deal_history_state_enum" AS ENUM('requested', 'seller_response', 'complete', 'complete_requested', 'deal_broken')`
    )

    // custom
    await queryRunner.query(
      `alter table "public"."product_deal_history" rename column "state" to "state_old"`
    )
    await queryRunner.query(
      `alter table "public"."product_deal_history" add "state" "public"."product_deal_history_state_enum_old"`
    )
    await queryRunner.query(
      `update "public"."product_deal_history" dest set state = src.state_old from "public"."product_deal_history" src where dest.id = src.id and src.state_old != 'breakdown'`
    )
    await queryRunner.query(
      `alter table "product_deal_history" alter column "state" type "public"."product_deal_history_state_enum" USING "state"::"text"::"public"."product_deal_history_state_enum"`
    )
    await queryRunner.query(
      `alter table "product_deal_history" alter column "state" set default 'requested'`
    )
    await queryRunner.query(
      `update "product_deal_history" set state = 'requested' where state is null`
    )
    await queryRunner.query(
      `alter table "product_deal_history" alter column "state" set not null`
    )
    await queryRunner.query(
      `update "public"."product_deal_history" set state = 'deal_broken' where state_old = 'breakdown'`
    )
    await queryRunner.query(
      `alter table "public"."product_deal_history" drop column "state_old"`
    )
    // /custom

    await queryRunner.query(
      `DROP TYPE "public"."product_deal_history_state_enum_old"`
    )
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      `ALTER TYPE "public"."product_deal_history_state_enum" RENAME TO "product_deal_history_state_enum_old"`
    )
    await queryRunner.query(
      `CREATE TYPE "public"."product_deal_history_state_enum" AS ENUM('requested', 'seller_response', 'complete', 'complete_requested', 'breakdown')`
    )

    // custom
    await queryRunner.query(
      `alter table "public"."product_deal_history" rename column "state" to "state_old"`
    )
    await queryRunner.query(
      `alter table "public"."product_deal_history" add "state" "public"."product_deal_history_state_enum_old"`
    )
    await queryRunner.query(
      `update "public"."product_deal_history" dest set state = src.state_old from "public"."product_deal_history" src where dest.id = src.id and src.state_old != 'deal_broken'`
    )
    await queryRunner.query(
      `alter table "product_deal_history" alter column "state" type "public"."product_deal_history_state_enum" USING "state"::"text"::"public"."product_deal_history_state_enum"`
    )
    await queryRunner.query(
      `alter table "product_deal_history" alter column "state" set default 'requested'`
    )
    await queryRunner.query(
      `update "product_deal_history" set state = 'requested' where state is null`
    )
    await queryRunner.query(
      `alter table "product_deal_history" alter column "state" set not null`
    )
    await queryRunner.query(
      `update "public"."product_deal_history" set state = 'breakdown' where state_old = 'deal_broken'`
    )
    await queryRunner.query(
      `alter table "public"."product_deal_history" drop column "state_old"`
    )
    // /custom

    await queryRunner.query(
      `DROP TYPE "public"."product_deal_history_state_enum_old"`
    )
  }
}
@ZungTa
I'm a backend developer
© ZungTa Devlog, Built with Gatsby