SpringJPA(hibernate)で連関エンティティの構成を実装する

Pocket

SpringBoot(SpringJPA, hibernate)で連関エンティティを作ろうとして10日ぐらい試行錯誤したので備忘録。

SpringBootで連関エンティティ構成を作る場合の参考情報として、記録を残します。

まず「学生」と「クラス」をDBに登録したかったのですが、「学生」は複数の「クラス」に所属し、「クラス」は複数の「学生」を持つので、間に中間テーブル(連関エンティティ)を入れることにしました。以下のようなテーブル構成です。
・ユーザーテーブル(t_user)[id, name]
・クラステーブル(t_class)[id, name]
・中間テーブル(t_user_class)[user_id, class_id]

前置きが長いのもあれなので、まずは上記の3テーブルに対応するエンティティクラスの完成形を以下に記します。
■t_userのエンティティクラス

@Setter
@Getter
@Entity
@Table(name = "t_user")
public class UserBean {

    @Id
    @Column(name = "id")
    private String id;

    @Column(name = "name")
    private String name;

    public UserBean() {
        userClassBeans = new HashSet<>();
        userCourseBeans = new HashSet<>();
    }

    @Setter(AccessLevel.NONE)
    @Getter(AccessLevel.NONE)
    @OneToMany(orphanRemoval=true, cascade = CascadeType.ALL )
    @JoinColumn(name="user_id")
    private Set<UserClassBean> userClassBeans;

    @Setter(AccessLevel.NONE)
    @Getter(AccessLevel.NONE)
    @OneToMany(orphanRemoval=true, cascade = CascadeType.ALL )
    @JoinColumn(name="user_id")
    private Set<UserCourseBean> userCourseBeans;
}

■t_classのエンティティクラス

@Entity
@Setter
@Getter
@Table(name = "t_class")
public class ClassBean {

    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name")
    private String name;

    public ClassBean() {
        userClassBeans = new HashSet<>();
        classCourseBeans = new HashSet<>();
    }

    @Setter(AccessLevel.NONE)
    @Getter(AccessLevel.NONE)
    @OneToMany(orphanRemoval=true, cascade = CascadeType.ALL )
    @JoinColumn(name="class_id")
    private Set<UserClassBean> userClassBeans;
}

■t_user_classのエンティティクラス

@Entity
@Setter
@Getter
@Table(name="t_user_class")
public class UserClassBean {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name="id")
    private Long id;

    @Column(name = "user_id")
    private String userId;
    @Column(name = "class_id")
    private Long classId;
}

■3テーブルの構成

CREATE TABLE t_user(id VARCHAR(20), name VARCHAR(100), PRIMARY KEY(id));
CREATE TABLE t_class(id BIGINT AUTO_INCREMENT, name VARCHAR(200), PRIMARY KEY(id));
CREATE TABLE t_user_class(id BIGINT AUTO_INCREMENT, user_id VARCHAR(20), class_id BIGINT, PRIMARY KEY(id));

以下のように、幾つか理解に苦労した点がありました。
(1)多重度と参照の方向性
(2)DBのリレーション(外部キー、非NULL制約など)
(3)外部参照するクラスを入れたコレクションの取り扱い
(4)その他色々

(1)多重度と参照の方向性
エンティティクラスのアノテーション@ManyToOneや@OneToManyはエンティティ同士の多重度を表現しています。例えば今回、t_userから見てt_user_classは1対多なのでt_userのエンティティクラスでは以下のように@OneToManyを使用します。

    @Setter(AccessLevel.NONE)
    @Getter(AccessLevel.NONE)
    @OneToMany(orphanRemoval=true, cascade = CascadeType.ALL )
    @JoinColumn(name="class_id")
    private Set<UserClassBean> userClassBeans;

ここでコレクションにUserClassBeanを入れていますが、これは学生(UserBean)側からどのクラスに所属するかを調べる時にUserClassBeanを参照するためのものです。学生がどのクラスに所属するかを調べるケースを想定しています。現在の状態は単方向ということになります。
もし逆にUserClassBean側からUserBeanの情報を必要とするのであれば、UserClassBean側にSetのようなパラメータを持たせることになります。これが双方向の状態です。(今回は不要でした)

この単方向と双方向の理解が怪しかったため、UserClassBeanにも不要なコレクションを無駄に作ったりして苦労しました。

