Doma2でPostgresのJSON型を利用する

# はじめに

Domaはシンプルで素晴らしいORMです。
例えば、取得したレコード1行を1つのエンティティにマッピングするとし、1:多のリレーションをサポートしないことで、厄介なペジネーションの問題がそもそも発生しないようにしていたりします。

ですが、詳細画面など、特定のテーブルとそのテーブルに1:多で紐づく関連テーブルの情報を取得したいことは往々にしてあり、できればSQL1発できるといいなと考えることは多いと思います。

そしてPostgreSQLではJSON型が使えるので、DB側で構造化したレコードを返し、エンティティにマッピングするところで処理を挟み込むことができればいけそうです。

Domaには`Domain`という特定の型で定義されたフィールドをDB側の型にマッピングする仕組みがあり、これを利用してやれば実現できます。

# 仕込み

古いDomaにはバグがあるので、`2.4.0`以上を使います。
また、JSONを変換するために、今回は`Jackson`を使いました。

まず、JSONを受け取るための基底クラスを作ります。

```java:JsonObject.java
public class JsonObject<T> {
    private static ObjectMapper mapper = buildMapper();
    private final T object;

    public JsonObject(String json) {
        JavaType javaType = mapper.constructType(((ParameterizedType) getClass().getGenericSuperclass()).getActualTypeArguments()[0]);
        try {
            this.object = mapper.readValue(String.valueOf(json), javaType);
        } catch (IOException e) {
            throw new UncheckedIOException(e);
        }
    }

    public JsonObject(T object) {
        this.object = object;
    }

    public T get() {
        return object;
    }

    public String getValue() {
        return toString();
    }

    @Override
    public String toString() {
        try {
            return mapper.writeValueAsString(object);
        } catch (JsonProcessingException e) {
            throw new UncheckedIOException(e);
        }
    }

    private static ObjectMapper buildMapper() {
        ObjectMapper mapper = new ObjectMapper();
        
        
        // DBの命名規則とJavaの命名規則の変換。ミスマッチがなければ必要ない。
        mapper.setPropertyNamingStrategy(PropertyNamingStrategy.CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES);

        SimpleModule module = new SimpleModule("DomaModule");
        // ここでmoduleにDate型などの(デ)シリアライザを登録する

        mapper.registerModule(module);
        return mapper;
    }
}
```

それから、`Dialect`をカスタマイズし、`Config`に登録しておきます。
特定クラスの`String`のタイプとして、`PortableObjectType`を使うようにします。

```java:CustomDialect.java
public class CustomDialect extends PostgresDialect {

  public CustomDialect() {
    super(new PgJdbcMappingVisitor());
  }

  private static class PgJdbcMappingVisitor extends PostgresJdbcMappingVisitor {
    @Override
    public Void visitStringWrapper(StringWrapper wrapper, JdbcMappingFunction p, JdbcMappingHint q) throws SQLException {
      if (q.getDomainClass().map(JsonObject.class::isAssignableFrom).orElse(false)) {
        return p.apply(wrapper, getPortableObject(JdbcTypes.STRING));
      }
      return super.visitStringWrapper(wrapper, p, q);
    }

    private static ConcurrentHashMap<JdbcType<?>, PortableObjectType<?>> cache = new ConcurrentHashMap<>();
    @SuppressWarnings("unchecked")
    private <T> PortableObjectType<T> getPortableObject(JdbcType<?> jdbcType) {
      return (PortableObjectType<T>) cache.computeIfAbsent(jdbcType, PortableObjectType::new);
    }
  }
}
```

あとは`JsonObject`を継承したドメインクラスを作ることで、JSON型のカラムをエンティティ上にマッピングすることができるはずです。

# 実装例

## JSON受け取り側のクラス

以下の様なドメインとエンティティを作成します。

```java:Children.java
@Domain(valueType = String.class)
public class Children extends JsonObject<List<Parent.Child>> {
  public Children(String json) { super(json); }
  public Children(List<Parent.Child> object) { super(object); }
}
```

```java:Parent.java
@Entity
public class Parent {
  public Integer id;
  public String name;
  public Children children;

  public static class Child {
    public Integer id;
    public String name;
  }
}
```

## SQL

以下のように、JSONを組み立てます。
`CASE WHEN...`のようにしているのは、0件のとき`[null]`というような値が入ってきてしまうという理由からです。

```sql:all.sql
SELECT
  parent.id,
  parent.name,
  CASE WHEN count(children.*) = 0 THEN
    '[]'::json
  ELSE
    json_agg(json_object_builder('id', children.id, 'name', children.name))
  END AS children
FROM parent
LEFT JOIN child
GROUP BY parent.id, parent.name
```

# 実際使ってみた所感

まず、SQLが複雑になりますし、複数のSQLに渡って結構重複が発生するので、マクロ的な機能が欲しくなりました。

また、この使い方ではその場でしか使わないドメインクラスを大量に作る必要が出てきてしまうので、インナークラスとして定義するほうが行儀がいいと思うのですが、Domaの成約がそれを許してくれなかったので、制約を緩めるためのフラグとかがあればいいかなと思いました。

そして、N+1問題やペジネーションの問題からは開放されるのはやはりありがたかったです。