notificationsテーブルを例に、1つのテーブルに複数の通知種別(EMAIL / PUSH)が混在するケースで discriminator タグを使った出し分けを解説します。association・collectionによる親子マッピングは resultMapのassociation/collection記事 で扱っているので、本記事では種別ごとに異なるフィールドをマッピングする discriminator に絞ります。
動作確認はSpring Boot 3系、mybatis-spring-boot-starterの3系、H2で行っています。discriminator自体はMyBatisの初期バージョンから存在する機能なので、バージョンによる挙動差はあまり気にしなくて大丈夫です。
1つのテーブルに複数種別が混在すると何が困るか
notificationsテーブルには type カラムがあり、EMAILなら subject と body_html に、PUSHなら device_token と payload_json に値が入ります。全部1行に収まっていますが、種別ごとに使うカラムが違うわけですね。
resultMapを素直に書くと、全カラムをフラットに1つのDTOへ詰め込むか、Service層で type を見てswitchしながら詰め替えるかのどちらかになりがちです。後者はエンティティへの詰め替えロジックが散らばり、種別が増えるたびに分岐を書き足す羽目になります。discriminator を使うと、この分岐をresultMap側に寄せられます。
discriminatorタグの基本構文
discriminator はresultMapの中に1つだけ置けるタグで、どの case を使うかを判定するカラムを指定します。
<discriminator column="type" javaType="string">
<case value="EMAIL" resultMap="emailNotificationMap"/>
<case value="PUSH" resultMap="pushNotificationMap"/>
</discriminator>
column には判定に使う実際のカラム名、javaType にはそのカラムの値をJavaのどの型として読むかを書きます。文字列で判定するなら string、数値コードなら int です。各 case の value がカラムの実際の値と一致したら、resultMap で指定したresultMapが使われます。仕組みとしてはこれだけです。
通知種別ごとにresultMapを出し分ける
ベースのresultMapに discriminator を仕込み、case ごとに専用のresultMapへ extends で継承させるのが基本形です。
<resultMap id="notificationMap" type="com.example.notification.NotificationBase">
<id property="id" column="id"/>
<result property="recipient" column="recipient"/>
<result property="createdAt" column="created_at"/>
<discriminator column="type" javaType="string">
<case value="EMAIL" resultMap="emailNotificationMap"/>
<case value="PUSH" resultMap="pushNotificationMap"/>
</discriminator>
</resultMap>
<resultMap id="emailNotificationMap" type="com.example.notification.EmailNotification"
extends="notificationMap">
<result property="subject" column="subject"/>
<result property="bodyHtml" column="body_html"/>
</resultMap>
<resultMap id="pushNotificationMap" type="com.example.notification.PushNotification"
extends="notificationMap">
<result property="deviceToken" column="device_token"/>
<result property="payloadJson" column="payload_json"/>
</resultMap>
<select id="findAll" resultMap="notificationMap">
SELECT id, type, recipient, created_at, subject, body_html, device_token, payload_json
FROM notifications
</select>
extends を使うとベースresultMapの id / result を引き継げるので、emailNotificationMap や pushNotificationMap 側で id や recipient を再定義する必要はありません。種別固有のカラムだけを書き足せばOKです。全カラムを1回のSELECTで取得し、discriminatorが行ごとに適切なresultMapへ振り分けるので、SQLにJOINやサブクエリを増やす必要もありません。
caseごとに異なるフィールドをクラス階層にマッピングする
Javaのクラス階層はこうなります。
public abstract class NotificationBase {
private Long id;
private String recipient;
private LocalDateTime createdAt;
// getter/setter省略
}
public class EmailNotification extends NotificationBase {
private String subject;
private String bodyHtml;
// getter/setter省略
}
public class PushNotification extends NotificationBase {
private String deviceToken;
private String payloadJson;
// getter/setter省略
}
Mapperインターフェースは List<NotificationBase> を返すだけです。
public interface NotificationMapper {
List<NotificationBase> findAll();
}
呼び出し元は instanceof やキャストをせずにポリモーフィックにリストを扱えます。どのサブクラスが返ってくるかはdiscriminatorが決めてくれるので、Mapperを呼ぶ側はもう型のことを気にしなくていいわけです。
association・collectionと組み合わせる
case で指定するresultMap自体に association や collection を含めても問題ありません。たとえばPUSH通知だけに配信ログの一覧を持たせたい場合は、こう書けます。
<resultMap id="pushNotificationMap" type="com.example.notification.PushNotification"
extends="notificationMap">
<result property="deviceToken" column="device_token"/>
<result property="payloadJson" column="payload_json"/>
<collection property="deliveryLogs" ofType="com.example.notification.DeliveryLog"
resultMap="deliveryLogMap"/>
</resultMap>
discriminatorとcollection/associationは独立した仕組みなので、このように自然にネストできます。ただしネストが2階層を超えて複雑になってきたら、discriminatorの単位でMapperファイル自体を分割したほうが読みやすいです。
導入前後でコードはどう変わるか
discriminator導入前は、Service層でこんな分岐を書いていたはずです。
public NotificationBase toNotification(NotificationRow row) {
switch (row.getType()) {
case "EMAIL":
EmailNotification email = new EmailNotification();
email.setSubject(row.getSubject());
email.setBodyHtml(row.getBodyHtml());
return email;
case "PUSH":
PushNotification push = new PushNotification();
push.setDeviceToken(row.getDeviceToken());
push.setPayloadJson(row.getPayloadJson());
return push;
default:
throw new IllegalStateException("unknown type: " + row.getType());
}
}
導入後は、この詰め替えコードがそのまま不要になります。
List<NotificationBase> notifications = notificationMapper.findAll();
種別が増えてもresultMapに case を1行追加するだけで済み、Service層のコードは変更不要です。switch文がコードベースのあちこちに散らばる心配もなくなります。
ハマりやすい設定ミス
一番多いのは javaType の指定漏れです。省略すると case の value とDBの値をうまく比較できず、全件が最初の case に落ちたり、どのcaseにも当たらずベースのresultMapで処理されたりします。
javaType と resultType / ofType を混同するのもよくある間違いです。discriminatorの javaType は「判定に使うカラムの値をどの型として読むか」を指定するものであり、マッピング先のクラス(EmailNotification など)を指定するものではありません。マッピング先のクラスは各 case の resultMap 属性(または resultType)で決まります。resultType は <select> の戻り値、ofType は <collection> の要素型を指定するための属性で、discriminatorの javaType とは役割が異なるので、ここを混同すると意図しない型にマッピングされてしまいます。
value とDB上の値の表記ゆれも定番の罠です。DBには小文字で email と入っているのに value="EMAIL" と書くと一致しません。Enumを使っている場合はDB値とEnum名を揃えておくと迷いにくいです。
discriminatorには「どのcaseにも当てはまらない場合」を明示的に指定する仕組みがありません。該当する case がなければ、ベースのresultMap(notificationMap)がそのまま使われるだけです。想定外の種別を見逃さずに検知したいなら、アプリ側で返ってきたインスタンスの型を確認するバリデーションを別途入れておくと安全です。
テストで出し分けを確認する
実データを入れて、返ってきたインスタンスの型とフィールドを確認するテストを書いておきましょう。@MybatisTest のようなMyBatis専用のテストスライスは存在しないので、@SpringBootTest でアプリケーションコンテキストを起動し、テスト用のH2に投入したデータに対してMapperを実行する形にします。
@SpringBootTest
class NotificationMapperTest {
@Autowired
private NotificationMapper notificationMapper;
@Test
void 種別ごとに正しいサブクラスにマッピングされる() {
List<NotificationBase> notifications = notificationMapper.findAll();
assertThat(notifications)
.filteredOn(n -> n instanceof EmailNotification)
.extracting(n -> ((EmailNotification) n).getSubject())
.isNotEmpty();
assertThat(notifications)
.anyMatch(n -> n instanceof PushNotification);
}
}
テストデータは src/test/resources の schema.sql / data.sql でH2に投入しておけばOKです。型のアサーションを入れておくと、カラム名を変更したときに case の対応漏れへ早めに気付けます。
まとめ
discriminator を使うと、1つのテーブルに複数種別が混在していても、Java側にswitch文を書かずにresultMapだけで型ごとの出し分けができます。ポイントは column / javaType で判定方法を決め、case の resultMap に extends で専用のresultMapをぶら下げることです。
association・collectionとの組み合わせ方は resultMapのassociation/collection記事 を、Mapperの基本に不安がある方は MyBatisの実装ガイド を参考にしてください。そもそもMyBatisとJPAのどちらで単一テーブル継承的なマッピングを扱うか迷っている場合は、MyBatisとJPAの比較記事 もあわせてどうぞ。