11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Doma2でPostgresのJSON型を利用する

Posted at

はじめに

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

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

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

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

仕込み

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

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

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を使うようにします。

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受け取り側のクラス

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

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); }
}
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]というような値が入ってきてしまうという理由からです。

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問題やペジネーションの問題からは開放されるのはやはりありがたかったです。

11
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?