MyBatisとは、自分で書いたSQLとJavaのメソッド・オブジェクトを対応づけてくれる、SQL中心のデータアクセスフレームワークです。 SQLはMapper XMLやアノテーションにそのまま書き、#{} で引数を安全に埋め込み、結果を自動でJavaオブジェクトへ詰め替えます。<if> や <foreach> などのタグで、条件に応じてSQLを組み立てる「動的SQL」を書けるのが大きな特長です。

この記事では、mybatis-spring-boot-starter 4.1.0(Spring Boot 4.1対応)を使い、Mapper XMLとアノテーションの両方でCRUD一式を示します。さらに、動的SQLタグ8種すべてについて「入力 → 生成されるSQL → 実行結果」の対照表を作りました。生成SQLはMyBatisのタグ仕様(公式ドキュメント)から組み立て、PostgreSQL 16.13 で実際に実行して件数を確認しています。なお、この環境ではMyBatis本体のjarを取得できないため、Java・XML部分は未実行です(公式ドキュメントに忠実に書いています)。

MyBatisの早見表

やりたいこと 書き方 ポイント
Spring Bootに導入 mybatis-spring-boot-starter 4.1.0 Boot 4.0以上は starter 4.x(Java 17以上)
Mapperを登録 インターフェースに @Mapper または @MapperScan("パッケージ")
XMLの場所 mybatis.mapper-locations: classpath:mapper/*.xml XMLの namespace はMapperのFQCN
スネークケース→キャメルケース mybatis.configuration.map-underscore-to-camel-case: true due_date → dueDate
引数を埋め込む #{status} PreparedStatementの ? になる(安全)
文字列をそのまま埋め込む ${column} SQLに直接連結。値は必ずホワイトリストで限定
結果の詰め替えを定義 <resultMap> 列名とプロパティ名が合わない/1対多のとき
採番されたIDを受け取る useGeneratedKeys="true" keyProperty="id" INSERT後に引数オブジェクトの id に入る
条件があるときだけ追加 <if test="..."> OGNL式で判定
どれか1つを選ぶ <choose><when><otherwise> switch文に相当
WHEREを自動で付ける <where> 先頭の AND/OR を自動で除去
UPDATEのSETを組み立て <set> 末尾のカンマを自動で除去
前後の文字を細かく制御 <trim prefix prefixOverrides suffixOverrides> where/setの汎用版
IN句・一括INSERT <foreach collection item open close separator> リスト・配列・Mapを展開
変数を作る <bind name value> LIKEの % 付与など
SQL断片を再利用 <sql id> + <include refid> 列リストの共通化
アノテーションで動的SQL @Select("<script>...</script>") 短いSQL向け
SQLログ logging.level.<Mapperのパッケージ>: debug ==> Preparing: 形式で出力

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「MyBatis使い方|Spring Boot連携と動的SQLタグ全実例」

バージョン対応表(公式で確認)

mybatis-spring-boot-starter の公式ページと GitHub のリリースで確認した対応関係です。

starter MyBatis-Spring Spring Boot Java
4.1.0(最新) 4.1.0 4.1 17以上
4.0.x 4.0 4.0以上 17以上
3.0.x 3.0 3.2〜3.5 17以上
2.3.x 2.1 2.7 8以上

Spring Boot 3以降で2系のstarterを使うと起動時にエラーになるため、Bootのメジャーバージョンに合わせてstarterを選びます。2020〜2022年ごろの記事にある 2.1.0/2.2.2 は Spring Boot 2 系向けです。

Spring Bootとの連携手順(コピペ一式)

依存関係

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.1.1</version>
</parent>

<dependencies>
  <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>4.1.0</version>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>
</dependencies>

Gradleの場合は次のとおりです。

dependencies {
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:4.1.0'
    runtimeOnly 'org.postgresql:postgresql'
}

application.yml

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/sample
    username: app
    password: app

mybatis:
  mapper-locations: classpath:mapper/*.xml
  type-aliases-package: com.example.task      # XMLで Task と短く書ける
  configuration:
    map-underscore-to-camel-case: true         # due_date → dueDate
    default-fetch-size: 100
    default-statement-timeout: 30

logging:
  level:
    com.example.task: debug                    # Mapperが出すSQLとパラメータ

テーブルとデータ(PostgreSQL)

CREATE TABLE task (
  id bigserial PRIMARY KEY,
  title varchar(100) NOT NULL,
  status varchar(20) NOT NULL DEFAULT 'OPEN',
  priority int NOT NULL DEFAULT 2,
  assignee varchar(30),
  due_date date,
  created_at timestamp NOT NULL DEFAULT now()
);
INSERT INTO task(title,status,priority,assignee,due_date) VALUES
 ('週次レポート作成','OPEN',2,'sato','2026-10-03'),
 ('備品の発注','DONE',1,'suzuki','2026-09-20'),
 ('会議室の予約','OPEN',1,NULL,'2026-10-02'),
 ('月次レポート作成','IN_PROGRESS',3,'sato','2026-10-10'),
 ('在庫の棚卸し','OPEN',3,'tanaka','2026-10-15'),
 ('マニュアル更新','DONE',2,'tanaka','2026-09-25'),
 ('新人向け資料作成','IN_PROGRESS',2,NULL,NULL),
 ('50%割引クーポン設定','OPEN',1,'suzuki','2026-10-05');

ドメインクラスと検索条件

package com.example.task;

import java.time.LocalDate;
import java.time.LocalDateTime;

public class Task {
    private Long id;
    private String title;
    private String status;
    private Integer priority;
    private String assignee;
    private LocalDate dueDate;
    private LocalDateTime createdAt;

    // getter / setter(MyBatisはsetterまたはフィールドに値を入れる)
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public Integer getPriority() { return priority; }
    public void setPriority(Integer priority) { this.priority = priority; }
    public String getAssignee() { return assignee; }
    public void setAssignee(String assignee) { this.assignee = assignee; }
    public LocalDate getDueDate() { return dueDate; }
    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.example.task;

import java.util.List;

/** 検索条件。null の項目は条件に含めない */
public record TaskSearch(String title, String status, String assignee,
                         Integer minPriority, List<Long> ids, String sort) { }
