はじめに
SQLが1行を返しているのに、MyBatis Mapperの戻り値がnullになることがあります。
今回扱うのは、DB列がitem_id、JavaプロパティがitemIdのように、snake_caseとcamelCaseで命名規約が異なるケースです。0件とマッピング失敗を切り分け、戻り値そのものがnullになり得る理由まで確認します。
サンプルコードは spring-transaction-self-invocation-lab の E006 に収録しています。
この記事で扱う問題
テーブルと Java クラスは次の名前を持ちます。
| DB列 | Javaプロパティ |
|---|---|
item_id |
itemId |
display_name |
displayName |
mapper XMLでは resultType だけを指定します。
<select id="findById"
resultType="com.example.transactionlab.mapping.InventoryItem">
SELECT item_id, display_name
FROM inventory_items
WHERE item_id = #{itemId}
</select>
テストデータには次の1行を入れます。
item_id = item-001
display_name = Mechanical Keyboard
そのため、WHERE item_id = 'item-001' は1行を返します。それでも、今回の条件では Mapper の戻り値が null になります。
snake_caseの列が1つでもあれば、必ずnullになるわけではありません。結果列のうち、Javaプロパティへマッピングできる非nullの値が1つでもあれば、foundValuesがtrueになり、通常は結果オブジェクト自体が残ります。
一方、今回のように、item_idとdisplay_nameのどちらもitemIdとdisplayNameに対応せず、マッピングされた値が1つもない場合、デフォルトでは結果オブジェクトがnullになります。
最小再現
環境は次のとおりです。
| 項目 | 値 |
|---|---|
| JDK | 21.0.11 |
| Maven | 3.8.7 |
| Spring Boot | 3.5.0 |
| MyBatis Spring Boot Starter | 3.0.4 |
| DB | H2 インメモリ |
mapUnderscoreToCamelCase |
既定値 false
|
テストは、検索結果の値を Java オブジェクトから利用できることを契約として表現します。
@Test
void snake_case列をJavaのcamelCaseプロパティへマッピングする() {
InventoryItem item = service.find("item-001");
assertThat(item.getItemId()).isEqualTo("item-001");
assertThat(item.getDisplayName()).isEqualTo("Mechanical Keyboard");
}
バグ状態を再現するには、次を実行します。
git clone https://github.com/tonbiattack/spring-transaction-self-invocation-lab.git
cd spring-transaction-self-invocation-lab
git switch --detach 5e3052f
mvn --batch-mode -Dtest=InventoryItemServiceIntegrationTest test
失敗出力は次のとおりです。
Cannot invoke "InventoryItem.getItemId()" because "item" is null
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
BUILD FAILURE
調査:0件とマッピング失敗を分離する
仮説A:WHERE条件に一致する行がない
0件なら Mapper の戻り値が null になるため、まず疑うべき仮説です。
しかし、テストの setUp では同じIDの行を INSERT しています。SQLログでも item-001 が条件値として渡され、SQL自体は1行を取得しています。
したがって、今回の null は「検索結果が0件だった」ことでは説明できません。
仮説B:H2がsnake_case列を扱えない
この仮説なら、列名へエイリアスを付けても結果は変わらないはずです。
しかし、次のように結果列名を Java プロパティ名へ合わせると値が設定されます。
SELECT
item_id AS itemId,
display_name AS displayName
FROM inventory_items
WHERE item_id = ?
DBから値を取得できているため、問題はH2ではなく、結果列名とJavaプロパティ名の対応です。
仮説C:snake_caseからcamelCaseへの変換が無効
MyBatisの mapUnderscoreToCamelCase は、A_COLUMN のような列名を aColumn のような Java プロパティ名へ自動マッピングする設定です。
公式ドキュメントでは既定値は false です。
MyBatis 3 Reference Documentation — Configuration
したがって、設定を変更していない状態では、
item_id -> itemId
display_name -> displayName
という変換は自動では行われません。
なぜ「プロパティがnull」ではなく「オブジェクト自体がnull」になるのか
今回の挙動で分かりにくいのは、プロパティではなくオブジェクト自体がnullになる点です。
MyBatisのDefaultResultSetHandlerは、結果オブジェクトを生成した後、自動マッピングや明示的なマッピングで非nullの値を設定できたかをfoundValuesで判定します。
最終的には、foundValuesがfalseで、returnInstanceForEmptyRowも無効なら、結果オブジェクトをnullとして扱います。
実装上は、概ね次の判定です。
rowValue = foundValues || configuration.isReturnInstanceForEmptyRow()
? rowValue
: null;
DefaultResultSetHandler.java — MyBatis
公式ドキュメントは、戻り行の全列がNULLのときにnullではなく空のインスタンスを返す設定としてreturnInstanceForEmptyRowを説明しています。一方、実装はfoundValuesを使って判定します。DBの列値が非nullでも、Javaプロパティへ値を設定できなければfoundValuesはfalseのままです。そのため、今回のように列名が対応しない場合も、同じ判定でrowValueがnullになります。
今回の処理は次の順序で進みます。
SELECTは1行返す
↓
結果オブジェクトを生成する
↓
item_id -> itemId が一致しない
display_name -> displayName も一致しない
↓
mapUnderscoreToCamelCase=falseなので変換もしない
↓
マッピングできた値が1つもない
↓
デフォルトでは最終的な戻り値がnullになる
一方、例えばidのように列名とプロパティ名が一致し、値がnullではない項目が1つでもあれば、foundValuesはtrueになります。その場合はオブジェクト自体が残り、対応できないプロパティだけが未設定になる可能性があります。
修正方法1:mapUnderscoreToCamelCaseを有効にする
DBが snake_case、Javaが camelCase という命名規約で統一されているなら、実務ではこの方法が第一候補になりやすいです。
Spring Bootでは例えば次のように設定できます。
mybatis:
configuration:
map-underscore-to-camel-case: true
これにより、
item_id -> itemId
display_name -> displayName
created_at -> createdAt
のような対応を各SQLで繰り返し書く必要がなくなります。
新規プロジェクトで命名規約が明確なら、この設定を最初から有効にする方が自然です。
ただし、既存システムで後から有効化する場合は、同じSqlSessionFactoryの設定を利用するMapper全体のマッピングへ影響するため、回帰確認が必要です。
修正方法2:列エイリアスを付ける
今回のサンプルでは、学習対象と変更範囲を局所化するため、列エイリアスを採用しています。
-SELECT item_id, display_name
+SELECT item_id AS itemId, display_name AS displayName
修正後は次のようになります。
<select id="findById"
resultType="com.example.transactionlab.mapping.InventoryItem">
SELECT item_id AS itemId,
display_name AS displayName
FROM inventory_items
WHERE item_id = #{itemId}
</select>
結果列名そのものがJavaプロパティ名と一致するため、mapUnderscoreToCamelCase=false のままでもマッピングできます。
この方法は、そのSQLだけを局所的に修正したい場合に向いています。
修正方法3:resultMapで明示する
列名とプロパティ名の対応が単純な命名規約では表現できない場合は、resultMap が適しています。
<resultMap id="inventoryItemResultMap"
type="com.example.transactionlab.mapping.InventoryItem">
<id property="itemId" column="item_id"/>
<result property="displayName" column="display_name"/>
</resultMap>
<select id="findById" resultMap="inventoryItemResultMap">
SELECT item_id, display_name
FROM inventory_items
WHERE item_id = #{itemId}
</select>
JOIN、ネストしたオブジェクト、特殊な命名など、対応関係が複雑になるほど resultMap の方が意図を明示しやすくなります。
3つの修正方法をどう選ぶか
単純な snake_case と camelCase の対応なら、次のように考えると分かりやすいです。
| 方法 | 向いているケース | 長所 | 注意点 |
|---|---|---|---|
mapUnderscoreToCamelCase=true |
DBがsnake_case、JavaがcamelCaseで統一されている | SQLが簡潔になる | 同じSqlSessionFactoryの設定を利用するMapper全体へ影響する |
| 列エイリアス | そのSQLだけ局所的に直したい | SQLを見るだけで対応が分かる | 列ごとに記述が必要 |
resultMap |
JOINや特殊な名前対応がある | 複雑なマッピングを明示できる | XMLの記述量が増える |
今回のサンプルでは列エイリアスを使いますが、一般的な新規アプリケーションで命名規約が統一されているなら、mapUnderscoreToCamelCase=true を検討する方が自然です。
E005との関連と分離
E005の CustomerOrder も order_id などの snake_case 列を持つため、E005の修正状態では列エイリアスを使っています。
これは、E005の主題である selectOne の件数契約と、E006の主題である列マッピングの契約を混ぜないためです。
E005で LIMIT 1 だけを追加して複数行例外を解消しても、列名とプロパティ名が対応していなければ、次はマッピングの問題が表面化します。
件数と列マッピングは、別の契約として切り分けて検証した方が原因を追いやすくなります。
回帰テスト
修正後、E006の対象テストと全テストを実行します。
mvn --batch-mode -Dtest=InventoryItemServiceIntegrationTest test
mvn --batch-mode test
結果は次のとおりです。
対象テスト: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
全テスト: Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
Git履歴はE005とE006をまとめてバグ状態・修正状態に分離しています。
| 役割 | コミット |
|---|---|
| E005・E006のバグ状態 |
5e3052f 再現: MyBatisのselectOneと自動マッピングの境界
|
| E005・E006の修正状態 |
ca8f6a6 修正: MyBatisの件数制約と列マッピングを明示する
|
まとめ
MyBatisでは、SQLが1行を返していても、非nullの値がJavaプロパティへ1つもマッピングされなければ、Mapperの戻り値がnullになることがあります。
今回のポイントは次の3つです。
-
mapUnderscoreToCamelCaseの既定値はfalseである - snake_caseとcamelCaseの対応に失敗し、非
nullの値がJavaプロパティへ1つもマッピングされなければ、条件によっては結果オブジェクト自体がnullになる - 解決策は
mapUnderscoreToCamelCase、列エイリアス、resultMapを作用範囲に応じて選ぶ
デバッグでは、まずSQLが本当に0件なのかを確認し、その後に結果列名とJavaプロパティ名の対応を確認します。
「検索結果が null だからWHERE条件が間違っている」と決めつけず、SQLの件数とORM・Mapperのマッピングを別々に観測すると原因を切り分けやすくなります。