🌊

typeormで多対多(@ManyToMany)を実装する

2021/11/25に公開

概要

過去に typeorm の交差(中間)テーブルを手動で実装する方法について以下にまとめました。

https://zenn.dev/msksgm/articles/20211118-typescript-typeorm-crosstable-nomany2many

今回は、typeorm で交差(中間)テーブルを自動生成する方法についてまとめます。
Cascade を指定しないとうまく動作しなかったりするので、そちらについてもまとめたいと考えています。

ソースコードは以下です。

https://github.com/Msksgm/typeorm-with-cascade

下準備

環境

macOS に node v14.14.0 と docker compose を用いて検証をしました。
node のインストール方法は、以下の記事を参照してください。

https://zenn.dev/msksgm/articles/20211106-anyenv-nodenv

ER 図

使用する DB は、以下の図です。
Userに対してFruitBasketが一対多、FruitBasketが多対多になっています。

ER図
ER 図

実装

User エンティティ

Userのエンティティは以下のソースコードです。
@OneToManyで一対多を実現しています。

./src/entity/User.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
import { Basket } from "./Basket";
import { Fruit } from "./Fruit";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  firstName: string;

  @Column()
  lastName: string;

  @Column()
  age: number;

  @OneToMany(() => Fruit, (fruit) => fruit.user)
  fruits: Fruit[];

  @OneToMany(() => Basket, (basket) => basket.user)
  baskets: Basket[];
}

Fruit エンティティと Basket エンティティ

次に、FruitBasketのエンティティは以下になります。
UserとはOneToManyで一対多のリレーションを実現しています。

FruitBasketManyToManyで多対多を生成できます。
また、onDelete: "CASCADE"で、片方が削除されたときに交差(中間)テーブルが自動で更新できるようになっています。

./src/entity/Fruit.ts
import {
  Column,
  Entity,
  JoinColumn,
  JoinTable,
  ManyToMany,
  ManyToOne,
  PrimaryGeneratedColumn,
} from "typeorm";
import { Basket } from "./Basket";
import { User } from "./User";

@Entity()
export class Fruit {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  userId: number;

  @ManyToOne(() => User, (user) => user.id, {
    onDelete: "CASCADE",
    nullable: false,
  })
  @JoinColumn({ name: "userId" })
  user!: User;

  @ManyToMany(() => Basket, (basket) => basket.fruits, {
    onDelete: "CASCADE",
  })
  baskets: Basket[];
}
./src/entity/Basket.ts
import {
  Column,
  Entity,
  JoinColumn,
  JoinTable,
  ManyToMany,
  ManyToOne,
  PrimaryGeneratedColumn,
} from "typeorm";
import { User } from "./User";
import { Fruit } from "./Fruit";

@Entity()
export class Basket {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @Column()
  userId: number;

  @ManyToOne(() => User, {
    onDelete: "CASCADE",
    nullable: false,
  })
  @JoinColumn()
  user!: User;

  @ManyToMany(() => Fruit, (fruit) => fruit.baskets, {
    onDelete: "CASCADE",
  })
  @JoinTable()
  fruits: Fruit[];
}

動作確認

下準備

GitHub から clone してください。

git clone https://github.com/Msksgm/typeorm-with-cascade.git
cd typeorm-with-cascade
yarn

src/index.tsには以下のように、一対多(UserFruitBasket)と多対多(UserBasket)の動作確認ができるように実装しています。

./src/index.ts
import "reflect-metadata";
import { createConnection } from "typeorm";
import { User } from "./entity/User";
import { Basket } from "./entity/Basket";

createConnection()
  .then(async (connection) => {
    console.log("db connection is success");

    // 一対多
    const fruitByUser = await connection
      .getRepository(User)
      .createQueryBuilder("user")
      .leftJoinAndSelect("user.fruits", "fruit")
      .where("user.id=2")
      .getOne();
    console.log("UserとFruit");
    console.log(fruitByUser);

    const basketByUser = await connection
      .getRepository(User)
      .createQueryBuilder("user")
      .leftJoinAndSelect("user.baskets", "basket")
      .where("user.id=1")
      .getOne();
    console.log("UserとBasket");
    console.log(basketByUser);

    // 多対多
    const fruitByBasket1 = await connection
      .getRepository(Basket)
      .createQueryBuilder("basket")
      .where("basket.id=1")
      .leftJoinAndSelect("basket.fruits", "fruit")
      .getOne();
    console.log("Basketに含まれるFruit1");
    console.log(fruitByBasket1);

    const fruitByBasket2 = await connection
      .getRepository(Basket)
      .createQueryBuilder("basket")
      .where("basket.id=2")
      .leftJoinAndSelect("basket.fruits", "fruit")
      .getOne();
    console.log("Basketに含まれるFruit2");
    console.log(fruitByBasket2);
  })
  .catch((error) => console.log(error));

