Tech Blog

วิธีการ Map PostgreSQL Array Types (text[]) กับ Spring Boot + JPA

by y104
PostgreSQL Spring Boot JPA Java

บทนำ

PostgreSQL มี array types อย่าง text[] และ integer[]
เมื่อพยายามใช้กับข้อกำหนด “บันทึก film tags เป็น arrays” ก็พบปัญหามากมายกับ Spring Boot + JPA combination

บทความนี้สรุป pain points และวิธีแก้ปัญหาเหล่านั้น


สิ่งที่ต้องการทำ

Auto-generate mood tags สำหรับภาพยนตร์โดยใช้ LLM และบันทึกลงใน column taste_tags ใน film table

ALTER TABLE film ADD COLUMN taste_tags text[];

ภาพของข้อมูลที่จัดเก็บ:

UPDATE film SET taste_tags = ARRAY['Action', 'Thrilling', 'Family-friendly'] WHERE film_id = 1;

ต้องการรับข้อมูลนี้ใน Java entity


Approach แรก (ล้มเหลว)

ขั้นแรกลอง map ด้วย String[] อย่างตรงไปตรงมา

@Entity
@Table(name = "film")
public class Film {
    // ... ละไว้ ...
    
    @Column(name = "taste_tags")
    private String[] tasteTags;  // คิดว่าจะทำงานได้
}

ผลลัพธ์: Error เมื่อ startup

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

Hibernate ไม่ map String[] ของ Java ไปยัง text[] ของ PostgreSQL โดยอัตโนมัติ


วิธีที่ 1: ระบุ columnDefinition อย่างชัดเจน

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

สิ่งนี้ลบ startup error แต่อาจมี error อื่นปรากฏขณะ runtime

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

เมื่อ Hibernate สร้าง SQL บางครั้งมันไม่ handle text[] ถูกต้อง


วิธีที่ 2: Implement Custom UserType (Hibernate 6.x)

ใน Hibernate 6 ขึ้นไป สามารถ implement UserType interface เพื่อสร้าง custom types ได้

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 ก็ต้อง implement ด้วย
    @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);
    }
}

การใช้งานใน entity:

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

วิธีที่ 3: ใช้ Hibernate Types Library (ง่ายที่สุด)

การใช้ library vladmihalcea/hibernate-types ทำให้ skip การ implement custom 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;
}

เนื่องจาก @TypeDef ถูก deprecated ใน Hibernate 6.x (Spring Boot 3.x) วิธีการปัจจุบันที่แนะนำคือ:

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

Alternative Approach ด้วย MyBatis

ในโปรเจกต์นี้ ฝั่ง batch processing (admin screen) ใช้ JPA
แต่ customer-facing API implement ด้วย MyBatis ซึ่งทำให้การ handle String[] ง่ายกว่า

// รับใน MyBatis ResultMap
public class FilmDetail {
    private String[] tasteTags;
    // สามารถรับ JDBC Array เป็น String array ได้
}
<!-- Mapper XML -->
<select id="findById" resultType="FilmDetail">
    SELECT taste_tags FROM film WHERE film_id = #{filmId}
</select>

MyBatis handle การแปลง ArrayString[] โดยอัตโนมัติ ง่ายกว่า JPA ในจุดนี้


สรุป

Approachความยากหมายเหตุ
String[] as-isMapping error
columnDefinition = "text[]" เท่านั้นอาจมี runtime errors
Custom UserType implementation⚠️ ยากImplement type conversion ทั้งหมดเอง
hypersistence-utils✅ ง่ายเพิ่ม library เพื่อแก้ปัญหา
MyBatis✅ ง่ายมีการแปลงอัตโนมัติ

หากต้องการใช้ PostgreSQL array types การใช้ hypersistence-utils คือ best approach ณ ขณะนี้
หากใช้ MyBatis จะทำงานได้ง่ายโดยไม่ต้องใช้ additional libraries


แผนที่บทความสำหรับซีรีส์นี้

สร้างแอป DVD Rental สำหรับผู้ใช้ปลายทาง — โครงสร้าง Vue 3 + Spring Boot คู่กับแอปผู้ดูแลระบบ พร้อมแผนที่บทความ

ส่งข้อความได้ตามสบาย

กรุณาส่งข้อความ หากมีคำปรึกษาด้านเทคนิค ความคิดเห็น หรือคำถาม