วิธีการ Map PostgreSQL Array Types (text[]) กับ Spring Boot + JPA
บทนำ
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 การแปลง Array → String[] โดยอัตโนมัติ ง่ายกว่า JPA ในจุดนี้
สรุป
| Approach | ความยาก | หมายเหตุ |
|---|---|---|
String[] as-is | ✗ | Mapping 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