DB サーバの起動

DB の生成には、docker compose を使います。
今回あらかじめtypeorm-with-cascade/initdb.d/sample.sqlに SQL を記述しています。
typeorm-with-cascade/docker-compose.ymlで docker compose 実行時に、読み込むようにしています。

docker compose up -d

DB の中身を確認すると以下のようになっています。

確認。

mysql -u root --port 3306 -h 127.0.0.1 -D typeorm_db -e 'SHOW tables;' -p
Enter password:

結果。

+----------------------+
| Tables_in_typeorm_db |
+----------------------+
| basket               |
| basket_fruits_fruit  |
| fruit                |
| user                 |
+----------------------+

確認。

mysql -u root --port 3306 -h 127.0.0.1 -D typeorm_db -e 'SELECT * FROM basket; SELECT * FROM basket_fruits_fruit; SELECT * FROM fruit; SELECT * FROM user;' -p

結果。

+----+---------------------------------------------+--------+
| id | name                                        | userId |
+----+---------------------------------------------+--------+
|  1 | Timber's basket                             |      1 |
|  2 | Timber とティンバーのバスケット             |      2 |
+----+---------------------------------------------+--------+
+----------+---------+
| basketId | fruitId |
+----------+---------+
|        1 |       1 |
|        1 |       2 |
|        2 |       1 |
|        2 |       2 |
|        2 |       3 |
|        2 |       4 |
+----------+---------+
+----+--------------------------+--------+
| id | name                     | userId |
+----+--------------------------+--------+
|  1 | orange                   |      1 |
|  2 | grape                    |      1 |
|  3 | グレープフルーツ         |      2 |
|  4 | みかん                   |      2 |
+----+--------------------------+--------+
+----+-----------------+----------+-----+
| id | firstName       | lastName | age |
+----+-----------------+----------+-----+
|  1 | Timber          | Saw      |  25 |
|  2 | ティンバー      | ソウ     |  25 |
+----+-----------------+----------+-----+

実行

yarn startで実行します。
もしエラーが発生した場合は、DB サーバの起動でエラーが発生している可能性があるので、docker compose up(-d を外す)でエラーがでているか確認してください。

yarn start

実行に成功すると、以下のように表示されます。

UserとFruit
User {
  id: 2,
  firstName: 'ティンバー',
  lastName: 'ソウ',
  age: 25,
  fruits: [
    Fruit { id: 3, name: 'グレープフルーツ', userId: 2 },
    Fruit { id: 4, name: 'みかん', userId: 2 }
  ]
}
UserとBasket
User {
  id: 1,
  firstName: 'Timber',
  lastName: 'Saw',
  age: 25,
  baskets: [ Basket { id: 1, name: "Timber's basket", userId: 1 } ]
}
Basketに含まれるFruit1
Basket {
  id: 1,
  name: "Timber's basket",
  userId: 1,
  fruits: [
    Fruit { id: 1, name: 'orange', userId: 1 },
    Fruit { id: 2, name: 'grape', userId: 1 }
  ]
}
Basketに含まれるFruit2
Basket {
  id: 2,
  name: 'Timber とティンバーのバスケット',
  userId: 2,
  fruits: [
    Fruit { id: 1, name: 'orange', userId: 1 },
    Fruit { id: 2, name: 'grape', userId: 1 },
    Fruit { id: 3, name: 'グレープフルーツ', userId: 2 },
    Fruit { id: 4, name: 'みかん', userId: 2 }
  ]
}

参考

https://typeorm.io/#/

https://qiita.com/karino-m/items/5d2ea4879a4dff0dae60

Discussion