- 技術 💻
MyBatis使い方|Spring Boot連携と動的SQLタグ全実例
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 >= #{minPriority}</when>
<otherwise>AND status <> '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 <> 'DONE'
GROUP BY assignee ORDER BY open_count DESC, assignee
</select>
</mapper>
XMLの中では < を <、> を > と書きます(または <![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中の < をそのまま書いた |
< に置き換えるか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期連続の黒字決算。