XMLの <if> や <where> で動的SQLを書いていると、IDEの補完が効かない、メソッド名をリネームしてもXML側のミスに気づけない、という不満が溜まってきますよね。MyBatisにはXMLを一切使わず、Javaのメソッドだけで動的SQLを組み立てる仕組みがあります。@SelectProvider / @InsertProvider / @UpdateProvider によるSqlProviderです。

この記事ではXML記法には触れず、SqlProviderでJavaコードだけから動的SQLを組み立てる方法に絞って解説し、最後にXML方式との使い分け基準を整理します。XMLタグの書き方は動的SQLタグの記事、Mapperの基本設定はMapper実装ガイドを参照してください。

@SelectProvider/@InsertProvider/@UpdateProviderの基本

SqlProvider系アノテーションは、SQLを組み立てるJavaメソッドをMapperメソッドに紐付ける仕組みです。type 属性にProviderクラス、method 属性にSQLを生成するメソッド名を指定します。

public interface UserMapper {
    @SelectProvider(type = UserSqlProvider.class, method = "selectActiveUsers")
    List<User> selectActiveUsers(@Param("minAge") Integer minAge);

    @InsertProvider(type = UserSqlProvider.class, method = "insertUser")
    int insertUser(User user);
}

Providerクラス側は、Mapperメソッドと同じ引数を受け取り、組み立てたSQL文字列を返すだけです。

public class UserSqlProvider {
    public String selectActiveUsers(Integer minAge) {
        StringBuilder sql = new StringBuilder(
                "SELECT * FROM users WHERE status = 'ACTIVE'");
        if (minAge != null) {
            sql.append(" AND age >= #{minAge}");
        }
        return sql.toString();
    }

    public String insertUser(User user) {
        return "INSERT INTO users (name, email) VALUES (#{name}, #{email})";
    }
}

戻り値は普通のSQL文字列で構いません。#{minAge} のようなプレースホルダは @Param 名と一致していれば、XMLのときと同じようにバインドされます。@UpdateProvider も書き方はまったく同じです。

ProviderMethodResolverでmethod属性を省略する(MyBatis 3.5.1+)

MyBatis 3.5.1以降では、Providerクラスに ProviderMethodResolver を実装すると method 属性を省略できます。Mapperメソッドと同名のメソッドを自動で解決してくれるので、タイプミスでのメソッド解決失敗を減らせます。

public class UserSqlProvider implements ProviderMethodResolver {
    public String selectActiveUsers(Integer minAge) {
        return "SELECT * FROM users WHERE status = 'ACTIVE'"
                + (minAge != null ? " AND age >= #{minAge}" : "");
    }
}
public interface UserMapper {
    @SelectProvider(type = UserSqlProvider.class)
    List<User> selectActiveUsers(@Param("minAge") Integer minAge);
}

メソッド名がオーバーロードしているなど命名規則から外れると解決に失敗するので、1メソッド1用途で揃えておくのが無難です。3.5.0以前ではこのインターフェースが存在しないため、method 属性は省略できません。

SqlBuilder(SQLクラス)で動的SQLを組み立てる

文字列結合は手軽ですが、ANDの付け忘れやスペースの打ち間違いに気づきにくいという弱点があります。org.apache.ibatis.jdbc.SQL クラス(俗にSqlBuilderと呼ばれます)を使うと、XMLの <where> や <set> と同じく先頭のANDを自動調整する挙動をJavaコードで得られます。

import org.apache.ibatis.jdbc.SQL;

public class UserSqlProvider {
    public String selectActiveUsers(final Integer minAge, final String name) {
        return new SQL() {{
            SELECT("*");
            FROM("users");
            WHERE("status = 'ACTIVE'");
            if (minAge != null) {
                WHERE("age >= #{minAge}");
            }
            if (name != null) {
                WHERE("name LIKE concat('%', #{name}, '%')");
            }
        }}.toString();
    }
}

条件が1つも成立しなければ WHERE 自体が出力されないので、XMLの <where> タグとほぼ同じ安心感があります。条件分岐が少ないクエリなら、先ほどのStringBuilderによる文字列結合のほうがシンプルで読みやすいこともあるので、複雑さに応じて使い分けるとよいでしょう。

