Java
SQL
データベース
jpa
Hibernate

Hibernate JPA で複合主キーの一部に @ManyToOne を付ける

今回のペイン(≒日記帳)

  • DTO中の List<? extends Enum> のようなフィールドを CSV でなくちゃんと正規化したい。
  • Entity で List<ChildEntity> 等として持った場合に、当該ChildEntity にサロゲートキーを付けると、update 操作のたびに毎回サロゲートキーを取りに行くか、さもなくば ChildEntity のレコードを全件 insert し直すことになる。
    • 後者の場合、親への update 操作の度に子の ID が飛んで気持ち悪い。(実害はあまりない気がするが)

=> そうだ複合主キーを使おう

テーブル例

MySQL なら例えばこんなテーブル

CREATE TABLE parent(
  id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  bar VARCHAR(30) NOT NULL
);

CREATE TABLE parent_child(
  parent_id INT NOT NULL,
  value_ VARCHAR(30) NOT NULL,

  PRIMARY KEY(parent_id, value_),
  FOREIGN KEY(parent_id) REFERENCES parent(id)
);

ChildEntity

複合主キーなので @EmbeddedId を使いますが、@ManyToOne を付ける親エンティティへの参照は PKクラス ではなく 外側の Entityクラスのフィールドにします。1
@ManyToOne と合わせて @MapsId を付すことで、親子同時に insert したときによろしく親テーブル側の ID を振ってくれます。が、親 update 子 insert の場合は面倒を見てくれないので、自分で parent と parentId を同期する必要があります。

ChildEntity.java
@Entity
@Table(name = "parent_child")
@ToString(exclude = "parent")
@NoArgsConstructor
public class ChildEntity {
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static class PK implements Serializable {
        @Column(name = "parent_id")
        private int parentId;

        @Column(name = "value_")
        private String value;
    }

    @EmbeddedId
    @Getter
    @Setter
    private PK pk;

    @ManyToOne
    @JoinColumn(name = "parent_id", referencedColumnName = "id")
    @MapsId("parentId")
    @Getter
    @Setter
    private ParentEntity parent;

    public ChildEntity(@NonNull final ParentEntity parent,
            @NonNull final String value) {
        this.pk = new PK(parent.getId(), value);
        this.parent = parent;
    }

    public void setParent(@NonNull final ParentEntity parent) {
        this.pk.setParentId(parent.getId());
        this.parent = parent;
    }
}

親子で循環参照になっているため、lombok.ToString や lombok.EqualsAndHashCode、Jackson 等を不用意に使うと無限ループを引き起こすことに注意。

ParentEntity

親側は通常の(サロゲートキーを使う場合の) @OneToMany と変わりません。

ParentEntity.java
@Entity
@Table(name = "parent")
@Data
public class ParentEntity {
    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @OneToMany(mappedBy = "parent", orphanRemoval = true, fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @Fetch(FetchMode.SUBSELECT)
    private List<ChildEntity> children;

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

@OneToMany のパラメータに orphanRemoval をセットしておかないと親側の children を消しても子のレコードが消えないので注意。
また、@Fetch(FetchMode.SUBSELECT)をつけないとN+1問題が発生します。

更新時に発行されるSQL

Hibernate 5.0.12 だとこんな感じ。
新しく追加した ChildEntity を insert する際に、ParentEntity と一緒に取得した List とは別に一件一件 select している点が気になります。
変更のない要素については EAGER フェッチで済んでいるので、今回の案件では問題ないかなというところ。

select parententi0_.id as id1_0_0_, parententi0_.bar as bar2_0_0_, children1_.parent_id as parent_i1_1_1_, children1_.value_ as value_2_1_1_, children1_.parent_id as parent_i1_1_2_, children1_.value_ as value_2_1_2_ from parent parententi0_ left outer join parent_child children1_ on parententi0_.id=children1_.parent_id where parententi0_.id=?
binding parameter [1] as [INTEGER] - [5]
select childentit0_.parent_id as parent_i1_1_0_, childentit0_.value_ as value_2_1_0_ from parent_child childentit0_ where childentit0_.parent_id=? and childentit0_.value_=?
binding parameter [1] as [INTEGER] - [5]
binding parameter [2] as [VARCHAR] - [0.42733237750132513]
insert into parent_child (parent_id, value_) values (?, ?)
binding parameter [1] as [INTEGER] - [5]
binding parameter [2] as [VARCHAR] - [0.42733237750132513]
delete from parent_child where parent_id=? and value_=?
binding parameter [1] as [INTEGER] - [5]
binding parameter [2] as [VARCHAR] - [0.8694858141499555]

  1. @ManyToOne 用のフィールドをPKクラスに持つと read 操作時に StackOverflowError が発生します。