MyBatisのif・choose・foreachの使い方|動的SQL入門

MyBatisの動的SQLでは、検索条件の有無に応じてSQLを追加するなら if、複数の条件から1つを選ぶなら choose、リストをIN句へ展開するなら foreach を使います。

if は条件の追加、choose は条件の選択、foreach はコレクションの展開に使う、と整理すると使い分けやすくなります。

この記事では、MyBatisのMapper XMLを使い、if / choose / foreachの基本的な書き方をコード例とともに解説します。あわせて、WHERE句を整える where、更新SQLに使う set、細かな調整ができる trim、#{} と ${} を扱う際の注意点も確認します。

目次

if・choose・foreachの使い分け

  • if:成立した条件をSQLへ追加する
  • choose / when / otherwise:複数の候補から最初に成立した1つを選ぶ
  • foreach:Listなどの要素をIN句へ展開する

たとえば、名前とステータスの両方を検索条件に加えられるようにする場合は if、メールアドレスと名前のどちらか一方を優先して使う場合は choose、複数のIDで検索する場合は foreach が適しています。

MyBatisの動的SQLを使う理由

条件によってSQLが変わる場面

検索画面では、すべての条件が常に入力されるとは限りません。ユーザー名、メールアドレス、ステータスを任意に指定できる場合は、入力された条件だけをWHERE句へ追加します。

動的SQLが使われる代表的な場面は、次のとおりです。

  • 入力された検索条件だけで絞り込む
  • 複数の候補から優先順位に応じて1つの条件を選ぶ
  • 複数のIDをIN句へ渡す
  • 変更された項目だけをUPDATEする
  • 検索対象や並び順を許可された範囲で切り替える

文字列連結でSQLを生成する問題点

動的な検索条件をJavaの文字列連結で作ると、次のようなコードになりがちです。

String sql = "SELECT id, name, email, status FROM users WHERE 1 = 1";

if (condition.getName() != null) {
    sql += " AND name LIKE '%" + condition.getName() + "%'";
}

if (condition.getStatus() != null) {
    sql += " AND status = '" + condition.getStatus() + "'";
}

この方法では、SQLの構造と条件分岐がJavaコード内に混在します。条件が増えるほど、空白やANDの付け忘れ、値のエスケープ漏れなども確認しなければなりません。

入力値を文字列連結でSQLへ直接入れる実装は、SQLインジェクションの原因にもなります。MyBatisでは、SQLの構造をMapper XMLで組み立て、検索値や更新値を通常 #{} で渡すことで、構造と値を分けて扱えます。

サンプルで使うMapper構成

以降の例では、ユーザー名、メールアドレス、ステータス、IDリストを持つ検索条件クラスを使用します。Mapper XMLのnamespaceやidの基本から確認したい場合は、次の記事も参考にしてください。

検索条件を表すJavaクラス

package com.example.dto;

import java.util.List;

public class UserSearchCondition {

    private String name;
    private String email;
    private String status;
    private List<Long> ids;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getStatus() {
        return status;
    }

    public void setStatus(String status) {
        this.status = status;
    }

    public List<Long> getIds() {
        return ids;
    }

    public void setIds(List<Long> ids) {
        this.ids = ids;
    }
}

XMLの test="name != null" や #{status} は、このクラスのプロパティを参照します。Java側のプロパティ名を変更した場合は、XML側の参照名も合わせて変更します。

Mapperインターフェース

package com.example.mapper;

import com.example.domain.User;
import com.example.dto.UserSearchCondition;
import org.apache.ibatis.annotations.Param;

import java.util.List;

public interface UserMapper {

    List<User> findByCondition(UserSearchCondition condition);

    List<User> findByIds(@Param("ids") List<Long> ids);

    List<User> findByPriorityCondition(UserSearchCondition condition);

    int updateUser(User user);

    List<User> findBySortKey(@Param("sortKey") String sortKey);
}

findByCondition と findByPriorityCondition では、検索条件オブジェクトのプロパティをXMLから直接参照します。findByIds は @Param("ids") を付けているため、XMLでは collection="ids" と指定できます。

Mapper XMLの namespace はMapperインターフェースの完全修飾名に合わせ、select や update の id は対応するメソッド名と一致させます。

ifで条件付きWHERE句を作る方法

ifの基本的な使い方

if は、test 属性の条件が成立した場合だけ、内部のSQLを追加します。複数の検索条件を同時に適用できるようにしたい場合は、それぞれを独立した if で記述します。