package com.example.task;

/** 集計結果の受け取り用(コンストラクタで詰める) */
public record AssigneeCount(String assignee, long openCount) { }

Mapperインターフェース

package com.example.task;

import java.util.List;
import java.util.Optional;
import org.apache.ibatis.annotations.*;

@Mapper
public interface TaskMapper {

    // ---- XMLで定義(TaskMapper.xml) ----
    Optional<Task> findById(long id);
    List<Task> search(TaskSearch cond);
    List<Task> findByChoice(TaskSearch cond);
    int insert(Task task);
    int insertAll(@Param("tasks") List<Task> tasks);
    int updateSelective(Task task);
    int deleteById(long id);
    List<AssigneeCount> countOpenByAssignee();

    // ---- アノテーションで定義 ----
    @Select("SELECT id, title, status, priority, assignee, due_date, created_at FROM task WHERE status = #{status} ORDER BY id")
    List<Task> findByStatus(String status);

    @Insert("INSERT INTO task (title, priority) VALUES (#{title}, #{priority})")
    @Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
    int insertSimple(Task task);

    @Update("UPDATE task SET status = #{status} WHERE id = #{id}")
    int updateStatus(@Param("id") long id, @Param("status") String status);

    @Delete("DELETE FROM task WHERE status = 'DONE' AND due_date < #{date}")
    int deleteDoneBefore(java.time.LocalDate date);

    @Select("""
            <script>
              SELECT id, title FROM task
              <where>
                <if test="status != null">status = #{status}</if>
              </where>
              ORDER BY id
            </script>
            """)
    List<Task> findByStatusOptional(@Param("status") String status);
}

