spring.svg

【Spring Data JPA】結合

SpringBoot
2024/03/06

はじめに

Spring JPA の結合について、以下のデータ構造を基に説明していきます。 Spring JPA の基本については過去の記事を参考にしてください。

またここでは REST API として JSON を扱うことを想定としています。

spring-vscode2

結合の基本

OneToOne

以下のようにClubTeacherの情報を含めて表示することを考えます。

JSON
{
  "clubId": 1,
  "name": "野球部",
  "teacher": {
    "teacherId": 1,
    "name": "田中太郎",
    "gender": "男",
    "dateOfBirth": "1983-10-21"
  }
}

まずはTeacherの Entity を作成します。

entity/Teacher.java
@Entity
public class Teacher {
  @Id
  private long teacherId;

  private String name;

  private String gender;

  @Column(columnDefinition = "DATE")
  private LocalDate dateOfBirth;
}

次にClubの Entity を作成します。

entity/Club.java
@Entity
public class Club {
  @Id
  private long clubId;

  private String name;

  @OneToOne
  @JoinColumn(name = "teacher_id")
  private Teacher teacher;
}

結合先の情報となるTeacherのフィールドには、@JoinColumnにより結合条件となるカラム名(name)を指定します。 今回はteacher_idで結合をすることとなります。

また今回は、ClubTeacherのリレーションが 1 対 1 のため@OneToOneを付与します。 後述するその他のリレーションでも基本的な考え方は同じです。

あとは Repository を作成し、findAll()findById()により参照すれば OK です。

OneToMany

次にTeacherSchool Classの情報を含めることを考えます。

