MyBatisで自己参照による階層データの取得

MyBatisを用いて、単一テーブル内の親子関係を持つ階層データ(自己参照)を効率的に取得する方法を解説します。例として、ニュースカテゴリのツリー構造(親カテゴリ → 子カテゴリ)を扱います。

テーブル構造は以下の通りです:

CREATE TABLE newslabel (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(100) NOT NULL,
  pid INT DEFAULT 0 COMMENT '親カテゴリID。0はルートを意味する'
);

データ例:

  • id=2, name='体育新闻', pid=0(親カテゴリ)
  • id=3, name='NBA', pid=2(子カテゴリ)
  • id=5, name='火箭', pid=3(孫カテゴリ)

エンティティクラスの定義

階層構造を表現するため、NewsCategoryクラスに自身のリストを含むフィールドを追加します。

import java.util.List;

public class NewsCategory {
    private Integer id;
    private String name;
    private List<NewsCategory> subCategories;

    // ゲッター・セッター
    public Integer getId() { return id; }
    public void setId(Integer id) { this.id = id; }

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

    public List<NewsCategory> getSubCategories() { return subCategories; }
    public void setSubCategories(List<NewsCategory> subCategories) { this.subCategories = subCategories; }

    @Override
    public String toString() {
        return "NewsCategory{id=" + id + ", name='" + name + "', subCategories=" + subCategories + "}";
    }
}

DAOインターフェース

親カテゴリの子孫を取得するメソッドと、自身を含む全階層を取得するメソッドを定義します。

public interface NewsCategoryDao {
    List<NewsCategory> findSubCategoriesByParentId(Integer parentId);
    List<NewsCategory> findTreeByCategoryId(Integer categoryId);
}

Mapper XMLの実装

1. 子孫カテゴリのみを取得(再帰的)

findSubCategoriesByParentIdは、指定された親IDの直下の子カテゴリを再帰的に取得します。

<?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="dao.NewsCategoryDao">

  <resultMap id="categoryTreeMap" type="model.NewsCategory">
    <id property="id" column="id"/>
    <result property="name" column="name"/>
    <collection property="subCategories"
                ofType="model.NewsCategory"
                select="findSubCategoriesByParentId"
                column="id"/>
  </resultMap>

  <select id="findSubCategoriesByParentId" resultMap="categoryTreeMap">
    SELECT id, name FROM newslabel WHERE pid = #{parentId}
  </select>

</mapper>

注意:column="id"は、親クエリの結果のidが子クエリの#{parentId}に渡されます。


2. 自身を含む全階層を取得

親カテゴリ自身も含む完全なツリーを取得するには、別途ルートクエリを定義し、resultMapを再利用します。

  <!-- 自身とそのすべての子孫を取得 -->
  <select id="findTreeByCategoryId" resultMap="categoryTreeMap">
    SELECT id, name FROM newslabel WHERE id = #{categoryId}
  </select>

</mapper>

この構成では:

  • findTreeByCategoryIdが最初に自身のレコードを1件取得
  • resultMap<collection>がそのidを引数にfindSubCategoriesByParentIdを再帰的に呼び出す
  • 子孫が存在しないレベルで再帰が終了

テストコード

import org.apache.ibatis.session.SqlSession;
import org.junit.Before;
import org.junit.Test;

import static org.junit.Assert.*;

public class NewsCategoryTest {

    private SqlSession sqlSession;
    private NewsCategoryDao dao;

    @Before
    public void setup() {
        sqlSession = MyBatisUtils.getSqlSession();
        dao = sqlSession.getMapper(NewsCategoryDao.class);
    }

    @Test
    public void testFindTreeByCategoryId() {
        List<NewsCategory> tree = dao.findTreeByCategoryId(2);
        tree.forEach(System.out::println);
    }

    @Test
    public void testFindSubCategoriesByParentId() {
        List<NewsCategory> children = dao.findSubCategoriesByParentId(2);
        children.forEach(System.out::println);
    }

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

実行結果の例

findTreeByCategoryId(2)の出力:

NewsCategory{id=2, name='体育新闻', subCategories=[
  NewsCategory{id=3, name='NBA', subCategories=[
    NewsCategory{id=5, name='火箭', subCategories=[]},
    NewsCategory{id=6, name='湖人', subCategories=[]}
  ]},
  NewsCategory{id=4, name='CBA', subCategories=[
    NewsCategory{id=7, name='北京金瓯', subCategories=[]},
    NewsCategory{id=8, name='浙江广夏', subCategories=[]},
    NewsCategory{id=9, name='青岛双星', subCategories=[]}
  ]}
]}

ログには、MyBatisが内部で複数のSELECTを再帰的に発行している様子が確認できます。各レベルでpidをパラメータとして渡し、葉ノードに到達するまで処理が続きます。


注意点

  • 再帰クエリは、階層が深いほどDBアクセスが増えるため、パフォーマンスに注意
  • 大規模なツリーでは、一度に全データを取得するより、Lazy Loadingやページネーションを検討
  • MyBatisの<collection>はデフォルトでEager Fetch(即時取得)なので、必要に応じてfetchType="lazy"を指定可能

タグ: MyBatis SQL 階層データ 自己参照 再帰クエリ

7月27日 21:24 投稿