iBATIS の TypeHandlerCallback で getter.wasNull() するときの注意点など

iBATIS*1には、データベース型とエンティティの型を独自にカスタムしたい場合、いわゆるJDBCの自然なマッピングではない型を使用したいというニーズのために TypeHandler の拡張ポイントである TypeHandlerCallback を提供しています。特定のJava*2にDBのカラム値をマップさせたい場合には、

  • マップさせたいJava型について、TypeHandlerCallback をインプリメントしたクラスを書く
  • 書いた TypeHandlerCallback を iBATIS の sqlmap-config.xml に登録する

という手順を踏むだけで、あとは iBATIS がよろしくやってくれるという便利機能です。これを利用すれば、DB側では VARCHAR で持っている区分値を Java 側では Enum で対応させたい、なんていうニーズにも対応できます。*3


で、今回のネタはといいますと。
先日その TypeHandlerCallback を使ったときに少しハマりまして。最終的に PostgreSQL JDBCドライバのソースまで追っかけていってなんとか解決したのですが、せっかくなのでその原因をbloggedして共有しておこうかなというものです。
問題の TypeHandlerCallback はこんな内容。本題と関係ない部分は適宜省略してます。

public class VarcharToBooleanTypeHandler implements TypeHandlerCallback {

    @Override
    public void setParameter(ParameterSetter setter, Object param) throws SQLException {
        // do something...
    }

    @Override
    public Object getResult(ResultGetter getter) throws SQLException {
        if (getter.wasNull()) {
            return null;
        }
        return getter.getString().equals("0");
    }

    @Override
    public Object valueOf(String s) {
        return s;
    }
}

一見、何の問題もなさそうに見えるのですが。これを設定ファイルに として登録し、queryForList などでSELECTをかけるとうまく動作しません。DBのカラムには値が入っているはずなのに、上手く取得できずに null が返ってきてしまいます。なぜだろう?
正解はこうです。

public class VarcharToBooleanTypeHandler implements TypeHandlerCallback {

    @Override
    public void setParameter(ParameterSetter setter, Object param) throws SQLException {
        // do something...
    }

    @Override
    public Object getResult(ResultGetter getter) throws SQLException {
        String value = getter.getString();
        if (getter.wasNull()) {
            return null;
        }
        return value.equals("0");
    }

    @Override
    public Object valueOf(String s) {
        return s;
    }
}

どこが違うのか? getResult メソッドの 中身がポイントです。

  1. getter.getString() して、その後で
  2. getter.wasNull()

しています。この順番が重要なのです。
では、この getter.wasNull() はいったい何をしているのでしょうか。実装を見てみましょう。例によって、関係ない部分は適宜省略してます。

public class ResultGetterImpl implements ResultGetter {

  private ResultSet rs;
  
  ...

  public boolean wasNull() throws SQLException {
    return rs.wasNull();
  }

  ...
}

どうやら、単に ResultSet#wasNull() に委譲しているだけのようです。では、 ResultSet#wasNull() の仕様はどうなっているのでしょうか。java.sql.ResultSet の Javadoc を紐解いてみますと……。

最後に読み込まれた列の値が SQL NULL であるかどうかを通知します。最初に列の getter メソッドの 1 つを呼び出してその値を読み込み、次に wasNull メソッドを呼び出して読み込まれた値が SQL NULL かどうかを判定する必要があります。

http://java.sun.com/javase/ja/6/docs/ja/api/java/sql/ResultSet.html#wasNull()

というわけです。wasNull の動作として、まず前段に何らかの getter メソッド(getString でも getInt でもなんでもいいです)が呼ばれて、その列の値が読み込まれた後でないと wasNull はきちんと動かないのですね。だから isNull じゃなくて wasNull なんだと。ここで私は初めて思い至ったわけです。*4勘のいい人なら wasNull というメソッド名を見ただけで感づいたのかもしれませんが……。

まとめ

  • TypeHandlerCallback#getResult をインプリメントして独自の処理を書くとき、wasNull の使い方には注意が必要
  • ドキュメント(Javadoc)はちゃんと読む


以上、TypeHandlerCallback の実装で少しハマってしまった、というお話でした。TypeHandler 自体は、正しく使えばすごーく便利なので皆さんもっと積極的に使えばいいと思います。区分値をEnum管理すると精神衛生上にも良いですよ。

*1:もう古いか。今はMyBatisですね

*2:プリミティブおよびそのラッパー、String、Date、Timestamp。これらに該当しないような、独自定義のクラスなど

*3:ただしEnumの分だけTypeHandlerCallbackを書かねばならないのですが……。

*4:実際には、すぐに Javadoc を見るには至らず、PostgreSQL JDBCドライバの ResultSet#wasNull の実装を読んで、あれ? と疑問に思って Javadoc を確認したら……という順序でした。