PostgreSQL で OUT パラメータに CURSOR を設定した関数を MyBatis から実行するとエラーが発生した

タイトルの通りだが、PostgreSQL で、OUT パラメータに CURSOR を設定した関数を定義し、MyBatis から実行するとエラーが発生してしまった。
原因となんとか回避する方法を調べてみた。

# 環境
* Java 11
* PostgreSQL 10
* PostgreSQL JDBC Driver 42.2.5
* MyBatis 3.5.0

# PostgreSQL の関数

テーブル定義、関数定義はこんな感じ。

```sql
CREATE TABLE test (
    id serial,
    hoge character(10),
    fuga character(10)
);

INSERT INTO test (hoge, fuga) VALUES('hogehoge', 'fugafuga');
INSERT INTO test (hoge, fuga) VALUES('hogege', 'fugaga');

CREATE FUNCTION testfunc (rc_out OUT refcursor) RETURNS refcursor AS $$
BEGIN
    OPEN rc_out FOR SELECT * FROM test;
END;
$$ LANGUAGE plpgsql;
```

# 普通に Java から実行してみる
`CallableStatement` を利用して実行する。クローズ処理は省略。

```java
public class App {
    public static void main(String[] args) throws Exception {
        HikariDataSource ds = new HikariDataSource();
        ds.setDriverClassName("org.postgresql.Driver");
        ds.setJdbcUrl("jdbc:postgresql://localhost:5432/postgres");
        ds.setUsername("postgres");
        ds.setPassword("postgres");

        Connection conn = ds.getConnection();
        conn.setAutoCommit(false);
        CallableStatement cstmt = conn.prepareCall("{call testfunc(?)}");
        cstmt.registerOutParameter(1, Types.REF_CURSOR);
        cstmt.execute();
        
        ResultSet rs = (ResultSet)cstmt.getObject(1);
        while(rs.next()){
            System.out.println(rs.getInt(1));
            System.out.println(rs.getString(2));
            System.out.println(rs.getString(3));
        }
    }
}
```

実行結果は以下のような感じ。ちゃんと実行できている。

```
1
hogehoge
fugafuga
2
hogege
fugaga
```

# MyBatis で実行してみる
実行結果をマッピングするための `Test` クラスと、そのラッパーを作る。

```java
@Data
public class Test {
    private int id;
    private String hoge;
    private String fuga;
}
```

```java
@Data
public class TestWrapper {
    private List<Test> test;
}
```

続いて Mapper をつくる。`@Result`は省略しても自動マッピングで上手くいく。

```java
public interface TestMapper {
    @Select(value = "{call testfunc(#{test, mode=OUT, jdbcType=CURSOR, resultMap=testMap})}")
    @Options(statementType = StatementType.CALLABLE)
    @ResultType(Test.class)
    @Results(id = "testMap", value = {
        @Result(id = true, column = "id", property = "id"),
        @Result(column = "hoge", property = "hoge"),
        @Result(column = "fuga", property = "fuga")
    })
    void out(TestWrapper wrapper);
}
```

以下のようにして実行してみる。

```java
public class App {
    public static void main(String[] args) throws Exception {
        HikariDataSource ds = new HikariDataSource();
        ds.setDriverClassName("org.postgresql.Driver");
        ds.setJdbcUrl("jdbc:postgresql://localhost:5432/postgres");
        ds.setUsername("postgres");
        ds.setPassword("postgres");

        Environment env = new Environment("postgres", new JdbcTransactionFactory(), ds);
        Configuration config = new Configuration(env);
        config.addMapper(TestMapper.class);

        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(config);

        try(SqlSession session = sqlSessionFactory.openSession()){
            TestWrapper wrapper = new TestWrapper();
            session.getMapper(TestMapper.class).out(wrapper);
            System.out.println(wrapper);
        }
    }
}
```

実行すると、こんなエラーが発生する。

```
Exception in thread "main" org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: org.postgresql.util.PSQLException: CallableStatement 関数が実行され、出力パラメータ 1 は java.sql.Types=2012 型 でした。しかし、java.sql.Types=-10 型 が登録されました。
### The error may exist in com/example/TestMapper.java (best guess)
### The error may involve com.example.TestMapper.out-Inline
### The error occurred while setting parameters
### SQL: {call testfunc(?)}
### Cause: org.postgresql.util.PSQLException: CallableStatement 関数が実行され、出力パラメータ 1 は java.sql.Types=2012 型 でした。しかし、java.sql.Types=-10 型 が登録されました。
```

