💨

MybatisのXMLファイルの書き方を整理してみた

2024/12/14に公開

はじめに

本業やプライベートでもMybatisを使っていますが、これまでXMLを適当に書いていたので改めて整理しました
参考:https://mybatis.org/mybatis-3/ja/sqlmap-xml.html

基本的な書き方

以下は基本的なselect構文です。

  • selectPersonというネームスペース内で固有な識別子をもつ
  • int型のidという変数を受け取る
  • 列名をkey、値をvalueとして保持するHashMapを返却す
<select id="selectPerson" parameterType="int" resultType="hashmap">
  SELECT * FROM PERSON WHERE ID = #{id}
</select>

またselect要素は多くの属性を持っていおり以下などが挙げられます。
各属性の詳細に関しては参考ドキュメントをご覧ください。
個人的にはtimeout(ドライバーがデータベースから応答がない時の最大待機時間)やfetchSize(ドライバが結果を返す際に内部的に使用するキャッシュサイズ)をクエリ単位で制御できることに驚きでした

<select
  id="selectPerson"
  parameterType="int"
  parameterMap="deprecated"
  resultType="hashmap"
  resultMap="personResultMap"
  flushCache="false"
  useCache="true"
  timeout="10"
  fetchSize="256"
  statementType="PREPARED"
  resultSetType="FORWARD_ONLY">

その他Insert・update・deleteにも多くの属性があります

<insert
  id="insertAuthor"
  parameterType="domain.blog.Author"
  flushCache="true"
  statementType="PREPARED"
  keyProperty=""
  keyColumn=""
  useGeneratedKeys=""
  timeout="20">

<update
  id="updateAuthor"
  parameterType="domain.blog.Author"
  flushCache="true"
  statementType="PREPARED"
  timeout="20">

<delete
  id="deleteAuthor"
  parameterType="domain.blog.Author"
  flushCache="true"
  statementType="PREPARED"
  timeout="20">

キーの自動生成

Insert時は使用しているDBMSがキーの自動生成に対応している場合はuseGeneratedKeysをtrueにするだけです。(とても便利)

