🐕

【TypeORM】onDeleteとcascadeの違い

に公開

こんにちは、アスエネの吉田です。

私はASUENE ESGの開発をしているのですが、そこでTypescriptを学び…TypeORMを使い…最初に混乱したonDeleteとcascadeの違いについてまとめてみようと思います。

私が調べた際には、これだ!と思うしっくりくる解説が見つけられなかったので、そんな人の役に立てたら嬉しいです。

はじめに

TypeORMとは、Node.jsのORMです。リレーショナルデータベースを扱う上で、エンティティ間のリレーション設定は避けては通れません。特に、「親エンティティを削除したとき、関連する子エンティティをどう扱うか?」という問題は、開発で悩むポイントではないでしょうか。

TypeORMでは、この問題を解決するためにonDeletecascadeという2つのオプションが用意されています。

この2つは仕組みと動作が異なるのでそこに注目して解説していきます!

前提

この記事では、Order(注文)とOrderItem(注文明細)という2つのエンティティを例にします。1注文に複数の商品が含まれることが推測されるのでOrderOrderItem は 1対多の関係となります。

  • Order

    項目
    id uuid
    order_date 注文日
    customer_name 顧客名
    deleted_at 削除日時
  • OrderItem

    項目
    id number
    order_id 注文ID(FK)
    productName 商品名
    price 価格
    quantity 数量
    deleted_at 削除日時

もし注文がキャンセルされたら?

もし注文が削除されたら、注文明細はどうするか? 注文明細も一緒に削除されるべきですよね。ここからはonDeleteのパターンとcascadeのパターンでどんな違いがあるかをみていきます。

onDelete

  • 設定する箇所

    データベースの外部キー制約のレベルで削除時の挙動を定義するオプションなので、OrderItemのように外部キーを設定している側に指定します。

    // ... (imports)
    
    @Entity()
    export class OrderItem {
      // ... (columns)
    
      @ManyToOne(() => Order, order => order.items, {
        // 親(Order)が削除されたら、自分もデータベースレベルで削除される
        onDelete: 'CASCADE', 
      })
      @JoinColumn({
        name: "order_id",
        referencedColumnName: "id"
      })
      order: Order;
    }
    

    migrationファイルを見ると、「ON DELETE CASCADE」の指定があるので、データベースレベルの設定であることがわかります。

    ALTER TABLE "order_items" ADD CONSTRAINT "FK_145532db85752b29c57d2b7b1f1" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
    
  • 動作

    repository.delete()Orderのデータを削除します。

    TypeORMが以下SQLを発行します。

    DELETE FROM "order" WHERE "id" = '...';
    

    このSQLが実行されると、データベースはOrderのレコードを削除し、さらにorder_itemテーブルに設定された外部キー制約(ON DELETE CASCADE)に従って、その注文IDを持つすべてのOrderItemレコードを自動で削除してくれます。

  • ポイント

    • 実行主体:データベース
    • トリガー:DELETE SQL文の実行(TypeORM経由、直接DB操作など、手段を問わない)
    • 注意点:repository.softDelete()では子テーブルは削除できない
      • softDelete は論理削除のためのupdate文が実行されるだけのため(delete文は実行されない)

