検索フォームの条件を <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つだけです。列は idnamecategorypricestatusupdated_at で、以降の例はすべてこのテーブルを使います。検索条件は ProductSearchCondition というプレーンなクラスにまとめ、フィールドは namecategoryminPricemaxPriceidssortKey の6つです。部分更新用の ProductUpdateRequestid と更新したいフィールドだけを持たせます。

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内で <&& はそのまま書けない。and or を使うか、&lt; か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 &gt;= #{minPrice}
    </if>
    <if test="maxPrice != null">
      AND price &lt;= #{maxPrice}
    </if>
  </where>
</select>

<where> は中身の先頭にある ANDOR を取り除き、中身が空なら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, か、そして前に付けるのが WHERESET か、というだけです。

<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 に落ちるだけです。sortKeyprice にして呼ぶと、ログには Preparing: SELECT * FROM products WHERE category = ? ORDER BY price と、<when> が1つだけ反映されたSQLが出ます。

1つ罠があって、OGNLでは 'A' のような1文字のシングルクォートは文字列ではなくchar扱いになります。status == 'A' はStringと一致しないので、1文字を比べるなら status == &quot;A&quot;'A'.toString() にしてください。

「IDが指定されたらIDだけで検索し、なければ名前とカテゴリで検索」といった優先順位付きの条件も、同じ形で書けます。ソートした一覧をページ分割するなら ページネーションの記事 に続きます。

bind - LIKE検索のワイルドカードを安全に連結する

部分一致検索で LIKE '%' || #{name} || '%' と書くと、H2やPostgreSQLでは動きますがMySQLでは既定で || が論理ORとして扱われます(sql_modePIPES_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だけを渡したなら listcollection、配列なら 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を書くので、&gt; のエスケープも必要ですし、条件が増えると可読性は落ちます。

もう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 &gt;= #{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 errorWHEREAND 付近WHERE 直書きで全条件が空<where>
UPDATEで末尾カンマ、または SET WHERE<set> 未使用、全項目null<set> と無条件更新列
WHERE ... IN で途切れて構文エラーforeachに空リスト<if> ガードか 1 = 0
BuilderException: evaluated to a null valueforeachにnull<if> ガード、または nullable="true" / nullableOnForEach
Parameter 'xxx' not foundcollection名の誤り@Paramlist/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の比較 も参考にしてください。