(2)DBのリレーション(外部キー、非NULL制約など)
続けてDBの構成です。
連関エンティティなので、t_user_classのuser_idやclass_idはt_user(id)、t_class(id)などを外部キー参照したくなりますが、制約を入れるとまた色々と面倒が発生します。
具体的には、新規にClassBean(t_class)のデータを作成する場合、t_class(id)は自動採番(AUTO_INCREMENT)なので、登録時にidが不明です。

ClassBean classBean = new ClassBean();
classBean.setName("名前");
UserClassBean userClassBean = new UserClassBean();
userClassBean.setClassId(???); ← ここが設定できない
userClassBean.setUserId("user1");
classBean.addUserClassBean(userClassBean);
〇〇Repository.save(classBean);

そこで、分からない部分は設定しなければよしなに対応してくれることを期待して、以下のようなコードで動かしてみました。分からないクラスIDは設定していません。この場合、t_user_classのclass_idにはnullが設定されてしまいます。

ClassBean classBean = new ClassBean();
classBean.setName("名前");
UserClassBean userClassBean = new UserClassBean();
userClassBean.setUserId("user1");
classBean.addUserClassBean(userClassBean);
〇〇Repository.save(classBean);

ここで外部キー制約や非NULL制約を入れているとSQLの実行エラーが発生します。この制約を取り払うと、以下のように有難い対応をしてくれます。プログラム自体は上記のようにclass_idにnullが設定される内容のまま変更していませんが、最後に自動採番したidで更新を掛けてくれました。
①t_classを登録する
②t_user_classを登録する(class_idはnull)
③t_user_classを更新する(class_idを①の登録時に自動採番された番号にする)

ちなみにこのプログラムであればリレーションを切っているので、NoSQLに対応しやすいエンティティの形になっています。将来性を考慮して、冒頭に書いた以下の中間テーブルの構成を修正し、サロゲートキー(代理キー)として、idを追加しました。これでkey-valueのような形でNoSQLに対応しやすくなるはずです。
・中間テーブル(t_user_class)[user_id, class_id] → [id, user_id, class_id]

最近サロゲートキーという言葉をしばしば聞くのは、こういう対応でよく使われるからなのでしょうか。

(3)外部参照するクラスを入れたコレクションの取り扱い

最後に、外部参照するクラスを入れたコレクションの取り扱い方についてです。以下のフィールドです。

    private Set<UserClassBean> userClassBeans;

このコレクションを改めてnewし直すなどすると、エラーが発生します。つまり、以下のようなコードです。

Set<UserClassBean> set = new HashSet<>();
UserBean userBean = new UserBean();
userBean.setUserClassBeans(set);

コンストラクタでnewした後は入れ替えができないよう、Setter/Getter自動生成対象外のフィールドにしました。中身がクリアできないと、更新する時に困るので、追加用メソッドとクリア用メソッドを用意しています。

(4)その他色々
今回の調査中には他にも色々と問題が起きました。
まずlombokを使っていたのでクラス先頭に@Dataを入れていたのですが、これもまた問題を引き起こしました。どうやら他クラスを参照する構成を作ると、循環してしまうようです。
具体的にはequalsとtoStringのメソッドで循環が発生するようです。
他クラスを参照するフィールドをexclude設定で除外するか、@Dataを削除して@Setter/@Getterを使用することでtoStringとequalsの自動生成をなくすことで対応できます。

あとコレクションには「cascade = CascadeType.ALL」と記述することで、参照先のUserClassBean(t_user_classテーブル)も一緒に更新してくれます。ただし「orphanRemoval=true」を付けないと削除されず、NULLのデータが残ってしまうので注意しましょう。

もし連関エンティティなどで複合主キーを使用する場合は、@MapsIdなどのアノテーションも必要になるかと思います。

最後に、今回は連関エンティティのオブジェクトであるUserClassBeanのコレクションを操作できないように、@Setter(AccessLevel.NONE)と@Getter(AccessLevel.NONE)を付与しました。Setter/Getterが生成されない状態にしているので、このままでは追加も削除もできません。私は以下のように追加、削除、参照用のメソッドを別途用意して対応しました。

public void addUserClassBean(UserClassBean userClassBean) {
    userClassBeans.add(userClassBean);
}
public void clearUserClassBean() {
    userClassBeans.clear();
}
public List<String> getUserClassBeanUserId() {
    List<String> list = new ArrayList<>();
    userCourseBeans.forEach(userCourseBean -> {
        list.add(String.valueOf(userCourseBean.getCourseId()));
    });
    return list;
}

ご参考まで。

とても勉強になったけど、疲れました。

広告

Pocket

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です