MyBatis Dynamic SQL(動的SQL)の書き方|if・foreach・whereとインジェクション対策
MyBatisで「検索条件が入力に応じて増減する」「IN句の要素数が実行時に変わる」といったクエリを書くとき、SQLを文字列連結で組み立てると保守が破綻し、SQLインジェクションの温床にもなります。これを解決するのがMyBatisの動的SQL(Dynamic SQL)です。本記事では、XMLマッパーの<if>・<choose>・<where>・<set>・<trim>・<foreach>・<bind>の使い方を実コードで示し、#{}と${}の違い(インジェクション対策)、そして型安全に書けるmybatis-dynamic-sqlライブラリ(v2.0.0)との使い分けまでを整理します。
まとめ:動的SQLの結論
- 「MyBatis Dynamic SQL」には2つの意味がある。ひとつはMyBatis標準機能のXML/アノテーション動的SQL要素(
<if>など)、もうひとつは型安全なJava/Kotlin DSLライブラリ「mybatis-dynamic-sql」。日本語で検索される「動的SQL」の多くは前者を指す。 - 条件が可変なWHERE句は
<where>+<if>が基本。<where>が先頭のAND/ORを自動で除去し、UPDATEの末尾カンマは<set>が処理する。IN句や一括INSERTは<foreach>で組む。 - 値の埋め込みは必ず
#{}を使う。#{}はPreparedStatementのプレースホルダ(?)にバインドされ、SQLインジェクションを防ぐ。${}は文字列をそのまま連結するため、原則ソート列やテーブル名などプレースホルダ化できない箇所に限定し、値はホワイトリスト検証する。 - コンパイル時に型で守りたい・SQLを文字列で書きたくないならmybatis-dynamic-sqlライブラリ(v2.0.0はJava 17以上)を使う。既存のXMLマッパー資産が多い現場ではXML動的SQLのままで十分。
MyBatis動的SQLの2つの意味(標準機能とDSLライブラリ)
MyBatisは、JavaオブジェクトとSQLを対応づけるSQLマッパー(ORM(Object-Relational Mapping)とは|仕組み・メリット・デメリットで解説する広義のORMの一種)です。SQLを開発者が明示的に書くのが特徴で、そのSQLを実行時に組み立てる仕組みが動的SQLです。検索で「mybatis dynamic sql」と入力したとき、たどり着く先は次の2つに分かれます。
| 呼び方 | 実体 | 書き方 | 向くケース |
|---|---|---|---|
| 動的SQL要素(標準機能) | MyBatis本体のXML/アノテーション機能 | <if>等のタグをSQL内に記述 |
既存XMLマッパー資産、SQLを直接書きたい |
| mybatis-dynamic-sqlライブラリ | 別配布の型安全DSL(org.mybatis.dynamic-sql) | Java/KotlinのメソッドチェーンでSQLを組立 | 文字列SQLを避けたい、コンパイル時に型検査したい |
本記事は前半で標準機能のXML動的SQLを、後半でライブラリを扱います。まずは利用頻度の高いXML動的SQLからです。
XMLで書く動的SQLの基本要素(if・choose・where・set・trim)
XMLマッパーでは、SQL文の中に制御用のタグを埋め込みます。OGNL式で条件を評価し、真の断片だけがSQLに連結されます。ここではusersテーブル(id/user_name/email/status)を例に、頻出の5要素を順に見ます。
if:成立時のみ条件を追加
もっとも基本の要素です。入力があるフィールドだけを検索条件に加えたい、という場面で使います。単独で使うと先頭のANDが余るため、通常は次の<where>と組み合わせます。
<select id="searchUsers" resultType="User">
SELECT id, user_name, email, status FROM users
WHERE 1 = 1
<if test="userName != null and userName != ''">
AND user_name = #{userName}
</if>
<if test="status != null">
AND status = #{status}
</if>
</select>
test属性はOGNL式で、userName != nullのようにJavaの条件式が書けます。上のWHERE 1 = 1は「先頭のANDを余らせない」ための逃げですが、これを不要にするのが次の<where>です。
where:先頭のAND/ORを自動除去
<where>は、中の条件が1つでも出力されたときだけWHEREを付け、断片の先頭にあるAND/ORを自動で削ります。前述のWHERE 1 = 1という小細工が要らなくなります。
<select id="searchUsers" resultType="User">
SELECT id, user_name, email, status FROM users
<where>
<if test="userName != null and userName != ''">
AND user_name = #{userName}
</if>
<if test="status != null">
AND status = #{status}
</if>
</where>
</select>
条件がすべて偽ならWHERE句そのものが出力されず、全件取得のSQLになります。
choose/when/otherwise:複数条件から1つだけ選ぶ
Javaのswitchに相当します。上から<when>を評価し、最初に真になった1つだけを採用、どれも偽なら<otherwise>を出力します。「メール優先、無ければ名前で検索、それも無ければ有効ユーザーのみ」のような排他的な分岐に向きます。
<select id="findUser" resultType="User">
SELECT id, user_name, email, status FROM users
<where>
<choose>
<when test="email != null">
AND email = #{email}
</when>
<when test="userName != null">
AND user_name LIKE #{userName}
</when>
<otherwise>
AND status = 'ACTIVE'
</otherwise>
</choose>
</where>
</select>
set:UPDATE文の末尾カンマ処理
UPDATE文で更新カラムを可変にすると、末尾のカンマが余ってSQLエラーになります。<set>はSET句を付けつつ、最後の余分なカンマを自動で除去します。
<update id="updateUser">
UPDATE users
<set>
<if test="userName != null">user_name = #{userName},</if>
<if test="email != null">email = #{email},</if>
<if test="status != null">status = #{status},</if>
</set>
WHERE id = #{id}
</update>
更新対象が1つも無いとSETだけの不正なSQLになるため、呼び出し側で「更新フィールドが最低1つある」ことを保証しておきます。
trim:whereやsetの自作
<where>と<set>は、汎用の<trim>で置き換えられます。prefix/suffixで前後に付ける文字列、prefixOverrides/suffixOverridesでパイプ区切りの除去対象を指定します。次は<where>と等価な例です。
<trim prefix="WHERE" prefixOverrides="AND |OR ">
<if test="state != null">state = #{state}</if>
<if test="title != null">AND title LIKE #{title}</if>
</trim>
prefixOverrides="AND |OR "の末尾スペースは意図的です(ANDという単語だけを消し、カラム名の一部を削らないため)。標準の<where>/<set>で足りるなら、まずはそちらを使う方が読みやすくなります。
foreachによるIN句・一括INSERTの組み立て
要素数が実行時に決まるIN句や、リストの一括登録は<foreach>で組みます。属性はcollection(反復対象)、item(各要素の変数)、index(添字)、open/close(前後の文字列)、separator(区切り)です。
IN句:可変長リストの条件指定
<select id="findByIds" resultType="User">
SELECT id, user_name, email FROM users
WHERE id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</select>
#{id}は要素ごとにプレースホルダ化されるため、リストの中身がそのままSQLに露出することはありません。ただしリストが空だとIN ()という不正なSQLになるので、空リストを渡さないガードを呼び出し側に入れます。引数が単一のコレクションならcollection="list"、配列ならcollection="array"、@Param("ids")で名前を付けたならcollection="ids"を指定します。
一括INSERT:VALUESを複数行にまとめる
<insert id="insertUsers">
INSERT INTO users (user_name, email) VALUES
<foreach item="u" collection="users" separator=",">
(#{u.userName}, #{u.email})
</foreach>
</insert>
1回のSQLで複数行を挿入でき、行数分のラウンドトリップを避けられます。ただし1文が長くなりすぎるとDB側のプレースホルダ上限やパケットサイズに当たるため、数百件単位で分割するのが実務的です。
bind:LIKE用のパターン文字列を安全に組む
部分一致検索で'%' + 値 + '%'を作るとき、${}で連結するとインジェクションの危険があります。<bind>でOGNL式の結果を変数に入れ、#{}でバインドすれば安全です。
<select id="searchByName" resultType="User">
<bind name="pattern" value="'%' + userName + '%'" />
SELECT id, user_name FROM users
WHERE user_name LIKE #{pattern}
</select>
動的SQLのSQLインジェクション対策(#{} と ${} の違い)
動的SQLで最も事故が多いのが、値の埋め込み方法の取り違えです。結論から言うと、値のバインドには#{}を使い、${}は原則使わない。この一線を守るだけで、動的SQL起因のインジェクションはほぼ防げます。
| 記法 | 展開のされ方 | インジェクション | 用途 |
|---|---|---|---|
#{値} |
PreparedStatementの?にバインド |
安全 | すべての「値」(検索語・ID・日付など) |
${値} |
文字列としてSQLに直接連結 | 危険 | プレースホルダ化できない箇所のみ(列名・テーブル名・ソート方向) |
たとえば次の2行は結果こそ同じでも、安全性がまったく違います。
<!-- 安全:値はプレースホルダにバインドされる -->
SELECT * FROM users WHERE user_name = #{userName}
<!-- 危険:入力がSQLにそのまま連結される(' OR '1'='1 などが通る) -->
SELECT * FROM users WHERE user_name = '${userName}'
${}がどうしても必要になるのは、値ではなくSQLの構造を動的にしたい場面――ORDER BYのソート列や、シャーディングでテーブル名を切り替えるケースです。プレースホルダは列名やテーブル名には使えないため、ここだけは${}に頼らざるを得ません。その場合は、入力を直接埋め込まず必ずアプリ側で許可リスト(ホワイトリスト)に照合してから渡します。
// ソート列は許可リストで検証してから ${} に渡す
private static final Set<String> SORTABLE =
Set.of("user_name", "email", "created_at");
public List<User> search(String sortColumn) {
if (!SORTABLE.contains(sortColumn)) {
throw new IllegalArgumentException("invalid sort column");
}
return userMapper.searchOrderBy(sortColumn);
}
<select id="searchOrderBy" resultType="User">
SELECT id, user_name, email FROM users
ORDER BY ${sortColumn}
</select>
「値には#{}、構造の切り替えだけ${}+ホワイトリスト」を徹底すれば、動的SQLでも静的SQLと同等の安全性を保てます。
アノテーションで動的SQLを書く(scriptタグ)
XMLマッパーを使わずアノテーションでSQLを書く構成でも、動的SQLは利用できます。SQLを<script>で囲むと、内部でXMLと同じ<if>や<where>が使えます。
@Select({"<script>",
"SELECT id, user_name, email 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);
ロジックが複雑になるとアノテーション内のSQLは読みにくくなります。分岐が多い動的SQLはXMLマッパーに寄せるか、次のライブラリでJavaコードとして組み立てる方が保守しやすくなります。
型安全に書くmybatis-dynamic-sqlライブラリ(DSL)
SQLを文字列で書かず、Javaのメソッドチェーンで組み立てたい場合は、別配布のmybatis-dynamic-sqlライブラリを使います。カラムやテーブルを型付きオブジェクトとして扱うため、存在しないカラム名や型の取り違えをコンパイル時に検出できます。
導入(v2.0.0はJava 17以上)
Mavenの依存関係に追加します。最新のv2.0.0(2026年3月公開)はJava 17以上が必須で、null許容性を示すJSpecifyが実行時依存に加わりました。Java 8/11環境では旧1.x系を使います(バージョンは公式リポジトリで確認してください)。
<dependency>
<groupId>org.mybatis.dynamic-sql</groupId>
<artifactId>mybatis-dynamic-sql</artifactId>
<version>2.0.0</version>
</dependency>
可変条件クエリの型安全な組み立て
テーブル・カラムを表すサポートクラス(下のUserDynamicSqlSupport)を用意し、静的インポートしたビルダーでSQLを組みます。サポートクラスはMyBatis Generatorの使い方(2.0.0の設定・Maven実行)で紹介するコード生成ツールで自動生成できます。
import static org.mybatis.dynamic.sql.SqlBuilder.*;
import org.mybatis.dynamic.sql.render.RenderingStrategies;
import org.mybatis.dynamic.sql.select.render.SelectStatementProvider;
import static com.example.mapper.UserDynamicSqlSupport.*;
SelectStatementProvider stmt = select(id, userName, email, status)
.from(user)
.where(userName, isEqualToWhenPresent(searchName))
.and(status, isEqualToWhenPresent(searchStatus))
.build()
.render(RenderingStrategies.MYBATIS3);
List<User> users = userMapper.selectMany(stmt);
isEqualToWhenPresentは引数がnullのとき条件自体を出力しません。XMLの<if test="… != null">に相当する動きを、型チェック付きのJavaコードで表現できます。
XML動的SQLとライブラリの使い分け
| 観点 | XML動的SQL(標準) | mybatis-dynamic-sqlライブラリ |
|---|---|---|
| SQLの見え方 | ほぼ生SQL、可読性が高い | Javaメソッドチェーン |
| 型安全 | 実行時までカラム名ミスが分からない | コンパイル時に検出 |
| Java要件 | 制約なし | v2.0.0はJava 17以上 |
| 既存資産 | XMLマッパーをそのまま利用 | サポートクラスの生成・移行が必要 |
既存のXMLマッパーが多い現場は無理に移行せず、新規開発で型安全を重視する部分から段階的に導入するとよいでしょう。
MyBatis動的SQLでつまずきやすい点と対処
動的SQLは便利な一方、生成後のSQLが見えづらく、想定外の挙動でハマりがちです。実際に生成されたSQLを確認する習慣が最短の対処になります。
- 生成SQLをログで確認する。ログレベルをマッパーインターフェースのパッケージに対してDEBUGにすると、実行される完成後のSQLとバインド値が出力される。動的SQLの不具合はまずこのログで、期待した断片が出ているかを見る。
<where>の中が全部偽になり全件取得になる。条件がすべてnull判定で落ちるとWHEREが消え、意図せず全件返る。全件取得を許さない画面では、呼び出し側で「条件が最低1つある」ことを検証する。- IN句に空リストを渡して
IN ()になる。構文エラーになるため、<foreach>の前段でリストの空チェックを入れる。 ${}を値に誤用してインジェクションを埋め込む。コードレビューで${}の出現箇所を機械的に洗い出し、列名・テーブル名以外の${}は#{}へ直す。test属性の文字列比較。OGNLでは<if test="status == 'ACTIVE'">のように書けるが、1文字だと文字リテラルと解釈され誤動作することがある。文字列は"ACTIVE".equals(status)のように書くと安全。
よくある質問
MyBatisの読み方は?「動的SQL」とは何を指しますか?
MyBatisは「マイバティス」と読みます。動的SQLは、実行時の条件に応じてSQL文を組み立てる仕組みで、MyBatisでは<if>などのXML要素、またはmybatis-dynamic-sqlライブラリのDSLで実現します。日本語での「mybatis dynamic sql」検索は、多くが前者のXML動的SQL要素を指します。
#{} と ${} はどちらを使えばよいですか?
値を渡すときは必ず#{}です。PreparedStatementのプレースホルダにバインドされ、SQLインジェクションを防ぎます。${}は文字列を直接連結するため、ORDER BYの列名やテーブル名などプレースホルダ化できない箇所に限り、値をホワイトリスト検証したうえで使います。
MyBatisとJPA/Hibernateはどちらを選ぶべきですか?
SQLを自分で細かく制御したい、複雑なクエリやDB固有の最適化が多いならMyBatisが向きます。エンティティ中心で定型的なCRUDが多く、SQLを書く手間を減らしたいならJPA/Hibernateが有利です。選定基準の詳細はJava ORM(O/Rマッパー)の種類と選び方(主要8製品を比較)で整理しています。
foreachのcollectionには何を指定しますか?
引数が単一のListならcollection="list"、配列ならcollection="array"です。@Param("ids")で名前を付けた場合はその名前(collection="ids")を指定します。Mapを渡した場合はキー名を指定します。
XML動的SQLとmybatis-dynamic-sqlライブラリ、新規開発ではどちらがよいですか?
既存のXMLマッパー資産が多いプロジェクトはXML動的SQLで統一する方が保守しやすくなります。カラム名ミスをコンパイル時に潰したい・SQLを文字列で書きたくないという要件が強いなら、Java 17以上を前提にmybatis-dynamic-sqlライブラリ(v2.0.0)を採用します。両者は混在も可能なので、新規の型安全にしたい箇所から段階導入するのが現実的です。