
Exended adalah perpustakaan SQL untuk Kotlin dengan DSL dan DAO API untuk interaksi database. Meskipun dilengkapi dengan dukungan untuk tipe data SQL standar, Anda dapat memperluas fungsinya dengan membuat tipe kolom kustom.
Tipe kolom khusus berguna ketika Exended tidak memiliki dukungan untuk tipe database tertentu (seperti PostgreSQL enum, inet atau ltree) atau saat Anda ingin memetakan kolom ke tipe khusus domain yang lebih selaras dengan logika bisnis Anda. Dengan menerapkan kolom khusus, Anda mendapatkan kendali atas penyimpanan dan pengambilan data sambil menjaga keamanan jenis.
Pada artikel ini, kita akan mempelajari cara membuat tipe kolom kustom di Exposted dengan membuat tipe kolom sederhana untuk PostgreSQL enum.
Kode sumber
Seluruh kode yang ditulis selama tutorial dipublikasikan di GitHub obabichev/expose-custom-column-type
Bekerja dengan Enum PostgreSQL melalui JDBC
Mari kita pahami cara kerja enum PostgreSQL di level JDBC. PostgreSQL mendukung tipe yang disebutkan. Untuk artikel ini, kita akan menggunakan contoh dari dokumentasi PostgreSQL dan membuat tipe enum dengan SQL berikut:
CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy');
Ekspos dibangun di atas JDBC, yang pada gilirannya berfungsi melalui SQL. Untuk memahami cara mengimplementasikan tipe kolom kustom di Expose, ada gunanya melihat terlebih dahulu bagaimana JDBC menangani nilai-nilai ini secara langsung. Mari kita buat tabel sederhana yang menggunakan mood enum:
CREATE TABLE person (
name TEXT,
mood mood
);
Saya akan membuat koneksi JDBC menggunakan driver secara langsung:
DriverManager.getConnection(url, user, password).use { connection ->
// Database operations go here
}
Untuk memasukkan entri baru dengan nilai enum, kita harus membuat pernyataan dan mengatur parameternya:
connection.prepareStatement("INSERT INTO person (name, mood) VALUES (?, ?)").use { insertStmt ->
insertStmt.setString(1, "John")
val mood = PGobject().also {
it.type = "mood"
it.value = "happy"
}
insertStmt.setObject(2, mood)
insertStmt.executeUpdate()
}
Setidaknya ada dua cara untuk menyetel parameter enum: menggunakan a PGobject (seperti yang ditunjukkan di atas) atau dengan menentukan java.sql.Types.OTHER sebagai argumen ketiga untuk setObject():
insertStmt.setObject(2, "happy", java.sql.Types.OTHER)
Membaca kembali nilai enum dari database sangatlah mudah:
connection.prepareStatement("SELECT name, mood FROM person WHERE name = ?").use { selectStmt ->
selectStmt.setString(1, "John")
selectStmt.executeQuery().use { rs ->
assert(rs.next())
assertEquals("John", rs.getString("name"))
assertEquals("happy", rs.getObject("mood"))
}
}
Sekarang setelah kita memahami cara JDBC menangani enum PostgreSQL, kita dapat membuat tipe kolom Ekspos kustom yang menyediakan fungsionalitas yang sama dengan keamanan dan kenyamanan tipe.
Membuat Tabel dengan Tipe Kolom Baru
Sekarang setelah kita memahami cara kerja enum PostgreSQL di tingkat JDBC, mari kita terapkan tipe kolom khusus di Expose. Kami akan membuat pembungkus yang aman untuk tipe kami mood enum menggunakan kelas enum Kotlin ini:
enum class Mood {
SAD,
OK,
HAPPY
}
Tipe kolom khusus di Exended dibuat dengan memperluas ColumnType<T> kelas, dimana T mewakili tipe Kotlin yang ingin kita gunakan. Karena kami ingin implementasi kami dapat digunakan kembali untuk tipe enum apa pun, kami akan membuat kelas generik:
class PostgresEnumerationColumnType<T : Enum<T>>() : ColumnType<T>() {
override fun sqlType(): String {
TODO("Not yet implemented")
}
override fun valueFromDB(value: Any): T? {
TODO("Not yet implemented")
}
}
Itu ColumnType kelas mengharuskan kita untuk mengimplementasikan dua metode penting:
-
sqlType()– Mengembalikan nama tipe SQL yang digunakan dalam pernyataan DDL (sepertiinteger,text,booleanatau dalam kasus kami, nama tipe enum) -
valueFromDB()– Mengubah nilai database menjadi objek Kotlin
Untuk tipe kolom enum PostgreSQL kami, tipe SQL adalah nama enum yang kami definisikan di database. Karena kita ingin kelas ini bekerja dengan enum apa pun, kita akan meneruskan nama tipe dan kelas enum sebagai parameter konstruktor:
class PostgresEnumerationColumnType<T : Enum<T>>(
val typeName: String,
val enumClass: Class<T>
) : ColumnType<T>() {
override fun sqlType() = typeName
override fun valueFromDB(value: Any): T? {
TODO("Not yet implemented")
}
}
Cara Exended adalah mendefinisikan fungsi ekstensi pada Table kelas untuk mendaftarkan kolom baru. Mari kita buat fungsi generik untuk enum apa pun dan fungsi praktis khusus untuk enum kita Mood jenis:
fun <T : Enum<T>> Table.pgEnum(name: String, typeName: String, enumClass: Class<T>): Column<T> =
registerColumn(name, PostgresEnumerationColumnType(typeName, enumClass))
fun Table.mood(name: String): Column<Mood> = pgEnum(name, "mood", Mood::class.java)
Sekarang kita dapat mendefinisikan tabel kita:
object PersonTable : Table("person") {
val name = text("name")
val mood = mood("mood")
}
Saat kita mengeksekusi SchemaUtils.create(PersonTable)Terkena menghasilkan SQL berikut:
CREATE TABLE IF NOT EXISTS person ("name" TEXT NOT NULL, mood mood NOT NULL)
Itu mood ketik SQL yang dihasilkan berasal langsung dari kami sqlType() metode.
Memasukkan dan membaca nilai Enum
Dengan definisi tabel yang ada, mari kita coba menyisipkan dan mengambil data. Perhatikan kode berikut:
PersonTable.insert {
it[name] = "John"
it[mood] = Mood.SAD
}
Menjalankan kode ini menghasilkan kesalahan: Transaction attempt #0 failed: Can't infer the SQL type to use for an instance of com.obabichev.Mood. Use setObject() with an explicit Types value to specify the type to use.
Ingatlah bahwa Exended beroperasi di atas JDBC. Saat menjalankan kueri, Exended menggunakan pernyataan berparameter dan perlu mengikat nilai Kotlin kami ke parameter pernyataan JDBC. Secara default, JDBC tidak memahami tipe enum khusus, jadi kita perlu mengajarkan tipe kolom kita cara mengonversi nilai enum ke dalam format yang bisa ditangani JDBC.
Exended menyediakan dua metode untuk konversi ini:
-
valueToDB()(dan saudaranyanotNullValueToDB()) – Mengonversi nilai Kotlin menjadi objek yang kompatibel dengan database -
setParameter()– Menetapkan nilai yang dikonversi pada pernyataan JDBC, menyediakan akses ke metode sepertisetNull(),setArray()DansetObject()
Metode-metode ini bekerja sama dalam alur: columnType.setParameter(statement, index + 1, columnType.valueToDB(value)).
Seperti yang kita lihat di bagian JDBC, enum PostgreSQL dapat diteruskan ke pernyataan baik sebagai a PGobject atau menggunakan java.sql.Types.OTHER. Saat ini setahu saya varian kedua belum didukung oleh Exended, jadi kita fokus saja PGobject mendekati.
Sebelum menerapkan pengikatan parameter, mari tambahkan metode pembantu untuk mengonversi antara nilai enum dan string. PGobject memerlukan nilai string, dan hasil database juga akan dikembalikan sebagai string:
class PostgresEnumerationColumnType<T : Enum<T>>(
val typeName: String,
val enumClass: Class<T>
) : ColumnType<T>() {
private val stringToEnumeration = enumClass.enumConstants.associateBy { it.name.lowercase() }
private val enumerationToString = stringToEnumeration.map { (k, v) -> v to k }.toMap()
private fun toEnumeration(name: String): T = stringToEnumeration[name.lowercase()]!!
private fun fromEnumeration(value: T): String = enumerationToString[value]!!
override fun sqlType() = typeName
// ... rest of the implementation
}
Menerapkan Konversi Nilai
Sekarang kita bisa menerapkannya notNullValueToDB() untuk mengembalikan konfigurasi yang benar PGobject:
override fun notNullValueToDB(value: T): Any {
return fromEnumeration(value).lowercase().let { enumValue ->
PGobject().also {
it.type = sqlType()
it.value = enumValue
}
}
}
Alternatifnya, kita dapat membagi logikanya notNullValueToDB() Dan setParameter() yang secara teknis akan menghasilkan hasil yang sama:
override fun notNullValueToDB(value: T): Any {
return fromEnumeration(value).lowercase()
}
override fun setParameter(
stmt: PreparedStatementApi,
index: Int,
value: Any?
) {
if (value == null) {
stmt.setNull(index, this)
} else {
PGobject().also {
it.type = sqlType()
// We can safely cast to String since we returned it from notNullValueToDB()
it.value = value as String
}.let {
stmt.set(index, it, this)
}
}
}
Sekarang kita sudah bisa memasukkan data, mari kita coba membacanya kembali:
val person = PersonTable.selectAll().first()
assertEquals("John", person[PersonTable.name])
assertEquals(Mood.SAD, person[PersonTable.mood])
Ini menimbulkan kesalahan lain: An operation is not implemented: Not yet implemented. Kita telah mencapai metode wajib kedua yang perlu kita terapkan: valueFromDB().
Jenis nilai yang diterima dalam metode ini bergantung sepenuhnya pada driver database. Kita dapat mengetahui tipe apa yang dikembalikan dengan memeriksa kode JDBC mentah atau dengan menambahkan keluaran debug ke metode yang belum diterapkan. Untuk enum PostgreSQL, driver mengembalikan a String nilai.
Karena kita sudah memiliki metode pembantu untuk mengonversi string menjadi nilai enum, implementasinya mudah:
override fun valueFromDB(value: Any): T? {
return when (value) {
is String -> toEnumeration(value.uppercase())
else -> error("Unexpected value $value of type ${value::class.qualifiedName}")
}
}
Dengan bagian terakhir ini, kita sekarang memiliki tipe kolom khusus yang berfungsi penuh yang dapat menyisipkan dan mengambil nilai enum PostgreSQL.
Menangani nilai default Kolom
Sekarang kita memiliki tipe kolom khusus yang berfungsi untuk enum PostgreSQL, mari jelajahi beberapa fitur tambahan. Salah satu kemampuan penting adalah menentukan nilai default untuk kolom.
Di Terkena, Anda dapat menentukan nilai default menggunakan default() pengubah:
object PersonTable : Table("person") {
val name = text("name")
val mood = mood("mood").default(Mood.OK)
}
Namun jika kita mencoba membuat tabel ini dengan SchemaUtils.create(PersonTable)kami menemukan kesalahan:
CREATE TABLE IF NOT EXISTS person ("name" TEXT NOT NULL, mood mood DEFAULT ok NOT NULL)
org.postgresql.util.PSQLException: ERROR: cannot use column reference in DEFAULT expression
Masalahnya terlihat di SQL yang dihasilkan—nilai default ok muncul tanpa tanda kutip, membuat PostgreSQL menafsirkannya sebagai referensi kolom dan bukan nilai enum literal.
Menerapkan Pemformatan Nilai Default
Itu ColumnType kelas menyediakan metode khusus untuk memformat nilai default dalam pernyataan DDL: valueAsDefaultString() dan saudaranya nonNullValueAsDefaultString(). Mari kita terapkan yang terakhir untuk memformat nilai default enum kita:
override fun nonNullValueAsDefaultString(value: T) =
fromEnumeration(value).lowercase().let { "'$it'::${sqlType()}" }
Dengan implementasi ini, SQL yang dihasilkan menjadi:
CREATE TABLE IF NOT EXISTS person ("name" TEXT NOT NULL, mood mood DEFAULT 'ok'::mood NOT NULL)
Ini mengikuti sintaks PostgreSQL untuk enum literal dengan tipe cast eksplisit. Namun, cast sebenarnya opsional untuk nilai default dalam konteks ini. Versi yang lebih sederhana juga bisa digunakan:
override fun nonNullValueAsDefaultString(value: T) =
fromEnumeration(value).lowercase().let { "'$it'" }
Ini menghasilkan:
CREATE TABLE IF NOT EXISTS person ("name" TEXT NOT NULL, mood mood DEFAULT 'ok' NOT NULL)
Kedua versi tersebut merupakan sintaks PostgreSQL yang valid dan akan berfungsi dengan benar. Versi pemeran eksplisit lebih bertele-tele tetapi membuat hubungan tipe lebih jelas, sedangkan versi sederhana lebih ringkas dan bergantung pada inferensi tipe PostgreSQL.
Membuat String Literal
Terkadang Anda mungkin ingin memasukkan nilai langsung ke dalam pernyataan SQL daripada menggunakan kueri berparameter. Exended menyediakan fungsi literal seperti intLiteral Dan stringLiteral untuk tujuan ini. Mari kita buat fungsi literal serupa untuk kita mood tipe kolom.
Untuk membuat ekspresi yang dimasukkan ke dalam SQL, kita perlu membuat instance LiteralOp. Untuk mood enum kita, tampilannya seperti ini:
fun moodLiteral(value: Mood): LiteralOp<Mood> =
LiteralOp(PostgresEnumerationColumnType("mood", Mood::class.java), value)
Ini dapat digunakan dalam pertanyaan seperti:
PersonTable.insert {
it[name] = "John"
it[mood] = moodLiteral(Mood.SAD)
}
Namun, menjalankan kode ini dengan implementasi kami saat ini menghasilkan kesalahan: org.postgresql.util.PSQLException: ERROR: column "sad" does not exist. Sekali lagi, nilai enum tidak diformat dengan benar untuk SQL.
Untuk mendukung literal, kita perlu mengimplementasikan valueToString() metode (atau saudaranya nonNullValueToString()). Metode ini mengontrol bagaimana nilai dikonversi menjadi representasi string dalam pernyataan SQL.
Keduanya nonNullValueAsDefaultString() Dan nonNullValueToString() perlu menghasilkan format output yang sama untuk nilai enum—string yang dikutip dengan tipe cast opsional. Kita dapat memfaktorkan ulang kode kita untuk menghindari duplikasi:
override fun nonNullValueAsDefaultString(value: T) =
nonNullValueToString(value)
override fun nonNullValueToString(value: T) =
fromEnumeration(value).lowercase().let { "'$it'::${sqlType()}" }
Dengan implementasi ini, SQL yang dihasilkan sekarang menyejajarkan nilai enum dengan benar:
INSERT INTO person ("name", mood) VALUES (?, 'sad'::mood)
Kesimpulan
Dalam artikel ini, kita telah mempelajari proses pembuatan tipe kolom kustom dan menjelajahi bagaimana Expose dapat diperluas untuk menulis kode yang lebih ekspresif dan aman untuk tipe.
Jika Anda ingin mendalami lebih dalam, saya sarankan untuk memeriksa bagaimana tipe bawaan Expose diimplementasikan—seperti kolom JSON atau tipe tanggal/waktu. Anda akan menemukan pola menarik seperti ArrayColumnTypeyang menggabungkan tipe kolom lain, dan tipe kolom yang menyesuaikan perilakunya berdasarkan dialek database saat ini.
Saya harap artikel ini bermanfaat baik Anda ingin membuat tipe kolom kustom Anda sendiri atau sekadar ingin lebih memahami cara kerja tipe kolom secara internal dan berkomunikasi dengan driver JDBC yang mendasarinya.


