Doma2 で JSON 型を使う

魔がさして、RDBMSJSON 型を導入しようとしてみました*1

前提条件

データ構造

create table customers
(
    customer_id   varchar(255) not null
        constraint customers_pkey
            primary key,
    customer_code varchar(255) not null,
    customer_name varchar(255) not null,
    attribute     json
);

エンティティクラス、ドメインクラス

Doma での エンティティクラスは以下のような感じです。

@Entity
@Table(name = "customers")
@Data
public class Customer {

  @Id
  @Column(name = "customer_id")
  private String customerId;

  @Column(name = "customer_code")
  private String customerCode;

  @Column(name = "customer_name")
  private String customerName;

  /** 永続化時に JSON カラムにするプロパティ. */
  @Column(name = "attribute")
  private Attribute attribute;
}

Attribute class は Domaドメインクラス相当です。こいつをまるっと JSON 型に入れたり取り出したりしちゃおうというのが今回のゴールです。

@Data
public class Attribute {

  @JsonProperty("memo")
  private String memo;

  @JsonProperty("age")
  private Integer age;

  @JsonProperty("point")
  private Long point;
}

手順

1. DomainConverter 作成

Attribute に対する DomainConverter を作成します。 型パラメータの String は JSON 文字列になります。

@ExternalDomain
public class AttributeConverter implements DomainConverter<Attribute, String> {

  private static final ObjectMapper MAPPER = new ObjectMapper();

  @Override
  public String fromDomainToValue(Attribute attribute) {
    try {
      return MAPPER.writeValueAsString(attribute);
    } catch (JsonProcessingException e) {
      throw new RuntimeException(e);
    }
  }

  @Override
  public Attribute fromValueToDomain(String value) {
    try {
      return value == null ? null : MAPPER.readValue(value, Attribute.class);
    } catch (JsonProcessingException e) {
      throw new RuntimeException(e);
    }
  }
}

2. DomainConverters に追加

DomainConverter を @DomainConverters に登録します。 今回のサンプルでは DomainConverterProvider クラスで管理しています。 この辺Doma に教えてます。

3. Dao インターフェース作成

最初の CustomerDao はこんな感じでした。

@Dao
public interface CustomerDao {

  @Select
  @Sql("SELECT * FROM customers WHERE customer_id = /*customerId*/'customer_001'")
  Optional<Customer> selectByCustomerId(String customerId);

  @Insert
  int insert(Customer customer);
}

4. 実行

テストクラスから実行します。

PostgreSQL で実行した所、

org.seasar.doma.jdbc.SqlExecutionException: [DOMA2009] The SQL execution is failed.
PATH=[null].
SQL=[insert into customers (customer_id, customer_code, customer_name, attribute) values ('b9d9900c-27da-4d88-b9a6-028b1a1d32ae', 'CUSTOMER_001', 'NAME_001', '{"memo":"memo_001","age":30,"point":343}')].
The cause is as follows: org.postgresql.util.PSQLException: ERROR: column "attribute" is of type json but expression is of type character varying
  ヒント: You will need to rewrite or cast the expression.
  位置: 98
The root cause is as follows: org.postgresql.util.PSQLException: ERROR: column "attribute" is of type json but expression is of type character varying
  ヒント: You will need to rewrite or cast the expression.
  位置: 98

どうも INSERT 時の JSON 型の扱いがよくないようです。

今度は H2 で実行してみましょう。

java.lang.RuntimeException: com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `examples.domain.Attribute` (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('{"memo":"memo_001","age":30,"point":343}')
 at [Source: (String)""{\"memo\":\"memo_001\",\"age\":30,\"point\":343}""; line: 1, column: 1]

SELECT の結果(JSON 文字列)を受け取って Javaインスタンスを生成する所に問題があるようです。 これは困った。

5. RDBMSJSON 型を使う時

どうも、JSON 型を使う時は TEXT 型のように扱うのではダメなようです。

ということは、RDBMS 毎に SQL を変更しないといけないのか...

6. sql ファイルを用意する

Doma 2 では、SQL ファイル名をつけ分けることによって使い分けができそうです。素敵!

insert-h2.sql

-- H2 用の JSON カラムを含む INSERT
insert into customers (
    customer_id,
    customer_code,
    customer_name,
    attribute
) values (
    /* customer.customerId */'id_001',
    /* customer.customerCode */'code_001',
    /* customer.customerName */'name_002',
    /* customer.attribute */'{"hoge"::30}' FORMAT JSON
)

insert-postgres.sql

-- PostgreSQL 用の JSON カラムを含む INSERT
insert into customers (
    customer_id,
    customer_code,
    customer_name,
    attribute
) values (
    /* customer.customerId */'id_001',
    /* customer.customerCode */'code_001',
    /* customer.customerName */'name_002',
    /* customer.attribute */'{"hoge"::30}'::json
)

CustomerDao#insert を SQL ファイルを使用するように設定しなおします。

@Dao
public interface CustomerDao {

  @Select
  @Sql("SELECT * FROM customers WHERE customer_id = /*customerId*/'customer_001'")
  Optional<Customer> selectByCustomerId(String customerId);

  @Insert(sqlFile = true) // sqlFile=true 追加
  int insert(Customer customer);
}

7. 再度実行

テストクラスから実行します。 どちらも通りました。やりましたね。

まとめ

PostgreSQL だと JSON 型より JSONB 型を使いそうですが、JSON 文字列として JSON 型にデータを入れる、JSON 型からデータを取り出すことができました。

実際に JSON 関数を使った生 SQL 発行するパターンは未検証ですが、RDBMS 毎にちがうでしょうし、運用時に SQL 叩くことを想定したら TEXT 型よりも JSON 型の方が楽だろう、というモチベーションだったのでアプリケーションからは JSON 関数を意識した SELECT 文は発行しないとは思うので、必要があったら検証することにします。

H2 と PostgreSQL 両方に対応しようとしてたのは、

  • Repository の Unit テスト / Integration テストは H2 を使って早く CI を回す
  • 本番は PostgreSQL

の構成でやりたかったからです。ただ、INSERT の時点で RDBMS を意識しないといけないのはちょっとアレなので、Unit テストも PostgreSQL で CI した方が良いのかなーと思い始めました*2

コード

ここにおきますここから fork しました。Doma2 素敵です。

*1:アンチパターンだというのはごもっともなのですが、それはそれで

*2:テストクラスが増えてって、CircleCI の parallelism 増やした時に、その分 PostgreSQL コンテナが立ち上がると思うのでリソース足りるのかなーというのが心配