cascade

  • 設定する箇所

    TypeORM(アプリケーション)のレベルで、親エンティティへの操作を関連エンティティに伝播させるオプションなので、親テーブル(Order)に指定します。

    // ... (imports)
    
    @Entity()
    export class Order {
      // ... (columns)
    
      @OneToMany(() => OrderItem, item => item.order, {
        // Orderを論理削除すると、関連するOrderItemも論理削除されるように設定
        cascade: true,
      })
      items: OrderItem[];
    }
    

    migrationファイルを見ると、「ON DELETE NO ACTION」となっていて、データベースレベルの設定ではないことがわかります。

    ALTER TABLE "order_items" ADD CONSTRAINT "FK_145532db85752b29c57d2b7b1f1" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
    
  • 動作

    repository.softRemove()Orderのデータを削除します。

    TypeORMが以下SQLを発行します。

    SELECT "Order"."id" AS "Order_id", "Order"."order_date" AS "Order_order_date", "Order"."customer_name" AS "Order_customer_name", "Order"."deleted_at" AS "Order_deleted_at" FROM "orders" "Order" WHERE "Order"."id" IN ($1) -- PARAMETERS: ["..."]
    SELECT "OrderItem"."id" AS "OrderItem_id", "OrderItem"."product_name" AS "OrderItem_product_name", "OrderItem"."price" AS "OrderItem_price", "OrderItem"."quantity" AS "OrderItem_quantity", "OrderItem"."deleted_at" AS "OrderItem_deleted_at", "OrderItem"."order_id" AS "OrderItem_order_id" FROM "order_items" "OrderItem" WHERE "OrderItem"."id" IN ($1) -- PARAMETERS: [...]
    SELECT "order_items"."id" AS "id", "order_items"."order_id" AS "order_id" FROM "order_items" "order_items" WHERE ( (("order_items"."order_id" = $1)) ) AND ( "order_items"."deleted_at" IS NULL ) -- PARAMETERS: ["..."]
    START TRANSACTION
    UPDATE "orders" SET "deleted_at" = CURRENT_TIMESTAMP WHERE "id" IN ($1) RETURNING "deleted_at" -- PARAMETERS: ["..."]
    UPDATE "order_items" SET "deleted_at" = CURRENT_TIMESTAMP WHERE "id" IN ($1) RETURNING "deleted_at" -- PARAMETERS: [...]
    COMMIT
    

    Orderを検索した後に、リレーションのデータ(OrderItem)を取得して、どちらもUPDATEをかけてくれます。

  • ポイント

    • 実行主体:TypeORM(アプリケーション)

    • トリガー:TypeORMの特定のメソッドの実行

      • 今回は cascade: trueで指定したが、cascade: [”soft-delete”] で論理削除時のみなどの指定もできる
    • 注意点:物理削除ができない…?

      • 検証で色々試してみたが、cascade: [”remove”]がうまく動かなかった。
      • cascade: [”remove”]を指定して、repository.remove()したとき…
        • 期待動作
          • 渡されたOrderのデータに含まれているOrderItemのデータを確認
          • OrderItemの対象レコードをDELETE
          • Orderの対象レコードをDELETE
        • 実際の動作
          • 渡されたOrderのデータに含まれているOrderItemのデータを確認
          • Orderの対象レコードをDELETE
            • OrderItemに外部キーが貼ってあるため、削除できない旨のエラー
            • 外部キーを貼らず、リレーションだけ設定してみたが、これもOrderItemの削除は行われない
        • 色々調べてみたが、現状repository.remove()で親テーブル削除時に子テーブルを削除するには、子テーブルの削除処理も合わせて記述する必要がある
      • 参考
    • メリット:softRemove()remove()をきっかけとした処理を実装できる

      • OrderItemのエンティティに以下記載をしておくと、softRemove()の処理の前に違う処理を実装できる
      @BeforeSoftRemove()
        async restoreStock() {
          console.log(
            `[在庫復元] 商品: ${this.productName}, 数量: ${this.quantity}`
          );
          // ここで実際の在庫管理サービスを呼び出す
          // await ProductService.addStock(this.productId, this.quantity);
        }
      

まとめ

  • 物理削除をcascadeでしたいのであれば、onDelete
    • cascade: ['remove']は自分で子テーブルのケアをしないといけません。
    • onDeleteの指定であれば、親テーブルのDELETE文のみで子テーブルの削除できるため、処理が早いというメリットもあります。
  • 論理削除、削除処理の前に共通して実行すべき処理があるときはcascade
    • onDeleteは論理削除に対応していません。
    • cascadeはアプリケーションでの制御なので、削除前処理の実装も可能で、削除処理とセットであるべき処理の実装もしやすいのかなと感じました。

最後に

cascade: ['remove']はドキュメント等を読んでできるものと思っていましたが、実際に動作を確認してみるとできないことがわかりました。

できなかった原因が、はっきりできずスッキリしない感はありましたが、実際に試して動作を確認することは大切だなと感じました。

アスエネでは、業務の中からもたくさん学びがあります。全方位で採用強化中なので、ご興味がある方は、ぜひこちらの採用サイトからご連絡ください!

(SNSからカジュアルにご連絡いただくのも大歓迎です!)

https://corp.asuene.com/recruit

Discussion