はじめに
Domaはシンプルで素晴らしいORMです。
例えば、取得したレコード1行を1つのエンティティにマッピングするとし、1:多のリレーションをサポートしないことで、厄介なペジネーションの問題がそもそも発生しないようにしていたりします。
ですが、詳細画面など、特定のテーブルとそのテーブルに1:多で紐づく関連テーブルの情報を取得したいことは往々にしてあり、できればSQL1発できるといいなと考えることは多いと思います。
そしてPostgreSQLではJSON型が使えるので、DB側で構造化したレコードを返し、エンティティにマッピングするところで処理を挟み込むことができればいけそうです。
DomaにはDomain
という特定の型で定義されたフィールドをDB側の型にマッピングする仕組みがあり、これを利用してやれば実現できます。
仕込み
古いDomaにはバグがあるので、2.4.0
以上を使います。
また、JSONを変換するために、今回はJackson
を使いました。
まず、JSONを受け取るための基底クラスを作ります。
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
を使うようにします。
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受け取り側のクラス
以下の様なドメインとエンティティを作成します。
@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); }
}
@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]
というような値が入ってきてしまうという理由からです。
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問題やペジネーションの問題からは開放されるのはやはりありがたかったです。