Doma で many-to-many 型のリレーションシップを扱う
Doma 3.4.0 で導入された アグリゲート戦略 を利用することで、many-to-many 型のリレーションシップが簡単に扱えるようになりました。
アグリゲートとは
「アグリゲート」という言葉は、DDD(ドメイン駆動設計)の用語として使われることもありますが、ここでは「関連先のエンティティの参照を持つエンティティ」といった意味で捉えてもらえればと思います。
(私の記憶では、DDD のアグリゲートという概念が登場する前から、一般的な用語として使われていたように思いますが、どうでしたっけ?)
Doma はアグリゲートの構築を簡単にする機能を持ちます。
DDL と初期データ
例えば、users
、roles
、users_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
であること、 User
の roles
プロパティが Role
と関連づけられ roles
に対応する roles
テーブルのエイリアスが r
であることが明示されます。
BiFunction<User, Role, User>
で表現される関数では、User
と Role
のエンティティの相互の関連づけを行います。ここでは双方向関連にしていますが、単方向にしてもいいでしょう。どのように関連づけるかは完全にユーザーの自由です。
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 では、users
、users_roles
、roles
を結合して users
と roles
のカラムを返すようにします。users
と roles
に対応するエイリアスには、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 を使って実現する方法も記事にしました。
Discussion