MyBatis における多表結合查詢とページネーション実装

概要

MyBatis フレームワークを用いた開発において、複数のテーブルを結合したデータ取得および結果のページネーション処理は頻出する要件です。本稿では、動的 SQL 機能を活用し、単表查詢を超える複雑な結合查詢の実装方法と、MySQL 環境における効率的なページネーション仕組みについて解説します。

ドメインモデルの定義

まず、データベースのレコードを映射する Java クラスを準備します。ここでは「プロジェクト」と「担当者」の関係を例に、多対一の関連性を表現します。パッケージ構成は com.example.mybatis.domain とします。

プロジェクト情報を保持する Project クラスでは、担当者情報を表す Account クラスをメンバー変数として持ちます。また、テスト出力用に toString メソッドをオーバーライドします。

package com.example.mybatis.domain;

import java.text.SimpleDateFormat;
import java.util.Date;

public class Project {
    private int projectId;
    private String projectName;
    private int ownerId;
    private int activeFlag;
    private Date registeredAt;
    private Account owner;

    public Project() {
        this.registeredAt = new Date();
        this.owner = new Account();
    }

    public Project(String name, int ownerId) {
        this.projectName = name;
        this.ownerId = ownerId;
        this.owner = new Account();
        this.activeFlag = 1;
        this.registeredAt = new Date();
    }

    // Getter/Setter 省略(標準的な実装)
    public int getProjectId() { return projectId; }
    public void setProjectId(int projectId) { this.projectId = projectId; }
    
    public Account getOwner() { return owner; }
    public void setOwner(Account owner) { this.owner = owner; }
    
    public String getProjectName() { return projectName; }
    public void setProjectName(String projectName) { this.projectName = projectName; }
    
    public int getActiveFlag() { return activeFlag; }
    public void setActiveFlag(int activeFlag) { this.activeFlag = activeFlag; }
    
    public Date getRegisteredAt() { return registeredAt; }
    public void setRegisteredAt(Date registeredAt) { this.registeredAt = registeredAt; }

    public int getOwnerId() {
        return (owner != null) ? owner.getId() : this.ownerId;
    }

    public void setOwnerId(int ownerId) {
        this.ownerId = ownerId;
    }

    @Override
    public String toString() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        StringBuilder sb = new StringBuilder();
        sb.append(String.format("Project=> {ID:%d, Name:%s, Time:%s}\n", 
            projectId, projectName, sdf.format(registeredAt)));
        if (owner != null) {
            sb.append(String.format("Account=> %s", owner.toString()));
        }
        return sb.toString();
    }
}

Mapper インターフェースの作成

データアクセス層のインターフェースを定義します。 CRUD 操作に加え、リスト取得メソッドを宣言します。

package com.example.mybatis.mapper;

import com.example.mybatis.domain.Project;
import java.util.List;

public interface ProjectMapper {
    int insert(Project project);
    int delete(int id);
    int update(Project project);
    Project selectById(int id);
    List<Project> selectAll();
}

多表結合查詢の設定

MyBatis の XML 映射ファイルにおいて、結合查詢の結果を適切にオブジェクトへマップするには、resultMapassociation タグを使用します。重要な点は、結合元のテーブル間でカラム名が重複する場合(例:id, name など)、SQL 側でエイリアスを付与し、映射定義で明確に区別することです。

<?xml version="1.0" encoding="UTF-8"?>
<mapper namespace="com.example.mybatis.mapper.ProjectMapper">
    
    <resultMap type="Account" id="accountResult">
        <id column="acc_id" property="id" />
        <result column="acc_name" property="name" />
    </resultMap>

    <resultMap type="Project" id="projectResult">
        <id column="proj_id" property="projectId" />
        <result column="proj_name" property="projectName" />
        <result column="proj_status" property="activeFlag" />
        <result column="proj_time" property="registeredAt" />
        <association property="owner" javaType="Account" resultMap="accountResult" />
    </resultMap>

    <select id="selectById" parameterType="int" resultMap="projectResult">
        SELECT 
            p.id AS proj_id, 
            p.name AS proj_name, 
            p.status AS proj_status, 
            p.created_at AS proj_time,
            a.id AS acc_id, 
            a.name AS acc_name
        FROM Project p
        INNER JOIN Account a ON p.owner_id = a.id
        WHERE p.status > 0 AND p.id = #{id}
    </select>

    <select id="selectAll" resultMap="projectResult">
        SELECT 
            p.id AS proj_id, 
            p.name AS proj_name, 
            p.status AS proj_status, 
            p.created_at AS proj_time,
            a.id AS acc_id, 
            a.name AS acc_name
        FROM Project p
        INNER JOIN Account a ON p.owner_id = a.id
        WHERE p.status > 0
    </select>