## エラーの原因を探る
SQL 側は 2012 の型（`java.sql.Types.REF_CURSOR`）で返しているけど、受ける側の型が -10 になっていて型が不一致だぞ、ということらしい。
そのため、`CallableStatement` に `registerOutParameter` で指定している型がおかしいっぽい。

MyBatis は`CallableStatementHandler` の `registerOutputParameters`メソッドで、`ParameterMapping` から取得した `JdbcType` の `TYPE_CODE` を型として設定している。
[CallableStatementHandler.java](https://github.com/mybatis/mybatis-3/blob/mybatis-3.5.0/src/main/java/org/apache/ibatis/executor/statement/CallableStatementHandler.java#L99)

そこで、`JdbcType.CURSOR` を見てみると、`TYPE_CODE` は -10 になっていることがわかった。
[JdbcType.java](https://github.com/mybatis/mybatis-3/blob/mybatis-3.5.0/src/main/java/org/apache/ibatis/type/JdbcType.java#L55)

ということで、原因はここ。
コメントに Oracle と書いてあるので、Oracle は 2012 ではなく -10 をなのだろうか？？
Oracle 用で PostgreSQL には対応してないっていうことなのかな？

# なんとかして動かしてみる
`registerOutParameter` で 2012 を設定できれば動きそうなので、Interceptor で無理やり設定してみる。
http://www.mybatis.org/mybatis-3/ja/configuration.html#plugins

```java

@Intercepts({ @Signature(type = StatementHandler.class, method = "parameterize", args = { Statement.class }) })
public class CursorInterceptor implements Interceptor {
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object ret = invocation.proceed();
        if(!(invocation.getArgs()[0] instanceof CallableStatement)){
            return ret;
        }

        CallableStatement cstmt = (CallableStatement) invocation.getArgs()[0];
        List<ParameterMapping> parameterMappings = ((StatementHandler) invocation.getTarget()).getBoundSql()
                .getParameterMappings();

        int parameterIndex = 1;
        for (ParameterMapping parameterMapping : parameterMappings) {
            if ((parameterMapping.getMode() == ParameterMode.INOUT || parameterMapping.getMode() == ParameterMode.OUT)
                    && parameterMapping.getJdbcType() == JdbcType.CURSOR) {
                cstmt.registerOutParameter(parameterIndex, Types.REF_CURSOR);
            }
            parameterIndex++;
        }
        return ret;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
    }
}
```

`JdbcType.CURSOR` が指定された OUT, INOUT パラメータに対して、`Types.REF_CURSOR` として登録している。
`invocation.proceed()` を呼び出すことで `CallableStatementHandler` の処理が実行されるので、事前に実行しておかないと、せっかく設定した値が上書きされてしまう。

## 再チャレンジしてみる
Interceptor を登録するには `Configuration` クラスの `addInterceptor` メソッドを利用すればいい。

```java
public class App {
    public static void main(String[] args) throws Exception {
        HikariDataSource ds = new HikariDataSource();
        ds.setDriverClassName("org.postgresql.Driver");
        ds.setJdbcUrl("jdbc:postgresql://localhost:5432/postgres");
        ds.setUsername("postgres");
        ds.setPassword("postgres");

        Environment env = new Environment("postgres", new JdbcTransactionFactory(), ds);
        Configuration config = new Configuration(env);
        config.addMapper(TestMapper.class);
        config.addInterceptor(new CursorInterceptor()); // ★ここ★

        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(config);

        try(SqlSession session = sqlSessionFactory.openSession()){
            TestWrapper wrapper = new TestWrapper();
            session.getMapper(TestMapper.class).out(wrapper);
            System.out.println(wrapper);
        }
    }
}
```

実行結果は以下のような感じ。

```
TestWrapper(test=[Test(id=1, hoge=hogehoge, fuga=fugafuga), Test(id=2, hoge=hogege, fuga=fugaga)])
```

# まとめ
これはバグなのか仕様なのか判断できなかった。
とはいえ、いろいろ調査することでMyBatis の中身に少し詳しくなった気がするぞ。