<select id="findByCondition"
        parameterType="com.example.dto.UserSearchCondition"
        resultType="com.example.domain.User">
  SELECT
    id,
    name,
    email,
    status
  FROM users
  <where>
    <if test="name != null and name != ''">
      AND name LIKE CONCAT('%', #{name}, '%')
    </if>
    <if test="email != null and email != ''">
      AND email = #{email}
    </if>
    <if test="status != null and status != ''">
      AND status = #{status}
    </if>
  </where>
</select>

name だけが入力された場合は名前の条件だけが追加されます。email と status も入力されていれば、それらの条件もANDで追加されます。

このSQL例ではCONCAT関数を使用しています。文字列結合の構文は、使用するデータベースに合わせてください。

whereと組み合わせてWHEREやANDを調整する

where 要素は、内部に有効な条件がある場合だけWHEREを追加し、先頭にあるANDやORを調整します。そのため、各条件をANDから書き始めても、生成されるSQLの不整合を防ぎやすくなります。

条件が0件になる可能性がある検索では、WHEREだけが残らないように if と where を組み合わせるのが基本です。

nullと空文字を確認する

画面から受け取る文字列は、未入力時に null になる場合と空文字になる場合があります。未入力値を検索条件へ含めないなら、両方を確認します。

<if test="name != null and name != ''">
  AND name LIKE CONCAT('%', #{name}, '%')
</if>

空白だけの文字列も除外する場合は、Java側で入力を正規化する方法があります。どの値を未入力とみなすかをアプリケーション側で統一しておくと、XMLの条件を読みやすく保てます。

chooseで複数条件から1つを選ぶ方法

choose / when / otherwiseの基本形

choose は、複数の候補から成立した1つのSQLだけを選ぶ場合に使います。Javaの if / else if / else に近い構造です。

<select id="findByPriorityCondition"
        parameterType="com.example.dto.UserSearchCondition"
        resultType="com.example.domain.User">
  SELECT
    id,
    name,
    email,
    status
  FROM users
  <where>
    <choose>
      <when test="email != null and email != ''">
        email = #{email}
      </when>
      <when test="name != null and name != ''">
        name LIKE CONCAT('%', #{name}, '%')
      </when>
      <when test="status != null and status != ''">
        status = #{status}
      </when>
      <otherwise>
        status = 'ACTIVE'
      </otherwise>
    </choose>
  </where>
</select>

複数の when が成立しても、選ばれるのは上から最初に成立した1つだけです。この例では、メールアドレス、名前、ステータスの順で判定します。どの条件も成立しない場合は、otherwise の条件を使用します。

ifとchooseの違い

if を複数並べると、成立した条件がすべてSQLへ追加されます。choose では、成立した候補のうち最初の1つだけが追加されます。

  • 名前とステータスの両方で絞り込む:複数の if
  • メールアドレスがあれば使い、なければ名前を使う:choose

otherwise を使う場合は、条件未指定時に全件検索するのか、標準条件を付けるのかを先に決めておきます。

foreachでIN句を組み立てる方法

Listを受け取るMapper定義

複数のIDを使って検索する場合は、Mapperメソッドでリストを受け取ります。@Param("ids") を指定すると、XMLから ids という名前で参照できます。

List<User> findByIds(@Param("ids") List<Long> ids);

foreachの属性

  • collection:繰り返すコレクションの名前
  • item:各要素を参照する変数名
  • open:繰り返しの前に付ける文字列
  • separator:要素間の区切り文字
  • close:繰り返しの後に付ける文字列

IN句へ複数のIDを渡す例

<select id="findByIds"
        resultType="com.example.domain.User">
  SELECT
    id,
    name,
    email,
    status
  FROM users
  WHERE id IN
  <foreach collection="ids"
           item="id"
           open="("
           separator=","
           close=")">
    #{id}
  </foreach>
</select>

ids に 10、20、30 を渡すと、概念的には次のSQLになります。

SELECT
  id,
  name,
  email,
  status
FROM users
WHERE id IN (?, ?, ?)

各IDは #{id} によってパラメータとして扱われるため、IDを文字列連結でSQLへ埋め込む必要はありません。

空リストを渡すときの注意点

空リストではIN句に値がなくなり、不正なSQLや意図しない検索になる可能性があります。空リストを「該当なし」「条件なし」「入力エラー」のどれとして扱うかを決めてください。

空リストを条件なしとして扱う場合は、次のようにIN句の追加を分岐できます。

<where>
  <if test="ids != null and ids.size() > 0">
    AND id IN
    <foreach collection="ids"
             item="id"
             open="("
             separator=","
             close=")">
      #{id}
    </foreach>
  </if>
  <if test="status != null and status != ''">
    AND status = #{status}
  </if>
</where>

空リストの扱いを決めないまま foreach を使うと、全件検索や不正なSQLにつながる可能性があります。Mapperを呼び出す前の入力チェックも検討してください。

setとtrimで更新SQLを組み立てる方法

setで変更項目だけを更新する

変更された項目だけをUPDATEする場合は、if と set を組み合わせます。set は、有効な項目がある場合にSET句を付け、末尾の余分なカンマを調整します。

<update id="updateUser"
        parameterType="com.example.domain.User">
  UPDATE users
  <set>
    <if test="name != null">
      name = #{name},
    </if>
    <if test="email != null">
      email = #{email},
    </if>
    <if test="status != null">
      status = #{status},
    </if>
  </set>
  WHERE id = #{id}
</update>

すべての更新項目がnullの場合、set が安全なUPDATEへ変換してくれるわけではありません。Mapperを呼び出す前に更新対象があるか確認してください。

また、name != null のような条件では、値をNULLへ更新できません。NULLを「未変更」と「NULLへ更新」のどちらとして扱うかは、更新用DTOなどで区別する必要があります。

trimでSQLの前後を調整する

trim は、接頭辞や末尾の文字を細かく調整できる要素です。たとえば、where に近い処理は次のように書けます。

<trim prefix="WHERE"
      prefixOverrides="AND |OR ">
  <if test="name != null and name != ''">
    AND name LIKE CONCAT('%', #{name}, '%')
  </if>
  <if test="status != null and status != ''">
    AND status = #{status}
  </if>
</trim>

SET句の末尾にあるカンマを除去する場合は、次のように記述できます。

<trim prefix="SET"
      suffixOverrides=",">
  <if test="name != null">
    name = #{name},
  </if>
  <if test="email != null">
    email = #{email},
  </if>
</trim>
  • where:条件がある場合だけWHEREを付け、先頭のANDやORを調整する
  • set:更新項目がある場合にSETを付け、末尾のカンマを調整する
  • trim:接頭辞や末尾文字を独自のルールで調整する

動的SQLで値を安全に扱う注意点

検索条件や更新値には#{}を使う

検索条件や更新値など、SQLへ渡す値には基本的に #{} を使います。

WHERE status = #{status}

SET email = #{email}
WHERE id = #{id}

${}を安易に使わない

${} は、指定された文字列をSQLへそのまま埋め込みます。ユーザー入力を直接渡すとSQLインジェクションの原因になるため、検索値や更新値には使用しません。

<!-- ユーザー入力をそのまま渡してはいけない -->
ORDER BY ${sortKey}

#{} と ${} の違いは、次の記事で詳しく解説しています。

ORDER BYを切り替える場合は許可値に限定する

カラム名やORDER BYの列名は、通常 #{} ではパラメータ化できません。動的に切り替える必要がある場合は、リクエストの文字列をそのまま渡さず、Java側で許可した値だけをSQL断片へ変換します。

public enum UserSortKey {
    NAME("name"),
    EMAIL("email"),
    STATUS("status");

    private final String sql;

    UserSortKey(String sql) {
        this.sql = sql;
    }

    public String getSql() {
        return sql;
    }
}
public List<User> findUsers(UserSortKey sortKey) {
    if (sortKey == null) {
        sortKey = UserSortKey.NAME;
    }

    return userMapper.findBySortKey(sortKey.getSql());
}
<select id="findBySortKey"
        resultType="com.example.domain.User">
  SELECT
    id,
    name,
    email,
    status
  FROM users
  ORDER BY ${sortKey}
</select>

${} に渡す値は、サービス層や入力変換処理で許可リストに限定してください。ユーザーが入力した文字列をそのままSQLへ埋め込んではいけません。

MyBatisの動的SQLで起きやすいミス

パラメータ名とXMLの参照名がずれる

検索条件オブジェクトを1つ渡す場合、XMLではそのプロパティ名を参照します。

List<User> findByCondition(UserSearchCondition condition);
<if test="name != null and name != ''">
  AND name LIKE CONCAT('%', #{name}, '%')
</if>

複数のパラメータやコレクションを渡す場合は、@Param で名前を明示すると対応関係を確認しやすくなります。

foreachのcollection名が合わない

@Param("ids") を指定した場合は、XMLの collection も ids にします。@Param、collection、ループ内の item をセットで確認してください。

条件未指定時の動作が決まっていない

条件が1つもない場合に、全件検索するのか、標準条件を付けるのか、入力エラーにするのかを決めておきます。更新SQLでも同様に、更新項目がない場合はMapperを呼び出さないなどの制御が必要です。

分岐を増やしすぎてSQLが読みにくくなる

動的SQLへ分岐を集めすぎると、実際に生成されるSQLを把握しにくくなります。単独条件の検索、優先順位を持つ検索、IDリスト検索など、用途が異なる処理はMapperメソッドを分ける方法もあります。

入力値の組み合わせによって生成されるSQLが変わるため、代表的な組み合わせごとにSQLとバインド値をログやテストで確認すると、条件漏れや不正なSQLを見つけやすくなります。

まとめ

MyBatisの動的SQLでは、追加したい条件の性質に応じて if、choose、foreach を使い分けます。

  • if:成立した検索条件をすべてSQLへ追加する
  • choose / when / otherwise:複数の候補から最初に成立した1つを選ぶ
  • foreach:Listなどの要素をIN句へ展開する
  • where:WHEREや先頭のAND、ORを調整する
  • set:SETや末尾のカンマを調整する
  • trim:接頭辞や末尾文字を独自の条件で調整する

MapperインターフェースとXMLの参照名を一致させ、null、空文字、空リスト、条件未指定時の扱いを事前に決めておくことも大切です。検索値や更新値には #{} を使い、${} が必要なSQL断片は許可した値だけに限定してください。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次