MyBatisにおける動的SQLのforeachタグを活用した複数データ操作

MyBatisの動的SQL機能は、柔軟なデータベース操作を実現するための強力なツールです。中でも<foreach>タグは、コレクション(配列、リスト、マップなど)を反復処理し、IN句の構築、バッチ挿入、バッチ更新、バッチ削除といった複雑なSQLステートメントを動的に生成する際に非常に役立ちます。

<foreach>タグの基本と応用

<foreach>タグは、Javaコードから渡されたコレクションの要素ごとに処理を繰り返し、SQLの一部を生成するために使用されます。これにより、複数のIDを指定した検索や削除、複数のレコードを一括でデータベースに登録・更新する処理を効率的に実装できます。

Javaデータモデルの定義

ここでは、ユーザー情報を表すAppUserというシンプルなデータモデルを使用します。

package com.example.app.model;

import java.util.Date;

public class AppUser {
    private Integer userId;
    private String userName;
    private Date birthDate;
    private String gender; // 性別
    private String location; // 住所

    public AppUser() {}

    public AppUser(String userName, Date birthDate, String gender, String location) {
        this.userName = userName;
        this.birthDate = birthDate;
        this.gender = gender;
        this.location = location;
    }

    public AppUser(Integer userId, String userName, Date birthDate, String gender, String location) {
        this.userId = userId;
        this.userName = userName;
        this.birthDate = birthDate;
        this.gender = gender;
        this.location = location;
    }

    // --- Getter & Setter ---
    public Integer getUserId() { return userId; }
    public void setUserId(Integer userId) { this.userId = userId; }
    public String getUserName() { return userName; }
    public void setUserName(String userName) { this.userName = userName; }
    public Date getBirthDate() { return birthDate; }
    public void setBirthDate(Date birthDate) { this.birthDate = birthDate; }
    public String getGender() { return gender; }
    public void setGender(String gender) { this.gender = gender; }
    public String getLocation() { return location; }
    public void setLocation(String location) { this.location = location; }

    @Override
    public String toString() {
        return "AppUser{" +
               "userId=" + userId +
               ", userName='" + userName + '\'' +
               ", birthDate=" + birthDate +
               ", gender='" + gender + '\'' +
               ", location='" + location + '\'' +
               '}';
    }
}

MyBatisマッパーインターフェース

データベース操作を定義するマッパーインターフェースです。<foreach>タグを利用する各種の一括操作メソッドを宣言しています。

package com.example.app.mapper;

import com.example.app.model.AppUser;
import java.util.List;

/**
 * ユーザーデータに関するCRUD操作を定義するインターフェース
 */
public interface UserDataMapper {

    // 複数のユーザーIDによるユーザー検索
    List<AppUser> findUsersByIdList(Integer[] userIds);

    // 複数のユーザーIDによるユーザーの一括削除
    int removeUsersByIds(Integer[] userIds);

    // 複数ユーザーの一括挿入
    int addMultipleUsers(List<AppUser> userList);

    // 複数ユーザーの一括更新
    int updateMultipleUsers(List<AppUser> userList);
}

MyBatisマッピングXMLの設定