Optional の戻り値は MyBatis 3.5 以降で使えます。 1件取得で該当なしのとき Optional.empty() が返ります。

Mapper XML(src/main/resources/mapper/TaskMapper.xml)

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.task.TaskMapper">

  <!-- 列リストを共通化 -->
  <sql id="taskColumns">
    id, title, status, priority, assignee, due_date, created_at
  </sql>

  <!-- 明示的な詰め替え定義(自動マッピングで足りる場合は不要) -->
  <resultMap id="taskMap" type="Task">
    <id     property="id"        column="id"/>
    <result property="title"     column="title"/>
    <result property="status"    column="status"/>
    <result property="priority"  column="priority"/>
    <result property="assignee"  column="assignee"/>
    <result property="dueDate"   column="due_date"/>
    <result property="createdAt" column="created_at"/>
  </resultMap>

  <resultMap id="assigneeCountMap" type="AssigneeCount">
    <constructor>
      <arg column="assignee"   javaType="String"/>
      <arg column="open_count" javaType="_long"/>
    </constructor>
  </resultMap>

  <select id="findById" resultMap="taskMap">
    SELECT <include refid="taskColumns"/> FROM task WHERE id = #{id}
  </select>

  <select id="search" resultType="Task">
    <bind name="titlePattern" value="'%' + title + '%'"/>
    SELECT <include refid="taskColumns"/> FROM task
    <where>
      <if test="title != null and title != ''">
        AND title LIKE #{titlePattern}
      </if>
      <if test="status != null">
        AND status = #{status}
      </if>
      <if test="ids != null and !ids.isEmpty()">
        AND id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
          #{id}
        </foreach>
      </if>
    </where>
    ORDER BY
    <choose>
      <when test="sort == 'due'">due_date DESC NULLS LAST</when>
      <when test="sort == 'priority'">priority DESC</when>
      <otherwise>id</otherwise>
    </choose>
  </select>

  <select id="findByChoice" resultType="Task">
    SELECT <include refid="taskColumns"/> FROM task
    <trim prefix="WHERE" prefixOverrides="AND |OR ">
      <choose>
        <when test="assignee != null">AND assignee = #{assignee}</when>
        <when test="minPriority != null">AND priority &gt;= #{minPriority}</when>
        <otherwise>AND status &lt;&gt; 'DONE'</otherwise>
      </choose>
    </trim>
    ORDER BY id
  </select>

  <insert id="insert" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
    INSERT INTO task (title, status, priority, assignee, due_date)
    VALUES (#{title}, #{status}, #{priority}, #{assignee}, #{dueDate})
  </insert>

  <insert id="insertAll" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
    INSERT INTO task (title, priority) VALUES
    <foreach collection="tasks" item="t" separator=",">
      (#{t.title}, #{t.priority})
    </foreach>
  </insert>

  <update id="updateSelective">
    UPDATE task
    <set>
      <if test="title != null">title = #{title},</if>
      <if test="status != null">status = #{status},</if>
      <if test="priority != null">priority = #{priority},</if>
      <if test="assignee != null">assignee = #{assignee},</if>
      <if test="dueDate != null">due_date = #{dueDate},</if>
    </set>
    WHERE id = #{id}
  </update>

  <delete id="deleteById">
    DELETE FROM task WHERE id = #{id}
  </delete>

  <select id="countOpenByAssignee" resultMap="assigneeCountMap">
    SELECT coalesce(assignee, '(未割当)') AS assignee, count(*) AS open_count
    FROM task WHERE status &lt;&gt; 'DONE'
    GROUP BY assignee ORDER BY open_count DESC, assignee
  </select>
</mapper>

XMLの中では < を &lt;、> を &gt; と書きます(または <![CDATA[ ... ]]> で囲む)。そのまま書くとXMLの解析エラーになります。

Service

package com.example.task;

import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class TaskService {
    private final TaskMapper mapper;

    public TaskService(TaskMapper mapper) { this.mapper = mapper; }

    @Transactional(readOnly = true)
    public List<Task> search(TaskSearch cond) {
        return mapper.search(cond);
    }

    @Transactional
    public Task create(Task task) {
        mapper.insert(task);          // 実行後 task.getId() に採番値が入る
        return task;
    }

    @Transactional
    public void complete(long id) {
        if (mapper.updateStatus(id, "DONE") == 0) {
            throw new IllegalArgumentException("タスクが見つかりません: id=" + id);
        }
    }
}

MyBatis-SpringはSpringのトランザクションに参加するため、@Transactional を付ければ複数のMapper呼び出しをまとめてコミット・ロールバックできます。更新系は戻り値の件数を確認すると、対象なしに気づけます。

動的SQLタグ8種の「入力→生成SQL」対照表

ここが本記事の中心です。上のXMLに値を渡したとき、タグの仕様どおりに組み立てられるSQLと、そのSQLをPostgreSQL 16.13 で実行した結果を並べます(実行時は ? の位置に値を入れています)。

タグ 入力 生成されるSQL(要点) 実行結果
if+where すべて null SELECT ... FROM task ORDER BY id 8件
if+where status=OPEN ... WHERE status = ? ORDER BY id 4件
if+where+bind title=レポート, status=OPEN ... WHERE title LIKE ? AND status = ? 1件
choose(1つ目) assignee=sato ... WHERE assignee = ? 2件
choose(2つ目) minPriority=3 ... WHERE priority >= ? 2件
choose(otherwise) どちらも null ... WHERE status <> 'DONE' 6件
set title だけ UPDATE task SET title = ? WHERE id = ? UPDATE 1
set status, priority UPDATE task SET status = ?, priority = ? WHERE id = ? UPDATE 1
foreach(IN) ids=[2,4,6] ... WHERE id IN (?,?,?) 3件
foreach(一括INSERT) 3件のリスト INSERT ... VALUES (?,?),(?,?),(?,?) INSERT 0 3
trim+choose assignee=tanaka ... WHERE assignee = ?(先頭のANDを除去) 2件
sql+include 全メソッド id, title, status, ... が展開される —

if と where:条件がないときWHEREごと消える

<where> は、中身が空なら何も出力せず、中身があれば WHERE を付けて先頭の AND/OR を取り除きます。各 <if> の先頭に AND を書いておけば、どの組み合わせでも正しいSQLになります。

-- 入力:title=レポート, status=OPEN
SELECT id,title,status FROM task WHERE title LIKE '%' || 'レポート' || '%' AND status = 'OPEN';
 id |      title       | status 
----+------------------+--------
  1 | 週次レポート作成 | OPEN
(1 row)

<where> を使わずに WHERE <if>AND ...</if> と書くと、条件がすべて null のとき WHERE だけが残り、WHERE AND のような構文エラーになります。直す前と後を比べると次のとおりです。

<!-- 直す前:条件なしで「WHERE ORDER BY」になる -->
SELECT * FROM task WHERE
<if test="status != null">status = #{status}</if>
ORDER BY id

<!-- 直した後:<where> が必要なときだけ WHERE を付ける -->
SELECT * FROM task
<where>
  <if test="status != null">AND status = #{status}</if>
</where>
ORDER BY id

choose:最初に当てはまった1つだけを使う

-- assignee=sato
 id |      title       
----+------------------
  1 | 週次レポート作成
  4 | 月次レポート作成
(2 rows)

-- assignee=null, minPriority=3
 id |      title       
----+------------------
  4 | 月次レポート作成
  5 | 在庫の棚卸し
(2 rows)

-- どちらも null(otherwise)
 count 
-------
     6
(1 row)

<if> は当てはまるものがすべて追加、<choose> は上から最初の1つだけです。「担当者指定があれば優先、なければ優先度」のような優先順位のある条件に向いています。

set:末尾のカンマを自動で取る

-- 入力:id=1, title=週次レポート提出(他は null)
UPDATE task SET title = '週次レポート提出' WHERE id = 1;
BEGIN
UPDATE 1
ROLLBACK

各 <if> の末尾に , を付けておくと、<set> が SET を付け、最後に残ったカンマを除去します。画面で変更された項目だけを更新する「部分更新」に使います。なお、すべての項目が null だと UPDATE task WHERE id = ? という不正なSQLになるため、Service側で「変更項目が1つ以上あるか」を確認しておくと安心です。

trim:where/setの汎用版

<where> は <trim prefix="WHERE" prefixOverrides="AND |OR "> と同じ動きです。<set> は <trim prefix="SET" suffixOverrides=","> に相当します。

属性 意味
prefix 中身があれば先頭に付ける文字列
prefixOverrides 中身の先頭から取り除く文字列(| 区切り。AND のように後ろの空白も含める)
suffix 中身があれば末尾に付ける文字列
suffixOverrides 中身の末尾から取り除く文字列
-- findByChoice:assignee=tanaka → WHERE assignee = ?
 id |     title      | status 
----+----------------+--------
  5 | 在庫の棚卸し   | OPEN
  6 | マニュアル更新 | DONE
(2 rows)

foreach:IN句と一括INSERT

-- ids=[2,4,6] → WHERE id IN (?,?,?)
 id |      title       
----+------------------
  2 | 備品の発注
  4 | 月次レポート作成
  6 | マニュアル更新
(3 rows)

一括INSERTは VALUES (?,?),(?,?),(?,?) の1文になります。PostgreSQLで RETURNING id を付けて確認すると、3件それぞれに採番されました(シーケンスはロールバックされないため、番号は検証の途中で進んでいます)。

 id |   title    
----+------------
 13 | ラベル印刷
 14 | 名札作成
 15 | 備品点検
(3 rows)

INSERT 0 3
foreach の属性 意味
collection 対象。@Param 名、または引数が List なら list、配列なら array
item / index 各要素の変数名 / 添字(Mapならキー)
open / close 全体の前後に付ける文字(( と ))
separator 要素の間に入れる文字(,)

空のリストを渡すと IN () という不正なSQLになるため、ids != null and !ids.isEmpty() の判定を <if> で囲みます(上のXMLのとおり)。また、一括INSERTは1文のパラメータ数に上限があるため(PostgreSQLのJDBCドライバでは1文あたり約32,767個)、件数が多い場合は数百〜数千件ずつに分けます。

bind:LIKE のパターンを組み立てる

<bind name="titlePattern" value="'%' + title + '%'"/> で作った変数は #{titlePattern} として使えます。'%' || #{title} || '%' のようなDBごとの連結構文を書かずに済むのが利点です。

ただし bind は利用者の入力に含まれる % や _ をエスケープしません。50% で検索したいなら、Java側で % を \% に置き換えてから渡し、SQLに ESCAPE '\' を付けます。

SELECT id,title FROM task WHERE title LIKE '%50\%%' ESCAPE '\';
 id |        title        
----+---------------------
  8 | 50%割引クーポン設定
(1 row)

sql と include:列リストを1か所で管理する

<sql id="taskColumns"> に列名を書き、<include refid="taskColumns"/> で展開します。列を追加したときの修正が1か所で済み、SELECT * を避けられます。<property> を使えば、${alias} のように別名を差し込んで再利用することもできます。

<sql id="cols">${alias}.id, ${alias}.title</sql>
<select id="findTitles" resultType="Task">
  SELECT <include refid="cols"><property name="alias" value="t"/></include>
  FROM task t
</select>

resultMapと自動マッピングの使い分け

方法 設定 向いている場面
自動マッピング+キャメルケース変換 map-underscore-to-camel-case: true、resultType="Task" 列名とプロパティ名が規則的に対応する通常の検索
列に別名を付ける SELECT due_date AS dueDate 一部だけ名前が違う
<resultMap>(id/result) 対応を1つずつ定義 名前の対応が不規則、型変換を指定したい
<resultMap>(constructor) <constructor><arg> recordなど不変クラスへ詰める
<association>/<collection> 1対1/1対多をネストして定義 親子をまとめて取得する(JOIN結果を <id> で束ねる)

担当者ごとの未完了件数を AssigneeCount(record)へ詰めるSQLの実行結果です。

 assignee | open_count 
----------+------------
 (未割当) |          2
 sato     |          2
 suzuki   |          1
 tanaka   |          1
(4 rows)

1対多を <collection> で取得する場合は、親の <id> 要素を必ず定義します。MyBatisは <id> の値で行をまとめるため、ないと親が行数分だけ重複します。

useGeneratedKeys:採番されたIDを受け取る

Task t = new Task();
t.setTitle("議事録テンプレート作成");
t.setStatus("OPEN");
t.setPriority(2);
mapper.insert(t);
System.out.println(t.getId());   // 採番されたIDが入っている

PostgreSQLで同等のINSERTに RETURNING id を付けると、次のように採番値が返ります。MyBatisは JDBC の getGeneratedKeys() を通じてこの値を受け取り、keyProperty に指定したプロパティへ入れます。

 id 
----
 12
(1 row)

INSERT 0 1

PostgreSQLでは keyColumn="id" も指定しておくと、どの列の値を返すかが明確になります。

#{}と${}の違い

#{} ${}
埋め込み方 PreparedStatementの ? にして値をバインド 文字列としてSQLに直接連結
安全性 値がSQLの構文として解釈されない 入力しだいでSQLの意味が変わる
使える場所 値(WHERE、VALUES、SET) 列名・テーブル名・ORDER BYの並び順など、値ではない部分
使い方 基本はこちら 許可した値だけを通す(ホワイトリスト)

並び替えの列を画面から選ばせるなら、${} に利用者の入力を直接渡さず、上のXMLのように <choose> で決め打ちの値を選ぶのが確実です(SQLインジェクションの仕組みと対策は別記事で詳しく扱っています)。

-- sort=due → ORDER BY due_date DESC NULLS LAST(status=OPEN、先頭3件)
 id |        title        |  due_date  
----+---------------------+------------
  5 | 在庫の棚卸し        | 2026-10-15
  8 | 50%割引クーポン設定 | 2026-10-05
  1 | 週次レポート作成    | 2026-10-03
(3 rows)

MyBatisとJPA(Spring Data JPA)の使い分け

観点 MyBatis Spring Data JPA
中心にあるもの SQL エンティティ(オブジェクト)
SQLの見通し 書いたSQLがそのまま実行される Hibernateが生成(ログで確認)
複雑な検索・集計・DB固有機能 得意(そのまま書ける) @Query・ネイティブクエリで対応
単純なCRUD 1つずつSQLを書く save・findById などが最初からある
動的条件 <if>・<where> などのタグ Specification・Querydsl
既存DB・複雑なスキーマへの対応 柔軟 対応づけの設計が必要
変更の自動検知・キャッシュ なし(明示的にUPDATE) あり(永続化コンテキスト)
学ぶ内容 SQLとタグ JPAの仕組み(遅延ロード、フラッシュなど)

SQLを主役にしたい、既存のテーブル設計に合わせたい、集計や帳票の検索が多いならMyBatis。エンティティ中心のCRUDが多く、ドメインモデルで設計したいならJPAが選びやすくなります。1つのアプリで両方を併用することもでき、その場合は同じ DataSource とトランザクションマネージャを共有させます。

SQLログの出し方

logging.level にMapperのパッケージ(またはMapperのFQCN)を debug で指定すると、MyBatisが実行SQLとパラメータを出力します。出力は次の形式です(形式の例で、この環境での実行ログではありません)。

==>  Preparing: SELECT id, title, status, priority, assignee, due_date, created_at FROM task WHERE status = ? ORDER BY id
==> Parameters: OPEN(String)
<==      Total: 4
設定 出力内容
logging.level.com.example.task: debug SQL・パラメータ・件数
logging.level.com.example.task: trace 上に加えて結果の各行
logging.level.com.example.task.TaskMapper.search: debug 特定のメソッドだけ

動的SQLは入力で形が変わるので、開発中はdebugログで実際に組み立てられたSQLを確認するのがいちばんの近道です。

よくあるエラーと対処

文言はバージョンにより細部が異なります。代表的な形を示します(この記事の環境では未実行)。

エラー(代表的なメッセージ) 原因 対処
BindingException: Invalid bound statement (not found): com.example.task.TaskMapper.search XMLが読み込まれていない、namespace やidがメソッドと一致しない mapper-locations のパス、namespace のFQCN、id 名を確認。XMLが src/main/resources 配下にあるか
BindingException: Parameter 'status' not found. Available parameters are [arg1, arg0, param1, param2] 複数引数の名前を解決できない 各引数に @Param("status") を付ける
ReflectionException: There is no getter for property named 'titel' in 'class ...' #{} や test 内のプロパティ名の誤り クラスのプロパティ名に合わせる
TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: 2 単一の戻り値なのに複数行が返った 条件を一意にする、List で受ける
IllegalArgumentException: Mapped Statements collection already contains value for ... 同じidが2回定義されている(XMLとアノテーションの重複など) どちらか一方に定義を寄せる
XMLの解析エラー(The content of elements must consist of well-formed character data or markup. など) SQL中の < をそのまま書いた &lt; に置き換えるかCDATAで囲む
PostgreSQLの syntax error at or near "WHERE"/")" <where>・<set> 未使用、空の foreach 本記事の「直す前と後」と空リストの判定を参照

Invalid bound statement の多くは「XMLの場所」か「namespace/idのつづり」です。 ビルド後の target/classes/mapper/ にXMLがコピーされているかも確認します。

よくある質問

Q. XMLとアノテーション、どちらで書くのがよいですか? A. 短く固定のSQLはアノテーション、動的SQLや長いSQLはXMLが読みやすくなります。アノテーションでも <script> で囲めばタグを使えますが、長くなるとJavaの文字列内で見通しが下がります。

Q. Spring Boot 4 で古い記事の設定は使えますか? A. mybatis.* のプロパティ名は同じです。starterのバージョンを4.x(Boot 4.1なら4.1.0)に上げることが必要です。

Q. ページングはどうしますか? A. MyBatis本体にページング専用の仕組みはないため、LIMIT #{size} OFFSET #{offset} を書き、件数用のSELECTを別に用意するのが基本です。大量データでは、前ページ最後のIDより後を取得するキーセット方式(WHERE id > #{lastId} ORDER BY id LIMIT #{size})が速く動きます。

Q. MyBatis GeneratorやPageHelperは必要ですか? A. 必須ではありません。テーブル数が多くCRUDのSQLを自動生成したいならGenerator、既存のSQLにページングを後付けしたいならPageHelperのような拡張が選択肢になります。まずは手書きのMapperで仕組みを理解してから導入を判断すると扱いやすくなります。

まとめ

  • Spring Boot 4.1 には mybatis-spring-boot-starter 4.1.0(Java 17以上)を組み合わせる
  • 値は #{}、${} はホワイトリストで限定した列名などだけに使う
  • <where>・<set>・<trim> が余計な AND やカンマを自動で除去する
  • <foreach> は空リストの判定とセットで、一括INSERTは件数を分けて実行する
  • <bind> はLIKEの % 付与に便利、入力の % _ はJava側でエスケープ
  • SQLを主役にするならMyBatis、エンティティ中心ならJPAで選ぶ
  • 動的SQLはdebugログで実際のSQLを確認しながら書くと早い

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「MyBatis使い方|Spring Boot連携と動的SQLタグ全実例」

株式会社RJCでは、MyBatisのようにSQLとJavaをつなぐ技術を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?