先週、商品テーブルにdiscount_rateカラムを1つ追加しただけで、直すMapper XMLが6ファイルもありました。原因は単純で、deleted = 0 AND status = ‘ACTIVE’という同じWHERE条件と同じSELECT列リストを検索・件数集計・一覧表示のたびにコピペしていたからです。1ファイル直し忘れると検索結果と件数表示の数字がズレる厄介なバグにもつながります。

MyBatisにはこの重複を解消するsqlタグとincludeタグがあります。フラグメント定義から別Mapper間の再利用、動的SQLタグとの組み合わせまでを動くXMLで確認します。動的SQLタグの構文は前回の記事が前提です。Mapperの基本設定がまだの方はMapper実装ガイドを先にどうぞ。

sqlタグでSQLフラグメントを定義する

sqlタグはSQLの断片に名前を付けて保存するタグです。selectと同じMapper XML内にidを付けて定義します。WHERE条件だけでなくSELECT列リストも共通化できます。

<sql id="activeCondition">
  deleted = 0 AND status = 'ACTIVE'
</sql>

<sql id="productColumns">
  id, name, category, price, status, updated_at
</sql>

テーブルに列が増えても直す場所はproductColumnsだけになります。

includeタグで同一Mapper内に差し込む

<include refid="id"/> で定義したフラグメントを差し込めます。

<select id="search" resultType="Product">
  SELECT <include refid="productColumns"/>
  FROM products
  WHERE <include refid="activeCondition"/>
</select>

<select id="countByCategory" resultType="int">
  SELECT COUNT(*) FROM products
  WHERE <include refid="activeCondition"/>
  AND category = #{category}
</select>

この解決はSQL実行のたびではなく、アプリケーション起動時のMapper XML読み込み時に静的なSQLとして解決されます。実行のたびに文字列を組み立て直すわけではないのでパフォーマンスへの影響はありません。

別のMapper XMLのフラグメントを参照する

フラグメントは別のMapper XMLからも参照でき、refidをnamespace.idの形式で指定します。ここで注意が必要です。activeConditionのようにテーブル修飾のないカラム名のフラグメントを複数テーブルのJOINで使うと、JOIN先にも同名カラムがある場合にambiguous column参照エラーになります。別Mapperから参照する共通フラグメントはテーブルエイリアスを渡せる形にしておくのが安全です。

<!-- CommonSqlMapper.xml -->
<mapper namespace="com.example.shop.mapper.CommonSqlMapper">
  <sql id="aliasedActiveCondition">
    ${alias}.deleted = 0 AND ${alias}.status = 'ACTIVE'
  </sql>
</mapper>
<!-- OrderMapper.xml から参照する -->
<select id="searchOrders" resultType="Order">
  SELECT o.* FROM orders o
  JOIN products p ON o.product_id = p.id
  WHERE <include refid="com.example.shop.mapper.CommonSqlMapper.aliasedActiveCondition">
    <property name="alias" value="o"/>
  </include>
</select>

namespaceはMapperインターフェースの完全修飾名と一致させる必要があり、タイプミスすると解決に失敗します。

propertyでパラメータを渡す

includeの子要素としてpropertyを渡すと、フラグメントの中身を呼び出し側で変えられます。渡したaliasはフラグメント内で ${alias} として展開され、単純な文字列置換です。渡す値はテーブル別名のように決め打ちできるものに限定しましょう。ユーザー入力をそのまま渡すとSQLインジェクションの入口になります。propertyのスコープはそのinclude呼び出し内に閉じています。

動的SQLタグと組み合わせる

foreachで組み立てるIN句と共通のカラムリストフラグメントは自然に組み合わせられます。

<select id="searchByIds" resultType="Product">
  SELECT <include refid="productColumns"/>
  FROM products
  WHERE <include refid="activeCondition"/>
  AND id IN
  <foreach item="id" collection="ids" open="(" separator="," close=")">
    #{id}
  </foreach>
</select>

choose句で条件を切り替えるケースでも、共通条件はincludeに任せ個別条件だけを動的SQLタグで組み立てられます。where・set・trim・chooseの詳しい挙動は動的SQLの記事にまとめています。

共通フラグメント専用のMapperに集約する

プロジェクトが大きくなるとフラグメントを持つMapperと持たないMapperが混在し分かりにくくなります。おすすめはフラグメント専用のCommonSqlMapper.xmlに共通条件やカラムリストを集約する構成です。

mapper/
  CommonSqlMapper.xml   # 共通フラグメントのみを定義
  ProductMapper.xml     # CommonSqlMapper.xxx を参照
  OrderMapper.xml       # CommonSqlMapper.xxx を参照

既存のProductMapper.xmlに直接書いていたactiveConditionは削除し、CommonSqlMapper.xml側の定義だけを参照するように書き換えます。対応するJavaインターフェースは不要で、mapper namespaceだけのXMLで十分です。

namespace指定ミスによるエラー

namespaceの指定ミスが一番つまずきやすい点です。パッケージ名をタイプミスすると起動時のMapper XML読み込み時にrefidが解決できずIncompleteElementExceptionになります。

Could not find SQL statement to include with refid
'com.example.shop.mapper.CommonSqlMapper.aliasedActiveCondition'

namespaceの綴りをCommonSqlMapper.xmlのmapper namespaceと見比べてください。もう1つの原因はmybatis.mapper-locationsの設定漏れでCommonSqlMapper.xml自体が読み込まれていないケースです。

まとめ

sqlでフラグメントを定義しincludeで同じMapper内や別Mapper XMLから差し込むのがSQL重複解消の基本パターンです。propertyでテーブル別名のような可変部分も吸収でき、foreachやchooseとも組み合わせられます。フラグメントが増えたらCommonSqlMapper.xmlに集約すると迷いません。

resultMapの結果マッピングはassociation・collectionの記事、トランザクション境界をまたぐ処理は@Transactionalの記事も参考にしてください。