MyBatis 利用時に SQL で FULL OUTER JOIN するときの注意点

検証環境

  • mybatis 3.4.5
  • java 1.8.0.25

ハマったこと

DBに以下のようなデータが入っているときに

f:id:kimulla:20191207124403p:plain

SQL で FULL OUTER JOIN すると、以下のようになる。

f:id:kimulla:20191207124423p:plain

これに対応するJavaのBeanを用意して、

@Data
public class Shelf {
    private Long id;
    private String name;
    private String position;
    private List<Book> books;
}
@Data
public class Book {
    private Long id;
    private String name;
}

以下のようなマッパーXMLを用意する。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
      PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
      "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.mybatissample.ShelfRepository">

  <resultMap id="shelfResult" type="com.example.mybatissample.Shelf">
    <id property="id" column="shelf_id"></id>
    <result property="name" column="shelf_name"></result>
    <result property="position" column="position"></result>
    <collection property="books" ofType="com.example.mybatissample.Book">
      <id property="id" column="book_id"></id>
      <result property="name" column="book_name"></result>
    </collection>
  </resultMap>

  <select id="findAll" resultMap="shelfResult">
      SELECT
        shelf_id, shelf_name, position, book_id, book_name
      FROM shelf
      FULL OUTER JOIN book USING (shelf_id)
      ORDER BY shelf_id
  </select>

</mapper>

理想は、以下のようにidがnullのオブジェクトがまとまること。

Shelf(id=1, name=本棚A, position=1F, books=[
  Book(id=1, name=ネコでもわかるJava), 
  Book(id=2, name=イヌでもわかるJava)
])
Shelf(id=2, name=本棚B, position=2F, books=[])
Shelf(id=null, name=null, position=null, books=[
  Book(id=3, name=サルでもわかるJava), 
  Book(id=4, name=キジでもわかるJava)
])

が、現実には、Shelfのidがnullのオブジェクトが2つに分割されてしまった。

Shelf(id=1, name=本棚A, position=1F, books=[
  Book(id=1, name=ネコでもわかるJava), 
  Book(id=2, name=イヌでもわかるJava)
])
Shelf(id=2, name=本棚B, position=2F, books=[])
Shelf(id=null, name=null, position=null, books=[
  Book(id=3, name=サルでもわかるJava)]
)
// ここが別のBeanにマッピングされる
Shelf(id=null, name=null, position=null, books=[
  Book(id=4, name=キジでもわかるJava)
])

原因

MyBatis は、ResultSetの1行単位でオブジェクトを生成する。ResultMapがネストしている場合(1:Nにマッピングする場合)は、ResultMapごとにオブジェクトの生成を繰り返す。
参考: DefaultResultSetHandlerのソースコード

生成したオブジェクトは、CacheKeyオブジェクトをKeyにしてマップに保存する。このCachedKeyオブジェクトを識別するときのキーがresultMapのidに指定したフィールドの値

f:id:kimulla:20191207124450p:plain

ResultSetの1行ごとの処理時に、idが既にキャッシュにある場合は、そのオブジェクトを取り出して使う。このときは、行の実行結果は最終的なメソッドの戻り値には含まれない。(ただし、キャッシュされている値と戻り値のオブジェクトは共有されたミュータブルなオブジェクトのため、Shelfオブジェクトのbooksフィールドに対する変更が戻り値に反映される。)

f:id:kimulla:20191207124510p:plain

ここで、idがnullの場合は NullCachedKey が利用され、キャッシュされない。

f:id:kimulla:20191207124519p:plain

idがnullの場合は生成されたオブジェクトがキャッシュに入らないので、次の行でも、新しくShelfオブジェクトを生成する。

f:id:kimulla:20191207124537j:plain

上記処理のreturnをまとめると最終的な戻り値のになり、以下が返ってくる。

Shelf(id=1, name=本棚A, position=1F, books=[
  Book(id=1, name=ネコでもわかるJava), 
  Book(id=2, name=イヌでもわかるJava)
])
Shelf(id=2, name=本棚B, position=2F, books=[])
Shelf(id=null, name=null, position=null, books=[
  Book(id=3, name=サルでもわかるJava)]
)
Shelf(id=null, name=null, position=null, books=[
  Book(id=4, name=キジでもわかるJava)
])

解決策

設定でidがnullの場合にキャッシュを有効化することは難しそう。

代替手段として、idがnullだった場合は代わりの値に置き換えれば、近いことはできる。

    <select id="findAll" resultMap="shelfResult">
        SELECT
          CASE
             WHEN shelf_id IS NULL THEN -1
             ELSE shelf_id
          END,
          shelf_name, position, book_id, book_name
        FROM shelf
        FULL OUTER JOIN book USING (shelf_id)
        ORDER BY shelf_id
    </select>
Shelf(id=-1, name=null, position=1F, books=[
  Book(id=3, name=サルでもわかるJava), 
  Book(id=4, name=キジでもわかるJava)
])
Shelf(id=1, name=本棚A, position=2F, books=[
  Book(id=1, name=ネコでもわかるJava), 
  Book(id=2, name=イヌでもわかるJava)]
)
Shelf(id=2, name=本棚B, position=null, books=[])