複数引数の受け渡し: @ParamとProviderContext

Providerメソッドの引数が2つ以上になる場合は、Mapperメソッド側で @Param を付けて名前を明示しておきます。

public interface UserMapper {
    @UpdateProvider(type = UserSqlProvider.class, method = "updatePartial")
    int updatePartial(@Param("id") Long id, @Param("user") User user);
}

Providerメソッドの引数名・型・順序はMapperメソッドと一致させる必要があります。ここがズレると実行時にメソッド解決エラーになるので、IDEのリネーム機能を使うときは両方を同時に変更しましょう。

Mapperのクラス名やメソッド名が欲しい場合は、引数に ProviderContext を追加すると受け取れます。型で解決されるので並び順は気にしなくて大丈夫です。

public String updatePartial(ProviderContext context, Long id, User user) {
    // context.getMapperMethod() などでメソッド情報を参照できる
    return "UPDATE users SET name = #{user.name} WHERE id = #{id}";
}

部分更新のUPDATE文をガード付きで組み立てる

nullのフィールドだけを更新対象から除外する部分更新は、SqlProviderが特に活躍する場面です。

public class UserSqlProvider {
    public String updatePartial(final Long id, final User user) {
        if (user.getName() == null && user.getEmail() == null) {
            throw new IllegalArgumentException("更新対象のフィールドがありません");
        }
        return new SQL() {{
            UPDATE("users");
            if (user.getName() != null) {
                SET("name = #{user.name}");
            }
            if (user.getEmail() != null) {
                SET("email = #{user.email}");
            }
            WHERE("id = #{id}");
        }}.toString();
    }
}

SET() が一度も呼ばれないと、SQLクラスは例外を出さずに SET 句自体を省略した文字列を返してしまいます。その結果 UPDATE users WHERE id = ? という構文エラーのSQLがそのままJDBCに渡り、データベース側で構文エラーになってようやく気づく、という遠回りな失敗の仕方をします。だからこそProviderメソッドの先頭でガードし、SQLを組み立てる前に弾いておくのが安全です。

実装でつまずきやすいポイント

複数引数のProviderメソッドでは @Param の付け忘れに気づきにくく、忘れると引数名が arg0 や param1 になってProvider側の #{name} と噛み合わず実行時エラーになります。複数引数のときは必ず @Param を付ける習慣にしておきましょう。

  • Providerメソッドのシグネチャ不一致 は、Mapperメソッドの引数の数・型・@Param 名とProviderメソッドが食い違うと起こり、BindingException やメソッド解決エラーにつながります。
  • SqlBuilderの組み立て漏れ は、前述のとおり SET/VALUES/WHERE の呼び出し忘れがそのまま構文エラーのSQLになって現れるので、ユニットテストで実際にSQLを発行して確認しておくと安心です。

XML動的SQLとSqlProviderの使い分け基準

観点XML動的SQLSqlProvider
IDE補完SQL拡張がないと弱いJavaの補完がそのまま効く
型安全性文字列ベースで実行時まで気づけないコンパイル時にある程度検出できる
リファクタリング耐性メソッド名変更に追従しないIDEのリネームで一緒に追従する
SQLの可読性SQLに近い見た目で書けるJavaのロジックに埋め込まれ複雑化しやすい
向いているケースSQLレビューを重視するチーム条件分岐が複雑なクエリ、Java中心のチーム

シンプルなCRUDはXML、条件分岐が多い検索や部分更新だけSqlProviderに切り出す、という併用パターンもよく見かけます。どちらかに統一する必要はなく、クエリごとの複雑さで選べば十分です。

まとめ

@SelectProvider/@InsertProvider/@UpdateProvider を使えば、XMLを書かずにJavaのメソッドだけで動的SQLを組み立てられます。ProviderMethodResolver でmethod属性を省略し、条件分岐が複雑ならSqlBuilder、シンプルなものは文字列結合、と使い分けるのが実務的な落とし所です。

XML動的SQLの書き方を見直したくなったら動的SQLタグの記事を、Mapperの基本に戻りたくなったらMapper実装ガイドもあわせてどうぞ。