MyBatisで「入力された項目だけを検索条件にする」「IN句の要素数が実行のたびに変わる」SQLを書くときに使うのが動的SQL(Dynamic SQL)です。手段は2つあります。XMLマッパーに<if>や<foreach>を書くMyBatis本体の機能と、Javaのメソッドチェーンで組み立てる別配布のライブラリmybatis-dynamic-sqlです。この記事では両方を、MyBatis 3.5.19とmybatis-dynamic-sql 2.0.0で実際に実行し、出力された生成SQLを並べて解説します。条件が1つも成立しなかったときにWHERE句が消えて全件が対象になる問題も、両者の挙動を比べて対処を示します。
まとめ:MyBatis動的SQLの要点
- 可変条件の検索はXMLの
<where>+<if>が基本です。<where>は先頭のANDとORを取り除き、UPDATEの末尾カンマは<set>が取り除きます。IN句と一括INSERTは<foreach>で組みます。 - 値は必ず
#{}で渡します。${}は文字列をそのままSQLへ連結するので、ORDER BYの列名など値ではない箇所に限り、許可リストで照合してから使います。 - XMLは条件が全部消えるとWHERE句ごと消えます。検索なら全件取得、DELETEやUPDATEなら全行が対象になります。mybatis-dynamic-sqlは同じ状況で既定で例外を投げて止めます。
- SQLを型で守りたいならmybatis-dynamic-sql 2.0.0です。Java 17以上が必須で、nullを渡してよいのは
isEqualToWhenPresentなど名前にWhenPresentが付く条件だけになりました。XMLマッパーの資産が多い現場は、XMLのままで足ります。
MyBatisの「Dynamic SQL」が指す2つの仕組み
MyBatisはSQLを開発者が書き、その結果をJavaオブジェクトへ対応づけるSQLマッパーです(O/Rマッパー全般の仕組みはORMとは?O/Rマッパーの仕組み・メリットとデメリットをSQL比較と実行結果で解説)。「MyBatis Dynamic SQL」で検索すると、次の2つの話が混ざって出てきます。
| 項目 | XML動的SQL(MyBatis本体) | mybatis-dynamic-sql(別ライブラリ) |
|---|---|---|
| 書き方 | SQLの中に<if>等のタグ |
Java・Kotlinのメソッドチェーン |
| Maven座標 | org.mybatis:mybatis | org.mybatis.dynamic-sql:mybatis-dynamic-sql |
| 現行版 | 3.5.19(2025年1月) | 2.0.0(2026年3月) |
| Java要件 | Java 8以上 | 2.x:Java 17以上/1.x:Java 8 |
| 列名の誤り | 実行時にDBエラー | 未定義のJava列参照を検出 |
公式サイトの名前がどちらも「Dynamic SQL」なので、英語の検索結果ではライブラリの公式ドキュメントが上位に来ます。XML動的SQLはMyBatis本体の機能で、ライブラリの追加なしで利用できます。本記事は前半でXML、後半でライブラリを扱います。
XML動的SQLの条件分岐と句の整形(if・where・choose・set・trim)
XMLマッパーでは、SQL文の中に制御用のタグを埋め込みます。test属性の条件はOGNL式で評価され、真になった断片だけがSQLに残ります。要素の一覧はMyBatis公式ドキュメントの「動的 SQL」にあります。以下の例はusersテーブル(id・user_name・email・status)を使い、H2データベース上で実行したときのログ(==> Preparing:の行が生成されたSQL)を添えています。
ifとwhereによる任意検索条件の組み立て
入力された項目だけを検索条件に加える、もっとも出番の多い形です。<if>だけで書くと先頭のANDが余るため、WHERE 1 = 1を置く書き方が古い記事に残っていますが、<where>で囲めば不要です。
<select id="search" resultType="User">
SELECT id, user_name FROM users
<where>
<if test="userName != null and userName != ''">AND user_name = #{userName}</if>
<if test="status != null">AND status = #{status}</if>
</where>
</select>
userNameだけを渡したときと、両方nullを渡したときの生成SQLは次のとおりです。
-- search("taro", null)
==> Preparing: SELECT id, user_name FROM users WHERE user_name = ?
==> Parameters: taro(String)
-- search(null, null)
==> Preparing: SELECT id, user_name FROM users
<where>は中身が1つでも出力されたときだけWHEREを付け、先頭のANDを削っています。両方nullのときはWHERE句そのものが消え、全件を返しました。この挙動の危うさは後の章で扱います。
chooseによる検索条件の排他的な選択
<choose>はJavaのswitchに当たります。上から<when>を評価して最初に真になった1つだけを採用し、どれも偽なら<otherwise>を出力します。「メールアドレスがあればそれで、無ければ名前で、どちらも無ければ有効ユーザーだけ」のような排他の分岐に使います。
<select id="findUser" resultType="User">
SELECT id, user_name FROM users
<where>
<choose>
<when test="email != null">AND email = #{email}</when>
<when test="userName != null">AND user_name = #{userName}</when>
<otherwise>AND status = 'A'</otherwise>
</choose>
</where>
</select>
-- findUser(null, "hanako")
==> Preparing: SELECT id, user_name FROM users WHERE user_name = ?
-- findUser(null, null)
==> Preparing: SELECT id, user_name FROM users WHERE status = 'A'
<otherwise>に固定の条件を置くと、<where>が空になる経路をなくせます。全件取得を許さない検索では、この形が安全です。
setによるUPDATE更新列の動的な指定
更新する列を可変にすると、最後の列の後ろにカンマが余ります。<set>はSETを付けたうえで、末尾の余分なカンマを取り除きます。
<update id="updateUser">
UPDATE users
<set>
<if test="userName != null">user_name = #{userName},</if>
<if test="email != null">email = #{email},</if>
</set>
WHERE id = #{id}
</update>
更新する値が1つも無いときは、SETという語も出力されません。MyBatis 3.5.19での生成SQLはUPDATE users WHERE id = ?で、H2は構文エラーを返しました。呼び出す側で「更新する項目が最低1つある」ことを確かめてから実行します。
trimによる接頭辞・接尾辞の整形
<where>と<set>は、汎用の<trim>の特別版です。prefixで前に付ける語を、prefixOverridesでパイプ区切りの除去対象を指定します。次の例は<where>と同じ結果になります。
<trim prefix="WHERE" prefixOverrides="AND |OR ">
<if test="status != null">AND status = #{status}</if>
<if test="userName != null">AND user_name = #{userName}</if>
</trim>
"AND |OR "の後ろの空白は意図的に入れています。prefixOverrides="AND|OR"と空白なしで書き、最初の条件がORDER_NO = #{x}だった場合、生成SQLはWHERE DER_NO = ?になりました。列名の頭のORまで削られたためです。<where>と<set>で足りるなら、読みやすいそちらを使います。
foreachによるIN句と一括INSERTの組み立て
<foreach>はコレクションを反復してSQLを組み立てます。属性はcollection(反復する対象)、item(各要素の変数名)、index(添字)、openとclose(前後に付ける文字列)、separator(区切り)です。
可変長リストによるIN句の生成
<select id="findByIds" resultType="User">
SELECT id FROM users WHERE id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</select>
-- findByIds([1, 3])
==> Preparing: SELECT id FROM users WHERE id IN ( ? , ? )
==> Parameters: 1(Integer), 3(Integer)
-- findByIds([])
==> Preparing: SELECT id FROM users WHERE id IN
要素ごとに?のプレースホルダが作られるので、リストの中身がSQL文に露出することはありません。注意したいのは空リストです。openとcloseも出力されないため、生成SQLはIN ()ではなくINで終わり、構文エラーになります。リストにnullを渡した場合はThe expression 'ids' evaluated to a null value.という例外になり、nullable="true"(3.5.9で追加)を付けるか、属性未指定で全体設定のnullableOnForEach=trueを使うと、foreachは何も出力しません。ただし上の例はINが<foreach>の外にあるため、SQLはINで終わって空リストと同じく構文エラーになります。
collection属性に書く名前
collectionに書く名前は、Mapperメソッドの引数の受け取り方で変わります。3.5.19で試した結果です。
| メソッドの引数 | collectionに書ける名前 |
|---|---|
@Param("ids") List<Integer> ids |
ids |
List<Integer> ids(@Param無し) |
list・collection・arg0 |
同上を-parameters付きでコンパイル |
list・collection・ids(useActualParamName=true) |
配列Integer[] ids(@Param無し) |
array |
@Param無しでcollection="ids"と書くと、-parametersなしのビルドではParameter 'ids' not found. Available parameters are [arg0, collection, list]で失敗しました。Mavenのspring-boot-starter-parentはコンパイラに-parametersを付ける設定を含むため、Spring Bootのプロジェクトでは動き、親POMを使わない別のビルドで落ちることがあります。名前で参照するなら@Paramを付けておくのが確実です。
一括INSERTの複数行VALUESとパラメータ数の上限
<insert id="insertUsers">
INSERT INTO users (id, user_name, status) VALUES
<foreach item="u" collection="users" separator=",">
(#{u.id}, #{u.userName}, #{u.status})
</foreach>
</insert>
==> Preparing: INSERT INTO users (id, user_name, status) VALUES (?, ?, ?) , (?, ?, ?) , (?, ?, ?)
<== Updates: 3
1回のSQLで3行が入りました。行数が増えるとプレースホルダの数が「行数×列数」で膨らみ、ドライバやDBの上限に当たります。pgjdbcの拡張クエリプロトコルでは、1文のバインドパラメータ数の上限は42.4.0以降で65,535個、それ以前は32,767個です。数百行ずつに分けて呼ぶのが無難です。
#{}と${}の違いとSQLインジェクション対策
動的SQLの事故で最も重いのが、値の埋め込み方の取り違えです。#{}はPreparedStatementの?に値をバインドし、${}は文字列をそのままSQL文に連結します。攻撃の仕組みそのものはSQLインジェクションとは?仕組み・攻撃例・対策をコード付きで解説で扱っています。
| 記法 | 展開のされ方 | インジェクション | 使う場所 |
|---|---|---|---|
#{値} |
?にバインド |
防げる | 検索語・ID・日付などすべての値 |
${値} |
SQL文へ直接連結 | 防げない | 列名・テーブル名・ソート方向だけ |
WHERE user_name = '${userName}'と書いたマッパーにx' OR '1'='1を渡すと、生成SQLは次のようになり、3行すべてが返りました。
==> Preparing: SELECT id FROM users WHERE user_name = 'x' OR '1'='1'
==> Parameters:
Parametersが空なのは、値がバインドされずSQL文の一部になったからです。
${}を使う場面と許可リストでの照合
プレースホルダは列名やテーブル名には使えないため、ORDER BYのソート列を切り替える方法には、許可リストで検証した列名を${}で展開する方法と、<choose>で固定の列名を選ぶ方法があります。${}で展開する場合は、入力をそのまま渡さず、アプリ側で許可リストに照合してから渡します。次の例はSet.ofを使うためJava 9以上が必要です。許可リスト型の入力検証の考え方は入力バリデーションとは?防げる攻撃の範囲と許可リスト設計を実装目線で解説にまとめています。
// ソート列は許可リストに照合してから ${} に渡す
private static final Set<String> SORTABLE = Set.of("user_name", "email");
public List<User> search(String sortColumn) {
if (!SORTABLE.contains(sortColumn)) {
throw new IllegalArgumentException("invalid sort column");
}
return userMapper.orderBy(sortColumn);
}
<select id="orderBy" resultType="User">
SELECT id, user_name FROM users ORDER BY ${sortColumn}
</select>
bindによるLIKE検索パターンの生成
部分一致検索で'%' + 値 + '%'を作るとき、${}で連結すると上と同じ穴が開きます。<bind>でOGNL式の結果を変数に入れ、#{}で渡します。
<select id="searchByName" resultType="User">
<bind name="pattern" value="'%' + userName + '%'" />
SELECT id FROM users WHERE user_name LIKE #{pattern}
</select>
==> Preparing: SELECT id FROM users WHERE user_name LIKE ?
==> Parameters: %a%(String)
条件が全部消えたときの挙動(XMLとmybatis-dynamic-sqlの違い)
動的SQLの本当の怖さは、構文エラーではなく「正しく動いてしまう」ことです。前の章までの実行結果を並べると、XMLとライブラリで考え方がはっきり分かれます。
| 状況 | XML(MyBatis 3.5.19) | mybatis-dynamic-sql 2.0.0 |
|---|---|---|
| 任意条件がすべてnull | WHERE句が消えて全件対象 | NonRenderingWhereClauseException |
| where内のforeachに空リスト | WHERE句が消えて全件対象 | isInはInvalidSqlException |
| where無しで書いた文 | 全件対象(意図どおり) | 全件対象(例外なし) |
表の2行目は見落とされやすい落とし穴です。公式ドキュメントのforeach例と同じく<where>の中にopen="id IN ("の<foreach>を置き、空リストを渡すと、生成SQLはSELECT id FROM usersになりました。これがDELETE文なら、テーブルの全行が消えます。
mybatis-dynamic-sqlは、この事故を既定で止める設計です。公式の「Where Conditions」は、where句を書いたのに描画されない場合は既定でNonRenderingWhereClauseExceptionを投げると明記しています(1.4.1から)。where句を最初から書かない文は全件対象として扱われ、例外は出ません。XMLで同じ安全性を得るには、次のどれかを入れます。
- DELETEとUPDATEでは、
<where>の中に、主キーなど対象範囲を限定する必須条件を無条件で出力し、任意条件をANDで追加する。必須値は呼び出し前に検証する。 - 検索では
<choose>の<otherwise>に固定の条件を置く。 - Mapperを呼ぶ前に、条件が1つ以上あること・リストが空でないことをサービス層で検証する。
mybatis-dynamic-sql 2.0.0の型付きSQL構築
SQLを文字列で書かず、Javaのコードとして組み立てたい場合はmybatis-dynamic-sqlを使います。定義済みの列オブジェクトを使うため、未定義のJavaフィールド参照や型の不一致をコンパイル時に検出できます。ただし、列名の文字列や定義が実際のDBスキーマと一致するかは検証されません。生成したSQLはMyBatisのMapperにも、SpringのNamedParameterJdbcTemplateにも渡せます。
依存関係の追加(Java 17・JSpecify)
<dependency>
<groupId>org.mybatis.dynamic-sql</groupId>
<artifactId>mybatis-dynamic-sql</artifactId>
<version>2.0.0</version>
</dependency>
2.0.0(2026年3月12日公開)はJava 17以上が必須で、null許容性の注釈ライブラリJSpecify 1.0.0が実行時の依存に加わりました。Java 8や11の環境では1.x系の最終版1.5.2を使います(公式の変更履歴)。Spring Bootで使う場合、MyBatis本体はmybatis-spring-boot-starter 4.1.0(2026年7月公開・Spring Boot 4.1.0とMyBatis 3.5.19を基に作成)で入るので、このライブラリだけを追加します。Spring Boot 4.1系の構成はSpring Bootとは|4.1系の依存名の変更と最小Web APIの作り方を参照してください。
テーブルと列を表すサポートクラスは、MyBatis Generatorの使い方|2.0.0の設定・Maven実行・生成コードの選び方で紹介しているコード生成ツールで、DBのスキーマから自動生成できます。
WhenPresentによる任意検索条件と省略時の挙動
XMLの<if test="… != null">に当たるのが、名前にWhenPresentが付く条件です。引数がnullなら、その条件は出力されません。ただし前の章のとおり、where句の条件が全部消えると例外になります。任意条件だけで組まず、常に出力される条件を1つ置くのが基本形です。次の例は、テーブル定義のサポートクラスusersと、selectManyを持つMapperを定義済みとしたSQL組み立て部分です。サポートクラスとMapperの書き方は公式のQuick Startにあります。
import static org.mybatis.dynamic.sql.SqlBuilder.*;
SelectStatementProvider stmt = select(users.id, users.userName)
.from(users)
.where(users.status, isEqualTo("A")) // 常に出力される条件
.and(users.userName, isEqualToWhenPresent(searchName)) // nullなら出力しない
.and(users.id, isInWhenPresent(searchIds)) // 空リストなら出力しない
.build()
.render(RenderingStrategies.MYBATIS3);
List<User> result = userMapper.selectMany(stmt);
実行時に生成されるSQLは次のとおりです。searchNameがnullでsearchIdsが空のとき、2つの任意条件は消えてstatusの条件だけが残りました。
select id, user_name from users where status = #{parameters.p1,jdbcType=VARCHAR}
任意条件だけで組み、その全部がnullだったときはNonRenderingWhereClauseException: A where clause was specified, but failed to renderになります。全件取得を意図的に許す画面では、文ごとに.configureStatement(c -> c.setNonRenderingWhereClauseAllowed(true))を付けて許可します。クラスパス直下のmybatis-dynamic-sql.propertiesにnonRenderingWhereClauseAllowed=trueと書けばライブラリ全体で許可できますが、DELETEまで巻き込むので勧めません(公式の設定ページ)。
IN句は条件の種類で空リストの扱いが違います。2.0.0で試した結果です。
| 書き方 | 結果 |
|---|---|
isIn(空リスト) |
InvalidSqlException |
isInWhenPresent(空リスト) |
条件を出力しない |
isInWhenPresent(2, 3, null) |
in (?, ?)(nullは除外) |
1.x系から2.0.0へ上げるときの変更点
2.0.0では、1.x系で動いていたコードにIDEの警告が出るようになります。主な変更は次の4つです。
- nullを渡してよい条件が限定された。JSpecifyの採用により、名前に
WhenPresentが付かない条件へのnull渡しは誤用とされました。isEqualTo(x).filter(Objects::nonNull)はisEqualToWhenPresent(x)へ書き換えます。 - 空リストの
isInが描画の時点で例外になった。1.5.2で「空でも条件を残す(DB側でエラー)」方針に変わり、2.0.0ではSQLを送る前にInvalidSqlExceptionを投げます。空を許すならisInWhenPresentを使います。 - 新しいDSLが
org.mybatis.dynamic.sql.dslパッケージに追加された。旧DSLは次のマイナー版で非推奨、次のメジャー版で削除される予定です。生成コードを使わずDSLを直接書いている場合は、早めの移行が勧められています。 - ページングに
limitWhenPresentなどが加わった。nullを渡すとLIMIT句ごと出力しません。
以前のリリースで非推奨だったAPIも2.0.0で削除されています。独自の条件や関数を実装している場合は、変更履歴の「Potentially Breaking Changes」を確認してから上げます。
XML動的SQLとライブラリを選ぶ基準
| 観点 | XML動的SQL | mybatis-dynamic-sql |
|---|---|---|
| SQLの読みやすさ | ほぼ生のSQL | メソッドチェーン |
| 列名の誤り | 実行時に発覚 | 未定義のJava列参照を検出 |
| 条件が全部消えたとき | 全件対象で実行 | 既定で例外 |
| Java要件 | Java 8以上 | 2.0.0はJava 17以上 |
| DBAとのSQLレビュー | そのまま渡せる | 生成SQLのログが必要 |
全件更新の防止だけが目的なら、既存のXMLマッパーを一括でライブラリへ移行するより、前の章の3つの対策(必須条件と呼び出し前の検証)をXMLに入れる方を勧めます。ライブラリが向くのは、Java 17以上の新規開発で、検索条件の組み合わせが多い画面を型で守りたい場合です。両者は同じMapperインターフェースの中で混在できます。
scriptタグによるアノテーション内の動的SQL
XMLファイルを使わずアノテーションにSQLを書く構成でも、文字列全体を<script>で囲めば、中で<if>や<where>が使えます。
@Select({"<script>",
"SELECT id, user_name FROM users",
"<where>",
" <if test='userName != null'>AND user_name = #{userName}</if>",
" <if test='status != null'>AND status = #{status}</if>",
"</where>",
"</script>"})
List<User> search(@Param("userName") String userName, @Param("status") String status);
search(null, "A")の生成SQLはSELECT id, user_name FROM users WHERE status = ?で、XMLと同じでした。条件の入れ子や長いSQLでアノテーション内の文字列を追いにくくなったら、XMLかライブラリへ移すことを勧めます。
生成SQLの確認方法とつまずきやすい点
動的SQLの不具合は、まず実際に生成されたSQLを見ると原因が早く分かります。公式の「ロギング」によると、SQL文はDEBUG、結果の行はTRACEのレベルで出力され、ログはパッケージ・Mapperの完全修飾名・ネームスペース・ステートメント単位で有効にできます。Spring Bootならapplication.propertiesにlogging.level.com.example.mapper=DEBUGと書けば、本記事に載せた==> Preparing:と==> Parameters:の2行が出ます。単体で試すなら、mybatis-config.xmlのlogImplにSTDOUT_LOGGINGを指定すると標準出力に出ます。
ログで見つけやすい、よくある失敗は次のとおりです。
testでの1文字の比較。<if test="status == 'A'">と書くと、OGNLは'A'を文字として扱い、3.5.19ではNumberFormatException: For input string: "A"で落ちました。属性をシングルクォートで囲み、中の文字列をダブルクォートにしてtest='status == "A"'と書くと通ります。- XMLの中の
<。age < 20をそのまま書くとXMLの構文エラーになります。<と書くか、<![CDATA[ ... ]]>で囲みます。
複雑な検索画面やDB移行に伴うSQLの見直しを社外に相談したい場合は、データベース設計・移行支援でも対応しています。
よくある質問
MyBatisの読み方は?
「マイバティス」と読みます。前身はApache Software FoundationのiBATIS(アイバティス)で、2010年にGoogle Codeへ移ってMyBatisと名前を変えました。
MyBatisでORDER BYの列を動的に切り替えるには?
列名はプレースホルダにできないため、<choose>で固定の列名を選び分けるか、ORDER BY ${sortColumn}のように${}で展開します。${}を使う場合は、画面から受け取った値をそのまま渡さず、許可リストに含まれる列名かどうかをJava側で照合してから渡します。
if testで文字列が等しいかを判定するには?
test='status == "ACTIVE"'のように、属性をシングルクォート、比較する文字列をダブルクォートで書きます。test="status == 'A'"のような1文字の比較は文字として解釈され、例外になります。空文字も除きたいときはuserName != null and userName != ''と書きます。
MyBatisとJPA(Hibernate)はどちらを選ぶべきですか?
SQLを自分で書いて細かく制御したい、DB固有の機能や複雑な集計が多いならMyBatisが向きます。エンティティ中心の定型的なCRUDが多く、SQLを書く量を減らしたいならJPAです。製品ごとの比較はJava ORM(O/Rマッパー)の種類と選び方|Hibernate・MyBatis・jOOQを比較【2026年版】で整理しています。
新規開発ではXML動的SQLとmybatis-dynamic-sqlのどちらがよいですか?
Java 17以上で、検索条件の組み合わせが多い画面を型で守りたいならmybatis-dynamic-sql 2.0.0です。SQLをDBAとレビューする運用や、既存のXMLマッパーが多いプロジェクトならXMLで統一します。どちらを選んでも、条件が全部消えたときに全件が対象にならないかは、ログで生成SQLを見て確かめます。