検索フォームの条件を <if> で切り替えていたら、全部空のときに WHERE だけ残って構文エラーになった。部分更新で SET の末尾にカンマが残った。IDのリストを <foreach> に渡したら、空リストで構文エラーになった。MyBatisで動的SQLを書き始めると、この3つにはほぼ確実に当たりますよね。
これらは <where>、<set>、<foreach> のガードで解決できます。この記事ではタグごとに「書いたXML」と「ログに出た実際のSQL」を並べて、何が自動で取り除かれ、何が付け足されるのかを確認していきます。
なお、扱うのはXML/アノテーションの動的SQLタグで、mybatis-dynamic-sqlライブラリやMyBatis-Plusは対象外です。依存追加やMapperの基本は MyBatisのMapper実装ガイド に譲ります。
前提環境と発行SQLをログで確認する設定
検証環境はSpring Boot 3.x、mybatis-spring-boot-starter 3.x、DBはH2です。動的SQLは「実際に何が発行されたか」を見ながら書くのが一番の近道なので、まずログ設定を入れましょう。
logging:
level:
com.example.shop.mapper: debug # Mapperインターフェースのパッケージ
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # SLF4Jを通さず標準出力に出したい場合
Mapperのパッケージを debug にすると、Preparing: にSQL、Parameters: にバインド値が出力されます。本番でこのDEBUGを残すとログ量が跳ね上がるので、プロファイル別に切り替えてください。
サンプルのテーブルは products の1つだけです。列は id、name、category、price、status、updated_at で、以降の例はすべてこのテーブルを使います。検索条件は ProductSearchCondition というプレーンなクラスにまとめ、フィールドは name、category、minPrice、maxPrice、ids、sortKey の6つです。部分更新用の ProductUpdateRequest は id と更新したいフィールドだけを持たせます。
if testの判定基準を整理する
土台になる <if test> から押さえます。test はOGNL式で評価され、nullと空文字は別物です。「入力があるときだけ条件を付ける」なら name != null and name != '' のように両方を見てください。リストなら ids != null and !ids.isEmpty() とメソッド呼び出しで空判定できます。
ハマりやすいのは次の3つです。
name != ''だけ書くとnullでも真になり、name = nullという条件が付いてしまう- 数値型に
price != ''を付けると、0が空文字扱いされて条件が消えることがある。数値はnullチェックだけにする - XML内で
<や&&はそのまま書けない。andorを使うか、<かCDATAで囲む
There is no getter for property 'name' が出たら、XML側の名前と引数が一致していません。引数が複数あるMapperメソッドは @Param("cond") を付けて cond.name のように参照しましょう。
where - 先頭のANDを除去し、条件ゼロならWHEREごと省く
まずは壊れる書き方から。WHERE を直書きして <if> を並べると、全条件が空のときに WHERE だけ残ります。
<!-- ビフォー: 全条件が空だと "... FROM products WHERE" で構文エラー -->
SELECT * FROM products
WHERE
<if test="name != null and name != ''">name = #{name}</if>
<if test="category != null and category != ''">AND category = #{category}</if>
WHERE 1=1 を置く回避策もよく見ますが、MyBatisには <where> があるのでそちらを使いましょう。
<select id="search" resultType="com.example.shop.entity.Product">
SELECT * FROM products
<where>
<if test="name != null and name != ''">
AND name = #{name}
</if>
<if test="category != null and category != ''">
AND category = #{category}
</if>
<if test="minPrice != null">
AND price >= #{minPrice}
</if>
<if test="maxPrice != null">
AND price <= #{maxPrice}
</if>
</where>
</select>
<where> は中身の先頭にある AND や OR を取り除き、中身が空ならWHERE句そのものを出力しません。だから最初の <if> にも遠慮なく AND を書けます。ここでは resultType にエンティティを直接指定していますが、JOINした結果をネストして受け取りたいときは resultMapの記事 を参照してください。
呼び出し側は cond.setCategory("coffee") と cond.setMinPrice(500) だけ設定して productMapper.search(cond) を呼ぶだけです。ログを見ると、条件ありと全条件なしでこう変わります。
==> Preparing: SELECT * FROM products WHERE category = ? AND price >= ?
==> Parameters: coffee(String), 500(Integer)
==> Preparing: SELECT * FROM products
==> Parameters:
1つ注意があるとすれば、OR を含む条件は括弧で囲むことです。AND (status = 'a' OR status = 'b') のように書かないと、他の AND と混ざって優先順位が崩れます。
set - 部分更新で末尾カンマを除去する
nullでないフィールドだけ更新するUPDATEは、<if> だけで組むと最後の項目の後ろにカンマが残ります。<set> は末尾のカンマを取り除いてくれるので、各項目の末尾にカンマを付けたまま並べて構いません(実装上は先頭のカンマも除去されます)。<where> との違いは、除去する対象が AND/OR か , か、そして前に付けるのが WHERE か SET か、というだけです。
<update id="updatePartial">
UPDATE products
<set>
<if test="name != null">name = #{name},</if>
<if test="category != null">category = #{category},</if>
<if test="price != null">price = #{price},</if>
updated_at = CURRENT_TIMESTAMP
</set>
WHERE id = #{id}
</update>
最後に updated_at = CURRENT_TIMESTAMP を無条件で置いているのがポイントです。これがないと、全フィールドがnullのときに UPDATE products SET WHERE id = ? となってSETが空のまま発行され、構文エラーになります。更新日時を常に更新するのは自然なので、この形にしておくと安全です。更新対象がゼロなら呼ばない、とサービス層で弾く方法もあります。
price だけ入れて呼ぶと、ログには Preparing: UPDATE products SET price = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? と出ます。price の後ろのカンマがちゃんと残り、末尾には付いていないことが分かりますね。
trim - prefixとoverridesで自由に整形する
実は <where> と <set> の正体は <trim> です。属性は4つで、意味を整理するとこうなります。
| 属性 | 意味 |
|---|---|
| prefix | 中身が空でなければ前に付ける文字列 |
| suffix | 中身が空でなければ後ろに付ける文字列 |
| prefixOverrides | 中身の先頭から取り除く文字列(| 区切りで複数) |
| suffixOverrides | 中身の末尾から取り除く文字列 |
<where> は <trim prefix="WHERE" prefixOverrides="AND |OR "> と、<set> は <trim prefix="SET" prefixOverrides="," suffixOverrides=","> と等価です。AND |OR の末尾スペースは意図的で、ANDROID のような列名の先頭を誤って削らないよう、スペース込みで指定します。
<trim> が本領を発揮するのは、<where> にも <set> にも当てはまらない場面です。たとえばINSERTの列リストとVALUESを動的に組む場合は、括弧をprefix/suffixにして末尾カンマを落とします。
<insert id="insertSelective">
INSERT INTO products
<trim prefix="(" suffix=")" suffixOverrides=",">
name, category,
<if test="price != null">price,</if>
status, updated_at,
</trim>
<trim prefix="VALUES (" suffix=")" suffixOverrides=",">
#{name}, #{category},
<if test="price != null">#{price},</if>
#{status}, CURRENT_TIMESTAMP,
</trim>
</insert>
price を入れずに呼ぶと Preparing: INSERT INTO products ( name, category, status, updated_at ) VALUES ( ?, ?, ?, CURRENT_TIMESTAMP ) のように、列とVALUESの両方から price が抜けた形で出ます。中身が空ならprefixもsuffixも出力されないのは <where> と同じです。
choose / when / otherwise - 排他的な条件分岐
<if> を並べると、複数が同時に真なら全部出力されます。「どれか1つだけ」にしたいときは <choose> です。最初に真になった <when> だけが出力され、どれも真でなければ <otherwise> が使われます。switch文と同じ感覚ですね。
典型的な使いどころはソート順の切り替えです。ORDER BYのカラム名は #{} でバインドできず、${} を使うとSQLインジェクションの入口になります。<choose> でホワイトリスト化すれば、${} を使わずに済みます。先ほどの search にORDER BYを足した別の文として書いておきます。
<select id="searchSorted" resultType="com.example.shop.entity.Product">
SELECT * FROM products
<where>
<if test="category != null and category != ''">AND category = #{category}</if>
</where>
<choose>
<when test="sortKey == 'price'">ORDER BY price</when>
<when test="sortKey == 'name'">ORDER BY name</when>
<otherwise>ORDER BY id</otherwise>
</choose>
</select>
sortKey に "price; DROP TABLE" のような値が来ても、どの <when> にも一致せず ORDER BY id に落ちるだけです。sortKey を price にして呼ぶと、ログには Preparing: SELECT * FROM products WHERE category = ? ORDER BY price と、<when> が1つだけ反映されたSQLが出ます。
1つ罠があって、OGNLでは 'A' のような1文字のシングルクォートは文字列ではなくchar扱いになります。status == 'A' はStringと一致しないので、1文字を比べるなら status == "A" か 'A'.toString() にしてください。
「IDが指定されたらIDだけで検索し、なければ名前とカテゴリで検索」といった優先順位付きの条件も、同じ形で書けます。ソートした一覧をページ分割するなら ページネーションの記事 に続きます。
bind - LIKE検索のワイルドカードを安全に連結する
部分一致検索で LIKE '%' || #{name} || '%' と書くと、H2やPostgreSQLでは動きますがMySQLでは既定で || が論理ORとして扱われます(sql_mode に PIPES_AS_CONCAT があれば連結になります)。CONCAT('%', #{name}, '%') はその逆で、DBによって引数の数や挙動が違います。
DB方言に依存させたくないなら <bind> です。OGNLで連結した値を新しい変数に入れて、それを #{} でバインドします。Mapper側は List<Product> searchByName(@Param("name") String name); です。
<select id="searchByName" resultType="com.example.shop.entity.Product">
SELECT * FROM products
<where>
<if test="name != null and name != ''">
<bind name="namePattern" value="'%' + name + '%'" />
name LIKE #{namePattern}
</if>
</where>
</select>
<!-- NG: name LIKE '%${name}%' は文字列連結なのでインジェクションが成立する -->
<bind> を <if> の中に置いているのには理由があります。ガードなしで先頭に置くと、name がnullのときにOGNLは '%' + null + '%' を %null% という文字列にしてしまい、エラーにならないまま結果が黙ってずれます。
${} 版に %' OR '1' = '1 のような値を渡すと、そのままSQLに埋め込まれて全件が返ります。<bind> なら値はプレースホルダに渡るだけなので安全です。
==> Preparing: SELECT * FROM products WHERE name LIKE ?
==> Parameters: %ブレンド%(String)
ただし、ユーザー入力に % や _ が含まれる場合の意味は変わりません。それらをリテラルとして扱いたいなら、Java側でエスケープしてから渡してください。
foreach - IN句とバッチINSERT、空リスト対策
<foreach> の属性は6つです。collection は繰り返す対象、item は各要素の変数名、index は添字、open/close は前後に付ける文字、separator は要素間の区切りです。Mapを渡すこともできて、その場合は index にキー、item に値が入ります。@Param("prices") Map<Long, Integer> prices を渡して <foreach collection="prices" index="id" item="price" separator=",">(#{id}, #{price})</foreach> と書く形です。
collection に指定する名前は間違えやすいので注意してください。@Param("ids") を付けたならその名前、付けずにListだけを渡したなら list か collection、配列なら array です。Spring BootのMaven/Gradleプラグインは -parameters 付きでコンパイルするので、実は ids という実引数名でも通りますが、コンパイル設定に依存するので @Param で明示しておく方が事故がありません。名前を間違えると Parameter 'ids' not found で落ちます。
そして本題の空リストです。空リストを渡すと <foreach> は open/close ごと何も出力しません。つまり WHERE id IN で途切れたSQLがそのままDBに届き、構文エラーになります(IN () になるのは括弧を <foreach> の外に直書きしたときで、落ちるのは同じです)。nullを渡した場合はもっと手前で、MyBatisがSQLを組み立てる段階で BuilderException を投げます。MyBatis 3.5.9以降なら nullable="true" 属性か mybatis.configuration.nullable-on-for-each: true でnullを空扱いにできますが、空リストの問題は残るので結局ガードは必要です。対策は2通りあります。
<!-- 対策1: IN句ごと省く(条件なし = 全件になる点に注意) -->
<select id="findByIds" resultType="com.example.shop.entity.Product">
SELECT * FROM products
<where>
<if test="ids != null and !ids.isEmpty()">
id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
</where>
</select>
<!-- 対策2: 空なら意図的に0件を返す -->
<where>
<choose>
<when test="ids != null and !ids.isEmpty()">
id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</when>
<otherwise>1 = 0</otherwise>
</choose>
</where>
Mapper側は List<Product> findByIds(@Param("ids") List<Long> ids); です。対策前と対策後で、ログと例外はこう変わります。
# 対策なしで空リスト: open/close ごと消えて途切れる
==> Preparing: SELECT * FROM products WHERE id IN
org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement "SELECT * FROM products WHERE id IN[*]"; expected "("
# 対策なしでnull: SQLを発行する前に落ちる
org.apache.ibatis.builder.BuilderException: The expression 'ids' evaluated to a null value.
# 対策1/2で要素あり
==> Preparing: SELECT * FROM products WHERE id IN ( ? , ? , ? )
==> Parameters: 1(Long), 2(Long), 3(Long)
# 対策2で空リスト
==> Preparing: SELECT * FROM products WHERE 1 = 0
「IDの絞り込みが空なら全件」で良いのか「空なら0件」であるべきかは業務次第です。一括削除のような処理で対策1を使うと全件が対象になるので、迷ったら対策2にしておく方が事故が少ないです。
バッチINSERTも <foreach> で VALUES を複数行生成できます。
<insert id="insertAll">
INSERT INTO products (name, category, price, status, updated_at)
VALUES
<foreach collection="list" item="p" separator=",">
(#{p.name}, #{p.category}, #{p.price}, #{p.status}, CURRENT_TIMESTAMP)
</foreach>
</insert>
件数が数千を超えるとSQLが巨大になり、プレースホルダ数の上限に当たるDBもあります。Java側で数百件ずつに分割して呼び出しましょう。
アノテーション方式で書く
XMLを使わないプロジェクトなら選択肢は2つです。1つは @Select の中を <script> で囲む方法で、XMLと同じタグがそのまま使えます。ただ文字列の中にXMLを書くので、> のエスケープも必要ですし、条件が増えると可読性は落ちます。
もう1つは @SelectProvider で、org.apache.ibatis.jdbc.SQL ビルダーを使ってJavaで組み立てる方法です。
public class ProductSqlProvider {
public String search(ProductSearchCondition cond) {
return new SQL() {{
SELECT("*");
FROM("products");
if (cond.getName() != null && !cond.getName().isEmpty()) {
WHERE("name = #{name}");
}
if (cond.getMinPrice() != null) {
WHERE("price >= #{minPrice}");
}
}}.toString();
}
}
public interface ProductMapper {
@SelectProvider(type = ProductSqlProvider.class, method = "search")
List<Product> search(ProductSearchCondition cond);
// <script> 版: XMLと同じタグが使える
@Select("<script>"
+ "SELECT * FROM products"
+ "<where>"
+ " <if test='name != null'>AND name = #{name}</if>"
+ " <if test='minPrice != null'>AND price >= #{minPrice}</if>"
+ "</where>"
+ "</script>")
List<Product> searchScript(ProductSearchCondition cond);
}
WHERE() を一度も呼ばなければWHERE句自体が出ず、複数回呼べば AND で連結されるので、<where> と同じ効果が得られます。値は #{name} のプレースホルダをそのまま文字列に書き、バインドはMyBatisに任せてください。ここで "name = '" + cond.getName() + "'" と連結してしまうと、せっかくのビルダーがインジェクションの温床になります。
エラーと原因の早見表
| 症状 | 原因 | 対処 |
|---|---|---|
There is no getter for property | パラメータ名の不一致、@Param 未指定 | if testの節 |
syntax error で WHERE や AND 付近 | WHERE 直書きで全条件が空 | <where> |
UPDATEで末尾カンマ、または SET WHERE | <set> 未使用、全項目null | <set> と無条件更新列 |
WHERE ... IN で途切れて構文エラー | foreachに空リスト | <if> ガードか 1 = 0 |
BuilderException: evaluated to a null value | foreachにnull | <if> ガード、または nullable="true" / nullableOnForEach |
Parameter 'xxx' not found | collection名の誤り | @Param か list/collection/array(-parameters なら実引数名も可) |
| MySQLでLIKEが効かない | || が既定で論理OR | <bind> |
条件が効かない、0 が無視される | != '' のみでnull漏れ、数値に空文字判定 | if testの節 |
まとめ
各タグの役割を1行ずつで振り返ります。<where> は先頭のANDを除去、<set> は末尾カンマを除去、<trim> はその汎用版、<choose> は排他分岐、<bind> は値の安全な加工、<foreach> は繰り返しで空ガードが必須、です。
どのタグも「書いたXMLがどんなSQLになるか」をDEBUGログで見ながら書くのが、結局いちばん速いデバッグ手段です。
JPA側で同じことをやるなら Specificationの記事、どちらを選ぶか迷っているなら MyBatisとJPAの比較 も参考にしてください。