<insert id="insertAuthor" useGeneratedKeys="true"
    keyProperty="id">
  insert into Author (username,password,email,bio)
  values (#{username},#{password},#{email},#{bio})
</insert>

またDBMSがキーの自動生成に対応してない時の対応策として、テクニカルではありますが以下のようにIDをランダムに生成することも可能です。

<insert id="insertAuthor">
  <selectKey keyProperty="id" resultType="int" order="BEFORE">
    select CAST(RANDOM()*1000000 as INTEGER) a from SYSIBM.SYSDUMMY1
  </selectKey>
  insert into Author
    (id, username, password, email,bio, favourite_section)
  values
    (#{id}, #{username}, #{password}, #{email}, #{bio}, #{favouriteSection,jdbcType=VARCHAR})
</insert>

上記では、まずselectKeyステートメントが実行されidプロパティに生成された乱数がセットされ、次にInsertステートメントが実行されます。
Springのアプリケーション側でUUIDを生成してInsertしたりするケースはよくありますが、一括登録をするケースを考えると、for文などでループして各データにUUIDをセットする必要がありデータ数が多いとSpringアプリケーション側のパフォーマンスに影響があるかもしれません。(実際に私は大量のデータの一括登録処理内でUUIDをセットするためだけにループ処理を実行する必要があり、これによってサーバーのCPU使用率が急増してしまう場面に遭遇しました)
※上記で紹介したドライバー側でキー生成してもパフォーマンス懸念はあるかも...

スニペット

sqlステートメントは再利用可能なSQLコードのスニペットを定義できます

<sql id="userColumns"> ${alias}.id,${alias}.username,${alias}.password </sql>
<select id="selectUsers" resultType="map">
  select
    <include refid="userColumns"><property name="alias" value="t1"/></include>,
    <include refid="userColumns"><property name="alias" value="t2"/></include>
  from some_table t1
    cross join some_table t2
</select>

ResultMap

下記のようなドメインモデルがあった時に

public class User {
    private id;
    private String name;
}

下記のプロパティは一致しているためHashMap同様にResultSetにマップすることができます

<select id="selectUsers" resultType="com.someapp.model.User">
  select id, name
  from some_table
  where id = #{id}
</select>

もしデータベースのカラム名がドメインモデルと一致しない場合はクエリのAS句を用いてXML内でドメインモデルのプロパティ名に揃えてあげる、もしくは下記のようなResultMapを用いた記述が可能です

<resultMap id="userResultMap" type="User">
  <id property="id" column="user_id" />
  <result property="username" column="name"/>
</resultMap>

<select id="selectUsers" resultMap="userResultMap">
  select user_id, user_name, hashed_password
  from some_table
  where id = #{id}
</select>

さらに高度なMapping

コンストラクタ経由でMapping

public class User {
    private int id;
    private String name;

    public User (int id, String name) {
        // 任意の処理
    }
}

上記のようなコンストラクタを持つクラスがある時、下記のようにコンストラクタに経由でmappingが可能です

<resultMap id="userResultMap" type="User">
    <constructor>
       <idArg column="id" javaType="int"/>
       <arg column="username" javaType="String"/>
       <arg column="arg" javaType="_int"/>
    </constructor>
</resultMap>

Has-Oneタイプのリレーション

Has-Oneのリレーションを取得するためには以下のように記述します

<association property="author" column="blog_author_id" javaType="Author">
  <id property="id" column="author_id"/>
  <result property="username" column="author_username"/>
</association>

これを用いて以下のような記述ができます

<resultMap id="blogResult" type="Blog">
  <association property="author" column="author_id" javaType="Author" select="selectAuthor"/>
</resultMap>

<select id="selectBlog" resultMap="blogResult">
  SELECT * FROM BLOG WHERE ID = #{id}
</select>

<select id="selectAuthor" resultType="Author">
  SELECT * FROM AUTHOR WHERE ID = #{id}
</select>

これは以下の順番で処理されます

  • Blogを取得
  • Blogレコードからauthor_idを抽出
  • author_idを用いてAuthorを取得
  • blogResultに定義されたマッピングに従い、各オブジェクトがマッピング

一方でこの書き方はN+1問題を抱えています。
N+1問題を解消するための解決策として一般的なテーブル結合を用いましょう

<resultMap id="blogResult" type="Blog">
  <id property="id" column="blog_id" />
  <result property="title" column="blog_title"/>
  <association property="author" javaType="Author">
    <id property="id" column="author_id"/>
    <result property="username" column="author_username"/>
    <result property="password" column="author_password"/>
    <result property="email" column="author_email"/>
    <result property="bio" column="author_bio"/>
  </association>
</resultMap>

<select id="selectBlog" resultMap="blogResult">
  select
    B.id       as blog_id,
    B.title     as blog_title,
    B.author_id   as blog_author_id,
    A.id       as author_id,
    A.username    as author_username,
    A.password     as author_password,
    A.email     as author_email,
    A.bio       as author_bio
  from Blog B left outer join Author A on B.author_id = A.id
  where B.id = #{id}
</select>

Has-Manyタイプのリレーション

Has-manyタイプのリレーションはassociationステートメントではなく、collectionステートメントを用います。

<resultMap id="blogResult" type="Blog">
  <collection property="posts" javaType="ArrayList" column="id" ofType="Post" select="selectPostsForBlog"/>
</resultMap>

<select id="selectBlog" resultMap="blogResult">
  SELECT * FROM BLOG WHERE ID = #{id}
</select>

<select id="selectPostsForBlog" resultType="Post">
  SELECT * FROM POST WHERE BLOG_ID = #{id}
</select>

この時、javaType属性で明示的にArrayListを定義していますが、基本的にはMybatisが推測してくれるため省略ができます。また上記の書き方は先と同様にN+1問題を抱えているため、リファクタリングした書き方が以下です。

<resultMap id="blogResult" type="Blog">
  <id property="id" column="blog_id" />
  <result property="title" column="blog_title"/>
  <collection property="posts" ofType="Post" resultMap="blogPostResult" columnPrefix="post_"/>
</resultMap>

<resultMap id="blogPostResult" type="Post">
  <id property="id" column="id"/>
  <result property="subject" column="subject"/>
  <result property="body" column="body"/>
</resultMap>

<select id="selectBlog" resultMap="blogResult">
  select
  B.id as blog_id,
  B.title as blog_title,
  B.author_id as blog_author_id,
  P.id as post_id,
  P.subject as post_subject,
  P.body as post_body,
  from Blog B
  left outer join Post P on B.id = P.blog_id
  where B.id = #{id}
</select>

上記はselectステートメント内で左外部結合を用いることでN+1問題を解消しているだけでなく、resultMapステートメントを分割しています。このようにresultMapを分割することで、ネストが深くなることの解消+再利用性の向上に繋がります。

自動マッピング

Mybatisの自動マッピングには3つのレベルが存在し、これらはグローバルに適用されます。

  • NONE:無効
  • PARTIAL:ネストされたResultMapを持たないResultMapのみを対象
  • FULL:全てのResultMapが対象
    デフォルト値はPARTIALです。

しかしFULLにすると複数のテーブルを結合した時、各テーブル間で同名のカラムがあると意図しないマッピングが行われるため注意が必要です。

また上記のようなグローバルな自動マッピング設定とは別にautoMapping属性を指定することで特定のステートメント内で自動マッピングを無効化することも可能です。

<resultMap id="userResultMap" type="User" autoMapping="false">
  <result property="password" column="hashed_password"/>
</resultMap>

Discussion