☘️

Doma で many-to-many 型のリレーションシップを扱う

2025/02/11に公開

Doma 3.4.0 で導入された アグリゲート戦略 を利用することで、many-to-many 型のリレーションシップが簡単に扱えるようになりました。

アグリゲートとは

「アグリゲート」という言葉は、DDD(ドメイン駆動設計)の用語として使われることもありますが、ここでは「関連先のエンティティの参照を持つエンティティ」といった意味で捉えてもらえればと思います。

(私の記憶では、DDD のアグリゲートという概念が登場する前から、一般的な用語として使われていたように思いますが、どうでしたっけ?)

Doma はアグリゲートの構築を簡単にする機能を持ちます。

DDL と初期データ

例えば、usersrolesusers_roles の 3 つのテーブルがあるとしましょう。1 人のユーザーが複数のロールを持つことができ、1 つのロールが複数のユーザーに割り当てられることがある、というケースです。

CREATE TABLE users (
    id INT PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

CREATE TABLE roles (
    id INT PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

CREATE TABLE users_roles (
    user_id INT NOT NULL,
    role_id INT NOT NULL,
    PRIMARY KEY (user_id, role_id),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);

INSERT INTO users (id, name) VALUES (1, 'Alice'), (2, 'Bob');
INSERT INTO roles (id, name) VALUES (1, 'Admin'), (2, 'Editor'), (3, 'Viewer');

-- Alice は Admin と Editor の両方のロールを持つ
INSERT INTO users_roles (user_id, role_id) VALUES (1, 1), (1, 2);

-- Bob は Editor と Viewer の両方のロールを持つ
INSERT INTO users_roles (user_id, role_id) VALUES (2, 2), (2, 3);

この DDL と DML は、H2 Database Engine で動作します。

エンティティクラス

users テーブルと roles テーブルに対応するエンティティクラスを作成します。一方で、users_roles テーブルに対応するクラスは不要です。

@Entity
@Table(name = "users")
public class User {
  @Id public Integer id;
  public String name;
  @Association public List<Role> roles = new ArrayList<>();
}

@Entity
@Table(name = "roles")
public class Role {
  @Id public Integer id;
  public String name;
  @Association public List<User> users = new ArrayList<>();
}

Doma の @Association アノテーションは、エンティティを参照するプロパティであることを示します。このアノテーションが付与されたフィールドは、カラムにマッピングされません(例えば、User クラスの roles フィールドは、 users テーブルの roles カラムにはマッピングされません)。

Doma には @ManyToMany@OneToMany のようなアノテーションはありません。そのため、関連を表現する場合は @Association を使用します(シンプルですね)。

例を簡単にするために、すべてのフィールドを public にしていますが、private にして getter/setter を用意しても問題ありません。

アグリゲート戦略

この記事の肝の部分です。

User エンティティをアグリゲートのルートとして、関連する Role エンティティを一緒に取得することを想定し、次のようなインターフェースを作成します。

@AggregateStrategy(root = User.class, tableAlias = "u")
public interface UserStrategy {
  @AssociationLinker(propertyPath = "roles", tableAlias = "r")
  BiFunction<User, Role, User> roles =
      (u, r) -> {
        u.roles.add(r);
        r.users.add(u);
        return u;
      };
}

このように定義することで、User がルートでありテーブルのエイリアスが u であること、 Userroles プロパティが Role と関連づけられ roles に対応する roles テーブルのエイリアスが r であることが明示されます。

BiFunction<User, Role, User> で表現される関数では、UserRole のエンティティの相互の関連づけを行います。ここでは双方向関連にしていますが、単方向にしてもいいでしょう。どのように関連づけるかは完全にユーザーの自由です。

DAO の実装

次に、DAO(Data Access Object)を作成します。

@Dao
public interface UserDao {
  @Sql(
      """
      select
          /*%expand */*
      from
          users u
          left outer join
          users_roles ur on u.id = ur.user_id
          left outer join
          roles r on ur.role_id = r.id
      order by
          u.id
      """)
  @Select(aggregateStrategy = UserStrategy.class)
  List<User> selectAll();
}

@Select には先ほど作成した AggregateStrategy を指定します。

SQL では、usersusers_rolesroles を結合して usersroles のカラムを返すようにします。usersroles に対応するエイリアスには、AggregateStrategy で指定したものをそれぞれ使います。

任意ですが /*%expand */ を利用することで SELECT リストにカラムを明示する必要がなくなります(カラムが自動展開されます)。

実行例

JUnit でテストしてみます。

  @Test
  public void manyToMany(Config config) {
    UserDao dao = new UserDaoImpl(config);

    // クエリを実行
    List<User> users = dao.selectAll();

    // 結果を出力
    for (User user : users) {
      String userName = user.name;
      String roleNames =
          user.roles.stream().map(role -> role.name).collect(Collectors.joining(","));
      System.out.printf("user=%s, roles=%s%n", userName, roleNames);
    }
  }

出力結果は次のようになります。

user=Alice, roles=Admin,Editor
user=Bob, roles=Editor,Viewer

発行される SQL は次のとおりです。

select
    u.ID as u_ID, u.NAME as u_NAME, r.ID as r_ID, r.NAME as r_NAME
from
    users u
    left outer join
    users_roles ur on u.id = ur.user_id
    left outer join
    roles r on ur.role_id = r.id
order by
    u.id

カラムが自動展開されていることがわかります。

ちなみに、アグリゲートの中で同じ ID(主キー) を持つデータは同一の参照となります。例えば、Editor を表す Role エンティティはアグリゲート内に1つのみで、 Alice と Bob の User エンティティからは同じ Role エンティティが参照されます。このことはデバッグで確認できます。

DBアクセスのライブラリによっては、参照の同一性は保証しないものもあると思います。Domaにおいてもエンティティがイミュータブルの場合は、参照の同一性は保証しませんので注意してください。

まとめ

Doma 3.4.0 からアグリゲートの構築機能がサポートされ、many-to-many 型のリレーションシップを簡単に扱えるようになりました。

Doma のアグリゲート戦略は、エンティティ同士をどう関連づけるかをユーザーが自由に決められるので、複雑さを抑えつつ柔軟性を確保できます。もし Doma が関連付けを内部で行う場合、ユーザーが細かい設定をアノテーションで指定する必要があり、結果として使いにくいものになってしまうでしょう。

参考

本記事と同様のことをCriteria API を使って実現する方法も記事にしました。
https://zenn.dev/nakamura_to/articles/64b35bfcbc3474

Discussion