Tech Blog

PostgreSQL の配列型(text[])を Spring Boot + JPA でマッピングする方法

PostgreSQL Spring Boot JPA Java

はじめに

PostgreSQLには text[]integer[] のような配列型があります。
「映画のタグを配列で保存したい」という要件で使おうとしたところ、Spring Boot + JPA との組み合わせでかなり詰まりました。

この記事は、その詰まりポイントと解決方法をまとめたものです。


やりたかったこと

LLMで映画の雰囲気タグを自動生成して、film テーブルの taste_tags カラムに保存したい。

ALTER TABLE film ADD COLUMN taste_tags text[];

格納されるデータのイメージ:

UPDATE film SET taste_tags = ARRAY['アクション', 'ハラハラ', '家族向け'] WHERE film_id = 1;

これをJavaのエンティティで受け取りたい。


最初のアプローチ(失敗)

まず素直に String[] でマッピングしてみました。

@Entity
@Table(name = "film")
public class Film {
    // ... 省略 ...
    
    @Column(name = "taste_tags")
    private String[] tasteTags;  // これでいけると思った
}

結果: 起動時にエラー

org.hibernate.MappingException: Could not determine type for: [Ljava.lang.String;

HibernateはJavaの String[] をPostgreSQLの text[] に自動マッピングしません。


解決策1:columnDefinition を明示する

@Column(name = "taste_tags", columnDefinition = "text[]")
private String[] tasteTags;

これで起動エラーは消えますが、実行時に別のエラーが出ることがあります。

ERROR: column "taste_tags" is of type text[] but expression is of type character varying

HibernateがSQLを生成するとき、text[] として正しく扱ってくれないことがあります。


解決策2:カスタム UserType を実装する(Hibernate 6系)

Hibernate 6以降では UserType インターフェースを実装してカスタム型を作れます。

public class StringArrayType implements UserType<String[]> {
    
    @Override
    public int getSqlType() {
        return Types.ARRAY;
    }
    
    @Override
    public Class<String[]> returnedClass() {
        return String[].class;
    }
    
    @Override
    public String[] nullSafeGet(ResultSet rs, int position, 
            SharedSessionContractImplementor session, Object owner) 
            throws SQLException {
        Array array = rs.getArray(position);
        if (array == null) return null;
        return (String[]) array.getArray();
    }
    
    @Override
    public void nullSafeSet(PreparedStatement st, String[] value, int index,
            SharedSessionContractImplementor session) 
            throws SQLException {
        if (value == null) {
            st.setNull(index, Types.ARRAY);
        } else {
            Array array = st.getConnection().createArrayOf("text", value);
            st.setArray(index, array);
        }
    }
    
    // equals, hashCode, deepCopy, isMutable, disassemble, assemble も実装が必要
    @Override
    public boolean equals(String[] x, String[] y) {
        return Arrays.equals(x, y);
    }
    
    @Override
    public int hashCode(String[] x) {
        return Arrays.hashCode(x);
    }
    
    @Override
    public String[] deepCopy(String[] value) {
        return value == null ? null : Arrays.copyOf(value, value.length);
    }
    
    @Override
    public boolean isMutable() { return true; }
    
    @Override
    public Serializable disassemble(String[] value) {
        return deepCopy(value);
    }
    
    @Override
    public String[] assemble(Serializable cached, Object owner) {
        return deepCopy((String[]) cached);
    }
}

エンティティで使用:

@Column(name = "taste_tags", columnDefinition = "text[]")
@Type(StringArrayType.class)
private String[] tasteTags;

解決策3:Hibernate Types ライブラリを使う(最もシンプル)

vladmihalcea/hibernate-types というライブラリを使うと、カスタム型の実装をスキップできます。

<!-- pom.xml -->
<dependency>
    <groupId>io.hypersistence</groupId>
    <artifactId>hypersistence-utils-hibernate-63</artifactId>
    <version>3.7.0</version>
</dependency>
import io.hypersistence.utils.hibernate.type.array.StringArrayType;

@Entity
@TypeDef(name = "string-array", typeClass = StringArrayType.class)
public class Film {
    
    @Type(type = "string-array")
    @Column(name = "taste_tags", columnDefinition = "text[]")
    private String[] tasteTags;
}

Hibernate 6系(Spring Boot 3.x)では @TypeDef が非推奨になっているため、下記の書き方が現在の推奨です:

@Column(name = "taste_tags", columnDefinition = "text[]")
@Type(io.hypersistence.utils.hibernate.type.array.StringArrayType.class)
private String[] tasteTags;

MyBatisでの代替手法

今回のプロジェクトではバッチ処理側(管理画面)でJPAを使いますが、
顧客向けAPIはMyBatisで実装しているため、こちらでは String[] の扱いが簡単です。

// MyBatisのResultMapでの受け取り
public class FilmDetail {
    private String[] tasteTags;
    // JDBCのArrayをStringの配列として受け取れる
}
<!-- Mapper XML -->
<select id="findById" resultType="FilmDetail">
    SELECT taste_tags FROM film WHERE film_id = #{filmId}
</select>

MyBatisは ArrayString[] の変換を自動でやってくれます。この点はJPAより楽でした。


まとめ

アプローチ難易度備考
String[] そのままマッピングエラー
columnDefinition = "text[]" のみ実行時エラーの可能性
カスタム UserType 実装⚠️ 難自前で型変換を全部実装
hypersistence-utils✅ 簡単ライブラリ追加で解決
MyBatis✅ 簡単自動変換あり

PostgreSQLの配列型を使いたい場合は hypersistence-utils の利用が現時点での最善策です。
MyBatisを使っているなら追加ライブラリ不要でシンプルに動きます。


このシリーズの記事マップ

dvdrental 管理アプリと対になるエンドユーザー向けDVDレンタルアプリを作っている話 — Vue 3 + Spring Boot の全体構成と記事マップ

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。