【Spring Data JPA】結合
はじめに
Spring JPA の結合について、以下のデータ構造を基に説明していきます。 Spring JPA の基本については過去の記事を参考にしてください。
またここでは REST API として JSON を扱うことを想定としています。
結合の基本
OneToOne
以下のようにClub
にTeacher
の情報を含めて表示することを考えます。
{
"clubId": 1,
"name": "野球部",
"teacher": {
"teacherId": 1,
"name": "田中太郎",
"gender": "男",
"dateOfBirth": "1983-10-21"
}
}
まずはTeacher
の Entity を作成します。
@Entity
public class Teacher {
@Id
private long teacherId;
private String name;
private String gender;
@Column(columnDefinition = "DATE")
private LocalDate dateOfBirth;
}
次にClub
の Entity を作成します。
@Entity
public class Club {
@Id
private long clubId;
private String name;
@OneToOne
@JoinColumn(name = "teacher_id")
private Teacher teacher;
}
結合先の情報となるTeacher
のフィールドには、@JoinColumn
により結合条件となるカラム名(name
)を指定します。
今回はteacher_id
で結合をすることとなります。
また今回は、Club
とTeacher
のリレーションが 1 対 1 のため@OneToOne
を付与します。
後述するその他のリレーションでも基本的な考え方は同じです。
あとは Repository を作成し、findAll()
やfindById()
により参照すれば OK です。
OneToMany
次にTeacher
にSchool Class
の情報を含めることを考えます。
{
"teacherId": 1,
"name": "田中太郎",
"gender": "男",
"dateOfBirth": "1983-10-21",
"schoolClasses": [
{
"classId": 1,
"name": "1-1",
"year": 2020
},
{
"classId": 21,
"name": "2-3",
"year": 2021
}
]
}
まずはSchool Class
の Entity を作成します。
@Entity
public class SchoolClass {
@Id
private long classId;
private String name;
private int year;
}
これに対し、School Class
を持つTeacher
の Entity を作成します。
@Entity
public class Teacher {
//省略
@OneToMany
@JoinColumn(name = "teacher_id")
private List<SchoolClass> schoolClasses;
}
OneToOne
の場合と同じく@JoinColumn
で結合条件を指定し、1 対多のリレーションを示す@OneToMany
を付与します。
School Class
は複数あることが想定できるため、型はList<>
としています。
ManyToOne
先程とは逆で、School Class
にTeacher
の情報を含めることを考えます。
{
"classId": 1,
"name": "1-1",
"year": 2020,
"teacher": {
"teacherId": 1,
"name": "田中太郎",
"gender": "男",
"dateOfBirth": "1983-10-21",
}
}
事前にTeacher
のschoolClasses
は削除しておいてください。
この理由については後述します。
先程のSchool Class
の Entity にTeacher
の情報を追加します。
リレーションが多対 1 のため、@ManyToOne
を付与します。
@Entity
public class SchoolClass {
//省略
@ManyToOne
@JoinColumn(name = "teacher_id")
private Teacher teacher;
}
ManyToMany
最後にSchool Class
にStudent
の情報を含めることを考えます。
{
"classId": 1,
"name": "1-1",
"year": 2020,
"students": [
{
"studentId": 1,
"name": "山田花子",
"gender": "女",
"dateOfBirth": "2005-06-01",
"dateOfEnrollment": "2020-04-08",
},
{
"studentId": 2,
"name": "鈴木一郎",
"gender": "男",
"dateOfBirth": "2005-10-21",
"dateOfEnrollment": "2020-04-08",
},
]
}
ひとまずStudent
の Entity を作成します。
@Entity
public class Student {
@Id
private long studentId;
private String name;
private String gender;
@Column(columnDefinition = "DATE")
private LocalDate dateOfBirth;
@Column(columnDefinition = "DATE")
private LocalDate dateOfEnrollment;
}
次にSchool Class
の Entity ですが、School Class
とStudent
のリレーションは多対多のため、@ManyToMany
を付与します。
しかし、これまでとはSchool Class List
という中間テーブルが存在する点が異なります。
このような場合は、@JoinColumn
ではなく@JoinTable
を付与します。
@Entity
public class SchoolClass {
//省略
@ManyToMany
@JoinTable(
name = "school_class_list",
joinColumns = @JoinColumn(name = "class_id"),
inverseJoinColumns = @JoinColumn(name = "student_id")
)
private List<Student> students;
}
@JoinTable
では、name
に中間テーブルの名前、joinColumns
に結合元の結合条件となるカラム、
inverseJoinColumns
に結合先の結合条件となるカラムを指定します。
Save / Delete
ここでは結合を用いた Entity の登録・更新・削除について説明していきます。
説明の際には@OneToOne
を使用しますが、その他のリレーションでも同じです。
結合元のみ
まずは結合元のみを対象とする場合を考えます。
例として、OneToOne のケースで紹介したClub
を使用します。
@Entity
public class Teacher {
@Id
private long clubId;
private String name;
@OneToOne
@JoinColumn(name = "teacher_id")
private Teacher teacher;
}
この Entity に対して、以下のデータを渡すことでsave()
が正しく実行されます。
{
"clubId": 1,
"name": "野球部",
"teacher": {
"teacherId": 1
}
}
これでも良いのですが、別パターンとして、以下のようなデータを渡した場合にsave()
が正しくが実行されることを考えます。
このとき、出力情報は OneToOne で示したもののままとします。
{
"clubId": 1,
"name": "野球部",
"teacherId": 1
}
説明のために、先に答えを提示しておきます。 コメントを追記している部分が変更点(追加部分)となります。
@Entity
public class Teacher {
@Id
private long clubId;
private String name;
// ①
@Column(name = "teacher_id")
private long teacherId;
@OneToOne
@JoinColumn(name = "teacher_id", insertable = false, updatable = false) // ②
private Teacher teacher;
// ③
@JsonIgnore
public long getTeacherId() {
return teacherId;
}
}
1 つ目は、Club
にteacherId
のフィールド定義を追加します。
しかし、単純に追加しただけでは以下のようなエラーとなります。
Error creating bean with name 'entityManagerFactory' defined in class path resource
[org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]:
Invocation of init method failed; nested exception is org.hibernate.DuplicateMappingException:
Table [club] contains physical column name [teacher_id] referred to by multiple logical column names:
[teacher_id], [teacherId]
これは、@JoinColumn
でteacher_id
を使用していることから、フィールド名とカラム名の紐づけができないというエラーです。
このような場合は、明示的に@Column
で使用するカラム名を設定することで解決します。
2 つ目は、@JoinColumn
を付与したフィールド(teacher
)による登録・更新を無効にします。
1 つ目の対応だけでは以下のようなエラーとなります。
Error creating bean with name 'entityManagerFactory' defined in class path resource
[org/springframework/boot/autoconfigure/orm/jpa/HibernateJpaConfiguration.class]:
Invocation of init method failed; nested exception is javax.persistence.PersistenceException:
[PersistenceUnit: default] Unable to build Hibernate SessionFactory; nested exception is org.hibernate.MappingException:
Repeated column in mapping for entity: com.example.demo.entity.Club column:
teacher_id (should be mapped with insert="false" update="false")
これは、teacher_id
に関する項目が重複するため、どちらを使って登録・更新を行えばよいかわからないため、
明示的に示してくださいという内容です。
今回は新たに追加したteacherId
フィールドを使用したいため、@JoinColumn
を付与したほうの登録・更新を無効化します。
具体的には、② にあるようにinsertable
とupdatable
プロパティをfalse
に設定します。
これらのプロパティは、@JoinColumn
のname
で指定したカラムの登録・更新を許可するかの設定となります。
デフォルトはtrue
のため、元の Entity ではあのデータ構造でsave()
が実行できたわけです。
これでエラーが解消できるはずです。
3 つ目は必須ではありませんが、teacherId
を JSON の出力対象から除外したい場合に、ゲッターに@JsonIgnore
を付与します。
フィールドそのものに@JsonIgnore
を付与する手もありますが、セッターも@JsonIgnore
されてしまうため、
@RequestBody
で 受け取ることができなくなる点に注意してください。
データ構造としては後者の方が自然な気がしますが、ひと手間必要なためお好きなほうで良いかと思います。
削除についてはこれまで通り、deleteById()
などで対象のレコードを消すことができます。
結合先を含める
では次に、Club
と同時にTeacher
の内容も同時に登録・更新・削除することを考えます。
Club
とTeacher
を同時に操作するのはありえないかもしれませんが、説明のためだと思ってください。
登録・更新(save)
以下のデータでClub
リポジトリ―のsave()
を実行したときに、Club
、Teacher
のどちらも登録・更新されるようにします。
{
"clubId": 1,
"name": "野球部",
"teacher": {
"teacherId": 1,
"name": "田中太郎",
"gender": "男",
"dateOfBirth": "1983-10-21"
}
}
現状だとこのデータを渡してもClub
の反映のみで、Teacher
には反映されません。
Teacher
を登録・更新対象とするには、以下のように@OneToOne
のcascade
プロパティを追加します。
@Entity
public class Teacher {
@Id
private long clubId;
private String name;
@OneToOne(cascade = {CascadeType.MERGE})
@JoinColumn(name = "teacher_id")
private Teacher teacher;
}
なぜこのようになるかという説明をするには、JPA の永続化について知っておく必要があります。 といっても私も詳しくはわかっていないので推測を含め簡単に説明をしておきます。
まず JPA で取り扱う Entity は Entity Manager で管理(永続化)されます。
findById()
で取得した Entity は Entity Manager で管理され、save()
などは Entity Manager の管理下に置いた上で実際の処理が実行されます。
つまり、Entity Manager の管理下にある Entity はsave()
の対象となるということです。
例えば@OneToOne
のcascade
プロパティを設定していない状態で以下のコードを実行するとどうなるでしょうか。
Club club = repository.findById(id)
.orElseThrow(() -> new ResponseStatusexception(HttpStatus.NOT_FOUND));
club.getTeacher().setName("鈴木一郎");
repository.save(club);
前述したようにfindById()
などで取得した Entity は Entity Manager で管理されます。
これはClub
のフィールドであるteacher
も同様です。
つまり、teacher
は Entity Manger の管理下にあるため、name
の変更は DB に反映されます。
では、リクエストで JSON を受け取った場合を考えてみます。
@PostMapping
public void save(@RequestBody Club club) {
repository.save(club);
}
当然ながら、リクエストとして受け取ったclub
は Entity Manager の管理下にはありません。
おそらく、save()
の中でclub
を Entity Manager の管理状態にする処理が含まれていると推測しています。
そこで先程の@OneToOne
のcascade
プロパティの設定によって、結合先の Entity を管理下に含めるかを判断しているのではないかと。
ちなみにCascadeType.MERGE
は、管理状態にない Entity を管理状態とするという意味があります。
これ以上については JPA の永続化についてより深く知る必要がありかもしれませんが、今回はここまでとします。
あともう 1 点、@OneToOne
にはoptional
というプロパティを持ちます。
これは対象の Entity のnull
を許容するかの設定になります。
デフォルトはtrue
となっており、以下のようにteacher
のないデータの登録も可能です。
{
"clubId": 1,
"name": "野球部"
}
これを許容しない場合は、optional = false
を設定します。
削除(delete)
次に削除についてです。
Club
と同時にTeacher
を削除する場合はcascade
プロパティにCascadeType.REMOVE
を追加します。
@Entity
public class Teacher {
@Id
private long clubId;
private String name;
@OneToOne(cascade = {CascadeType.MERGE, CascadeType.REMOVE})
@JoinColumn(name = "teacher_id")
private Teacher teacher;
}
これにより、deleteById()
などでTeacher
も一緒に削除されます。
ちなみにですが、CascadeType.REMOVE
を設定していない状態で、以下のコードを実行してもTeacher
は削除されません。
削除をしたい場合はCascadeType.REMOVE
は必須ということです。
Club club = repository.findById(id)
.orElseThrow(() -> new ResponseStatusexception(HttpStatus.NOT_FOUND));
repository.delete(club);
Delete - Insert
次にTeacher
を更新ではなく Delete - Insert とする場合を考えます。
実際は見積書や請求書のように、本体と明細でわかれるようなケースで使用することが多いかと思います。
具体的に以下のデータを渡した場合、元々登録してあったteacher_id: 1
のデータは削除され、teacher_id: 2
のデータが新しく登録されるというイメージです。
{
"clubId": 1,
"name": "野球部",
"teacher": {
"teacherId": 2,
"name": "鈴木一郎",
"gender": "男",
"dateOfBirth": "1987-03-13"
}
}
このような動きとする場合は、@OneToOne
のorphanRemoval
をtrue
に設定します。
@Entity
public class Teacher {
@Id
private long clubId;
private String name;
@OneToOne(cascade = {CascadeType.MERGE, CascadeType.REMOVE}, orphanRemoval = true)
@JoinColumn(name = "teacher_id")
private Teacher teacher;
}
相互参照
mappedBy
最後に相互参照について説明します。
これまでClub
がTeacher
を参照するといったいわゆる単方向の参照だけでした。
相互参照はそのままの意味で、Club
とTeacher
が互いに参照し合うことです。
Club
をベースに、Teacher
にClub
の参照を追加する場合は、@OneToOne
のmappedBy
プロパティを使用します。
mappedBy
には、Club
のTeacher
に該当するフィールド名を設定します。
@Entity
public class Teacher {
@Id
private long clubId;
private String name;
@OneToOne
@JoinColumn(name = "teacher_id")
private Teacher teacher;
}
@Entity
public class Teacher {
@Id
private long teacherId;
private String name;
private String gender;
@Column(columnDefinition = "DATE")
private LocalDate dateOfBirth;
@OneToOne(mappedBy = "teacher")
private Club club;
}
これで相互参照の状態となりました。 しかし、1 つ問題があります。 それは循環参照となってしまっていることです。
循環参照
循環参照とは、相互参照の状態にある Entity を参照した場合、互いの Entity を通して参照がループすることです。 鏡同士を向かい合わせると、自分の鏡には相手の鏡が、その鏡には自分の鏡が、またその鏡には相手の鏡が…、のようにループします。 循環参照もこれと同じイメージです。
JSON を取り扱う場合、そのまま出力すると循環参照としてエラーとなってしまうため対策が必要です。
1 つは余計な参照を無くすことです。 単に片方でも参照を無くせばループしなくなるということですね。
方法はいくつかあります。
- そもそも相互参照をやめる
- 出力不要なフィールドのゲッターに
@JsonIgnore
を付与する @JsonManagedReference
、@JsonBackReference
を付与するentity/Club.java@OneToOne @JoinColumn(name = "teacher_id") @JsonBackReference // こちら側は出力されない private Teachere teacher;
entity/Teacher.java@OneToOne(mappedBy = "teacher") @JsonManagedReference // こちら側は出力される private Club club;
あとは無理矢理ですが、出力時に循環対象となるフィールドの値を強制的にnull
にするなどが考えられます。
ですが余り気乗りはしませんね。
もう 1 つ、@JsonIdentityInfo
を使用する方法があります。
これは、JSON の対象となる Entity(オブジェクト)にid
を付与するといったものです。
以下のコードではClub
にid
としてuuid
を生成して設定しています。
Teacher
にも同じく追加します。
@Entity
@JsonIdentityInfo(generator = ObjectIdGenerators.UUIDGenerator.class)
public class Club {
//
}
これにより、以下のように@id
が追加された JSON が取得できます。
{
"@id": "6e909392-9cdd-4c82-9386-096803fe2f57"
"teacherId": 1,
"name": "田中太郎",
"gender": "男",
"dateOfBirth": "1983-10-21",
"club": {
"@id": "68697ddb-e246-465a-962e-6dbcdb72fea9",
"clubId": 1,
"name": "野球部",
"teacher": "6e909392-9cdd-4c82-9386-096803fe2f57"
}
}
見ての通り、club
のteacher
は生成されたid
の値が設定されるため、循環参照がおきません。
@id
という情報は付加されてしまいますが、どちらの参照も活かしたい場合に便利です。
ちなみに@id
という名前は、JsonIdentityInfo
のproperty
プロパティで変更できます。
おわりに
今回は結合について基本的なところについてやっていきました。 JPA は単純なデータ構造だと SQL を省略できたりとよい面も多いのですが、理解するまでがやはり大変といった印象です。
この記事を書くためにいろいろ試していましたが、まだまだ理解できていないことが多いなと感じています。 また気になったことがあれば追記か新しく記事を作りたいと思います。