<foreach>タグを使用した具体的なSQL定義を示します。

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.app.mapper.UserDataMapper">

    <!-- 全カラムの共通定義 (例として直接記述) -->
    <sql id="allAppUserColumns">
        user_id, user_name, birth_date, gender, location
    </sql>

    <!-- 複数のIDによるユーザー検索 -->
    <select id="findUsersByIdList" resultType="com.example.app.model.AppUser">
        SELECT
            <include refid="allAppUserColumns"/>
        FROM
            app_users
        WHERE
            user_id IN
            <foreach collection="array" item="idValue" open="(" close=")" separator=",">
                #{idValue}
            </foreach>
    </select>

    <!-- 複数のIDによるユーザーの一括削除 -->
    <delete id="removeUsersByIds">
        DELETE FROM
            app_users
        WHERE
            user_id IN
        <foreach collection="array" item="idValue" separator="," open="(" close=")">
            #{idValue}
        </foreach>
    </delete>

    <!-- 複数ユーザーの一括挿入 -->
    <insert id="addMultipleUsers">
        INSERT INTO
            app_users(user_name, birth_date, gender, location)
        VALUES
            <foreach collection="list" item="usr" separator=",">
                (#{usr.userName}, #{usr.birthDate}, #{usr.gender}, #{usr.location})
            </foreach>
    </insert>

    <!-- 複数ユーザーの一括更新 -->
    <update id="updateMultipleUsers">
        <foreach collection="list" item="usr" separator=";">
            UPDATE
                app_users
            <set>
                <if test="usr.userName != null and usr.userName != ''">
                    user_name=#{usr.userName},
                </if>
                <if test="usr.birthDate != null">
                    birth_date=#{usr.birthDate},
                </if>
                <if test="usr.gender != null and usr.gender != ''">
                    gender=#{usr.gender},
                </if>
                <if test="usr.location != null and usr.location != ''">
                    location=#{usr.location},
                </if>
            </set>
            WHERE
                user_id=#{usr.userId}
        </foreach>
    </update>
</mapper>

<foreach>タグの属性解説

  • collection: イテレーション対象のコレクションタイプを指定します。Javaのパラメータが配列の場合は"array"、Listの場合は"list"、Mapの場合は"map"またはマップのキー名を指定します。
  • item: 各イテレーションで取得される要素に割り当てる変数名を指定します。この名前を使って、<foreach>タグ内で要素にアクセスします。
  • open: 生成されるSQLの先頭に挿入される文字列です。例えば、IN句の場合は"("を指定します。
  • close: 生成されるSQLの末尾に挿入される文字列です。例えば、IN句の場合は")"を指定します。
  • separator: 各要素が反復処理された後に挿入される区切り文字です。IN句では","、バッチ更新で複数のUPDATE文を生成する場合は";"などが使われます。

openとcloseは、繰り返し処理される要素全体を囲む場合に有効です。各要素自体に括弧が必要な場合は、<foreach>タグの内部で手動で追加します(例:一括挿入)。

JDBCドライバーの設定に関する注意点

複数件の一括更新を行う際に、<update>タグ内で<foreach>とseparator=";"を使用して複数のUPDATEステートメントを連結する場合、多くのJDBCドライバーはこれを単一のSQL文としてではなく、複数の独立したSQL文として扱います。この場合、データベースURLにallowMultiQueries=trueのような設定を追加して、複数のクエリの実行を許可する必要があります。これは特にMySQLドライバーで必要です。

jdbc.driverClassName=com.mysql.cj.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/mydatabase?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true
jdbc.username=dbuser
jdbc.password=dbpassword

この設定がない場合、通常は最初のSQL文のみが実行されるか、エラーが発生します。

MyBatisが生成するSQLの例

  • **一括検索 (IDリスト)**:
    SELECT user_id, user_name, birth_date, gender, location FROM app_users WHERE user_id IN ( ?, ?, ? )
  • **一括削除 (IDリスト)**:
    DELETE FROM app_users WHERE user_id IN ( ?, ? )
  • **一括挿入**:
    INSERT INTO app_users(user_name, birth_date, gender, location) VALUES (?, ?, ?, ?) , (?, ?, ?, ?) , (?, ?, ?, ?)
  • **一括更新**:
    UPDATE app_users SET user_name=?, birth_date=?, gender=?, location=? WHERE user_id=? ; UPDATE app_users SET user_name=?, birth_date=?, gender=?, location=? WHERE user_id=? ; UPDATE app_users SET user_name=?, birth_date=?, gender=?, location=? WHERE user_id=?

テストコードの例

上記マッパーの機能を検証するためのJUnitテストクラスです。

package com.example.app.mapper;

import com.example.app.model.AppUser;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;

import java.io.IOException;
import java.io.InputStream;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.List;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;

public class AppUserDaoTest {

    private SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
    private SqlSession sqlSession;
    private UserDataMapper userDataMapper;

    @Before
    public void setUp() throws IOException {
        InputStream is = Resources.getResourceAsStream("mybatis-config.xml");
        SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(is);
        sqlSession = sessionFactory.openSession();
        userDataMapper = sqlSession.getMapper(UserDataMapper.class);
    }

    @After
    public void tearDown() {
        if (sqlSession != null) {
            sqlSession.close();
        }
    }

    // ユーザーIDリストによる一括検索テスト
    @Test
    public void testFindUsersByIdList() {
        Integer[] targetUserIds = {1, 3, 5};
        List<AppUser> foundUsers = userDataMapper.findUsersByIdList(targetUserIds);
        foundUsers.forEach(System.out::println);
        assertEquals(3, foundUsers.size());
    }

    // ユーザーIDリストによる一括削除テスト
    @Test
    public void testRemoveUsersByIds() {
        // テスト用に存在しないIDも含める
        Integer[] userIdsToDelete = {3, 29, 999};
        int affectedRows = userDataMapper.removeUsersByIds(userIdsToDelete);
        System.out.println("削除された行数: " + affectedRows);
        // 仮に3と29が存在し、999が存在しない場合を想定
        // 実際の影響行数はデータベースの状態に依存
        assertTrue(affectedRows >= 1);
        sqlSession.commit(); // トランザクションをコミット
    }

    // 複数ユーザーの一括挿入テスト
    @Test
    public void testAddMultipleUsers() throws ParseException {
        List<AppUser> newUsers = new ArrayList<>();
        newUsers.add(new AppUser("田中太郎", dateFormat.parse("1990-05-15"), "男", "東京都"));
        newUsers.add(new AppUser("佐藤花子", dateFormat.parse("1992-11-20"), "女", "大阪府"));
        newUsers.add(new AppUser("鈴木一郎", dateFormat.parse("1988-03-01"), "男", "愛知県"));

        int affectedRows = userDataMapper.addMultipleUsers(newUsers);
        System.out.println("挿入された行数: " + affectedRows);
        assertEquals(3, affectedRows);
        sqlSession.commit();
    }

    // 複数ユーザーの一括更新テスト
    @Test
    public void testUpdateMultipleUsers() throws ParseException {
        List<AppUser> usersToUpdate = new ArrayList<>();
        // 既存のIDを指定して更新
        usersToUpdate.add(new AppUser(31, "田中改", dateFormat.parse("1990-05-16"), "男", "神奈川県"));
        usersToUpdate.add(new AppUser(32, "佐藤改", dateFormat.parse("1992-11-21"), "女", "京都府"));
        usersToUpdate.add(new AppUser(33, "鈴木改", dateFormat.parse("1988-03-02"), "男", "福岡県"));

        int affectedRows = userDataMapper.updateMultipleUsers(usersToUpdate);
        System.out.println("更新された行数: " + affectedRows);
        // IMPORTANT: 複数クエリをallowMultiQueries=trueで実行した場合、
        // 多くのJDBCドライバでは、このメソッドは最後のUPDATE文の影響行数(通常1)を返します。
        // 全体の合計ではありません。
        assertEquals(1, affectedRows);
        sqlSession.commit();
    }
}

一括更新時の戻り値に関する考察

testUpdateMultipleUsersメソッドの実行結果として、更新された行数affectedRowsが1と表示される場合があります。これは、データベースの複数行が実際に更新された場合でも発生する現象です。 この理由として、MyBatisの<update>タグ内で<foreach>タグとseparator=";"を使用して複数の独立したUPDATEステートメントを生成し、これをJDBCドライバーに渡した場合、多くのJDBCドライバー(特にMySQL Connector/J)では、Statement.executeUpdate()メソッドが最後に実行されたSQL文の影響行数のみを返すという特性があります。

したがって、MyBatisが実行する個々のUPDATE文がそれぞれ1行を更新する場合、最終的なaffectedRowsの値は1となります。これは、合計更新行数が3であっても、MyBatisが内部的にそれぞれの更新の結果を個別に受け取り、最終的なint値として最後の結果のみをアプリケーションに返すためです。この動作は、一括操作で合計の更新行数を正確に知りたい場合に注意が必要です。

タグ: MyBatis Dynamic SQL Java SQL Batch Operations

10月14日 03:14 投稿