JSON
{
  "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/SchoolClass.java
@Entity
public class SchoolClass {
  @Id
  private long classId;

  private String name;

  private int year;
}

これに対し、School Classを持つTeacherの Entity を作成します。

entity/Teacher.java
@Entity
public class Teacher {
  //省略

  @OneToMany
  @JoinColumn(name = "teacher_id")
  private List<SchoolClass> schoolClasses;
}

OneToOneの場合と同じく@JoinColumnで結合条件を指定し、1 対多のリレーションを示す@OneToManyを付与します。 School Classは複数あることが想定できるため、型はList<>としています。

ManyToOne

先程とは逆で、School ClassTeacherの情報を含めることを考えます。

JSON
{
  "classId": 1,
  "name": "1-1",
  "year": 2020,
  "teacher": {
    "teacherId": 1,
    "name": "田中太郎",
    "gender": "男",
    "dateOfBirth": "1983-10-21",
  }
}

事前にTeacherschoolClassesは削除しておいてください。 この理由については後述します。

先程のSchool Classの Entity にTeacherの情報を追加します。 リレーションが多対 1 のため、@ManyToOneを付与します。

entity/SchoolClass.java
@Entity
public class SchoolClass {
  //省略

  @ManyToOne
  @JoinColumn(name = "teacher_id")
  private Teacher teacher;
}

ManyToMany

最後にSchool ClassStudentの情報を含めることを考えます。

JSON
{
  "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/Student.java
@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 ClassStudentのリレーションは多対多のため、@ManyToManyを付与します。

しかし、これまでとはSchool Class Listという中間テーブルが存在する点が異なります。 このような場合は、@JoinColumnではなく@JoinTableを付与します。

entity/SchoolClass.java
@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/Club.java
@Entity
public class Teacher {
  @Id
  private long clubId;

  private String name;

  @OneToOne
  @JoinColumn(name = "teacher_id")
  private Teacher teacher;
}

この Entity に対して、以下のデータを渡すことでsave()が正しく実行されます。

JSON
{
  "clubId": 1,
  "name": "野球部",
  "teacher": {
    "teacherId": 1
  }
}

これでも良いのですが、別パターンとして、以下のようなデータを渡した場合にsave()が正しくが実行されることを考えます。 このとき、出力情報は OneToOne で示したもののままとします。

JSON
{
  "clubId": 1,
  "name": "野球部",
  "teacherId": 1
}

説明のために、先に答えを提示しておきます。 コメントを追記している部分が変更点(追加部分)となります。

entity/Club.java
@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 つ目は、ClubteacherIdのフィールド定義を追加します。 しかし、単純に追加しただけでは以下のようなエラーとなります。

 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]

これは、@JoinColumnteacher_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を付与したほうの登録・更新を無効化します。 具体的には、② にあるようにinsertableupdatableプロパティをfalseに設定します。 これらのプロパティは、@JoinColumnnameで指定したカラムの登録・更新を許可するかの設定となります。 デフォルトはtrueのため、元の Entity ではあのデータ構造でsave()が実行できたわけです。 これでエラーが解消できるはずです。

3 つ目は必須ではありませんが、teacherIdを JSON の出力対象から除外したい場合に、ゲッターに@JsonIgnoreを付与します。 フィールドそのものに@JsonIgnoreを付与する手もありますが、セッターも@JsonIgnoreされてしまうため、 @RequestBodyで 受け取ることができなくなる点に注意してください。

データ構造としては後者の方が自然な気がしますが、ひと手間必要なためお好きなほうで良いかと思います。 削除についてはこれまで通り、deleteById()などで対象のレコードを消すことができます。

結合先を含める

では次に、Clubと同時にTeacherの内容も同時に登録・更新・削除することを考えます。 ClubTeacherを同時に操作するのはありえないかもしれませんが、説明のためだと思ってください。

登録・更新(save)

以下のデータでClubリポジトリ―のsave()を実行したときに、ClubTeacherのどちらも登録・更新されるようにします。

JSON
{
  "clubId": 1,
  "name": "野球部",
  "teacher": {
    "teacherId": 1,
    "name": "田中太郎",
    "gender": "男",
    "dateOfBirth": "1983-10-21"
  }
}

現状だとこのデータを渡してもClubの反映のみで、Teacherには反映されません。 Teacherを登録・更新対象とするには、以下のように@OneToOnecascadeプロパティを追加します。

entity/Club.java
@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()の対象となるということです。

例えば@OneToOnecascadeプロパティを設定していない状態で以下のコードを実行するとどうなるでしょうか。

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 の管理状態にする処理が含まれていると推測しています。 そこで先程の@OneToOnecascadeプロパティの設定によって、結合先の Entity を管理下に含めるかを判断しているのではないかと。 ちなみにCascadeType.MERGEは、管理状態にない Entity を管理状態とするという意味があります。

これ以上については JPA の永続化についてより深く知る必要がありかもしれませんが、今回はここまでとします。

あともう 1 点、@OneToOneにはoptionalというプロパティを持ちます。 これは対象の Entity のnullを許容するかの設定になります。 デフォルトはtrueとなっており、以下のようにteacherのないデータの登録も可能です。

JSON
{
  "clubId": 1,
  "name": "野球部"
}

これを許容しない場合は、optional = falseを設定します。

削除(delete)

次に削除についてです。 Clubと同時にTeacherを削除する場合はcascadeプロパティにCascadeType.REMOVEを追加します。

entity/Club.java
@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のデータが新しく登録されるというイメージです。

JSON
{
  "clubId": 1,
  "name": "野球部",
  "teacher": {
    "teacherId": 2,
    "name": "鈴木一郎",
    "gender": "男",
    "dateOfBirth": "1987-03-13"
  }
}

このような動きとする場合は、@OneToOneorphanRemovaltrueに設定します。

entity/Club.java
@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

最後に相互参照について説明します。 これまでClubTeacherを参照するといったいわゆる単方向の参照だけでした。 相互参照はそのままの意味で、ClubTeacherが互いに参照し合うことです。

Clubをベースに、TeacherClubの参照を追加する場合は、@OneToOnemappedByプロパティを使用します。 mappedByには、ClubTeacherに該当するフィールド名を設定します。

entity/Club.java
@Entity
public class Teacher {
  @Id
  private long clubId;

  private String name;

  @OneToOne
  @JoinColumn(name = "teacher_id")
  private Teacher teacher;
}
entity/Teacher.java
@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を付与するといったものです。

以下のコードではClubidとしてuuidを生成して設定しています。 Teacherにも同じく追加します。

entity/Club.java
@Entity
@JsonIdentityInfo(generator = ObjectIdGenerators.UUIDGenerator.class)
public class Club {
  //
}

これにより、以下のように@idが追加された JSON が取得できます。

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"
  }
}

見ての通り、clubteacherは生成されたidの値が設定されるため、循環参照がおきません。 @idという情報は付加されてしまいますが、どちらの参照も活かしたい場合に便利です。

ちなみに@idという名前は、JsonIdentityInfopropertyプロパティで変更できます。

おわりに

今回は結合について基本的なところについてやっていきました。 JPA は単純なデータ構造だと SQL を省略できたりとよい面も多いのですが、理解するまでがやはり大変といった印象です。

この記事を書くためにいろいろ試していましたが、まだまだ理解できていないことが多いなと感じています。 また気になったことがあれば追記か新しく記事を作りたいと思います。