</mapper>

もしエイリアスを付与せず、両テーブルの id をそのまま選択した場合、後続のカラム値が前の値を上書きしてしまう現象が発生します。これを防ぐため、AS キーワードを用いて列名を一意に識別できるようにします。

ページネーション処理の実装

Web アプリケーションでは大量データの表示ためにページネーションが不可欠です。ここでは、開始オフセットと表示件数をパラメータとして受け取り、MySQL の LIMIT 句を用いて制御します。

查詢条件をカプセル化する PageCondition クラスと、ソート方向を定義する列挙型 SortType を作成します。

package com.example.mybatis.domain;

public class PageCondition {
    private int pageNum;
    private int pageSize;
    private int offset;
    private String sortColumn;
    private String sortOrder;

    public PageCondition(int pageNum, int pageSize, String sortColumn, String sortOrder) {
        this.pageNum = pageNum;
        this.pageSize = pageSize;
        this.sortColumn = sortColumn;
        this.sortOrder = sortOrder;
        this.offset = pageNum * pageSize;
    }

    public int getOffset() { return offset; }
    public int getPageSize() { return pageSize; }
    public String getSortColumn() { return sortColumn; }
    public String getSortOrder() { return sortOrder; }
}

public enum SortType {
    ASC, DESC
}

動的 SQL による分页查詢

担当者リストの取得において、ページネーションと動的ソートを実現する Mapper 設定です。ソートカラムや方向はユーザー入力に依存するため、プレースホルダー ${} を使用しますが、SQL インジェクション対策として入力値の検証はアプリケーション側で行う必要があります。

<mapper namespace="com.example.mybatis.mapper.AccountMapper">
    
    <sql id="baseQuery">
        SELECT * FROM Account WHERE status > 0
    </sql>

    <sql id="orderClause">
        ORDER BY ${sortColumn} ${sortOrder}
    </sql>

    <select id="selectByPage" parameterType="PageCondition" resultType="Account">
        SELECT * FROM (
            <include refid="baseQuery" />
            <include refid="orderClause" />
        ) AS temp
        <if test="offset >= 0 and pageSize > 0">
            LIMIT #{offset}, #{pageSize}
        </if>
    </select>
</mapper>

テスト実行時には、例えば 2 ページ目(オフセット 5)、1 ページ 5 件表示、ID 降順といった条件を PageCondition に設定して渡します。これにより、データベース側で必要なレコードのみを取得でき、メモリ効率を最適化できます。

行番号の付与

MySQL には Oracle のような ROWNUM が標準で存在しないため、変数を用いて擬似的に行番号を生成する手法があります。查詢結果に現在の行位置を表示したい場合、以下のような SQL 構造を採用します。

<select id="selectByPageWithRowNum" resultMap="accountWithRowNumResult">
    SELECT t.RowNum, t.Id, t.Name, t.Email, t.Status, t.CreateTime 
    FROM (
        SELECT @rownum:=@rownum+1 AS RowNum, result.* 
        FROM (
            SELECT @rownum:=0, Account.* FROM Account WHERE status > 0
        ) AS result
        <include refid="orderClause" />
    ) AS t
    <if test="offset >= 0 and pageSize > 0">
        LIMIT #{offset}, #{pageSize}
    </if>
</select>

この方法を用いることで、ページネーションされた結果セット内での相対的な行番号を Java オブジェクトの rownum プロパティに映射することが可能になります。

タグ: MyBatis MySQL Java ORM pagination

6月15日 22:21 投稿